From 7f83ea60a674ffe3daa0598fb44cefad10844242 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:42:59 -0700 Subject: [PATCH 1/7] fix(admin): five leftovers from the SONA-220 review The markup test's function-body slicer counted braces written inside comments and strings, and scanned to the end of the file when the depth never came back to zero. It now steps over line comments, block comments and string literals, and throws a message naming the function when the closing brace never arrives. Four cases cover it. The FuzzySearch key spec left the saved key behind for the test after it: a failure part way through the saving test handed the serial retry a connected section, which fails the unconnected-state test for a reason that has nothing to do with it. Each test now removes the key before it starts, the way artist-lookup.spec.ts already does for the same row, and the afterAll still puts the row back for the other spec files. pickParent read the tile's lookup kind through a groupMode test that had no second answer to give: both callers run in the new-set mode only. The branch is gone and the kind comes off the tile. The lookup panel's idle arm drew nothing after a parent move onto a tile that was never looked up, so the two shared fields went blank with only the announcement saying why. It now draws the sentence the searching arm draws, aria-hidden so the panel's status region does not repeat what pickParent just announced, and the panel keeps its card while that line is up. The latch comment said each latch rises on input to either field. Each one rises on input to its own field. --- src/lib/artist-lookup-markup.test.ts | 179 +++++++++++++++++--- src/lib/components/ArtistLookupPanel.svelte | 17 +- src/routes/admin/upload/+page.svelte | 17 +- tests/e2e/fuzzysearch-key.spec.ts | 52 ++++-- 4 files changed, 217 insertions(+), 48 deletions(-) diff --git a/src/lib/artist-lookup-markup.test.ts b/src/lib/artist-lookup-markup.test.ts index ba29189a..0ba6b7ef 100644 --- a/src/lib/artist-lookup-markup.test.ts +++ b/src/lib/artist-lookup-markup.test.ts @@ -21,34 +21,152 @@ 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. +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 of the file)` + ); +} + // 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/); + }); + + // 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 +372,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( /
{m.admin_lookup_private_notice()}
{/if} + {:else if movedEmptied} + + {/if}\{movedEmptied\}<\/p>/ ); - // The same sentence the searching arm draws and pickParent announces, off - // the one chooser: a second source could name a different field. + // 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\)\);/); - // aria-hidden is the whole reason the announcement can stay: the paragraph - // lands inside the panel's own status region, which would otherwise say it - // a second time. + // 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(/
{movedEmptied}
{/if}