Files
maestro/ui/src/components/chat/ChatAttachmentList.test.tsx
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

174 lines
9.0 KiB
TypeScript

// @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 (
<ChatAttachmentList
attachments={attachments}
onRemove={name => 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(<ChatAttachmentList attachments={[]} onRemove={() => {}} />);
expect(container.innerHTML).toBe('');
expect(screen.queryByRole('list')).toBeNull();
});
it('1 件 1 行で縦に積む(折り返さない)', () => {
render(<ChatAttachmentList attachments={[attachment('a.txt'), attachment('b.txt'), attachment('c.txt')]} onRemove={() => {}} />);
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(<ChatAttachmentList attachments={[attachment('a.txt')]} onRemove={() => {}} />);
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(<ChatAttachmentList attachments={Array.from({ length: 12 }, (_, i) => 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(<ChatAttachmentList attachments={[attachment(long)]} onRemove={() => {}} />);
const label = screen.getByTitle(long);
expect(label).toBeInTheDocument();
expect(label.textContent).toBe(long);
expect(label.classList.contains('truncate')).toBe(true);
});
it('消すボタンは hover しなくても常に見えている', () => {
render(<ChatAttachmentList attachments={[attachment('a.txt')]} onRemove={() => {}} />);
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(<ChatAttachmentList attachments={[attachment('見積書.xlsx'), attachment('memo.txt')]} onRemove={() => {}} />);
// 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(<ChatAttachmentList attachments={[attachment('a.txt')]} onRemove={() => {}} />);
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(<Harness initial={[attachment('a.txt'), attachment('b.txt'), attachment('c.txt')]} />);
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(<Harness initial={[attachment('only.txt')]} />);
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}}');
}
});
});