diff --git a/src/lib/artist-lookup-markup.test.ts b/src/lib/artist-lookup-markup.test.ts index ba29189a..0d56dbc5 100644 --- a/src/lib/artist-lookup-markup.test.ts +++ b/src/lib/artist-lookup-markup.test.ts @@ -21,34 +21,161 @@ const ANNOUNCER = read('src/lib/components/LiveAnnouncer.svelte'); // tag-suggestion note it also holds (SONA-220). const APP_CSS = read('src/app.css'); +// The end of the string literal that opens at `i`, one past its closing quote. +// Throws rather than running to the end of the file: an unterminated literal +// means the scanner has lost its place, and a silent slice from there would +// hand every assertion under it the wrong text. +function endOfString(source: string, i: number): number { + const quote = source[i]; + for (let j = i + 1; j < source.length; j++) { + if (source[j] === '\\') { + j++; + continue; + } + if (source[j] === quote) return j + 1; + // A plain quote cannot span a line; a template literal can. + if (quote !== '`' && source[j] === '\n') break; + } + throw new Error(`unterminated ${quote} literal at index ${i} in source`); +} + +// The index of the `close` that matches the first `open` at or after `from`, +// stepping over `//` line comments, `/* */` block comments and string literals +// so a delimiter written inside one never moves the depth. Throws when the +// closer never arrives, so a scan that walks off the end says so instead of +// returning a slice that stops wherever the source did. +// Regex literals are NOT tracked: a `/[{}]/` inside a scanned body would move +// the depth and a `/won't/` would open a string that never closes. Nor are +// template literals nested inside a `${}` expression: the scan ends at the +// next backtick. No function this file slices has either. +function matchDelim( + source: string, + from: number, + open: string, + close: string, + what: string +): number { + let depth = 0; + let i = from; + while (i < source.length) { + if (source.startsWith('//', i)) { + const eol = source.indexOf('\n', i); + i = eol < 0 ? source.length : eol + 1; + continue; + } + if (source.startsWith('/*', i)) { + const end = source.indexOf('*/', i + 2); + if (end < 0) throw new Error(`unterminated block comment at index ${i} in source`); + i = end + 2; + continue; + } + if (source[i] === '"' || source[i] === "'" || source[i] === '`') { + i = endOfString(source, i); + continue; + } + if (source[i] === open) depth++; + else if (source[i] === close && --depth === 0) return i; + i++; + } + throw new Error( + depth === 0 + ? `no ${open} for ${what} in source` + : `no closing ${close} for ${what} in source (depth ${depth} at the end)` + ); +} + // A function's body, sliced from its declaration by matching braces. A // `[\s\S]*?\n\t}` span stops at the first one-tab closing brace, which on a // multi-line return type is the end of the annotation rather than the end of // the body, and every assertion under it then reads the signature alone // (SONA-220). Throws when the function is gone, so a rename can never leave a -// negative assertion asserting nothing. +// negative assertion asserting nothing, and throws again when the braces never +// balance rather than scanning to the end of the file and slicing whatever it +// reached. function fnBody(source: string, name: string): string { const start = source.indexOf(`function ${name}(`); if (start < 0) throw new Error(`no function named ${name} in source`); - let i = start; - for (let depth = 0; ; i++) { - if (source[i] === '(') depth++; - else if (source[i] === ')' && --depth === 0) break; - } + let i = matchDelim(source, start, '(', ')', `${name}'s parameter list`) + 1; // Past the parameter list, a brace-balanced span with another `{` after it is // a return-type annotation; the one nothing follows is the body. for (;;) { - let close = source.indexOf('{', i); - for (let depth = 0; ; close++) { - if (source[close] === '{') depth++; - else if (source[close] === '}' && --depth === 0) break; - } + const close = matchDelim(source, i, '{', '}', `${name}'s body`); const rest = source.slice(close + 1); if (rest[rest.search(/\S/)] !== '{') return source.slice(start, close + 1); i = close + 1; } } +// Every assertion in this file that reads one function rather than the whole +// source rests on the slicer above, so the slicer is pinned too: a brace it +// miscounts hands the assertion under it a body that stops in the middle, and +// a negative assertion then passes on text that was never searched. +describe('the function-body slicer', () => { + it('ignores a brace written inside a comment', () => { + const line = [ + 'function sample() {', + '\t// a stray } in a line comment', + '\tconst kept = 1;', + '}', + 'const after = 2;' + ].join('\n'); + expect(fnBody(line, 'sample')).toContain('const kept = 1;'); + expect(fnBody(line, 'sample')).not.toContain('const after'); + + const block = [ + 'function sample() {', + '\t/* a stray } and a { in a block comment', + '\t that runs over two lines */', + '\tconst kept = 1;', + '}', + 'const after = 2;' + ].join('\n'); + expect(fnBody(block, 'sample')).toContain('const kept = 1;'); + expect(fnBody(block, 'sample')).not.toContain('const after'); + }); + + it('ignores a brace written inside a string', () => { + const strings = [ + 'function sample() {', + "\tconst quoted = '}';", + '\tconst template = `${quoted} {`;', + '\tconst kept = 1;', + '}', + 'const after = 2;' + ].join('\n'); + expect(fnBody(strings, 'sample')).toContain('const kept = 1;'); + expect(fnBody(strings, 'sample')).not.toContain('const after'); + }); + + // The reason the scan is bounded: run off the end and it used to read past + // the string entirely, comparing undefined forever. + it('says so when the closing brace never comes', () => { + expect(() => fnBody('function sample() {\n\tconst unclosed = 1;\n', 'sample')).toThrow( + /no closing \} for sample's body/ + ); + expect(() => fnBody('const x = 1;\n', 'sample')).toThrow(/no function named sample/); + // And the other way the scan can walk off the end: the opening delimiter + // never arrives at all, which is not a missing closer. + expect(() => fnBody('function sample()\n', 'sample')).toThrow( + /no \{ for sample's body in source/ + ); + }); + + // The case the slicer exists for: a multi-line return type annotation is a + // balanced brace span that another `{` follows, and the body is the one + // nothing follows. + it('takes the body rather than a multi-line return type', () => { + const annotated = [ + 'function sample(): {', + '\tfield: string;', + '} {', + '\tconst kept = 1;', + '}' + ].join('\n'); + expect(fnBody(annotated, 'sample')).toContain('const kept = 1;'); + }); +}); + describe('lookup button and its disclosure hint', () => { it('offers the button only when a key is configured, on both pages', () => { for (const source of [UPLOAD, EDIT]) { @@ -254,7 +381,9 @@ describe('the panel', () => { // and that first content is the "lookup has started" message. The region is // in the DOM from the first render; the state branch is inside it. it('keeps the live region mounted while idle, collapsed to nothing', () => { - expect(PANEL).toMatch(/class:idle=\{lookup\.kind === 'idle'\}/); + // Collapsed only while the idle arm has nothing to draw: with a cleared + // sentence in it, the padless card would put that line against the edge. + expect(PANEL).toMatch(/class:idle=\{lookup\.kind === 'idle' && !movedEmptied\}/); expect(PANEL).toMatch( /
\s*\{#if lookup\.kind !== 'idle'\}/ ); @@ -263,6 +392,41 @@ describe('the panel', () => { expect(PANEL).not.toMatch(/\.lookup-panel\.idle \{[^}]*display: none/); }); + // A parent move onto a tile that was never looked up empties the two shared + // fields, and the idle arm drew nothing: the announcement was the whole + // telling, so a sighted operator watched the fields go blank with no reason + // anywhere on screen (4.1.3). + it('draws the cleared sentence in the idle arm too, spoken by the panel', () => { + expect(PANEL).toMatch( + /\{:else if movedEmptied\}(?:\s|)*

\{movedEmptied\}<\/p>/ + ); + // The same sentence the searching arm draws, off the one chooser: a second + // source could name a different field. + expect(PANEL).toMatch(/const movedEmptied = \$derived\(clearedLine\(cleared, edited\)\);/); + // And NOT aria-hidden: the paragraph lands inside the panel's own status + // region, which speaks it the way it speaks every other arm, so the move + // needs no announcement of its own and the sentence stays in the + // accessibility tree rather than being spoken and gone. + expect(PANEL).not.toMatch(/class="lookup-status lookup-emptied" aria-hidden/); + expect(PANEL).toMatch(/

