diff --git a/messages/en.json b/messages/en.json index 9acdf946..47330f15 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1980,9 +1980,16 @@ "admin_lookup_status_date_only": "Sona filled the commissioned date from the {site} post and left the source post URL as it was. You can change the date before you save.", "admin_lookup_status_clash": "Sona filled the commissioned date from the {site} post and left the source post URL empty, because that post is already the source of {title}. You can change the date before you save.", "admin_lookup_status_clash_kept": "Sona filled the commissioned date from the {site} post and left your source post URL as it was, because that post is already the source of {title}. You can change the date before you save.", + "admin_lookup_status_clash_emptied": "Sona cleared the source post URL the last lookup filled, because the {site} post Sona found is already the source of {title}. You can type one in before you save.", + "admin_lookup_status_clash_date_url_emptied": "Sona filled the commissioned date from the {site} post and cleared the source post URL the last lookup filled, because the post Sona just found is already the source of {title}. You can change the date before you save.", "admin_lookup_status_url_kept": "Sona filled the source post URL from the {site} post. You can change it before you save.", "admin_lookup_status_date_kept": "Sona filled the commissioned date from the {site} post. You can change it before you save.", - "admin_lookup_status_kept": "Sona filled the commissioned date from the {site} post and left your source post URL as it was.", + "admin_lookup_status_kept": "Sona filled the commissioned date from the {site} post and left your source post URL as it was. You can change the date before you save.", + "admin_lookup_status_url_and_date_emptied": "Sona filled the source post URL from the {site} post and cleared the commissioned date the last lookup filled, because that post has no date. You can change the URL before you save.", + "admin_lookup_status_date_and_url_emptied": "Sona filled the commissioned date from the {site} post and cleared the source post URL the last lookup filled, because the result Sona found has no link to put there. You can change the date before you save.", + "admin_lookup_status_both_emptied": "Sona cleared the source post URL and commissioned date the last lookup filled, because this lookup filled neither one. You can fill them in before you save.", + "admin_lookup_status_url_emptied": "Sona cleared the source post URL the last lookup filled, because this lookup filled nothing in its place. You can type one in before you save.", + "admin_lookup_status_date_emptied": "Sona cleared the commissioned date the last lookup filled, because this lookup filled no date in its place. You can set one before you save.", "admin_lookup_status_artist_hint": "Sets the artist to {name}.", "admin_lookup_status_seed_both": "Sona filled the new artist's name and {site} link. They're a guess until you check them, and you're the one publishing them.", "admin_lookup_status_seed_name": "Sona filled the new artist's name. It's a guess until you check it, and you're the one publishing it.", @@ -2032,8 +2039,11 @@ "admin_lookup_announce_new_form": "Switched to the new artist form. Add the artist by hand.", "admin_lookup_announce_form_already_open": "The new artist form is already open.", "admin_lookup_announce_seed_kept": "The new artist's fields already have values, so Sona left them alone.", - "admin_lookup_announce_searching_cleared": "Sona cleared the fields the last lookup filled.", + "admin_lookup_announce_searching_cleared": "Sona cleared the new artist details the last lookup filled.", "admin_lookup_announce_shared_refilled": "Sona filled the shared fields from the parent image's result.", "admin_lookup_announce_shared_refilled_source": "Sona filled the source post URL from the parent image's result.", - "admin_lookup_announce_shared_refilled_date": "Sona filled the commissioned date from the parent image's result." + "admin_lookup_announce_shared_refilled_date": "Sona filled the commissioned date from the parent image's result.", + "admin_lookup_announce_shared_cleared": "Sona cleared the source post URL and commissioned date the last lookup filled.", + "admin_lookup_announce_shared_cleared_source": "Sona cleared the source post URL the last lookup filled.", + "admin_lookup_announce_shared_cleared_date": "Sona cleared the commissioned date the last lookup filled." } diff --git a/messages/ja.json b/messages/ja.json index cffc0511..45a006b5 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -1579,9 +1579,16 @@ "admin_lookup_status_date_only": "{site}の投稿から制作依頼日を入力しました。投稿元URLはそのままにしています。保存前に日付を変更できます。", "admin_lookup_status_clash": "{site}の投稿から制作依頼日を入力しました。見つかった投稿はすでに「{title}」の投稿元になっているため、投稿元URLは空のままにしています。保存前に日付を変更できます。", "admin_lookup_status_clash_kept": "{site}の投稿から制作依頼日を入力しました。見つかった投稿はすでに「{title}」の投稿元になっているため、入力済みの投稿元URLはそのままにしています。保存前に日付を変更できます。", + "admin_lookup_status_clash_emptied": "見つかった{site}の投稿はすでに「{title}」の投稿元になっているため、前回の検索で入力した投稿元URLは消去しました。保存前に入力できます。", + "admin_lookup_status_clash_date_url_emptied": "{site}の投稿から制作依頼日を入力しました。見つかった投稿はすでに「{title}」の投稿元になっているため、前回の検索で入力した投稿元URLは消去しました。保存前に日付を変更できます。", "admin_lookup_status_url_kept": "{site}の投稿から投稿元URLを入力しました。保存前に変更できます。", "admin_lookup_status_date_kept": "{site}の投稿から制作依頼日を入力しました。保存前に変更できます。", - "admin_lookup_status_kept": "{site}の投稿から制作依頼日を入力しました。入力済みの投稿元URLはそのままにしています。", + "admin_lookup_status_kept": "{site}の投稿から制作依頼日を入力しました。入力済みの投稿元URLはそのままにしています。保存前に日付を変更できます。", + "admin_lookup_status_url_and_date_emptied": "{site}の投稿から投稿元URLを入力しました。その投稿には日付がないため、前回の検索で入力した制作依頼日は消去しました。保存前にURLを変更できます。", + "admin_lookup_status_date_and_url_emptied": "{site}の投稿から制作依頼日を入力しました。見つかった検索結果には入れられるリンクがないため、前回の検索で入力した投稿元URLは消去しました。保存前に日付を変更できます。", + "admin_lookup_status_both_emptied": "今回の検索はどちらの項目も入力しなかったため、前回の検索で入力した投稿元URLと制作依頼日は消去しました。保存前にどちらも入力できます。", + "admin_lookup_status_url_emptied": "今回の検索は代わりの値を入力しなかったため、前回の検索で入力した投稿元URLは消去しました。保存前に入力できます。", + "admin_lookup_status_date_emptied": "今回の検索は代わりの日付を入力しなかったため、前回の検索で入力した制作依頼日は消去しました。保存前に入力できます。", "admin_lookup_status_artist_hint": "アーティストに{name}を設定します。", "admin_lookup_status_seed_both": "新しいアーティストの名前と{site}のリンクを入力しました。確認するまでは推測にすぎず、公開するのはあなたです。", "admin_lookup_status_seed_name": "新しいアーティストの名前を入力しました。確認するまでは推測にすぎず、公開するのはあなたです。", @@ -1631,8 +1638,11 @@ "admin_lookup_announce_new_form": "新しいアーティストのフォームに切り替えました。アーティストを手で入力してください。", "admin_lookup_announce_form_already_open": "新しいアーティストのフォームはすでに開いています。", "admin_lookup_announce_seed_kept": "新しいアーティストの各項目にはすでに値が入っているため、そのままにしました。", - "admin_lookup_announce_searching_cleared": "前回の検索で入力した項目は消去しました。", + "admin_lookup_announce_searching_cleared": "前回の検索で入力した新しいアーティストの情報は消去しました。", "admin_lookup_announce_shared_refilled": "親画像の検索結果から共有項目を入力しました。", "admin_lookup_announce_shared_refilled_source": "親画像の検索結果から投稿元URLを入力しました。", - "admin_lookup_announce_shared_refilled_date": "親画像の検索結果から制作依頼日を入力しました。" + "admin_lookup_announce_shared_refilled_date": "親画像の検索結果から制作依頼日を入力しました。", + "admin_lookup_announce_shared_cleared": "前回の検索で入力した投稿元URLと制作依頼日は消去しました。", + "admin_lookup_announce_shared_cleared_source": "前回の検索で入力した投稿元URLは消去しました。", + "admin_lookup_announce_shared_cleared_date": "前回の検索で入力した制作依頼日は消去しました。" } diff --git a/src/app.css b/src/app.css index 10531b60..589f54c1 100644 --- a/src/app.css +++ b/src/app.css @@ -552,13 +552,74 @@ select.input { flex-wrap: wrap; } -@media (max-width: 640px) { - /* Wrapped onto its own line, the note lines up under the label text rather - than under the checkbox. */ +/* Where this row breaks is the COLUMN's business, not the window's: the edit + form's column is 219px wide in an 800px window, so a viewport query left the + whole middle of the range wrapping to the checkbox's edge. */ +@container admin-form not (min-width: 560px) { + /* Wrapped onto its own line, each of these lines up under the label text + rather than under the checkbox. CSS cannot ask whether an item wrapped, so + the indent goes by column width alone, as the note has always done. */ .tag-check-row .tag-rating-note, + .tag-check-row .rating-tag, .tag-check-row .btn { margin-left: 24px; } + + /* Stacked rather than wrapped, which is what the mocks draw. Wrapping put the + 24px indent on items that had not wrapped, so an inline row carried 32px + gaps; stacking makes the indent true of every item that has it. */ + .tag-check-row { + flex-direction: column; + align-items: flex-start; + } +} + +/* The lookup's rating pill (SONA-156) — both admin forms and the upload tiles + hold the same one. A fixed radius, not the pill token: wrapped to three lines + the token clamps to half the box height and the end caps' arc lands inside the + text's own inset. */ +.rating-tag { + font-family: var(--font-primary); + font-size: 11px; + color: var(--muted-foreground); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1px 8px; + /* The text grows with the number of sites, so the pill wraps inside whatever + holds it rather than pushing the document into a sideways scroll. */ + white-space: normal; + overflow-wrap: break-word; + max-width: 100%; +} + +/* With room for all four, the two rating items give up the difference instead + of pushing "Mark it NSFW" onto a second row: flex breaks a line on content + size, so a zero basis takes them out of that decision and the floor keeps a + narrow column from shrinking them to a character per line. */ +@container admin-form (min-width: 560px) { + .tag-check-row .rating-tag, + .tag-check-row .tag-rating-note { + flex: 1 1 0; + min-width: 14ch; + max-width: max-content; + } + + /* Three gaps at 8px against an 8px shortfall: the edit form's row wants 608px + in a 600px column, and 4px buys back the 12px that keeps all four on one + line. The wider gap stays wherever the row has room for it. */ + .tag-check-row { + gap: 4px; + } + + /* The 4px above is the gap BETWEEN the two rating items, which report the + same kind of thing and belong together. The action after them is a + different kind of thing, so it takes some of that separation back. 4px, not + the 8px the row would read better with: the edit form's column has about + 6px of slack at 1280, and 8px spent here wrapped "entail.dev." onto a + second line — which is the shortfall the narrower gap was paying off. */ + .tag-check-row .btn { + margin-left: 4px; + } } /* The pill that opens a lookup — the outline capsule the "Add New Artist" diff --git a/src/lib/artist-lookup-markup.test.ts b/src/lib/artist-lookup-markup.test.ts index 5905fe8c..ba29189a 100644 --- a/src/lib/artist-lookup-markup.test.ts +++ b/src/lib/artist-lookup-markup.test.ts @@ -12,8 +12,42 @@ const read = (path: string) => readFileSync(path, 'utf8'); const UPLOAD = read('src/routes/admin/upload/+page.svelte'); const EDIT = read('src/routes/admin/images/[id]/edit/+page.svelte'); const PANEL = read('src/lib/components/ArtistLookupPanel.svelte'); +// The status sentences moved off the panel's markup and into this module, so +// the panel and the upload page's announcement read the same mapping (SONA-220). +const LOOKUP = read('src/lib/artist-lookup.ts'); const DIALOG = read('src/lib/components/NewArtistDialog.svelte'); const ANNOUNCER = read('src/lib/components/LiveAnnouncer.svelte'); +// The row the two rating pills share is styled globally, beside the +// tag-suggestion note it also holds (SONA-220). +const APP_CSS = read('src/app.css'); + +// 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. +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; + } + // 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 rest = source.slice(close + 1); + if (rest[rest.search(/\S/)] !== '{') return source.slice(start, close + 1); + i = close + 1; + } +} describe('lookup button and its disclosure hint', () => { it('offers the button only when a key is configured, on both pages', () => { @@ -511,16 +545,63 @@ describe('the "From lookup" tag', () => { ] as const) { // Nothing writes the record but the prefill and the resets. expect(source).not.toMatch(new RegExp(`${record} = \\{ \\.\\.\\.${record}`)); + // Untagged and holding something counts as edited too, so a field the + // operator has filled since it went blank stops being one the panel may + // say Sona cleared. The filled half is untouched by that arm: a status + // line naming a field needs it filled as well as untagged (SONA-220). + // + // That arm reads a LATCH rather than the text in the input. Derived from + // the text, deleting what the operator typed into an emptied field puts + // the cleared claim back and the panel re-attributes their own deletion + // to Sona, so the flag goes up on their first non-empty input and no + // later keystroke lowers it. It comes down where the cleared record it + // speaks for is replaced: at every lookup start on the edit page, and + // in applyShared on the upload page, whose parent tile has no per- + // lookup reset (see the recompute pinned below). expect(source).toMatch( new RegExp( - `const ${edited} = \\$derived\\(\\{[\\s\\S]{0,300}?sourcePostUrl: ${record}\\.sourcePostUrl !== undefined && !sourceTagged` + `const ${edited} = \\$derived\\(\\{[\\s\\S]{0,400}?sourcePostUrl: !sourceTagged && \\(${record}\\.sourcePostUrl !== undefined \\|\\| sourceTypedIn\\)` ) ); expect(source).toMatch( new RegExp( - `const ${edited} = \\$derived\\(\\{[\\s\\S]{0,300}?commissionedAt: ${record}\\.commissionedAt !== undefined && !dateTagged` + `const ${edited} = \\$derived\\(\\{[\\s\\S]{0,400}?commissionedAt: !dateTagged && \\(${record}\\.commissionedAt !== undefined \\|\\| dateTypedIn\\)` ) ); + // Raised on the operator's own input, off the event rather than the + // bound value, and never lowered by a later keystroke. + expect(source).toMatch( + /oninput=\{[\s\S]{0,400}?sourceTagged = false;[\s\S]{0,300}?if \(event\.currentTarget\.value\.trim\(\) !== ''\) sourceTypedIn = true;/ + ); + expect(source).toMatch( + /oninput=\{[\s\S]{0,400}?dateTagged = false;[\s\S]{0,300}?if \(event\.currentTarget\.value\.trim\(\) !== ''\) dateTypedIn = true;/ + ); + // And lowered beside the record at EVERY site that resets it, so the two + // can never speak for different results. Counted rather than matched + // once: an alternation that passes on one file's single reset site says + // nothing about the edit page's second one, which could drop its latch + // lines unnoticed (SONA-220). + const clearedRecord = source === UPLOAD ? 'sharedCleared' : 'lookupCleared'; + // Every site that resets it beside what a result wrote, which is every + // one but startLookup's own drop: that one clears a sentence the page + // has already spoken, touches nothing the result filled, and must NOT + // lower the latches — they carry text the operator typed before the + // search, which the result still has to be told about. Cut out by + // slicing that function away rather than by a lookbehind on the line: + // the drop is a bare statement, so nothing on the line itself tells it + // from the resets that do count. The edit page's two reset sites both + // sit outside its startLookup, so the slice costs that file nothing. + const counted = source.replace(fnBody(source, 'startLookup'), ''); + const resets = counted.match(new RegExp(`${clearedRecord} = \\{\\};`, 'g')) ?? []; + const withLatches = + counted.match( + new RegExp( + `${clearedRecord} = \\{\\};\\s+sourceTypedIn = false;\\s+dateTypedIn = false;`, + 'g' + ) + ) ?? []; + expect(resets.length).toBeGreaterThan(0); + expect(withLatches.length).toBe(resets.length); // And the panel is handed both halves. expect(source).toMatch(new RegExp(`filled=\\{${record}\\}\\s+edited=\\{${edited}\\}`)); } @@ -534,7 +615,7 @@ describe('the "From lookup" tag', () => { // cannot drop a field it did not write. It still cannot carry a PREVIOUS // result's seed: applyPrefill empties it before the new one is described. expect(EDIT).toMatch( - /function applyPrefill\([\s\S]{0,300}?lookupSeeded = \{\};/ + /function applyPrefill\([\s\S]{0,1200}?lookupSeeded = \{\};/ ); expect(EDIT).toMatch( /const lookupSeedEdited = \$derived\(\{[\s\S]{0,300}?artistName: lookupSeeded\.artistName !== undefined && !nameTagged/ @@ -555,11 +636,13 @@ describe('the "From lookup" tag', () => { // mentioned at all, and the region is atomic so the change is heard once per // edited field rather than once per keystroke. it('has a sentence that claims only the field still attributable', () => { - expect(PANEL).toContain("statusKind === 'url_kept'"); - expect(PANEL).toContain("statusKind === 'date_kept'"); - expect(PANEL).toContain('m.admin_lookup_status_url_kept('); - expect(PANEL).toContain('m.admin_lookup_status_date_kept('); - expect(PANEL).toMatch(/statusLineKind\(filled, \{ clash: !!clash, edited, urlHeld: sourceUrlHeld \}\)/); + expect(LOOKUP).toContain("case 'url_kept':"); + expect(LOOKUP).toContain("case 'date_kept':"); + expect(LOOKUP).toContain('m.admin_lookup_status_url_kept('); + expect(LOOKUP).toContain('m.admin_lookup_status_date_kept('); + expect(PANEL).toMatch( + /statusLineKind\(filled, \{ clash: !!clash, edited, urlHeld: sourceUrlHeld, cleared \}\)/ + ); expect(PANEL).toMatch(/seedStatusKind\(seeded, seedEdited\)/); }); @@ -610,7 +693,7 @@ describe('the "From lookup" tag', () => { // duplicate artist and re-credit the piece on the next save. describe('the artist on the edit page', () => { it('never switches to the inline new-artist form without a click', () => { - const applyPrefill = EDIT.match(/function applyPrefill\([\s\S]*?\n\t\}/)?.[0] ?? ''; + const applyPrefill = fnBody(EDIT, 'applyPrefill'); expect(applyPrefill).toMatch(/lookupFilled = fields/); expect(applyPrefill).not.toMatch(/artistMode/); expect(applyPrefill).not.toMatch(/seedNewArtist/); @@ -626,11 +709,11 @@ describe('the artist on the edit page', () => { // nothing, and leave a "From lookup" tag on a value from the other post. it('undoes the previous lookup before running another one', () => { expect(EDIT).toMatch(/function startLookup\(\)[\s\S]{0,400}?resetLookupPrefill\(\);/); - const reset = EDIT.match(/function resetLookupPrefill\(\)[\s\S]*?\n\t\}/)?.[0] ?? ''; - // Only what the lookup itself wrote: the tag is the record of that. + const reset = fnBody(EDIT, 'resetLookupPrefill'); + // Only what the lookup itself wrote: the tag is the record of that. The + // inline new-artist fields go at the click; the two prefilled fields wait + // for the result, which is the only thing that can refill them (SONA-220). for (const [tag, field] of [ - ['sourceTagged', 'sourcePostUrl'], - ['dateTagged', 'commissionedAt'], ['nameTagged', 'artistName'], ['twitterTagged', 'newTwitter'], ['furaffinityTagged', 'newFuraffinity'] @@ -638,8 +721,19 @@ describe('the artist on the edit page', () => { expect(reset).toMatch(new RegExp(`if \\(${tag}\\) ${field} = '';`)); expect(reset).toMatch(new RegExp(`${tag} = false;`)); } + expect(reset).not.toMatch(/sourceTagged|dateTagged/); expect(reset).toMatch(/lookupFilled = \{\};/); expect(reset).toMatch(/lookupSeeded = \{\};/); + // The two the reset no longer owns are still undone, by the result. + const applyPrefill = fnBody(EDIT, 'applyPrefill'); + for (const [tag, field] of [ + ['sourceTagged', 'sourcePostUrl'], + ['dateTagged', 'commissionedAt'] + ]) { + expect(applyPrefill).toMatch(new RegExp(`\\} else if \\(${tag}\\) \\{`)); + expect(applyPrefill).toMatch(new RegExp(`${field} = '';`)); + expect(applyPrefill).toMatch(new RegExp(`${tag} = false;`)); + } }); // Five of the inline form's inputs (Bluesky, Telegram, DeviantArt, Patreon, @@ -647,14 +741,14 @@ describe('the artist on the edit page', () => { // form and take anything typed in them with it. The reset leaves the mode // alone and only empties what the lookup itself filled. it('never closes the inline form the reset just emptied', () => { - const reset = EDIT.match(/function resetLookupPrefill\(\)[\s\S]*?\n\t\}/)?.[0] ?? ''; + const reset = fnBody(EDIT, 'resetLookupPrefill'); expect(reset).not.toMatch(/artistMode = /); }); // The inline new-artist form stays on screen while the reset empties the // fields the last lookup filled, so the clearing has to be spoken. it('announces the fields the reset cleared from an open new-artist form', () => { - const start = EDIT.match(/function startLookup\(\)[\s\S]*?\n\t\}/)?.[0] ?? ''; + const start = fnBody(EDIT, 'startLookup'); expect(start).toMatch( /const clearedInline =\s*\n?\s*artistMode === 'new' && \(nameTagged \|\| twitterTagged \|\| furaffinityTagged\);/ ); @@ -670,15 +764,20 @@ describe('round 11 wiring', () => { it('feeds the current source URL into the clash sentence, from both pages', () => { expect(PANEL).toContain('sourceUrlHeld'); expect(PANEL).toMatch(/statusLineKind\(filled, \{[\s\S]*?urlHeld: sourceUrlHeld[\s\S]*?\}\)/); - expect(PANEL).toContain("statusKind === 'clash_kept'"); - expect(PANEL).toContain('m.admin_lookup_status_clash_kept'); + expect(LOOKUP).toContain("case 'clash_kept':"); + expect(LOOKUP).toContain('m.admin_lookup_status_clash_kept'); // A snapshot taken when the prefill ran, not a live read of the field: the // sentence describes what the lookup did once, and clearing a pasted URL // afterwards would otherwise flip it to "Sona left it empty". for (const source of [UPLOAD, EDIT]) { expect(source).toMatch(/sourceUrlHeld=\{(shared|lookup)UrlHeld\}/); expect(source).not.toContain("sourceUrlHeld={sourcePostUrl.trim() !== ''}"); - expect(source).toMatch(/(shared|lookup)UrlHeld = sourcePostUrl\.trim\(\) !== '';/); + // Both pages read the value the operator OWNS rather than the raw field: + // a URL their own last prefill wrote is not held by anybody, and that one + // is no longer blanked before the result lands. + // The upload page names that test, because its cleared seed reads it too; + // the edit page has the one reader and spells it inline. + expect(source).toMatch(/(shared|lookup)UrlHeld = (ownSource\.trim\(\) !== ''|sourceHeld);/); } }); @@ -686,7 +785,8 @@ describe('round 11 wiring', () => { // the sentence goes away as soon as the operator edits the field the lookup // filled — which says nothing about whether the artist is still unapplied. it('renders the artist hint on its own condition, not the status line\'s', () => { - const status = PANEL.match(/\{#if statusKind !== 'none' && prefill\}[\s\S]*?\{\/if\}/)?.[0] ?? ''; + const status = PANEL.match(/\{:else if statusText\}[\s\S]*?\{\/if\}/)?.[0] ?? ''; + expect(status).toContain('{statusText}'); expect(status).not.toContain('admin_lookup_status_artist_hint'); expect(PANEL).toMatch( /const artistHintShown = \$derived\(\s*\n?\s*editMode && outcome === 'existing' && !appliedArtist && !!candidates\[0\]/ @@ -735,7 +835,7 @@ describe('round 11 wiring', () => { // cleared the name, then clicked again) is the same retraction one field // narrower, which is why the record merges rather than being replaced. it('keeps the seed record when a repeat seed writes nothing', () => { - const seedFn = EDIT.match(/function seedNewArtist\([\s\S]*?\n\t\}/)?.[0] ?? ''; + const seedFn = fnBody(EDIT, 'seedNewArtist'); expect(seedFn).toMatch(/lookupSeeded = \{ \.\.\.lookupSeeded, \.\.\.seed \};/); expect(seedFn).not.toMatch(/^\t\tlookupSeeded = seed;$/m); }); @@ -744,7 +844,7 @@ describe('round 11 wiring', () => { // "did THIS click write anything" — read for the announcement, it reports the // first click's work as this one's and the second click says nothing at all. it('announces the repeat add-new click from what that click wrote', () => { - const seedFn = EDIT.match(/function seedNewArtist\([\s\S]*?\n\t\}/)?.[0] ?? ''; + const seedFn = fnBody(EDIT, 'seedNewArtist'); expect(seedFn).toMatch(/\}\): NewArtistSeed \{|linkable: boolean\): NewArtistSeed \{/); expect(seedFn).toMatch(/\n\t\treturn seed;\n/); expect(EDIT).toMatch( @@ -769,7 +869,7 @@ describe('round 11 wiring', () => { /appliedArtist=\{artistMode === 'existing' \? appliedArtist : null\}/ ); expect(EDIT).toMatch(/seeded=\{artistMode === 'new' \? lookupSeeded : \{\}\}/); - const use = EDIT.match(/function useLookupArtist\([\s\S]*?\n\t\}/)?.[0] ?? ''; + const use = fnBody(EDIT, 'useLookupArtist'); expect(use).toMatch(/appliedArtist = artist;/); // The record and the tags survive the click, so the fields, their tags, // and the sentence come back together when the operator flips back. @@ -783,7 +883,7 @@ describe('round 11 wiring', () => { // an artist that now exists, and the second click creates a duplicate row: // POST /api/artists enforces no name uniqueness on a non-registry create. it('folds a created artist back into the result on the upload page', () => { - const created = UPLOAD.match(/function onArtistCreated\([\s\S]*?\n\t\}/)?.[0] ?? ''; + const created = fnBody(UPLOAD, 'onArtistCreated'); expect(created).toContain('withCreatedArtist(tile.lookup.data, artist)'); expect(created).toMatch(/tile\.lookup\.kind !== 'results'/); // Only an artist the LOOKUP asked for. The standalone "+ Add New Artist" @@ -878,6 +978,266 @@ describe('what the lookup copy names', () => { } }); + // The mirror of those three. Moving the parent empties the two fields the + // last lookup filled, and a parent with no result of its own puts nothing in + // the panel to say so (4.1.3). + it('names the cleared shared fields in both catalogs, and says them out loud', () => { + expect(ja.admin_lookup_announce_shared_cleared).toBeTruthy(); + expect(ja.admin_lookup_announce_shared_cleared_source).toContain('投稿元URL'); + expect(ja.admin_lookup_announce_shared_cleared_date).toContain('制作依頼日'); + for (const catalog of [en, ja]) { + const sentences = [ + catalog.admin_lookup_announce_shared_cleared, + catalog.admin_lookup_announce_shared_cleared_source, + catalog.admin_lookup_announce_shared_cleared_date + ]; + expect(new Set(sentences).size).toBe(3); + } + // Both catalogs say it the way the status lines already do, and all six are + // pinned whole. Naming the parent image was wrong on the other path this + // copy covers — a return to the new-set mode clears what the SAME tile's + // earlier lookup filled — and a clause-level assertion on one key while + // its siblings were pinned in full is how the two languages drifted into + // describing different things without a test noticing (SONA-220). + expect(en.admin_lookup_announce_shared_cleared).toBe( + 'Sona cleared the source post URL and commissioned date the last lookup filled.' + ); + expect(en.admin_lookup_announce_shared_cleared_source).toBe( + 'Sona cleared the source post URL the last lookup filled.' + ); + expect(en.admin_lookup_announce_shared_cleared_date).toBe( + 'Sona cleared the commissioned date the last lookup filled.' + ); + expect(ja.admin_lookup_announce_shared_cleared).toBe( + '前回の検索で入力した投稿元URLと制作依頼日は消去しました。' + ); + expect(ja.admin_lookup_announce_shared_cleared_source).toBe( + '前回の検索で入力した投稿元URLは消去しました。' + ); + expect(ja.admin_lookup_announce_shared_cleared_date).toBe( + '前回の検索で入力した制作依頼日は消去しました。' + ); + // The reset hands back what it emptied, and it goes to applyShared, which + // unsets the fields the new parent's result writes back. One writer of + // sharedCleared: a caller that recomputed the record alongside it could + // disagree with the panel about which field is blank. + expect(UPLOAD).toMatch( + /function resetSharedPrefill\(\): LookupCleared \{[\s\S]{0,400}?emptied\.sourcePostUrl = true;[\s\S]{0,300}?emptied\.commissionedAt = true;[\s\S]{0,300}?return emptied;/ + ); + expect(UPLOAD).toMatch(/applyShared\(tile\?\.lookup \?\? \{ kind: 'idle' \}, emptied\)/); + // Both bodies are brace-matched, and both are checked for something they + // DO say before being checked for what they must not: this function's + // return type spans lines, so the old `\n\t}` span ended inside the + // signature and the negative below passed over 133 characters that could + // never have held an assignment (SONA-220). + const parentBody = fnBody(UPLOAD, 'onParentChanged'); + expect(parentBody).toContain('pendingCleared ='); + expect(parentBody).not.toContain('sharedCleared ='); + const appliedShared = fnBody(UPLOAD, 'applyShared'); + expect(appliedShared).toMatch(/sharedCleared = \{ \.\.\.emptied \};/); + expect(appliedShared).toMatch(/const cleared: LookupCleared = \{ \.\.\.emptied \};/); + // The operator's own text is one named answer, which the result's own fill + // branches read too: a second copy of the untagged-and-non-empty test could + // disagree with them about whose text is in the field. The URL half is + // shared with the clash sentence's own snapshot. + expect(UPLOAD).toMatch( + /const sourceHeld = ownSource\.trim\(\) !== '';/ + ); + // And it is what the two latches are recomputed from as each result lands. + // The upload page's parent tile has no per-lookup reset, so a latch raised + // once survived every later lookup on that tile: a lookup fills the URL, + // the operator types a character and deletes it, a second lookup refills + // the field, and the stale latch then dropped the third result's cleared + // flag over a field it had just blanked (SONA-220). + expect(appliedShared).toMatch( + /sourceTypedIn = sourceHeld;\s+dateTypedIn = ownDate\.trim\(\) !== '';/ + ); + // The recompute sits at the tagging site, where the result takes the two + // fields over, so a latch and the tag it qualifies are always written for + // the same result. The edit page deliberately has none: resetLookupPrefill + // runs at every lookup start there, so no latch can outlive its result, + // and a second lowering would drop a latch the operator raised while that + // page's search was still out. + const applyPrefill = fnBody(EDIT, 'applyPrefill'); + expect(applyPrefill).toContain('sourceTagged = true;'); + expect(applyPrefill).not.toMatch(/TypedIn =/); + expect(EDIT).toMatch( + /function startLookup\(\)[\s\S]{0,600}?resetLookupPrefill\(\);/ + ); + // `emptied` is the raw record of what the caller's reset took out of the + // fields, and it is spread as it came in at both sites above. It is NOT + // re-checked against the fields here: the latch above carries the + // operator's text into `sharedEdited`, which the status line applies where + // the sentence speaks about the screen, and erasing the raw emptied fact + // instead routed a date the move emptied and the operator retyped to + // "left the commissioned date as it was" (SONA-220). + expect(appliedShared).not.toMatch(/emptied\.sourcePostUrl = (?:true|false)/); + expect(appliedShared).not.toMatch(/emptied\.commissionedAt = (?:true|false)/); + expect(UPLOAD).toMatch(/sharedUrlHeld = sourceHeld;/); + // And nothing else writes sharedCleared. The catch that synthesises a + // failure used to assign the held record straight, which skipped the check + // above and had the panel report a URL the operator had typed back in. + // Five assignments in the file and no more: the declaration, two blankings + // that hand over no record — startLookup's and the reset's — and + // applyShared's two. Everything that DESCRIBES a clearing goes through + // applyShared. + expect(UPLOAD.match(/sharedCleared = [^;]*/g) ?? []).toEqual([ + 'sharedCleared = $state({})', + 'sharedCleared = {}', + 'sharedCleared = {}', + 'sharedCleared = { ...emptied }', + 'sharedCleared = cleared' + ]); + expect(UPLOAD).toMatch(/if \(isParent\(key\)\) applyShared\(failed, emptied\);/); + // The record dies with the request it was waiting for, in BOTH arms of + // that request and whatever the tile's parent status is by then: each arm + // takes it once, before the branch that asks whether this tile is the + // parent. Taken only on the parent branch, a flip into the existing-piece + // mode mid-flight left it behind, and the next lookup on that tile + // consumed it and re-reported a clearing announced several steps earlier + // (SONA-220). + const startBody = fnBody(UPLOAD, 'startLookup'); + // And a lookup started on the parent tile drops the move's record on the + // way in, where nothing else would, whatever that tile's last lookup came + // to. Whatever the record describes has been told already — by pickParent + // on an idle tile, by the settled arm on any other — and this search has + // changed nothing yet, so the searching arm about to render must not put + // the same sentence in the panel's atomic status region (4.1.3). Gated on + // the tile being idle, a plain repeat lookup after a no-match carried the + // no-match's record into the searching arm and said it a second time. + expect(startBody).toMatch( + /if \(isParent\(key\)\) \{[\s\S]{0,900}?\n\t\t\tsharedCleared = \{\};\s+resetSharedResult\(\);\s+\}/ + ); + // The one record the searching arm does speak for — a parent move that + // landed on a search still in flight — is out of this drop's reach because + // no lookup can start on a searching tile at all. The guard returns rather + // than restarting, which is the same fact takePendingCleared rests on. + expect(startBody).toMatch( + /if \(!tile \|\| !tile\.file \|\| tile\.lookup\.kind === 'searching'\) return;/ + ); + expect(startBody.match(/const emptied = takePendingCleared\(key\);/g) ?? []).toHaveLength(2); + expect(startBody).toMatch(/const emptied = takePendingCleared\(key\);\s+if \(isParent\(key\)\) applyShared\(next, emptied\);/); + // And neither arm drops it by hand any more: takePendingCleared is the one + // place the record is read and cleared together. + expect(startBody).not.toContain('pendingCleared = null'); + // Those two arms and the cancel are the whole account of a record's life, + // because a tile holds one only while it is SEARCHING and startLookup + // returns on a searching tile — it never aborts the request a record is + // waiting for and leaves the record behind for the lookup after. + expect(startBody).toMatch( + /if \(!tile \|\| !tile\.file \|\| tile\.lookup\.kind === 'searching'\) return;\s+lookupAborts\.get\(key\)\?\.abort\(\);/ + ); + expect(UPLOAD).toMatch( + /pendingCleared = tile\?\.lookup\.kind === 'searching' \? \{ key: tile\.key, emptied \} : null;/ + ); + // A cancelled search is never coming back, so the record it was holding + // goes with it: left behind, the NEXT lookup on that tile consumes it and + // reports fields that have been blank the whole time. + expect(UPLOAD).toMatch( + /function cancelLookup\(key: number\) \{[\s\S]{0,600}?if \(pendingCleared\?\.key === key\) pendingCleared = null;/ + ); + // The move itself says nothing: one tick holds one line, so the caller + // picks it. Each of the three callers says at most one. + expect(UPLOAD).not.toMatch(/function onParentChanged\([\s\S]{0,800}?announcer\.say/); + for (const name of ['pickParent', 'returnToNewSet']) { + const body = fnBody(UPLOAD, name); + // One chain, so however many sentences it can choose between, a tick + // reaches exactly one of them: two say() calls in a tick leave the region + // holding the second, which is how the clearing went unspoken. pickParent + // ends on one line held in a variable, returnToNewSet on an else-if chain + // that ends the same way — either shape says at most one thing. + expect(body).toMatch(/announcer\.say/); + expect(body).toMatch( + /const line = clearedLine\(cleared, \{\}\);\s+if \(line\) announcer\.say\(line\);/ + ); + } + // One chain for what a move emptied, in the shared module rather than on + // the page: the panel's searching arm renders the same three sentences + // while the result is still out, and a copy on the page could disagree + // with it about which field to name. + expect(LOOKUP).toMatch( + /export function clearedLine\(cleared: LookupCleared, edited: LookupEdited\): string \| null \{[\s\S]{0,600}?return null;/ + ); + expect(UPLOAD).not.toContain('function clearedLine('); + // The mixed case — one field refilled, the other emptied — borrows the + // panel's own sentence rather than saying a refill line and a cleared line + // in the same tick. Off the same kind the panel renders, not picked by + // hand: chosen here, the announcement named a reason the panel did not. + expect(UPLOAD).toMatch( + /const mixed = statusSentence\(\s+statusLineKind\(sharedFilled, \{\s+clash: !!data\.sourceClash,\s+edited: sharedEdited,\s+urlHeld: sharedUrlHeld,\s+cleared\s+\}\),\s+site,\s+\{ title: data\.sourceClash\?\.title \?\? '' \}\s+\);/ + ); + expect(UPLOAD).toMatch(/if \(refilledOne && mixed\) \{\s+announcer\.say\(mixed\);/); + // And the panel reads that same mapping, so neither side can be changed + // alone. + expect(PANEL).toMatch( + /const statusText = \$derived\(\s+statusSentence\(statusKind, prefill\?\.site \?\? null, \{ title: clash\?\.title \?\? '', editMode \}\)\s+\);/ + ); + // And the radio path stays quiet wherever the panel carries the sentence, + // which is every arm but idle: the searching arm renders it too, and the + // region is atomic, so a say() alongside it would be the second telling. + const pickBody = fnBody(UPLOAD, 'pickParent'); + expect(pickBody).toMatch(/if \(kind !== 'idle'\) return;/); + // Gated the way sharedLookup is, not 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 — read from the tile + // there, the guard would fall silent with nothing on screen in its place + // (4.1.3). + expect(pickBody).toContain( + "const kind = (groupMode === 'new' ? tiles[index]?.lookup.kind : undefined) ?? 'idle';" + ); + expect(UPLOAD).toMatch( + /const parentTile = \$derived\(groupMode === 'new' \? \(tiles\[parentIndex\] \?\? null\) : null\);/ + ); + }); + + // A move onto a tile that is still searching empties the two fields, and the + // result on its way calls applyShared again knowing nothing about that move. + // It used to overwrite the record with one saying nothing was emptied, so a + // failure landing afterwards showed no cleared sentence at all (4.1.3). + it('holds what a move onto a searching tile emptied until its result lands', () => { + expect(UPLOAD).toMatch( + /pendingCleared = tile\?\.lookup\.kind === 'searching' \? \{ key: tile\.key, emptied \} : null;/ + ); + expect(UPLOAD).toMatch( + /function takePendingCleared\(key: number\): LookupCleared \{[\s\S]{0,400}?pendingCleared = null;\s+return emptied;/ + ); + // The result that lands merges it in. + expect(UPLOAD).toMatch(/const emptied = takePendingCleared\(key\);\s+if \(isParent\(key\)\) applyShared\(next, emptied\);/); + // And so does the failure the catch synthesises, which calls nothing else: + // without this the record the LAST result left renders over fields that + // never changed. Merged the same way a result is, through applyShared, so a + // flag the operator's own typing has invalidated is dropped on this path too. + expect(UPLOAD).toMatch( + /const failed: LookupState = \{ kind: 'failed', reason: 'unavailable', sent \};\s+live\.lookup = failed;[\s\S]{0,800}?if \(isParent\(key\)\) applyShared\(failed, emptied\);/ + ); + }); + + // On the no-match and failed arms the cleared sentence lands under the lead + // that explains the result, a point of size apart and margin-collapsed to the + // 10px every status line sits at — two subjects reading as one paragraph. + it('separates the cleared sentence from the lead above it', () => { + // The searching arm has a progress line where those two have a lead, and + // the sentence sits under it at the same gap. + expect(PANEL).toMatch( + /\.lookup-lead \+ \.lookup-emptied,\s+\.searching-line \+ \.lookup-emptied \{\s+margin-top: 18px;/ + ); + // The rule is an adjacent-sibling one, so a paragraph slipped between the + // line above and the cleared sentence would silently take the gap away and + // leave the two subjects reading as one. Pinned here in all three arms: + // only comments and whitespace stand between them. + expect(PANEL).toMatch( + /

