// @vitest-environment jsdom // // 入力欄の添付一覧(#1553)。 // // 元の申告は「名前が長いとスクロールバーが出て、消すボタンが見つからない」。 // 守りたいのは見た目の好みではなく、操作できるかどうかの話: // 1. 0 件なら何も描かない(余白を増やさない) // 2. 1 件 1 行で縦に積む(行そのものが横並びで、名前が縮み、ボタンが右端) // 3. 長い名前は 1 行に省略し、全文は title で読める // 4. 消すボタンは常に見えていて、押すとその 1 件だけが消える // 5. 操作の名前にファイル名が入り、辞書から引かれている // // **多言語化を実物の辞書で初期化してから描く。**初期化しないと t() は鍵の // 文字列をそのまま返し、ファイル名が 1 文字も入らない。その状態で名前を見る // 検査を書くと、画面直書きに差し替えても緑のままになる(前の版がそれで // 空振りしていた)。作法は WorkerStatusWindow.test.tsx 等と揃える。 import '../../test/dom-setup'; import { useState } from 'react'; import { describe, it, expect, beforeAll, afterEach } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import jaChat from '../../i18n/locales/ja/chat.json'; import enChat from '../../i18n/locales/en/chat.json'; import { ChatAttachmentList, type ChatAttachment } from './ChatAttachmentList'; beforeAll(async () => { await i18n.use(initReactI18next).init({ lng: 'ja', resources: { ja: { chat: jaChat }, en: { chat: enChat } }, ns: ['chat'], defaultNS: 'chat', interpolation: { escapeValue: false }, }); }); afterEach(async () => { // 言語を切り替える検査があるので、毎回 ja に戻す。 if (i18n.language !== 'ja') await i18n.changeLanguage('ja'); }); /** 辞書の文言にファイル名を差し込んだ、画面に出るはずの文字列。 */ function removeLabel(catalog: { attachment: { remove: string } }, name: string): string { return catalog.attachment.remove.replace('{{name}}', name); } function attachment(name: string): ChatAttachment { return { name, contentBase64: 'AAAA' }; } /** 本番と同じく「消したら一覧から減る」まで動かす受け皿。 */ function Harness({ initial }: { initial: ChatAttachment[] }) { const [attachments, setAttachments] = useState(initial); return ( setAttachments(prev => prev.filter(a => a.name !== name))} /> ); } /** 名前でボタンを引く。役割と名前だけで取れることがそのまま検査になる。 */ function removeButton(name: string): HTMLElement { return screen.getByRole('button', { name: removeLabel(jaChat, name) }); } describe('ChatAttachmentList (#1553)', () => { it('添付が 0 件なら何も描かない', () => { const { container } = render( {}} />); expect(container.innerHTML).toBe(''); expect(screen.queryByRole('list')).toBeNull(); }); it('1 件 1 行で縦に積む(折り返さない)', () => { render( {}} />); const rows = screen.getAllByRole('listitem'); expect(rows).toHaveLength(3); const list = screen.getByRole('list', { name: jaChat.attachment.listLabel }); // 縦積み。横並びに戻したら赤くする——1 行に複数件を押し込む形が元の不具合。 expect(list.classList.contains('flex')).toBe(true); expect(list.classList.contains('flex-col')).toBe(true); expect(list.classList.contains('flex-wrap')).toBe(false); }); it('行そのものは横並びで、名前が縮み、ボタンは縮まない', () => { render( {}} />); const row = screen.getByRole('listitem'); // 行が横並びでないと名前とボタンが縦に割れ、ボタンが右端から離れる(実測で // 429px 内側へずれ、横スクロールバーまで復活した)。 expect(row.classList.contains('flex')).toBe(true); expect(row.classList.contains('items-center')).toBe(true); const label = screen.getByTitle('a.txt'); // 名前が縮めないと、長い名前の分だけ行が広がってボタンを右端から押し出す。 expect(label.classList.contains('min-w-0')).toBe(true); expect(label.classList.contains('flex-1')).toBe(true); expect(label.classList.contains('truncate')).toBe(true); // ボタンは逆に縮まない(名前に押し潰されない)。 expect(removeButton('a.txt').classList.contains('flex-shrink-0')).toBe(true); }); it('件数が増えても一覧の高さに上限があり、内側だけがスクロールする', () => { render( attachment(`file-${i}.txt`))} onRemove={() => {}} />); const list = screen.getByRole('list', { name: jaChat.attachment.listLabel }); // 上限の値まで押さえる。max-h- とだけ見ると max-h-none も通ってしまい、 // 入力欄そのものが伸び続ける(実測 216px → 540px)のを止められない。 expect(list.classList.contains('max-h-32')).toBe(true); expect(list.classList.contains('overflow-y-auto')).toBe(true); }); it('長い名前は 1 行に省略し、全文は title で読める', () => { const long = 'とても長い添付ファイルの名前_2026-09-11_打ち合わせ議事録_最終版_改訂3.xlsx'; render( {}} />); const label = screen.getByTitle(long); expect(label).toBeInTheDocument(); expect(label.textContent).toBe(long); expect(label.classList.contains('truncate')).toBe(true); }); it('消すボタンは hover しなくても常に見えている', () => { render( {}} />); const button = removeButton('a.txt'); expect(button).toBeVisible(); // hover で初めて現れる作りに戻したら赤くする for (const hidden of ['opacity-0', 'group-hover:', 'invisible', 'hidden']) { expect(button.className, `常時表示でなくなっている: ${hidden}`).not.toContain(hidden); } }); it('消すボタンの名前と吹き出しに、そのファイル名が入る', () => { render( {}} />); // 1 件ずつ、名前だけで取り分けられる(どれを消すのか読み上げで分かる) const first = screen.getByRole('button', { name: removeLabel(jaChat, '見積書.xlsx') }); const second = screen.getByRole('button', { name: removeLabel(jaChat, 'memo.txt') }); expect(first).not.toBe(second); expect(first.getAttribute('aria-label')).toContain('見積書.xlsx'); expect(first.getAttribute('title')).toBe(removeLabel(jaChat, '見積書.xlsx')); expect(second.getAttribute('title')).toBe(removeLabel(jaChat, 'memo.txt')); }); it('文言は辞書から引く(言語を英語にすると名前も英語になる)', async () => { await i18n.changeLanguage('en'); render( {}} />); expect(screen.getByRole('list', { name: enChat.attachment.listLabel })).toBeInTheDocument(); expect(screen.getByRole('button', { name: removeLabel(enChat, 'a.txt') })).toBeInTheDocument(); // 画面直書きに差し替えたらここで赤くなる(日本語のまま出る) expect(screen.queryByRole('button', { name: removeLabel(jaChat, 'a.txt') })).toBeNull(); }); it('消すボタンを押すと、その 1 件だけが消える', async () => { const user = userEvent.setup(); render(); await user.click(removeButton('b.txt')); const names = screen.getAllByRole('listitem').map(row => row.textContent); expect(names).toEqual(['a.txt', 'c.txt']); }); it('最後の 1 件を消すと一覧そのものが消える', async () => { const user = userEvent.setup(); render(); await user.click(removeButton('only.txt')); expect(screen.queryByRole('list')).toBeNull(); }); it('文言は日本語・英語の両方のロケールにあり、ファイル名の差し込み口を持つ', () => { for (const [locale, catalog] of [['ja', jaChat], ['en', enChat]] as const) { expect(catalog.attachment.listLabel, `${locale}/chat.json`).toBeTruthy(); expect(catalog.attachment.remove, `${locale}/chat.json`).toContain('{{name}}'); } }); });