/); + // Which is why the radio path says nothing at all. + expect(fnBody(UPLOAD, 'pickParent')).not.toContain('announcer.say('); + }); + + // The same card with no way out of it: the actions row was gated on a lookup + // that was not idle, so this arm drew a bordered card holding the sentence + // and nothing to dismiss it with, while every other arm that draws that + // sentence offers Close or Cancel (SONA-220). + it('gives the idle cleared card a Close button', () => { + expect(PANEL).toMatch(/\{#if lookup\.kind !== 'idle' \|\| movedEmptied\}\s*
/); + // Close alone — nothing is running and nothing has settled, so there is + // nothing to retry, apply or cancel — and on the same onclose the settled + // arms use, which drops the record and collapses the panel. + expect(PANEL).toMatch( + /\{#if lookup\.kind === 'idle'\}(?:\s|)* + {:else if lookup.kind === 'searching'} @@ -673,7 +699,9 @@ /* Idle: no card, no space, but the live region above still exists so the first message written into it is announced. Not display:none — a hidden - region is not a region a screen reader watches. */ + region is not a region a screen reader watches. Dropped when the idle arm + has a cleared sentence to draw, so that line sits in the card every other + arm's status line sits in rather than against a padless edge. */ .lookup-panel.idle { border: 0; padding: 0; diff --git a/src/routes/admin/images/[id]/edit/+page.svelte b/src/routes/admin/images/[id]/edit/+page.svelte index aa401a28..03625278 100644 --- a/src/routes/admin/images/[id]/edit/+page.svelte +++ b/src/routes/admin/images/[id]/edit/+page.svelte @@ -474,7 +474,10 @@ extraParents = [...extraParents, { id: clash.imageId, title: clash.title }]; } selectedParentId = String(clash.imageId); + // Idle, and without the cleared record, for the same reason closeLookup + // drops it: the idle arm draws whatever stands there. lookup = { kind: 'idle' }; + lookupCleared = {}; // The click unmounted its own button; land on the select it just set. await tick(); parentSelect?.focus(); @@ -484,6 +487,10 @@ * back to the control the lookup started from. */ function closeLookup() { lookup = { kind: 'idle' }; + // And the record of what that lookup emptied: the panel's idle arm draws + // it, so a record left standing kept the panel open as a bordered card + // holding the sentence after the operator asked for it to go (SONA-220). + lookupCleared = {}; lookupPill?.focus(); } diff --git a/src/routes/admin/upload/+page.svelte b/src/routes/admin/upload/+page.svelte index dbd45df1..e89ef8af 100644 --- a/src/routes/admin/upload/+page.svelte +++ b/src/routes/admin/upload/+page.svelte @@ -519,9 +519,9 @@ // put back (SONA-220). Without it the status line says a field was left as it // was while the operator watched it go blank (4.1.3). let sharedCleared = $state({}); - // Whether the operator has text of their own in each field. Raised on input - // to either field, recomputed against the fields in applyShared when a result - // lands, and lowered in resetSharedPrefill. See sharedEdited. + // Whether the operator has text of their own in each field. Each one rises on + // input to its own field, is recomputed against the fields in applyShared when + // a result lands, and is lowered in resetSharedPrefill. See sharedEdited. let sourceTypedIn = $state(false); let dateTypedIn = $state(false); // Not $state: nothing renders it. See takePendingCleared. @@ -536,7 +536,7 @@ // emptied puts "Sona cleared the source post URL" back over a field THEY just // emptied, and the panel re-attributes their own deletion to Sona (SONA-220). // Each latch lives exactly as long as the cleared record it speaks for: it - // rises on input to either field, and applyShared recomputes it against the + // rises on input to its own field, and applyShared recomputes it against the // fields as each result lands. See the recompute there for why. const sharedEdited = $derived({ sourcePostUrl: !sourceTagged && (sharedFilled.sourcePostUrl !== undefined || sourceTypedIn), @@ -608,10 +608,10 @@ // LAST result does go now: it is about a search that is over. if (isParent(key)) { // The move's own record goes too. Whatever it describes has already been - // told — by pickParent's announcement when the move landed on an idle - // tile, by the settled arm otherwise — and this search has changed - // nothing yet, so the searching arm about to render must not say it - // again into the panel's atomic status region (4.1.3). The one record + // told — by the panel's idle arm when the move landed on an idle tile, + // by the settled arm otherwise — and this search has changed nothing + // yet, so the searching arm about to render must not say it again into + // the panel's atomic status region (4.1.3). The one record // the searching arm does speak for is a move that landed on a search // still in flight, and no lookup can start on that tile: the guard above // returns on a searching tile rather than restarting it, which is also @@ -862,6 +862,13 @@ if (pendingCleared?.key === key) pendingCleared = null; const tile = tiles.find((t) => t.key === key); if (tile) tile.lookup = { kind: 'idle' }; + // `sharedCleared` is NOT dropped here. Cancelling ends the search, but the + // two shared fields stay blank, and that record is the only account of why + // they are (4.1.3): dropped, the panel collapsed and the sentence went with + // it, leaving the operator with two empty fields and no reason anywhere on + // screen. The panel goes to its idle arm instead, which draws the sentence + // and a Close button — and Close is what drops the record and collapses the + // card (SONA-220). } /** Undo what a previous shared prefill wrote, but only where the operator has @@ -1119,23 +1126,17 @@ } /** The Parent radio moved, or the parent tile was removed and the radio - * landed on another one. The panel under it is already mounted and its status - * region carries the sentence for every settled result, and the searching arm - * carries it too, so announcing here on either would say the same thing twice - * — the region is atomic, so the panel re-speaks whole when the sentence - * appears in it. Only a lookup that never ran draws no sentence at all, and - * then the two fields empty with nothing on screen saying why (4.1.3). + * landed on another one. Says nothing: the panel under it is already mounted + * and its status region carries the sentence in every arm — the settled ones, + * the searching one, and the idle one, which draws what the move emptied — so + * the region is what tells the operator, once, and an announcement here would + * be the same thing twice (4.1.3). * - * Gated the way `sharedLookup` is, rather than read straight off the tile: - * the panel is mounted in the new-set mode only, so outside it no arm carries - * the sentence however that tile's own lookup ended, and the announcement is - * the only telling there is. */ + * Both callers run in the new-set mode only — the radio renders under + * `{#if groupMode === 'new'}`, and the removal path checks the mode before + * calling — so there is no other mode to answer for. */ function pickParent(index: number) { - const { cleared } = onParentChanged(index); - const kind = (groupMode === 'new' ? tiles[index]?.lookup.kind : undefined) ?? 'idle'; - if (kind !== 'idle') return; - const line = clearedLine(cleared, {}); - if (line) announcer.say(line); + onParentChanged(index); } function useLookupArtist(artist: { id: number; name: string }) { @@ -1207,6 +1208,10 @@ function closeSharedLookup(options: { focus?: boolean } = {}) { const tile = parentTile; if (tile) tile.lookup = { kind: 'idle' }; + // And the record of what that lookup emptied: the idle arm draws it, so a + // record left standing kept the panel open as a bordered card holding the + // sentence after the operator asked for it to go (SONA-220). + sharedCleared = {}; if (options.focus !== false) focusLookupOrigin(); } diff --git a/tests/e2e/artist-lookup.spec.ts b/tests/e2e/artist-lookup.spec.ts index d579a69e..5d7d7dc3 100644 --- a/tests/e2e/artist-lookup.spec.ts +++ b/tests/e2e/artist-lookup.spec.ts @@ -258,6 +258,25 @@ const THREE_TAG_SUGGESTION = { const panel = (page: Page) => page.getByRole('region', { name: 'Artist lookup' }); +/** The sentence a parent move onto a tile with no lookup of its own leaves on + * screen. The panel's status region draws it and speaks it, so it is one node: + * visible, drawn as a report of what the two fields just did rather than in the + * muted colour the advice lines use, and left in the accessibility tree instead + * of spoken and gone (4.1.3). */ +async function expectMovedEmptiedSentence(page: Page) { + const emptied = panel(page) + .locator('p.lookup-status') + .filter({ + hasText: 'cleared the source post URL and commissioned date the last lookup filled' + }); + await expect(emptied).toBeVisible(); + await expect(emptied).toHaveClass(/lookup-emptied/); + await expect(emptied).not.toHaveAttribute('aria-hidden', /.*/); + // And the panel is the open card, not the collapsed idle one it would be + // with nothing to draw. + await expect(panel(page)).toBeVisible(); +} + test('without a key there is no button, only a pointer at Settings', async ({ page }) => { test.setTimeout(60_000); await adminLogin(page, PASSWORD); @@ -928,11 +947,10 @@ test.describe('with a key saved', () => { await expect(dateInput(page)).toHaveValue(''); await expect(page.locator('#source-lookup-tag')).toHaveCount(0); await expect(page.locator('#commissioned-lookup-tag')).toHaveCount(0); - // And it says so. The new parent has no result, so the panel shows nothing - // about the two fields that just emptied under the operator (4.1.3). - await expect(page.locator(LIVE_REGION)).toContainText( - "Sona cleared the source post URL and commissioned date the last lookup filled." - ); + // And it says so. The new parent has no result, so the panel's idle arm is + // what carries the sentence for the two fields that just emptied under the + // operator (4.1.3). + await expectMovedEmptiedSentence(page); }); test('a clash after a lookup says it emptied the field, not that it was empty', async ({ @@ -2583,9 +2601,10 @@ test.describe('with a key saved', () => { ); }); - // A parent with no lookup of its own draws no sentence at all: the panel under - // it is idle, and the two fields go blank with nothing on screen saying why - // (4.1.3). So the move says it, and says it once. + // A parent with no lookup of its own used to draw no sentence at all: the + // panel under it is idle, and the two fields went blank with nothing on + // screen saying why (4.1.3). The idle arm draws it now, inside the panel's + // own status region, so one node both shows the sentence and speaks it. test('a parent move onto a tile with no lookup says what it emptied', async ({ page }) => { await stubLookup(page, matchedBody()); await twoDoneTiles(page); @@ -2598,9 +2617,10 @@ test.describe('with a key saved', () => { await expect(sourceInput(page)).toHaveValue(''); await expect(dateInput(page)).toHaveValue(''); - await expect(page.locator(LIVE_REGION)).toHaveText( - "Sona cleared the source post URL and commissioned date the last lookup filled." - ); + await expectMovedEmptiedSentence(page); + // And nothing says it a second time: the page's own live region is the + // other place this sentence could come from. + await expect(page.locator(LIVE_REGION)).not.toContainText('the last lookup filled'); }); // And starting that tile's own lookup must not say it again. The searching @@ -2616,9 +2636,7 @@ test.describe('with a key saved', () => { await expect(dateInput(page)).toHaveValue('2026-03-04'); await page.locator('input[name="parentPick"]').nth(1).check(); - const spoken = - "Sona cleared the source post URL and commissioned date the last lookup filled."; - await expect(page.locator(LIVE_REGION)).toHaveText(spoken); + await expectMovedEmptiedSentence(page); // The second tile is the parent now and has never been looked up. Held // open, so the searching arm is what is on screen. @@ -2626,9 +2644,9 @@ test.describe('with a key saved', () => { await tileLookup(page).nth(1).click(); await expect(panel(page)).toContainText('Looking up'); await expect(panel(page)).not.toContainText('the last lookup filled'); - // And the announcer holds the one telling, unchanged: nothing was queued - // behind it, so nothing was said twice. - await expect(page.locator(LIVE_REGION)).toHaveText(spoken); + // And nothing was queued into the page's live region behind it either, so + // the move was never told twice. + await expect(page.locator(LIVE_REGION)).not.toContainText('the last lookup filled'); release(); @@ -3396,6 +3414,131 @@ test.describe('with a key saved', () => { await expect(page.locator('#source-lookup-tag')).toBeVisible(); }); + // Close puts the lookup back to idle, and the idle arm draws whatever that + // lookup emptied. The record left standing, Close left the panel on screen as + // a bordered card holding that sentence, with no way to dismiss it (SONA-220). + test('closing a lookup that emptied the fields collapses the panel', async ({ page }) => { + await stubLookup(page, matchedBody()); + await oneDoneTile(page); + await pill(page).click(); + await expect(sourceInput(page)).toHaveValue(POST_URL); + await expect(dateInput(page)).toHaveValue('2026-03-04'); + + // A no-match is a result with nothing to prefill, so it empties both fields + // and the panel says so. + await stubLookup(page, { enabled: true, matches: [] }); + await pill(page).click(); + await expect(sourceInput(page)).toHaveValue(''); + await expect(panel(page)).toContainText( + 'Sona cleared the source post URL and commissioned date the last lookup filled, because this lookup filled neither one.' + ); + + await panel(page).getByRole('button', { name: 'Close' }).click(); + await expect(panel(page)).toBeHidden(); + }); + + // Cancel does NOT end that way. The searching arm it ends is the one arm that + // draws a parent move's record, and the two fields the move emptied are still + // blank after the cancel: dropping the record collapsed the panel and took + // the only reason for those blank fields with it (4.1.3). The panel falls to + // its idle arm holding the sentence, and Close is the way out of it + // (SONA-220). + test('cancelling a lookup that emptied the fields keeps the sentence until Close', async ({ + page + }) => { + await stubLookup(page, matchedBody()); + await twoDoneTiles(page); + + await tileLookup(page).nth(0).click(); + await expect(sourceInput(page)).toHaveValue(POST_URL); + await expect(dateInput(page)).toHaveValue('2026-03-04'); + + // The parent moves onto the second tile while its search is still out, so + // the move empties both fields and the searching arm says so. + const release = await deferredLookup(page, matchedBody()); + await tileLookup(page).nth(1).click(); + await expect(tileLookup(page).nth(1)).toHaveAttribute('aria-busy', 'true'); + + await page.locator('input[name="parentPick"]').nth(1).check(); + await expect(sourceInput(page)).toHaveValue(''); + await expect(dateInput(page)).toHaveValue(''); + const moved = 'Sona cleared the source post URL and commissioned date the last lookup filled.'; + await expect(panel(page)).toContainText(moved); + + await panel(page).getByRole('button', { name: 'Cancel lookup' }).click(); + release(); + + // The idle arm: the sentence still on screen over the two fields it + // describes, with Close as its one action and nothing left to cancel. + await expectMovedEmptiedSentence(page); + await expect(panel(page).getByRole('button', { name: 'Cancel lookup' })).toHaveCount(0); + const close = panel(page).getByRole('button', { name: 'Close' }); + await expect(close).toBeVisible(); + // And the page's own live region does not say it a second time: the panel's + // status region is where this sentence lives. + await expect(page.locator(LIVE_REGION)).not.toContainText('the last lookup filled'); + + await close.click(); + await expect(panel(page)).toBeHidden(); + await expect(page.locator('body')).not.toContainText(moved); + }); + + // The other way into that idle card, and the one with no test on it: a parent + // move onto a tile that was never looked up. No lookup runs, no result lands, + // so the card the sentence sits in has only its own Close to go by — without + // one the operator's ways out were typing into a field or starting another + // lookup (SONA-220). + test('the idle card a parent move leaves closes on its own Close', async ({ page }) => { + await stubLookup(page, matchedBody()); + await twoDoneTiles(page); + + await tileLookup(page).nth(0).click(); + await expect(sourceInput(page)).toHaveValue(POST_URL); + await expect(dateInput(page)).toHaveValue('2026-03-04'); + + // The second tile has never been looked up, so the move empties both fields + // with no result on its way and the idle arm is what says so. + await page.locator('input[name="parentPick"]').nth(1).check(); + await expect(sourceInput(page)).toHaveValue(''); + await expect(dateInput(page)).toHaveValue(''); + await expectMovedEmptiedSentence(page); + + await panel(page).getByRole('button', { name: 'Close' }).click(); + await expect(panel(page)).toBeHidden(); + await expect(page.locator('body')).not.toContainText('the last lookup filled'); + }); + + // The edit page's two drops, which only source assertions covered: Close and + // "Add as a variant" both go idle with a record standing, and the idle arm + // draws it there too (SONA-220). + test('the edit page collapses the panel on Close too', async ({ page }) => { + await stubLookup(page, xMatchBody()); + await gotoEditHydrated(page); + await aNoMatchEmptiesWhatTheLastLookupFilled(page); + + await panel(page).getByRole('button', { name: 'Close' }).click(); + await expect(panel(page)).toBeHidden(); + }); + + test('the edit page collapses the panel when the clash becomes the parent', async ({ page }) => { + await stubLookup(page, xMatchBody()); + await gotoEditHydrated(page); + + await pill(page).click(); + await expect(sourceInput(page)).toHaveValue(X_POST); + + // The same post, now claimed by another piece: the prefill puts no URL + // back, so the field the first lookup filled empties and the panel says so. + await stubLookup(page, { ...xMatchBody(), sourceClash: sourceClash(9001, 'Clash Piece') }); + await pill(page).click(); + await expect(sourceInput(page)).toHaveValue(''); + await expect(panel(page)).toContainText('cleared the source post URL the last lookup filled'); + + await panel(page).getByRole('button', { name: 'Add as a variant' }).click(); + await expect(page.locator('select[name="parentImageId"]')).toHaveValue('9001'); + await expect(panel(page)).toBeHidden(); + }); + // parentIndex is submitted as the hidden field the server picks the parent // with, so a tile removed ahead of the parent must move it along: otherwise // the piece that saves is a different file than the shared artist, date and @@ -3435,9 +3578,8 @@ test.describe('with a key saved', () => { // Removing the parent tile itself routes through the same pickParent the // radio does, so the fields re-derive from the tile the parent pick lands on. - // The tile that lands there has no lookup of its own, so its panel draws no - // sentence and nothing on screen says why the two fields just went blank - // (4.1.3) — the removal says it, once. + // The tile that lands there has no lookup of its own, and the panel's idle arm + // is what says why the two fields just went blank (4.1.3). test('removing the parent tile re-derives the fields and says what it emptied', async ({ page }) => { @@ -3471,12 +3613,10 @@ test.describe('with a key saved', () => { await expect(dateInput(page)).toHaveValue(''); await expect(page.locator('#source-lookup-tag')).toHaveCount(0); await expect(page.locator('#commissioned-lookup-tag')).toHaveCount(0); - // Said once, and by the removal: the panel under an idle lookup carries no - // sentence to read it from. - await expect(page.locator(LIVE_REGION)).toHaveText( - "Sona cleared the source post URL and commissioned date the last lookup filled." - ); - await expect(panel(page)).not.toContainText('cleared the source post URL'); + // Said once, and by the panel: its idle arm draws the sentence and its + // status region speaks it. + await expectMovedEmptiedSentence(page); + await expect(page.locator(LIVE_REGION)).not.toContainText('the last lookup filled'); }); // In the existing-piece mode no tile is the parent: the panel is not rendered diff --git a/tests/e2e/fuzzysearch-key.spec.ts b/tests/e2e/fuzzysearch-key.spec.ts index 3c70ead7..10d17459 100644 --- a/tests/e2e/fuzzysearch-key.spec.ts +++ b/tests/e2e/fuzzysearch-key.spec.ts @@ -127,15 +127,37 @@ async function openConnectionsTab(page: Page) { }).toPass(); } -// Serial: the two tests below share one settings row, and the second one's -// starting point is the state the seed leaves behind. +// Take the key away if one is saved, leaving the section unconnected. The +// Connections tab has to be open already. Returns whether it removed anything, +// so a caller can say where the leftover came from. +async function removeSavedKey(page: Page): Promise { + if ((await removeButton(page).count()) === 0) return false; + await removeButton(page).click(); + await confirmRemoval(page); + await expect(keyInput(page)).toBeVisible({ timeout: 15_000 }); + return true; +} + +// Serial: the three tests below share one settings row, and each one's starting +// point is the state the seed leaves behind. test.describe.configure({ mode: 'serial' }); test.describe('admin settings artist lookup key', () => { + // Every test here starts from the unconnected state, whoever left the row + // otherwise. The saving test writes the shared row, so a failure part way + // through it used to hand the next run a connected section: under serial + // retries the block restarts at the unconnected-state test, which then fails + // for a reason that has nothing to do with it. Removing here rather than + // only in afterAll makes each test's starting point its own (the shape + // artist-lookup.spec.ts uses for the same row). test.beforeEach(async ({ page }) => { await adminLogin(page, PASSWORD); await page.goto('/admin/settings'); await openConnectionsTab(page); + if (await removeSavedKey(page)) { + console.warn('fuzzysearch-key: a key was saved before this test; removed it'); + await openConnectionsTab(page); + } }); test('the unconnected state discloses what leaves the site and takes a key', async ({ @@ -179,14 +201,9 @@ test.describe('admin settings artist lookup key', () => { // button (it lives in the panel's else branch) and spin to the timeout. await page.goto('/admin/settings'); await openConnectionsTab(page); - if ((await removeButton(page).count()) > 0) { - // The aborted attempt saved the key: put the section back to - // unconnected before trying again. - await removeButton(page).click(); - await confirmRemoval(page); - await expect(keyInput(page)).toBeVisible(); - await openConnectionsTab(page); - } + // The aborted attempt saved the key: put the section back to + // unconnected before trying again. + if (await removeSavedKey(page)) await openConnectionsTab(page); await page.evaluate(() => { (window as unknown as Record).__sonaSaveMarker = true; }); @@ -264,20 +281,19 @@ test.describe('admin settings artist lookup key', () => { }); // The save above writes to the SHARED seeded DB. If anything between it and the -// final Remove fails, the key stays saved: the CI retry restarts this serial -// block at the unconnected-state test, which then fails for the wrong reason, -// and every later spec sees a connected section. Put the row back the way this -// file found it (legal.spec.ts carries the same guard for privacyPolicy). +// final Remove fails, the key stays saved and every later spec sees a connected +// section — the beforeEach only covers the tests in this file. Put the row back +// the way this file found it (legal.spec.ts carries the same guard for +// privacyPolicy). test.afterAll(async ({ browser }) => { const page = await browser.newPage(); try { await adminLogin(page, PASSWORD); await page.goto('/admin/settings'); await openConnectionsTab(page); - if ((await removeButton(page).count()) === 0) return; - await removeButton(page).click(); - await confirmRemoval(page); - await expect(keyRecord(page)).toHaveCount(0); + if (await removeSavedKey(page)) { + console.warn('fuzzysearch-key: the serial chain left the key behind; removed it here'); + } } finally { await page.close(); }