[\s\S]{0,300}?<\/p>(?:\s|)*\{#if movedEmptied\}/ + ); + expect(PANEL).toMatch( + /

\{m\.admin_lookup_no_match_body\(\)\}<\/p>(?:\s|)*\{#if emptiedOnly\}/ + ); + // Every branch of the failed arm ends on a lead, so the last one before + // the arm's own {/if} stands for all of them. + expect(PANEL).toMatch( + /

\{m\.admin_lookup_failed_body\(\)\}<\/p>\s*\{\/if\}(?:\s|)*\{#if emptiedOnly\}/ + ); + }); + // An unreadable file is a dead end on a variant tile: not retryable, no // Settings remedy, and the focus handler sends the operator to the artist // select. So the body has to end by telling them to type the name, the way @@ -976,9 +1336,12 @@ describe('what the lookup copy names', () => { // The panel's own status line already says the image is on its way to // FuzzySearch, so this one carries only what that line does not. + // It fires for the inline new-artist fields alone now: the two prefilled ones + // wait for the result and are reported by the panel's status line, so a line + // saying "the fields" named none of what the operator watched empty. it('carries the cleared-fields announcement in both catalogs, without the searching line', () => { expect(en.admin_lookup_announce_searching_cleared).toBe( - 'Sona cleared the fields the last lookup filled.' + 'Sona cleared the new artist details the last lookup filled.' ); expect(en.admin_lookup_announce_searching_cleared).not.toMatch(/FuzzySearch/); expect(ja.admin_lookup_announce_searching_cleared).toBeTruthy(); @@ -1045,9 +1408,13 @@ describe('focus after the panel goes away', () => { // Same on the upload page, where a second lookup replaces the shared // prefill: the first clash's piece stops being on offer unless the operator // chose it, and dropping a chosen one would blank the select instead. + // In resetSharedResult, which resetSharedPrefill calls and a starting + // lookup calls on its own: the clash is about the search that is over, + // while the two shared FIELDS now wait for the new result to land. expect(UPLOAD).toMatch( - /function resetSharedPrefill\(\)[\s\S]{0,900}?extraParents = extraParents\.filter\(\(c\) => String\(c\.id\) === existingParentId\);/ + /function resetSharedResult\(\)[\s\S]{0,900}?extraParents = extraParents\.filter\(\(c\) => String\(c\.id\) === existingParentId\);/ ); + expect(UPLOAD).toMatch(/function resetSharedPrefill\(\)[\s\S]{0,400}?resetSharedResult\(\);/); // And on the edit page's own repeat lookup, which resetForImage does not // cover: it only runs on a move to another image. expect(EDIT).toMatch( @@ -1081,17 +1448,17 @@ describe('focus after the panel goes away', () => { // the group-mode round trip that the snapshot was covering is closed by the // "new" radio re-deriving the shared fields from the parent tile. it('applies the shared prefill by the role the tile has when the result lands', () => { - expect(UPLOAD).toMatch( - /function startLookup\(key: number\)[\s\S]{0,900}?if \(isParent\(key\)\) resetSharedPrefill\(\);/ + expect(fnBody(UPLOAD, 'startLookup')).toMatch( + /if \(isParent\(key\)\) \{[\s\S]{0,900}?resetSharedResult\(\);/ ); - expect(UPLOAD).toMatch(/if \(isParent\(key\)\) applyShared\(next\);/); + expect(UPLOAD).toMatch(/if \(isParent\(key\)\) applyShared\(next, emptied\);/); expect(UPLOAD).not.toMatch(/wasParent/); expect(UPLOAD).toMatch(/groupMode = 'new';\s+returnToNewSet\(\);/); // Only re-derive from a parent that still HAS a result: with the panel // closed its lookup is idle and the fields it filled are still on screen, // and an unconditional re-derivation cleared them and applied nothing. expect(UPLOAD).toMatch( - /function returnToNewSet\(\)[\s\S]{0,400}?if \(tiles\[parentIndex\]\?\.lookup\.kind !== 'results'\) return;/ + /function returnToNewSet\(\)[\s\S]{0,400}?const data = parent\?\.kind === 'results' \? parent\.data : null;\s+if \(!data\) return;/ ); // The panel and its status region are mounted by the same mode swap, so a // refill lands in a region inserted with its first content. Say it — but @@ -1099,12 +1466,12 @@ describe('focus after the panel goes away', () => { // operator typed over, so an operator who typed over both heard that Sona // filled them while nothing had changed. expect(UPLOAD).toMatch( - /function applyShared\(next: LookupState\): \{ sourcePostUrl: boolean; commissionedAt: boolean \}/ + /function applyShared\(\s+next: LookupState,\s+emptied: LookupCleared = \{\}\s+\): \{ sourcePostUrl: boolean; commissionedAt: boolean \}/ ); // One field written is one field named: the plural sentence over a single // refill told the operator both had changed. expect(UPLOAD).toMatch( - /function returnToNewSet\(\)[\s\S]{0,900}?const wrote = onParentChanged\(parentIndex\);[\s\S]{0,120}?if \(wrote\.sourcePostUrl && wrote\.commissionedAt\) \{[\s\S]{0,120}?m\.admin_lookup_announce_shared_refilled\(\)/ + /function returnToNewSet\(\)[\s\S]{0,1200}?const \{ wrote, cleared \} = onParentChanged\(parentIndex\);[\s\S]{0,1600}?\} else if \(wrote\.sourcePostUrl && wrote\.commissionedAt\) \{[\s\S]{0,120}?m\.admin_lookup_announce_shared_refilled\(\)/ ); expect(UPLOAD).toMatch( /\} else if \(wrote\.sourcePostUrl\) \{\s+announcer\.say\(m\.admin_lookup_announce_shared_refilled_source\(\)\);\s+\} else if \(wrote\.commissionedAt\) \{\s+announcer\.say\(m\.admin_lookup_announce_shared_refilled_date\(\)\);/ @@ -1114,14 +1481,140 @@ describe('focus after the panel goes away', () => { // still-selected artist also spared it on a SECOND lookup, whose result // names somebody else, leaving "Using {name}" over an unrelated match. expect(UPLOAD).toMatch( - /function resetSharedPrefill\(\)[\s\S]{0,900}?\n\t\tappliedArtist = null;/ + /function resetSharedResult\(\)[\s\S]{0,900}?\n\t\tappliedArtist = null;/ ); expect(UPLOAD).not.toMatch(/if \(!appliedArtist \|\| Number\(selectedArtistId\) !== appliedArtist\.id\)/); expect(UPLOAD).toMatch( - /function returnToNewSet\(\)[\s\S]{0,600}?const held = appliedArtist;\s+const wrote = onParentChanged\(parentIndex\);\s+if \(held && Number\(selectedArtistId\) === held\.id\) appliedArtist = held;/ + /function returnToNewSet\(\)[\s\S]{0,900}?const held = appliedArtist;\s+const \{ wrote, cleared \} = onParentChanged\(parentIndex\);\s+if \(held && Number\(selectedArtistId\) === held\.id\) appliedArtist = held;/ ); }); + // A repeat lookup used to blank the source post URL at the click and refill it + // when the result came back. Everything downstream of that field saw it change + // twice — the tag-suggestion control drops its chips and its rating on any + // change (SONA-220) — for a lookup that landed on the very same post. Both + // pages now keep the two prefilled fields until the result is in. + it('keeps the two prefilled fields until the result lands, on both pages', () => { + // Neither click-time reset touches them any more. + expect(UPLOAD).not.toMatch( + /function resetSharedResult\(\)[\s\S]{0,900}?if \(sourceTagged\) sourcePostUrl = '';/ + ); + expect(EDIT).not.toMatch( + /function resetLookupPrefill\(\)[\s\S]{0,900}?if \(sourceTagged\) sourcePostUrl = '';/ + ); + expect(EDIT).not.toMatch( + /function resetLookupPrefill\(\)[\s\S]{0,900}?if \(dateTagged\) commissionedAt = '';/ + ); + // The inline new-artist fields still go at the click: nothing downstream + // watches them, and the announcement in startLookup says they went. + expect(EDIT).toMatch( + /function resetLookupPrefill\(\)[\s\S]{0,400}?if \(nameTagged\) artistName = '';/ + ); + expect(EDIT).toMatch(/resetLookupPrefill\(\);/); + for (const [source, apply] of [ + [UPLOAD, 'applyShared'], + [EDIT, 'applyPrefill'] + ] as const) { + // Sliced rather than reached for through a character span: the span + // measured the prose between the function's name and its code, and grew + // with every comment added to either. + const body = fnBody(source, apply); + // The result reads a still-tagged value as the lookup's own, so it may + // refill it. Only the tag tells that from something the operator typed. + expect(body).toMatch( + /const ownSource = sourceTagged \? '' : sourcePostUrl;\s+const ownDate = dateTagged \? '' : commissionedAt;/ + ); + // And where the result offers nothing, the deferred field is emptied + // there instead — recorded, so the status line can say so (4.1.3). + expect(body).toMatch( + /\} else if \(sourceTagged\) \{[\s\S]{0,600}?sourcePostUrl = '';\s+sourceTagged = false;\s+cleared\.sourcePostUrl = true;/ + ); + expect(body).toMatch( + /\} else if \(dateTagged\) \{\s+commissionedAt = '';\s+dateTagged = false;\s+cleared\.commissionedAt = true;/ + ); + expect(source).toMatch(/cleared=\{(shared|lookup)Cleared\}/); + // A no-match is a result too: it fills neither field, which empties what + // the last lookup filled. Returning early on it left the first lookup's + // URL and date on the form, still tagged, under "no match". + expect(body).toMatch(/if \(next\.kind !== 'results' && next\.kind !== 'no_match'\)/); + // And a no-match prefills nothing, so every fill branch is skipped and + // every clearing branch runs. + expect(source).toMatch( + new RegExp( + `const fields: LookupFields =\\s+next\\.kind === 'results'\\s+\\? prefillForResult\\([\\s\\S]{0,200}?\\)\\s+: \\{\\};` + ) + ); + } + // The searching arm has no sentence about the fields either, and a parent + // move onto a tile whose lookup is still out empties them right then. It + // rendered nothing at all until the result landed, so a sighted operator + // watched two fields go blank with only the progress line to read. + expect(PANEL).toMatch( + /\{#if lookup\.kind === 'searching'\}[\s\S]{0,1300}?\{#if movedEmptied\}\s+

\{movedEmptied\}<\/p>/ + ); + // And it is the reasonless sentence, not the status line's. Every emptied + // kind blames the result — "because this lookup filled neither one" — and + // nothing is settled about a lookup that is still running. + expect(PANEL).toMatch(/const movedEmptied = \$derived\(clearedLine\(cleared, edited\)\);/); + expect(PANEL).not.toMatch( + /\{#if lookup\.kind === 'searching'\}[\s\S]{0,1300}?\{#if emptiedOnly\}/ + ); + // The operator is free to type into a field the move emptied while the + // search is out, so that sentence reads `edited` the way the status line + // does — off one copy of the drop, not two. + expect(LOOKUP).toMatch( + /function clearedOnScreen\(held: LookupCleared, edited: LookupEdited\): LookupCleared \{[\s\S]{0,400}?commissionedAt: held\.commissionedAt === true && edited\.commissionedAt !== true/ + ); + expect(LOOKUP).toMatch(/const cleared = clearedOnScreen\(held, edited\);/); + // The no-match arm has no status line of its own, so the sentence is + // rendered there as well as under a result. + expect(PANEL).toMatch( + /\{:else if lookup\.kind === 'no_match'\}[\s\S]{0,600}?\{#if emptiedOnly\}\s+

\{statusText\}<\/p>/ + ); + // The failed arm has no status line of its own either, and a parent move + // onto a tile whose lookup failed empties the fields just the same. Above + // the advice: what happened to the form is read before what to do next. + expect(PANEL).toMatch( + /\{#if emptiedOnly\}\s+

\{statusText\}<\/p>\s+\{\/if\}\s+\{#if failedHint\}/ + ); + expect(PANEL).not.toMatch(/

\{m\.admin_lookup_paused_hint\(\)\}<\/p>/); + // Where the sentence renders: the three kinds that name no site are the + // ones an arm with no match to name can still say. + expect(PANEL).toMatch(/const emptiedOnly = \$derived\(namesNoSite\(statusKind\)\);/); + // Off the mapping's own test rather than a second copy of the same list: + // the two could drift, and the sentence would land in an arm with no post + // to name. + expect(LOOKUP).toMatch( + /export function namesNoSite\([\s\S]{0,200}?return kind === 'both_emptied' \|\| kind === 'url_emptied' \|\| kind === 'date_emptied';/ + ); + // And it reports a change the fields just made, so it is not drawn in the + // muted colour the advice lines use. + expect(PANEL).toMatch(/\.lookup-emptied \{\s+color: var\(--foreground\);/); + // And that colour is for every sentence reporting a field gone blank, not + // only the three that report nothing else: the combined ones say a field + // was filled AND one was emptied, which is still a change to the form. + expect(PANEL).toMatch( + /const reportsEmptied = \$derived\(\s+emptiedOnly \|\|\s+statusKind === 'url_and_date_emptied' \|\|\s+statusKind === 'date_and_url_emptied' \|\|\s+statusKind === 'clash_emptied' \|\|\s+statusKind === 'clash_date_url_emptied'\s+\);/ + ); + expect(PANEL).toMatch( + /

\{statusText\}<\/p>/ + ); + // The panel reads that record, and says the field was emptied rather than + // left as it was. + expect(PANEL).toMatch(/statusLineKind\(filled, \{ clash: !!clash, edited, urlHeld: sourceUrlHeld, cleared \}\)/); + for (const key of [ + 'admin_lookup_status_url_and_date_emptied', + 'admin_lookup_status_date_and_url_emptied', + 'admin_lookup_status_both_emptied', + 'admin_lookup_status_url_emptied', + 'admin_lookup_status_date_emptied', + 'admin_lookup_status_clash_emptied', + 'admin_lookup_status_clash_date_url_emptied' + ]) { + expect(LOOKUP).toContain(`m.${key}`); + } + }); + // The Remove button lives inside the tile it removes, so activating it from // the keyboard dropped focus on and the next Tab restarted at the top // of the page (2.4.3). Every tile's button also read "Remove file" (2.4.6). @@ -1171,7 +1664,7 @@ describe('the rating tag beside NSFW', () => { // The pill is a SIBLING of the label, not inside it (SONA-220). Any // comment between the two is prose, not part of the contract. expect(EDIT).toMatch( - /

