test(transcription): AC-6 single-dropdown invariant + stale-fetch guard (#628)
Locks in the single-owner controller guarantees: pencil→pencil, fresh-@→pencil and pencil→fresh-@ all leave exactly one dropdown open; the request-token bump on open discards a superseded open's in-flight fetch (open A → open B → A resolves, deterministic, no sleeps). Plus a #380 AC-1 regression guard that the fresh-@ path still inserts the typed text as displayName after the controller refactor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1047,3 +1047,133 @@ describe('PersonMentionEditor — #628 re-edit pencil', () => {
|
||||
await expect.element(dismissButton()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #628 AC-6: at most one mention dropdown open at a time ───────────────────
|
||||
|
||||
describe('PersonMentionEditor — #628 AC-6 single-dropdown invariant', () => {
|
||||
const TWO_MENTIONS = [
|
||||
{ personId: 'p-aug', displayName: 'Aug' },
|
||||
{ personId: 'p-bert', displayName: 'Bert' }
|
||||
];
|
||||
|
||||
function mentionButtons(): HTMLButtonElement[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll('[data-type="mention"] button')
|
||||
) as HTMLButtonElement[];
|
||||
}
|
||||
|
||||
function click(el: HTMLElement) {
|
||||
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
function listboxCount() {
|
||||
return document.querySelectorAll('[role="listbox"]').length;
|
||||
}
|
||||
|
||||
it('pencil → pencil: opening a second mention pencil closes the first', async () => {
|
||||
mockFetchEmpty();
|
||||
renderHost({ value: '@Aug @Bert ', mentionedPersons: TWO_MENTIONS });
|
||||
await vi.waitFor(() => expect(mentionButtons().length).toBe(2));
|
||||
const [pencilA, pencilB] = mentionButtons();
|
||||
|
||||
click(pencilA);
|
||||
await vi.waitFor(() => expect(listboxCount()).toBe(1));
|
||||
|
||||
click(pencilB);
|
||||
await vi.waitFor(async () => {
|
||||
expect(listboxCount()).toBe(1);
|
||||
await expect.element(page.getByRole('searchbox')).toHaveValue('Bert');
|
||||
});
|
||||
});
|
||||
|
||||
it('fresh-@ → pencil: activating a pencil closes the open fresh-@ dropdown', async () => {
|
||||
mockFetchEmpty();
|
||||
renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
|
||||
await vi.waitFor(() => expect(mentionButtons().length).toBe(1));
|
||||
|
||||
// Open a fresh-@ dropdown (prefilled with the typed query "x").
|
||||
await userEvent.type(page.getByRole('textbox'), '@x');
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('searchbox')).toHaveValue('x');
|
||||
});
|
||||
|
||||
// Now activate the saved mention's pencil — the fresh dropdown must give way.
|
||||
click(mentionButtons()[0]);
|
||||
await vi.waitFor(async () => {
|
||||
expect(listboxCount()).toBe(1);
|
||||
await expect.element(page.getByRole('searchbox')).toHaveValue('Aug');
|
||||
});
|
||||
});
|
||||
|
||||
it('pencil → fresh-@: typing @ in the editor closes the open re-edit dropdown', async () => {
|
||||
mockFetchEmpty();
|
||||
renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
|
||||
await vi.waitFor(() => expect(mentionButtons().length).toBe(1));
|
||||
|
||||
click(mentionButtons()[0]);
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('searchbox')).toHaveValue('Aug');
|
||||
});
|
||||
|
||||
// Typing @ in the editor starts a fresh suggestion — the re-edit dropdown
|
||||
// must give way to it (single owner, both orderings).
|
||||
await userEvent.type(page.getByRole('textbox'), '@y');
|
||||
await vi.waitFor(async () => {
|
||||
expect(listboxCount()).toBe(1);
|
||||
await expect.element(page.getByRole('searchbox')).toHaveValue('y');
|
||||
});
|
||||
});
|
||||
|
||||
it('regression: fresh-@ still inserts the typed text as displayName (#380 AC-1 intact)', async () => {
|
||||
mockFetchWithPersons();
|
||||
const host = renderHost();
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||
});
|
||||
const option = (await page
|
||||
.getByRole('option', { name: /Auguste Raddatz/ })
|
||||
.element()) as HTMLElement;
|
||||
option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
|
||||
});
|
||||
});
|
||||
|
||||
it('discards a stale fetch from a superseded open (open A → open B → A resolves)', async () => {
|
||||
// A's fetch hangs; B's resolves empty. When A finally resolves with a
|
||||
// person, the request-token guard must discard it so B's dropdown is not
|
||||
// repopulated. Deterministic — no sleeps.
|
||||
let resolveA!: (v: { ok: boolean; json: () => Promise<{ items: Person[] }> }) => void;
|
||||
const pendingA = new Promise<{ ok: boolean; json: () => Promise<{ items: Person[] }> }>((r) => {
|
||||
resolveA = r;
|
||||
});
|
||||
let call = 0;
|
||||
const fetchMock = vi.fn().mockImplementation(() => {
|
||||
call += 1;
|
||||
if (call === 1) return pendingA;
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ items: [] }) });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderHost({ value: '@Aug @Bert ', mentionedPersons: TWO_MENTIONS });
|
||||
await vi.waitFor(() => expect(mentionButtons().length).toBe(2));
|
||||
const [pencilA, pencilB] = mentionButtons();
|
||||
|
||||
click(pencilA);
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Aug'));
|
||||
});
|
||||
|
||||
click(pencilB);
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Bert'));
|
||||
});
|
||||
|
||||
// A's stale response arrives last — it must NOT repopulate B's dropdown.
|
||||
resolveA({ ok: true, json: () => Promise.resolve({ items: [AUGUSTE] }) });
|
||||
await tick();
|
||||
await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user