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:
Marcel
2026-06-02 19:45:18 +02:00
committed by marcel
parent 2430092e43
commit 58a30a6e2e

View File

@@ -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();
});
});