[\s\S]*?<\/label>[\s\S]*?/ + /
[\s\S]*?<\/label>[\s\S]*?/ ); // entail.dev's rating shares the row since SONA-220, so the checkbox points // at a derived that joins whichever of the two pills is on screen. @@ -1191,25 +1684,112 @@ describe('the rating tag beside NSFW', () => { } }); - it('keeps the tag on one line and lets the row wrap instead', () => { + it('lets the row wrap a pill to its own line', () => { + // One rule for the row, in app.css. A page-scoped copy outranks the global + // one on specificity, so the copies quietly held the wide gap and the + // centred alignment that the two container queries are trying to change. + expect(APP_CSS).toMatch(/\.tag-check-row \{[^}]*flex-wrap: wrap;/); for (const source of [UPLOAD, EDIT]) { - expect(source).toMatch(/\.rating-tag \{[^}]*white-space: nowrap;/); - expect(source).toMatch(/\.nsfw-row[\s\S]{0,80}\{[^}]*flex-wrap: wrap;/); + expect(source).not.toMatch(/\.nsfw-row/); } - }); - - // The text grows with the number of sites; nowrap alone made the pill wider - // than its column and gave the whole document a horizontal scrollbar at - // 320px (1.4.10), and wider than its tile in the grid. + // Wrapped, the two pills and the "Mark it NSFW" button all line up under + // the label text rather than one of them under the checkbox. Asked of the + // COLUMN: both forms hold this row in a grid column far narrower than the + // window, so a viewport query indented the phone and left every width in + // between wrapping to the checkbox's edge. + expect(APP_CSS).toMatch( + /@container admin-form not \(min-width: 560px\) \{[\s\S]{0,900}?\.tag-check-row \.tag-rating-note,\s+\.tag-check-row \.rating-tag,\s+\.tag-check-row \.btn \{\s+margin-left: 24px;/ + ); + expect(APP_CSS).not.toMatch(/@media \(max-width: 640px\)[\s\S]{0,900}?\.tag-check-row/); + // The two ranges abut: `max-width: 559px` and `min-width: 560px` leave a + // fractional column width — 559.5px is what a fractional grid track gives + // — matching neither, with the row indented by neither rule. + expect(APP_CSS).not.toMatch(/@container admin-form \(max-width/); + // Stacked rather than wrapped in that range, which is what the mocks draw: + // wrapping put the 24px indent on items that had not wrapped, so an inline + // row carried 32px gaps at 1024 and 1150 on the edit form. + expect(APP_CSS).toMatch( + /@container admin-form not \(min-width: 560px\) \{[\s\S]{0,900}?\.tag-check-row \{\s+flex-direction: column;\s+align-items: flex-start;/ + ); + }); + + // The text grows with the number of sites; nowrap made the pill wider than + // its column and gave the whole document a horizontal scrollbar at 320px + // (1.4.10), wider than its tile in the grid, and — kept on one line beside a + // second pill and a button — it pushed that button onto a row of its own. + // + // One rule for all three pills, in app.css: the two forms and the tiles hold + // the same pill, and the copy in each page was byte-for-byte the other's. it('lets the pill wrap rather than pushing the page sideways', () => { + expect(APP_CSS).toMatch(/\.rating-tag \{[^}]*max-width: 100%/); + expect(APP_CSS).toMatch(/\.rating-tag \{[^}]*white-space: normal;/); + // break-word, not anywhere: anywhere breaks mid-word, which is how a + // squeezed pill ends up one character per line. + expect(APP_CSS).toMatch(/\.rating-tag \{[^}]*overflow-wrap: break-word;/); + expect(APP_CSS).not.toMatch(/\.rating-tag \{[^}]*overflow-wrap: anywhere;/); + // No rule anywhere puts it back on one line, and neither page carries a + // copy of the rule any more. + expect(APP_CSS).not.toMatch(/\.rating-tag \{[^}]*white-space: nowrap;/); for (const source of [UPLOAD, EDIT]) { - expect(source).toMatch(/\.rating-tag \{[^}]*max-width: 100%/); - expect(source).toMatch( - /@media \(max-width: 480px\) \{[\s\S]{0,200}?\.rating-tag \{[^}]*white-space: normal/ - ); + // A page-local copy of the whole pill, which is what the consolidation + // took away. The tile's one-property override below is not one. + expect(source).not.toMatch(/^\t\.rating-tag \{/m); } expect(UPLOAD).toMatch(/\.tile-nsfw-row \{[^}]*min-width: 0/); - expect(UPLOAD).toMatch(/\.tile-nsfw-row \.rating-tag \{[^}]*white-space: normal/); + // break-word does not shrink an item's min-content width, and the tile is + // about 170px wide, so the tile pill keeps the `anywhere` it always had. + expect(UPLOAD).toMatch(/\.tile-nsfw-row \.rating-tag \{[^}]*overflow-wrap: anywhere;/); + }); + + // Wrapped to three or more lines, the pill token clamps to half the box height + // and the end caps' arc lands inside the text's own inset, so the first and + // last lines run flush against the border. A fixed radius holds through any + // number of lines, and at one line the two are the same shape. + it('caps the pill radius rather than following the box height', () => { + expect(APP_CSS).toMatch(/\.rating-tag \{[^}]*border-radius: 12px;/); + expect(APP_CSS).not.toMatch(/\.rating-tag \{[^}]*border-radius: var\(--radius-pill\)/); + }); + + // Where the column has room for all four, the two rating items give up the + // difference rather than pushing "Mark it NSFW" onto a second row: flex breaks + // a line on each item's CONTENT size however shrinkable it is, so a zero basis + // is what takes them out of that decision. The floor is what makes that safe — + // keyed to the window instead, the same rule ran in a 219px column and shrank + // both items to about one character per line. + it('shrinks the two rating items only where the column has room', () => { + expect(APP_CSS).toMatch( + /@container admin-form \(min-width: 560px\) \{\s+\.tag-check-row \.rating-tag,\s+\.tag-check-row \.tag-rating-note \{\s+flex: 1 1 0;\s+min-width: 14ch;\s+max-width: max-content;/ + ); + // Three gaps at 8px against an 8px shortfall: the edit form's row wants + // 608px in a 600px column, so the note wrapped "entail.dev." to a second + // line. 4px buys back the 12px that keeps all four items on one line. + expect(APP_CSS).toMatch( + /@container admin-form \(min-width: 560px\) \{[\s\S]{0,600}?\.tag-check-row \{\s+gap: 4px;/ + ); + // That gap is between the two rating items, which report the same kind of + // thing. The action after them is a different kind of thing and takes some + // of that separation back — 4px, which is all the column's slack allows. + expect(APP_CSS).toMatch( + /@container admin-form \(min-width: 560px\) \{[\s\S]{0,900}?\.tag-check-row \.btn \{\s+margin-left: 4px;/ + ); + // Nothing shrinks off a viewport query any more, and the rule is keyed to + // the row rather than to either page's copy of it. + expect(APP_CSS).not.toMatch(/@media \([^)]*\) \{\s+\.tag-check-row \.rating-tag,/); + for (const source of [UPLOAD, EDIT]) { + expect(source).not.toMatch(/@container admin-form \(min-width: 560px\)/); + } + // The tile row is not a tag-check-row, so the tile pill keeps its own + // width — the rule above would shrink it inside a 170px tile. + expect(UPLOAD).toMatch(/
/); + expect(UPLOAD).not.toMatch(/class="tile-nsfw-row tag-check-row"/); + // The column each form names, and the tile row is not one of them. + expect(EDIT).toMatch( + /\.edit-form \{[\s\S]{0,400}?container-type: inline-size;\s+container-name: admin-form;/ + ); + expect(UPLOAD).toMatch( + /\.upload-form \{[\s\S]{0,400}?container-type: inline-size;\s+container-name: admin-form;/ + ); + expect(UPLOAD).not.toMatch(/\.tile-nsfw-row[\s\S]{0,200}?container-type/); }); }); @@ -1421,7 +2001,7 @@ describe('the upload page grid', () => { it('lands a throw while applying a result in the failed state', () => { for (const source of [UPLOAD, EDIT]) { expect(source).toMatch( - /\.catch\(\(\) => \{[\s\S]{0,400}?kind: 'failed', reason: 'unavailable', sent[\s\S]{0,120}?console\.error\(LOOKUP_RESULT_THREW\)/ + /\.catch\(\(\) => \{[\s\S]{0,800}?kind: 'failed', reason: 'unavailable', sent[\s\S]{0,900}?console\.error\(LOOKUP_RESULT_THREW\)/ ); } // Either callback runs for every settled kind, including a too_large the @@ -1490,7 +2070,7 @@ describe('the upload page grid', () => { // constant and says nothing rather than escaping the catch. it('announces a variant tile outcome from the catch', () => { expect(UPLOAD).toMatch( - /if \(!isParent\(key\)\) \{\s+try \{\s+announcer\.say\(tileLookupLine\(live\)\);\s+\} catch \{\s+console\.error\(LOOKUP_RESULT_THREW\);/ + /if \(!isParent\(key\)\) \{[\s\S]{0,400}?try \{\s+announcer\.say\(tileLookupLine\(live\)\);\s+\} catch \{\s+console\.error\(LOOKUP_RESULT_THREW\);/ ); // Nothing is said as a bare failure any more: over a result that stands, // that line would contradict the matches the tile is showing. @@ -1512,7 +2092,7 @@ describe('the upload page grid', () => { // uses: Private was ticked when the lookup ran AND the state says the file // actually left. Off sentPrivate alone, a client-refused too_large would // be spoken as a private send of a file FuzzySearch never saw. - const composer = UPLOAD.match(/function tileLookupLine\(tile: Tile\): string \{[\s\S]*?\n\t\}/)?.[0] ?? ''; + const composer = fnBody(UPLOAD, 'tileLookupLine'); expect(composer).toContain('tile.sentPrivate && lookupSentFile(tile.lookup)'); // One key holding both parts, so the locale decides the separator. expect(composer).toMatch( @@ -1531,7 +2111,7 @@ describe('the upload page grid', () => { }); it('re-derives the shared prefill when the parent moves or goes', () => { - expect(UPLOAD).toMatch(/onchange=\{\(\) => onParentChanged\(i\)\}/); + expect(UPLOAD).toMatch(/onchange=\{\(\) => pickParent\(i\)\}/); // parentIndex is submitted as the hidden field the server picks the parent // with, so a removal ahead of the parent has to move the index with it — // otherwise the saved parent is a different file than the shared artist, @@ -1544,14 +2124,14 @@ describe('the upload page grid', () => { ); // Only a parent that is actually gone re-derives the shared fields. expect(UPLOAD).toMatch( - /if \(movedTo !== -1\) \{\s+parentIndex = movedTo;\s+\} else \{[\s\S]{0,900}?onParentChanged\(parentIndex\);/ + /if \(movedTo !== -1\) \{\s+parentIndex = movedTo;\s+\} else \{[\s\S]{0,900}?pickParent\(parentIndex\);/ ); // And only in the mode where a tile is the parent. In 'existing' no tile // owns the shared fields, so re-deriving there clears a lookup-tagged // source URL and date that survived the flip and writes nothing back, // erasing them without the announcement returnToNewSet makes. expect(UPLOAD).toMatch( - /if \(groupMode !== 'existing'\) onParentChanged\(parentIndex\);/ + /if \(groupMode !== 'existing'\) pickParent\(parentIndex\);/ ); }); @@ -1561,7 +2141,7 @@ describe('the upload page grid', () => { // end: the shared panel went quiet and the save action dereferenced a tile // that was no longer there. it('removes a declined duplicate the same way the Remove button does', () => { - const uploadOne = UPLOAD.match(/async function uploadOne\([\s\S]*?\n\t\}/)?.[0] ?? ''; + const uploadOne = fnBody(UPLOAD, 'uploadOne'); expect(uploadOne).toContain('removeTile(tile.key);'); expect(uploadOne).not.toMatch(/tiles = tiles\.filter/); // removeTile owns the revoke, so the decline path must not keep its own. diff --git a/src/lib/artist-lookup.test.ts b/src/lib/artist-lookup.test.ts index 739fed9f..28b85b3b 100644 --- a/src/lib/artist-lookup.test.ts +++ b/src/lib/artist-lookup.test.ts @@ -3,6 +3,7 @@ import { LOOKUP_MAX_BYTES, bandLabel, candidateArtists, + clearedLine, isCrossSiteAmbiguity, matchForArtist, matchHandle, @@ -11,6 +12,7 @@ import { lookupSentFile, mergeSamePost, nameMatchArtists, + namesNoSite, newArtistSeed, seedStatusKind, pickPrefillMatch, @@ -23,17 +25,22 @@ import { siteLabel, stateFromResponse, statusLineKind, + statusSentence, strictestRating, tileResultText, runLookup, sentAfterApplyThrew, withCreatedArtist, + type LookupCleared, + type LookupEdited, type LookupFailReason, + type LookupFields, type LookupMatch, type LookupRating, type LookupResponse, type LookupSite, - type LookupState + type LookupState, + type StatusLineKind } from './artist-lookup'; // A node test may reach into the server module; the browser bundle may not. // Importing both here is how the wire shape and the shared rules stay in step. @@ -749,6 +756,359 @@ describe('statusLineKind', () => { } }); + // A second lookup keeps the fields the first one filled until its own result + // lands, so a result with no post or no date empties one of them THEN. Saying + // it was "left as it was" is a false report of a field the operator just + // watched go blank (4.1.3). + it('says a deferred field was emptied rather than left alone', () => { + expect( + statusLineKind({ sourcePostUrl: 'u' }, { cleared: { commissionedAt: true } }) + ).toBe('url_and_date_emptied'); + expect( + statusLineKind({ commissionedAt: 'd' }, { cleared: { sourcePostUrl: true } }) + ).toBe('date_and_url_emptied'); + // Nothing filled at all: a no-match, or a result the operator's own typing + // left no room for. + expect( + statusLineKind({}, { cleared: { sourcePostUrl: true, commissionedAt: true } }) + ).toBe('both_emptied'); + expect(statusLineKind({}, { cleared: { sourcePostUrl: true } })).toBe('url_emptied'); + expect(statusLineKind({}, { cleared: { commissionedAt: true } })).toBe('date_emptied'); + // The shape a parent move hands over: both keys present, false for the + // field the new parent's result wrote back. A move that empties both and + // refills only the URL has to reach the same sentence as a plain result + // that filled the URL and emptied the date (SONA-220). + expect( + statusLineKind( + { sourcePostUrl: 'u' }, + { cleared: { sourcePostUrl: false, commissionedAt: true } } + ) + ).toBe('url_and_date_emptied'); + expect( + statusLineKind( + { commissionedAt: 'd' }, + { cleared: { sourcePostUrl: true, commissionedAt: false } } + ) + ).toBe('date_and_url_emptied'); + // Both written back: nothing was left blank, so nothing claims it was. + expect( + statusLineKind( + { sourcePostUrl: 'u', commissionedAt: 'd' }, + { cleared: { sourcePostUrl: false, commissionedAt: false } } + ) + ).toBe('both'); + // An empty record changes nothing about the sentences that were there. + expect(statusLineKind({ sourcePostUrl: 'u' }, { cleared: {} })).toBe('url_only'); + expect(statusLineKind({ sourcePostUrl: 'u', commissionedAt: 'd' }, { cleared: {} })).toBe( + 'both' + ); + }); + + // The record is worked out once, when the fields go blank, and read on every + // render after. The operator is free to type into a field it names, and the + // sentence for a cleared field invites them to fill in something already + // sitting in the input — so the field being theirs drops the flag, the same + // way it drops a filled value they have typed over (SONA-220). + it('drops a cleared flag off a field the operator has since filled', () => { + expect( + statusLineKind( + {}, + { cleared: { sourcePostUrl: true, commissionedAt: true }, edited: { sourcePostUrl: true } } + ) + ).toBe('date_emptied'); + expect( + statusLineKind( + {}, + { cleared: { sourcePostUrl: true, commissionedAt: true }, edited: { commissionedAt: true } } + ) + ).toBe('url_emptied'); + // Both fields typed back in: nothing on screen is Sona's doing any more, + // so the panel says nothing about them at all. + expect( + statusLineKind( + {}, + { + cleared: { sourcePostUrl: true, commissionedAt: true }, + edited: { sourcePostUrl: true, commissionedAt: true } + } + ) + ).toBe('none'); + expect( + statusLineKind({}, { cleared: { sourcePostUrl: true }, edited: { sourcePostUrl: true } }) + ).toBe('none'); + expect( + statusLineKind({}, { cleared: { commissionedAt: true }, edited: { commissionedAt: true } }) + ).toBe('none'); + // The mixed sentences drop the same half, but they do not fall back to the + // "only" pair: date_only says the URL was "left as it was", and this result + // emptied it. The kept sentence claims the date and stays silent about a + // field whose state neither of them can state honestly. + expect( + statusLineKind( + { commissionedAt: 'd' }, + { cleared: { sourcePostUrl: true }, edited: { sourcePostUrl: true } } + ) + ).toBe('date_kept'); + // And the clash pair, where the emptied URL is what picks the sentence: the + // plain clash says the URL was left EMPTY, which the operator's own text in + // the field contradicts, so the kept one answers instead. + expect( + statusLineKind( + { commissionedAt: 'd' }, + { clash: true, cleared: { sourcePostUrl: true }, edited: { sourcePostUrl: true } } + ) + ).toBe('clash_kept'); + expect( + statusLineKind( + {}, + { clash: true, cleared: { sourcePostUrl: true }, edited: { sourcePostUrl: true } } + ) + ).toBe('none'); + // Typing into the OTHER field leaves the flag alone. + expect( + statusLineKind({}, { cleared: { sourcePostUrl: true }, edited: { commissionedAt: true } }) + ).toBe('url_emptied'); + }); + + // The clash sentence says the URL was "left empty", which is true of a field + // that WAS empty and a false report of one the result just blanked under the + // operator — so a dated clash over an emptied URL gets its own sentence. + // (The truth table below is the whole of this rule; this block keeps the + // sentences themselves beside the kind they belong to.) + it('says a dated clash emptied the URL rather than leaving it empty', () => { + expect( + statusLineKind({ commissionedAt: 'd' }, { clash: true, cleared: { sourcePostUrl: true } }) + ).toBe('clash_date_url_emptied'); + // A clash whose URL was empty all along keeps the sentence that says so. + expect(statusLineKind({ commissionedAt: 'd' }, { clash: true })).toBe('clash'); + expect( + m.admin_lookup_status_clash_date_url_emptied( + { site: 'FurAffinity', title: 'Ref' }, + { locale: 'en' } + ) + ).toBe( + 'Sona filled the commissioned date from the FurAffinity post and cleared the source post URL the last lookup filled, because the post Sona just found is already the source of Ref. You can change the date before you save.' + ); + for (const locale of ['en', 'ja'] as const) { + const line = m.admin_lookup_status_clash_date_url_emptied( + { site: 'FurAffinity', title: 'Ref' }, + { locale } + ); + expect(line).toContain('Ref'); + expect(line).toMatch(/cleared the source post URL|消去しました/); + } + // A clash that carries no date either: the clash sentence claims a date it + // did not fill, so the emptied one is what is left to say. + expect( + statusLineKind( + {}, + { clash: true, cleared: { sourcePostUrl: true, commissionedAt: true } } + ) + ).toBe('both_emptied'); + // Only the URL emptied, under a body saying Sona found a post and declined + // it. The plain url_emptied sentence gives "this lookup filled nothing in + // its place" as the reason, which contradicts that body, so the clash gets + // a sentence naming its own reason. + expect(statusLineKind({}, { clash: true, cleared: { sourcePostUrl: true } })).toBe( + 'clash_emptied' + ); + expect( + m.admin_lookup_status_clash_emptied({ site: 'FurAffinity', title: 'Ref' }, { locale: 'en' }) + ).toBe( + 'Sona cleared the source post URL the last lookup filled, because the FurAffinity post Sona found is already the source of Ref. You can type one in before you save.' + ); + for (const locale of ['en', 'ja'] as const) { + expect( + m.admin_lookup_status_clash_emptied({ site: 'FurAffinity', title: 'Ref' }, { locale }) + ).toContain('Ref'); + } + }); + + // The panel renders the sentence and the upload page announces it. Picked by + // hand on either side, the two named different reasons for the same move: + // the announcement said the result had no link to put there while the panel + // said the post already belonged to another piece (SONA-220). + // The panel renders these three in a paragraph of their own, on the no-match + // and failed arms, where there is no prefill match to name. It used to decide + // that off its own list of the same three kinds, which could drift from the + // mapping's — and a kind in one list but not the other renders a sentence + // with an empty site in it, or no sentence at all. + // The sentence for what a move emptied, said with no reason attached. The + // panel's searching arm renders it while the result that would explain the + // blank fields is still out, and the upload page announces the same three on + // a parent move — so a lookup that is still running is never blamed for the + // clearing, and the two surfaces name the same fields. + it('names what a move emptied without blaming a result', () => { + expect(clearedLine({ sourcePostUrl: true, commissionedAt: true }, {})).toBe( + m.admin_lookup_announce_shared_cleared() + ); + expect(clearedLine({ sourcePostUrl: true }, {})).toBe( + m.admin_lookup_announce_shared_cleared_source() + ); + expect(clearedLine({ commissionedAt: true }, {})).toBe( + m.admin_lookup_announce_shared_cleared_date() + ); + // Nothing emptied, nothing said: the arm renders no paragraph at all. + expect(clearedLine({}, {})).toBe(null); + expect(clearedLine({ sourcePostUrl: false, commissionedAt: false }, {})).toBe(null); + }); + + // The operator is free to type into a field the move emptied while the + // search is still out. Their own text is not something Sona cleared, so the + // sentence drops that field the way the status line does — and with both + // fields theirs again it says nothing. + it('drops a field the operator has typed into since', () => { + expect( + clearedLine({ sourcePostUrl: true, commissionedAt: true }, { sourcePostUrl: true }) + ).toBe(m.admin_lookup_announce_shared_cleared_date()); + expect( + clearedLine({ sourcePostUrl: true, commissionedAt: true }, { commissionedAt: true }) + ).toBe(m.admin_lookup_announce_shared_cleared_source()); + expect( + clearedLine( + { sourcePostUrl: true, commissionedAt: true }, + { sourcePostUrl: true, commissionedAt: true } + ) + ).toBe(null); + }); + + it('answers which kinds name no post', () => { + expect(namesNoSite('both_emptied')).toBe(true); + expect(namesNoSite('url_emptied')).toBe(true); + expect(namesNoSite('date_emptied')).toBe(true); + // Every other kind names the post the prefill came from, which is exactly + // why they cannot render where there is none. + for (const kind of [ + 'both', + 'url_only', + 'date_only', + 'url_kept', + 'date_kept', + 'clash', + 'clash_kept', + 'clash_emptied', + 'clash_date_url_emptied', + 'url_and_date_emptied', + 'date_and_url_emptied', + 'none' + ] as StatusLineKind[]) { + expect(namesNoSite(kind)).toBe(false); + // And the mapping agrees: with no site, these say nothing at all. + expect(statusSentence(kind, null, { title: 'Ref' })).toBe(''); + } + }); + + it('maps every status kind to exactly one sentence', () => { + const kinds: StatusLineKind[] = [ + 'both', + 'url_only', + 'date_only', + 'url_kept', + 'date_kept', + 'clash', + 'clash_kept', + 'clash_emptied', + 'clash_date_url_emptied', + 'url_and_date_emptied', + 'date_and_url_emptied', + 'both_emptied', + 'url_emptied', + 'date_emptied' + ]; + const said = kinds.map((kind) => statusSentence(kind, 'FurAffinity', { title: 'Ref' })); + // Every kind says something, and no two of them say the same thing: a kind + // that fell through to another one's sentence would report the wrong reason. + expect(said.filter((line) => line === '')).toEqual([]); + expect(new Set(said).size).toBe(kinds.length); + // Nothing to report, nothing said. + expect(statusSentence('none', 'FurAffinity')).toBe(''); + // The three that report only an emptied field name no site — which is why + // they are the ones a no-match, with no match to name, can still say. + expect(statusSentence('both_emptied', null)).toBe(m.admin_lookup_status_both_emptied()); + expect(statusSentence('url_emptied', null)).toBe(m.admin_lookup_status_url_emptied()); + expect(statusSentence('date_emptied', null)).toBe(m.admin_lookup_status_date_emptied()); + // Every other kind names a post. With none to name there is nothing to say + // rather than a sentence with an empty site in it. + expect(statusSentence('both', null)).toBe(''); + expect(statusSentence('clash', null, { title: 'Ref' })).toBe(''); + // The edit page's URL belongs to the image, so the sentence there calls it + // the operator's own rather than something the lookup left alone. + expect(statusSentence('date_only', 'FurAffinity', { editMode: true })).toBe( + m.admin_lookup_status_kept({ site: 'FurAffinity' }) + ); + // Spelled out rather than compared to the key it came from: read off the + // key on both sides, the assertion passes whatever the key says, and this + // is the one sentence in the set that says the date can still be changed + // — its siblings all end on that and it used to stop before it. + expect(m.admin_lookup_status_kept({ site: 'Twitter' }, { locale: 'en' })).toBe( + 'Sona filled the commissioned date from the Twitter post and left your source post URL as it was. You can change the date before you save.' + ); + expect(m.admin_lookup_status_kept({ site: 'Twitter' }, { locale: 'ja' })).toMatch( + /保存前に日付を変更できます。$/ + ); + expect(statusSentence('date_only', 'FurAffinity')).toBe( + m.admin_lookup_status_date_only({ site: 'FurAffinity' }) + ); + expect(statusSentence('clash_kept', 'FurAffinity', { title: 'Ref' })).toContain('Ref'); + }); + + it('names the emptied field in the sentence, in both locales', () => { + expect(m.admin_lookup_status_url_and_date_emptied({ site: 'Twitter' }, { locale: 'en' })).toBe( + 'Sona filled the source post URL from the Twitter post and cleared the commissioned date the last lookup filled, because that post has no date. You can change the URL before you save.' + ); + expect(m.admin_lookup_status_url_emptied({}, { locale: 'en' })).toBe( + 'Sona cleared the source post URL the last lookup filled, because this lookup filled nothing in its place. You can type one in before you save.' + ); + // A clash result DOES have a post — Sona declined it — so no sentence on + // this path may say the lookup found nothing to put there. + expect(m.admin_lookup_status_date_and_url_emptied({ site: 'Twitter' }, { locale: 'en' })).toBe( + 'Sona filled the commissioned date from the Twitter post and cleared the source post URL the last lookup filled, because the result Sona found has no link to put there. You can change the date before you save.' + ); + // 一致 is what a database calls a matching row. The thing the operator is + // looking at is a search result, which is what the rest of the file calls + // it, so the Japanese sentence names it that way too. + expect(m.admin_lookup_status_date_and_url_emptied({ site: 'Twitter' }, { locale: 'ja' })).toBe( + 'Twitterの投稿から制作依頼日を入力しました。見つかった検索結果には入れられるリンクがないため、前回の検索で入力した投稿元URLは消去しました。保存前に日付を変更できます。' + ); + expect(m.admin_lookup_status_both_emptied({}, { locale: 'en' })).toBe( + 'Sona cleared the source post URL and commissioned date the last lookup filled, because this lookup filled neither one. You can fill them in before you save.' + ); + // The reason blames the lookup, the way its siblings do: this sentence + // renders on the no-match arm too, where there is no result to have a date. + // And it ends on the next step, the way its URL sibling does — the field + // is empty and the operator is the one who can put a date back in it. + expect(m.admin_lookup_status_date_emptied({}, { locale: 'en' })).toBe( + 'Sona cleared the commissioned date the last lookup filled, because this lookup filled no date in its place. You can set one before you save.' + ); + expect(m.admin_lookup_status_date_emptied({}, { locale: 'ja' })).toBe( + '今回の検索は代わりの日付を入力しなかったため、前回の検索で入力した制作依頼日は消去しました。保存前に入力できます。' + ); + // The panel's click-time announcement and these lines describe the same + // thing, so they use the same verb. + expect(m.admin_lookup_announce_searching_cleared({}, { locale: 'en' })).toContain('cleared'); + for (const line of [ + m.admin_lookup_status_url_and_date_emptied({ site: 'Twitter' }, { locale: 'ja' }), + m.admin_lookup_status_date_and_url_emptied({ site: 'Twitter' }, { locale: 'ja' }), + m.admin_lookup_status_both_emptied({}, { locale: 'ja' }), + m.admin_lookup_status_url_emptied({}, { locale: 'ja' }), + m.admin_lookup_status_date_emptied({}, { locale: 'ja' }) + ]) { + expect(line).toContain('消去しました'); + // 検索 is what every other Japanese string in the file calls a lookup. + expect(line).not.toContain('ルックアップ'); + } + // None of them claims a field was left alone, which is what they replace. + for (const locale of ['en', 'ja'] as const) { + for (const key of [ + m.admin_lookup_status_both_emptied, + m.admin_lookup_status_url_emptied, + m.admin_lookup_status_date_emptied + ]) { + expect(key({}, { locale })).not.toMatch(/left as it was|そのままに/); + } + } + }); + // url_only has two causes and the kind cannot tell them apart: the post // carried no date, or the date field already held one. The sentence used to // assert the first ("That post has no date"), which is a false claim in the @@ -773,6 +1133,295 @@ describe('statusLineKind', () => { }); }); +/** + * Every branch of statusLineKind against the one invariant it answers to: no + * sentence claims a field state the screen contradicts. A row is one pair of + * field states and the kind they have to produce, and one it() runs each — so a + * change that flips a single row fails by name instead of disappearing into a + * block of assertions (SONA-220). + * + * The five states a field can be in by the time the panel renders, and what + * each one puts in the two records the function reads: + * + * - `empty`: nobody filled it, this result did not clear it, the operator has + * not typed in it. Both records silent. + * - `theirs`: this result blanked what the last lookup filled and the operator + * has typed in it since. Cleared flag set, edited flag set, and the two + * disagree about the field on purpose. One state, not two: a field they + * refilled and one they typed into, emptied and typed into again read the + * same here, because the page LATCHES the typed-into flag on the first + * non-empty input instead of deriving it from the text. Without the latch the + * cleared claim comes back when they delete their text, and the panel tells + * them Sona cleared a field they emptied themselves (SONA-220). The sentence + * therefore has to be honest about both readings, which is what `forbidden` + * asks of it. + * - `emptied`: blanked and still blank, and nobody has typed in it since. + * Cleared flag set, edited flag clear. + * - `filled`: this result wrote it. + * - `typed_over`: this result wrote it and the operator has typed over what it + * wrote. Filled record set, edited flag set, cleared flag clear — the state + * both pages produce when someone corrects a prefilled field, and the one no + * row pinned until now. Nothing about the field is the lookup's to claim: it + * may not say it filled it, and it may not say it was left as it was either, + * because it did change it (SONA-220). + * - `held`: the operator's own text was already in it when the result landed, + * so no prefill touched it. This is the state `urlHeld` snapshots. + * + * A clash never fills the URL — `prefillForResult` skips it whatever the field + * holds — so `url: 'filled'` and `url: 'typed_over'` have no clash rows. + */ +// The `theirs` state below is one state, not two: a field this result emptied +// and the operator has text in now reaches statusLineKind identically whether +// they refilled it or typed and deleted and typed again. Only the latch on the +// two admin pages knows the difference, and it is pinned by the markup test and +// the parent-move e2e rather than here (SONA-220). +describe('statusLineKind — the truth table', () => { + type FieldState = 'empty' | 'theirs' | 'emptied' | 'filled' | 'typed_over' | 'held'; + + interface Row { + url: FieldState; + date: FieldState; + clash?: boolean; + kind: StatusLineKind; + /** Why that kind is the only honest one for this pair. */ + why: string; + } + + function argsFor(row: Row): Parameters { + const filled: LookupFields = {}; + const cleared: LookupCleared = {}; + const edited: LookupEdited = {}; + if (row.url === 'filled' || row.url === 'typed_over') + filled.sourcePostUrl = 'https://x.com/kuttoya/status/1'; + if (row.date === 'filled' || row.date === 'typed_over') filled.commissionedAt = '2026-03-04'; + if (row.url === 'theirs' || row.url === 'emptied') cleared.sourcePostUrl = true; + if (row.date === 'theirs' || row.date === 'emptied') cleared.commissionedAt = true; + if (row.url === 'theirs' || row.url === 'typed_over' || row.url === 'held') + edited.sourcePostUrl = true; + if (row.date === 'theirs' || row.date === 'typed_over' || row.date === 'held') + edited.commissionedAt = true; + return [filled, { clash: row.clash, edited, urlHeld: row.url === 'held', cleared }]; + } + + /** The claims a field in this state would contradict, whichever kind the row + * lands on. A field holding the operator's text is not empty; a field this + * result blanked was not left as it was. */ + function forbidden(row: Row): string[] { + const out: string[] = []; + if (row.url === 'theirs' || row.url === 'held' || row.url === 'typed_over') + out.push('left the source post URL empty'); + if (row.url === 'emptied' || row.url === 'typed_over') + out.push('left the source post URL as it was', 'left your source post URL as it was'); + if (row.date === 'emptied' || row.date === 'typed_over') + out.push('left the commissioned date as it was'); + // A field the operator has typed over holds their text, not the lookup's, + // so the sentence may not claim it filled it either. + if (row.url === 'typed_over') out.push('filled the source post URL'); + if (row.date === 'typed_over') out.push('filled the commissioned date'); + // `theirs` covers a field they refilled and one they typed into, emptied + // and typed into again, so the sentence has to be honest about both: it + // may not call the field empty, and it may not claim the clearing they did + // last. "As it was" stays allowed — Sona did leave the field as they left + // it. + if (row.url === 'theirs') out.push('cleared the source post URL'); + if (row.date === 'theirs') out.push('cleared the commissioned date'); + return out; + } + + const rows: Row[] = [ + // Nothing to report at all. + { url: 'empty', date: 'empty', kind: 'none', why: 'the result touched neither field' }, + { url: 'empty', date: 'theirs', kind: 'none', why: 'the date on screen belongs to the operator' }, + { url: 'empty', date: 'emptied', kind: 'date_emptied', why: 'the date went blank under them' }, + { url: 'empty', date: 'filled', kind: 'date_only', why: 'the URL really was left as it was' }, + { url: 'empty', date: 'held', kind: 'none', why: 'the result wrote nothing' }, + + { url: 'theirs', date: 'empty', kind: 'none', why: 'the URL on screen belongs to the operator' }, + { url: 'theirs', date: 'theirs', kind: 'none', why: 'both fields are theirs again' }, + { + url: 'theirs', + date: 'emptied', + kind: 'date_emptied', + why: 'only the date is still blank' + }, + { + url: 'theirs', + date: 'filled', + kind: 'date_kept', + why: 'date_only would say the URL was left as it was, and this result emptied it' + }, + { url: 'theirs', date: 'held', kind: 'none', why: 'the result wrote nothing' }, + + { url: 'emptied', date: 'empty', kind: 'url_emptied', why: 'the URL went blank under them' }, + { url: 'emptied', date: 'theirs', kind: 'url_emptied', why: 'the date is theirs again' }, + { url: 'emptied', date: 'emptied', kind: 'both_emptied', why: 'both fields went blank' }, + { + url: 'emptied', + date: 'filled', + kind: 'date_and_url_emptied', + why: 'the sentence names the fill and the clearing together' + }, + { url: 'emptied', date: 'held', kind: 'url_emptied', why: 'the date belongs to the operator' }, + + { url: 'filled', date: 'empty', kind: 'url_only', why: 'the date really was left as it was' }, + { + url: 'filled', + date: 'theirs', + kind: 'url_kept', + why: 'url_only would say the date was left as it was, and this result emptied it' + }, + { + url: 'filled', + date: 'emptied', + kind: 'url_and_date_emptied', + why: 'the sentence names the fill and the clearing together' + }, + { url: 'filled', date: 'filled', kind: 'both', why: 'the result wrote both' }, + { url: 'filled', date: 'held', kind: 'url_only', why: 'their date was left as it was' }, + + // This result filled the field and the operator typed over what it wrote. + // Nothing here claims the fill, and nothing says the field was left as it + // was either — the result did change it (SONA-220). + { url: 'typed_over', date: 'empty', kind: 'none', why: 'the fill is theirs now and no date was written' }, + { + url: 'typed_over', + date: 'filled', + kind: 'date_kept', + why: 'date_only would say the URL was left as it was, and this result filled it' + }, + { + url: 'typed_over', + date: 'emptied', + kind: 'date_emptied', + why: 'the date is the only field left to name' + }, + { url: 'typed_over', date: 'held', kind: 'none', why: 'neither field is the result\'s to claim' }, + { url: 'typed_over', date: 'typed_over', kind: 'none', why: 'both fills are theirs now' }, + { url: 'empty', date: 'typed_over', kind: 'none', why: 'the date on screen is theirs' }, + { + url: 'filled', + date: 'typed_over', + kind: 'url_kept', + why: 'url_only would say the date was left as it was, and this result filled it' + }, + { + url: 'emptied', + date: 'typed_over', + kind: 'url_emptied', + why: 'the URL went blank under them and the date is theirs' + }, + { url: 'held', date: 'typed_over', kind: 'none', why: 'neither field is the result\'s to claim' }, + + { url: 'held', date: 'empty', kind: 'none', why: 'the result wrote nothing' }, + { url: 'held', date: 'theirs', kind: 'none', why: 'both fields are theirs' }, + { url: 'held', date: 'emptied', kind: 'date_emptied', why: 'the date went blank under them' }, + { url: 'held', date: 'filled', kind: 'date_only', why: 'their URL was left as it was' }, + { url: 'held', date: 'held', kind: 'none', why: 'the result wrote nothing' }, + + // The clash half. The URL is never filled here, so the question is only + // what the sentence may say about it. + { url: 'empty', date: 'empty', clash: true, kind: 'none', why: 'nothing happened to report' }, + { + url: 'empty', + date: 'filled', + clash: true, + kind: 'clash', + why: 'the URL was empty before the clash and is empty now' + }, + { + url: 'empty', + date: 'emptied', + clash: true, + kind: 'date_emptied', + why: 'the clash sentence would claim a date it did not fill' + }, + { + url: 'theirs', + date: 'empty', + clash: true, + kind: 'none', + why: 'the URL is theirs and no date was filled' + }, + { + url: 'theirs', + date: 'filled', + clash: true, + kind: 'clash_kept', + why: 'clash would say the URL was left empty, and their URL is in it' + }, + { + url: 'emptied', + date: 'empty', + clash: true, + kind: 'clash_emptied', + why: 'the clash names its own reason for the clearing' + }, + { + url: 'emptied', + date: 'theirs', + clash: true, + kind: 'clash_emptied', + why: 'the date is theirs again, so the clearing is all that is left to name' + }, + { + url: 'emptied', + date: 'filled', + clash: true, + kind: 'clash_date_url_emptied', + why: 'the URL went blank under them and the date was filled' + }, + { + url: 'emptied', + date: 'emptied', + clash: true, + kind: 'both_emptied', + why: 'a clash with no date to report claims no reason its body contradicts' + }, + { + url: 'held', + date: 'empty', + clash: true, + kind: 'none', + why: 'the result wrote nothing' + }, + { + url: 'held', + date: 'filled', + clash: true, + kind: 'clash_kept', + why: 'their URL was never empty' + }, + { + url: 'held', + date: 'emptied', + clash: true, + kind: 'date_emptied', + why: 'only the date changed' + }, + { + url: 'emptied', + date: 'typed_over', + clash: true, + kind: 'clash_emptied', + why: 'the date it filled is theirs now, so the clearing is all that is left to name' + } + ]; + + for (const row of rows) { + const name = `${row.clash ? 'a clash with' : 'a result with'} the URL ${row.url} and the date ${row.date} reads ${row.kind}: ${row.why}`; + it(name, () => { + const [filled, options] = argsFor(row); + expect(statusLineKind(filled, options)).toBe(row.kind); + // And the sentence it picks says nothing the screen contradicts, on + // either page — the edit page swaps one of the two mixed sentences. + for (const editMode of [false, true]) { + const line = statusSentence(row.kind, 'Twitter', { title: 'Clash Piece', editMode }); + for (const claim of forbidden(row)) expect(line).not.toContain(claim); + } + }); + } +}); + describe('stateFromResponse', () => { it('reads matches into the results state', async () => { const state = await stateFromResponse(jsonResponse(response())); diff --git a/src/lib/artist-lookup.ts b/src/lib/artist-lookup.ts index 859c7501..e7f06f5a 100644 --- a/src/lib/artist-lookup.ts +++ b/src/lib/artist-lookup.ts @@ -468,6 +468,47 @@ export interface LookupEdited { commissionedAt?: boolean; } +/** Which of the two fields this result EMPTIED: the previous lookup filled it, + * the operator never typed over it, and the new result has nothing to put in + * its place. A field can never be both filled and emptied by one result. */ +export interface LookupCleared { + sourcePostUrl?: boolean; + commissionedAt?: boolean; +} + +/** The cleared record as the SCREEN has it: a field the operator has typed into + * since is theirs again, and no sentence may still say Sona cleared it. The one + * copy of that drop — `statusLineKind` and `clearedLine` both answer "did Sona + * clear this field" and a second copy could answer it differently (SONA-220). */ +function clearedOnScreen(held: LookupCleared, edited: LookupEdited): LookupCleared { + return { + sourcePostUrl: held.sourcePostUrl === true && edited.sourcePostUrl !== true, + commissionedAt: held.commissionedAt === true && edited.commissionedAt !== true + }; +} + +/** + * The one sentence for what a move emptied, or null when it emptied nothing. + * No reason is attached to it, which is what makes it the sentence for a moment + * when no reason is settled: the upload page announces it on a parent move, and + * the panel's searching arm renders it while the result that would explain the + * blank fields is still out. The status-line kinds say "because this lookup + * filled neither one", which is not yet true of a lookup still running. + * + * `edited` drops a field the operator has typed into since, the way + * `statusLineKind` does — during a search they are free to fill a field the + * move emptied, and the sentence must not claim their own text. Required, not + * defaulted: a caller with nothing to pass says so with `{}` rather than + * forgetting the argument exists. + */ +export function clearedLine(cleared: LookupCleared, edited: LookupEdited): string | null { + const now = clearedOnScreen(cleared, edited); + if (now.sourcePostUrl && now.commissionedAt) return m.admin_lookup_announce_shared_cleared(); + if (now.sourcePostUrl) return m.admin_lookup_announce_shared_cleared_source(); + if (now.commissionedAt) return m.admin_lookup_announce_shared_cleared_date(); + return null; +} + /** Which sentence describes what the prefill actually did. */ export type StatusLineKind = | 'both' @@ -477,41 +518,185 @@ export type StatusLineKind = | 'date_kept' | 'clash' | 'clash_kept' + | 'clash_emptied' + | 'clash_date_url_emptied' + | 'url_and_date_emptied' + | 'date_and_url_emptied' + | 'both_emptied' + | 'url_emptied' + | 'date_emptied' | 'none'; /** + * The one invariant every branch here answers to: NO SENTENCE CLAIMS A FIELD + * STATE THE SCREEN CONTRADICTS. A kind is chosen off two records of the same + * two fields — `cleared`, what the last result BLANKED, worked out once when + * the fields went blank, and `edited`, which of them hold the operator's own + * text NOW — and the pair disagree on a field the result emptied and the + * operator then typed into. Which record answers depends on the question the + * sentence asks: "did Sona clear this field" is about the screen, so it reads + * the cleared flag with the edited one applied; "was this field empty before" + * is about history, so it reads the raw flag, which no later keystroke undoes. + * The truth table for all five states of a field is in the unit tests. + * * The sentence names only the fields still attributable to the lookup, and * asserts nothing about one the operator has edited since. `url_only` says the * date was "left as it was", which is true of a date the lookup never filled * and false of one it filled and the operator then changed — that case gets * `url_kept`, which claims the URL and stays silent about the date. `date_kept` - * is the mirror. + * is the mirror, and a date this result EMPTIED under the operator picks it + * too: the field is theirs again, but "left as it was" is still a false report + * of a field they watched go blank. * * The clash pair follows the same rule for the URL. `prefillForResult` skips the * URL on any clash, whatever the field holds, so "left the source post URL * empty" is true only of a field that WAS empty. `urlHeld` says it is not, and * picks `clash_kept`, which claims the date and says the URL was not filled - * without claiming it is empty. + * without claiming it is empty. A URL this result emptied and the operator has + * typed back in is not empty either, so it picks `clash_kept` as well. + * + * `cleared` is the other half of the same honesty. A second lookup keeps the + * fields the first one filled until its own result lands, so a result with no + * post or no date EMPTIES one of them then (SONA-220) — and `url_only` saying + * the date was "left as it was" would be a false report of a field the operator + * just watched go blank. The emptied kinds name it instead. */ export function statusLineKind( filled: LookupFields, - options: { clash?: boolean; edited?: LookupEdited; urlHeld?: boolean } = {} + options: { + clash?: boolean; + edited?: LookupEdited; + urlHeld?: boolean; + cleared?: LookupCleared; + } = {} ): StatusLineKind { const edited = options.edited ?? {}; + // A cleared flag is worked out once, when the field goes blank, and read on + // every render after — including the renders that follow the operator typing + // into the field it names. Their own text is not a field Sona cleared, and + // the sentence for it invites them to fill in something already sitting in + // the input. So an edited field drops the flag, the same way the filled half + // below drops a value the operator has typed over (SONA-220). + const held = options.cleared ?? {}; + const cleared = clearedOnScreen(held, edited); + // The raw flags, which the keystroke above does not reach. A field the result + // blanked is one it CHANGED, whoever has typed into it since — so the kept + // sentences, which exist to avoid claiming a field was "left as it was", are + // chosen off these rather than off the dropped ones (SONA-220). + const urlWasEmptied = held.sourcePostUrl === true; + const dateWasEmptied = held.commissionedAt === true; const urlFilled = filled.sourcePostUrl !== undefined; const dateFilled = filled.commissionedAt !== undefined; const url = urlFilled && !edited.sourcePostUrl; const date = dateFilled && !edited.commissionedAt; if (options.clash) { - if (!date) return 'none'; - return options.urlHeld ? 'clash_kept' : 'clash'; + // The clash sentence says the URL was "left empty", which is true of a + // field that WAS empty and a false report of one this result just blanked + // — the operator watched it go, and only the emptied kind names it. + if (date) { + if (cleared.sourcePostUrl) return 'clash_date_url_emptied'; + // The snapshot is taken when the prefill runs, so it still says "empty" + // about a URL the operator has typed in since — and the field they are + // looking at is not empty. The raw emptied flag answers for them. + return options.urlHeld || urlWasEmptied ? 'clash_kept' : 'clash'; + } + // A clash with no date to report either. The plain url_emptied sentence + // gives "this lookup filled nothing in its place" as the reason, which + // reads as a lookup that found nothing under a body saying Sona found a + // post and declined it, so the clash names its own reason. Both fields + // emptied keeps both_emptied below: that one claims no reason the clash + // body contradicts. + if (cleared.sourcePostUrl && !cleared.commissionedAt) return 'clash_emptied'; + } else { + if (url && date) return 'both'; + if (url) { + if (cleared.commissionedAt) return 'url_and_date_emptied'; + return dateFilled || dateWasEmptied ? 'url_kept' : 'url_only'; + } + if (date) { + if (cleared.sourcePostUrl) return 'date_and_url_emptied'; + return urlFilled || urlWasEmptied ? 'date_kept' : 'date_only'; + } } - if (url && date) return 'both'; - if (url) return dateFilled ? 'url_kept' : 'url_only'; - if (date) return urlFilled ? 'date_kept' : 'date_only'; + if (cleared.sourcePostUrl && cleared.commissionedAt) return 'both_emptied'; + if (cleared.sourcePostUrl) return 'url_emptied'; + if (cleared.commissionedAt) return 'date_emptied'; return 'none'; } +/** + * Whether this kind's sentence names no post. The three that report an emptied + * field and NOTHING else: they are the ones a no-match or a failure can still + * say, having no prefill match to name, which is also why the panel renders + * them in a paragraph of their own well above its status line. One answer for + * both questions — listed separately, the mapping and the panel could disagree + * about which kinds belong here. + */ +export function namesNoSite( + kind: StatusLineKind +): kind is 'both_emptied' | 'url_emptied' | 'date_emptied' { + return kind === 'both_emptied' || kind === 'url_emptied' || kind === 'date_emptied'; +} + +/** + * The sentence for a status kind. The panel renders it and the upload page + * announces it, off this one mapping: read separately, the announcement picked + * a sentence by hand and named a different reason for the same move than the + * panel did (SONA-220). + * + * `site` is the post the prefill match came from. The kinds that report only an + * emptied field name no site — they render over a no-match, which has no match + * to name — so a missing site answers with the empty string rather than an + * unfilled placeholder, and so does `none`. + */ +export function statusSentence( + kind: StatusLineKind, + site: LookupSite | null, + options: { title?: string; editMode?: boolean } = {} +): string { + switch (kind) { + case 'both_emptied': + return m.admin_lookup_status_both_emptied(); + case 'url_emptied': + return m.admin_lookup_status_url_emptied(); + case 'date_emptied': + return m.admin_lookup_status_date_emptied(); + case 'none': + return ''; + } + if (!site) return ''; + const label = siteLabel(site); + const title = options.title ?? ''; + switch (kind) { + case 'both': + return m.admin_lookup_status_both({ site: label }); + case 'url_only': + return m.admin_lookup_status_url_only({ site: label }); + case 'url_kept': + return m.admin_lookup_status_url_kept({ site: label }); + case 'date_kept': + return m.admin_lookup_status_date_kept({ site: label }); + case 'url_and_date_emptied': + return m.admin_lookup_status_url_and_date_emptied({ site: label }); + case 'date_and_url_emptied': + return m.admin_lookup_status_date_and_url_emptied({ site: label }); + case 'clash': + return m.admin_lookup_status_clash({ site: label, title }); + case 'clash_kept': + return m.admin_lookup_status_clash_kept({ site: label, title }); + case 'clash_emptied': + return m.admin_lookup_status_clash_emptied({ site: label, title }); + case 'clash_date_url_emptied': + return m.admin_lookup_status_clash_date_url_emptied({ site: label, title }); + case 'date_only': + // The edit page's source URL belongs to the image rather than to this + // lookup, so the sentence there calls it the operator's own. + return options.editMode + ? m.admin_lookup_status_kept({ site: label }) + : m.admin_lookup_status_date_only({ site: label }); + } +} + /** * Whether FuzzySearch has a copy of the file in this state, which is what the * private-image disclosure claims. A result and a no-match both mean it does. diff --git a/src/lib/components/ArtistLookupPanel.svelte b/src/lib/components/ArtistLookupPanel.svelte index 7667b1af..d65454dd 100644 --- a/src/lib/components/ArtistLookupPanel.svelte +++ b/src/lib/components/ArtistLookupPanel.svelte @@ -12,12 +12,14 @@ import { bandLabel, candidateArtists, + clearedLine, isCrossSiteAmbiguity, matchForArtist, matchHandle, matchHandles, matchKey, nameMatchArtists, + namesNoSite, pickPrefillMatch, postDateToInput, ratingLabel, @@ -25,7 +27,9 @@ seedStatusKind, siteLabel, statusLineKind, + statusSentence, type ArtistChoice, + type LookupCleared, type LookupEdited, type LookupFields, type LookupMatch, @@ -43,14 +47,21 @@ /** What the page's prefill actually wrote, for the status line. This record * is immutable — it describes the lookup, not the form as it stands now. */ filled?: LookupFields; - /** Which of those fields the operator has typed over since. An edited field - * is no longer the lookup's, so the sentence stops claiming it AND stops - * saying it was left alone. */ + /** Which of the two fields holds the operator's own text: one they typed + * over since, or one they filled that no lookup had. An edited field is + * no longer the lookup's, so the sentence stops claiming it, stops saying + * it was left alone, and stops saying Sona cleared it. */ edited?: LookupEdited; /** What the page seeded into an inline new-artist form, for the status * line — the seed is subject to the same never-overwrite rule, so the * sentence has to say which of the two fields it actually filled. */ seeded?: NewArtistSeed; + /** Which of the two fields this result EMPTIED, because the last lookup + * filled them and this one has nothing to put in their place (SONA-220). + * A snapshot like `filled`: the sentence says what this result did. Read + * against `edited`, which is live — a field the operator has filled since + * is theirs, and no sentence may still say Sona cleared it. */ + cleared?: LookupCleared; /** `edited`, for the seeded fields — same rule, same reason. */ seedEdited?: SeedEdited; /** Whether the source post URL field held anything when the prefill ran. @@ -84,6 +95,7 @@ fileName = '', filled = {}, edited = {}, + cleared = {}, seeded = {}, seedEdited = {}, sourceUrlHeld = false, @@ -117,7 +129,49 @@ const clash = $derived(data?.sourceClash ?? null); const siteCount = $derived(data ? new Set(data.matches.map((x) => x.site)).size : 0); const statusKind = $derived( - statusLineKind(filled, { clash: !!clash, edited, urlHeld: sourceUrlHeld }) + statusLineKind(filled, { clash: !!clash, edited, urlHeld: sourceUrlHeld, cleared }) + ); + // The one sentence for that kind, from the mapping the upload page's + // announcement reads too — picked by hand on either side, the two named + // different reasons for the same move (SONA-220). + const statusText = $derived( + statusSentence(statusKind, prefill?.site ?? null, { title: clash?.title ?? '', editMode }) + ); + // The kinds that report an emptied field and NOTHING else, off the same test + // the mapping uses to answer them without a site: a second list here could + // disagree with that one about which kinds get rendered in their own + // paragraph, and the sentence would land in the arm that has no site to name. + const emptiedOnly = $derived(namesNoSite(statusKind)); + // The same fields, said without a reason, for the searching arm: a move onto + // a tile whose lookup is still out empties them right then, and every + // status-line sentence blames a lookup that has not answered yet ("because + // this lookup filled neither one"). Off the chooser the upload page's own + // announcement of that move reads, so the two name the same fields. + const movedEmptied = $derived(clearedLine(cleared, edited)); + // Every sentence that reports a field going blank, and not only the three + // that report nothing else — the single answer to "does this line describe a + // change the operator's fields just made". + const reportsEmptied = $derived( + emptiedOnly || + statusKind === 'url_and_date_emptied' || + statusKind === 'date_and_url_emptied' || + statusKind === 'clash_emptied' || + statusKind === 'clash_date_url_emptied' + ); + // The two failure reasons that carry advice under the lead. Held as text so + // the emptied sentence can go ABOVE it: a parent move onto a tile whose + // lookup failed empties the fields, and what just happened to the form is + // read before what to do about the failure. This list and the failed arm's + // own reason branches have to stay in step — a reason that grows a hint in + // one and not the other renders nothing. + const failedHint = $derived( + lookup.kind !== 'failed' + ? '' + : lookup.reason === 'rate_limited' + ? m.admin_lookup_paused_hint() + : lookup.reason === 'key_refused' + ? m.admin_lookup_refused_hint() + : '' ); const seedKind = $derived(seedStatusKind(seeded, seedEdited)); // The "Sets the artist to {name}." sentence and the button it @@ -175,9 +229,12 @@ The region is atomic, so every change to the status line re-speaks the whole panel. What the lookup filled is a fixed record and only the edited-since - flags move, and a flag flips on the FIRST keystroke in a field and not - again — so an operator revising a filled field hears the panel once per - field, not once per keystroke. --> + flags move, and a flag flips the first time a field holds text of the + operator's and not again: the tag half is dropped by the first keystroke + over a filled field, and the typed-into half is latched by the page on the + first non-empty input rather than derived from the text, so deleting what + they typed does not flip it back (SONA-220). An operator revising a field + hears the panel once for it, not once per keystroke. -->