feat(review): link an image to a library prompt from Needs review (#536) (#540)

Closes #536
This commit is contained in:
Vito Sansevero
2026-10-30 16:52:42 -07:00
parent 07c2442e23
commit 06ea91da66
20 changed files with 1330 additions and 239 deletions
+6 -2
View File
@@ -26,6 +26,10 @@ async function openReview(page, theme) {
await openProduction(page, 'review', theme);
await expect(panel(page).getByRole('combobox', { name: 'Sampler context', exact: true })).toBeEnabled();
await settle(page);
// D179, D181: the two buttons the concept lacks are pinned here, then left out of the comparison.
await expect(panel(page).getByRole('button', { name: 'Leave unresolved', exact: true })).toBeVisible();
await expect(panel(page).getByRole('button', { name: 'Link to a prompt…', exact: true })).toBeVisible();
await page.addStyleTag({ content: '.review-panel .review-leave-unresolved, .review-panel .review-link-open { display: none !important; }' });
}
async function settle(page) {
await page.mouse.move(0, 0);
@@ -90,7 +94,7 @@ for (const [width, height] of SIZES) for (const theme of THEMES) {
expect(surface.children[1].text).toBe(CONCEPT_FILE);
const actions = surface.children.at(-1);
expect(actions.children.map(button => [button.text, button.primary])).toEqual([['Leave for later', false], ['Apply association', true]]);
expect(node(surface, 'select').options.at(-1)).toEqual(['Leave unresolved', false]);
expect(node(surface, 'select').options.map(option => option[0])).not.toContain('Leave unresolved'); // D179
expect(node(main, 'div.image-grid').children.map(card => node(card, 'h3').text))
.toEqual(['alpine_00001.png', 'dunes_00001.png', 'forest_00001.png']);
expect(actual.noPageScroll).toBe(true);
@@ -169,7 +173,7 @@ test('the comparison notices a dropped notice border and reordered actions', asy
const reordered = await productionInventory(page);
expect(diffInventory(reference, reordered).map(item => item.path)).toEqual(expect.arrayContaining([
'main.children.2.children.1.children.6.children.0.text', 'main.children.2.children.1.children.6.children.0.primary']));
await panel(page).locator('.dialog-actions').evaluate(row => row.prepend(row.lastElementChild));
await panel(page).locator('.dialog-actions').evaluate(row => row.append(row.firstElementChild));
expect(diffInventory(reference, await productionInventory(page))).toEqual([]);
});
@@ -279,7 +279,7 @@ export function mapConceptReview(reference,actual) {
});
main=editNodes(main,'select',select=>{
const twin=findNode(actual.main,'select');
return select.id==='review-context'?{...select,options:[...twin.options.slice(0,-1),select.options.at(-1)]}:select;
return select.id==='review-context'?{...select,options:twin.options}:select; // D179: no Leave unresolved option
});
main=editNodes(main,node=>/^section\..*\bprompt-section\b/.test(node.tag),section=>({...section,label:section.tag.includes('positive')?'Positive prompt':'Negative prompt'}));
main=editNodes(main,node=>node.tag?.startsWith('button')&&/^(Copy|Send) (positive|negative) prompt/.test(node.label??''),
+85 -23
View File
@@ -15,8 +15,11 @@ const cards = page => main(page).locator('.review-queue article.media-card');
const toast = page => page.locator('#workspace-toast');
const NOTICE = 'Multiple sampler paths were found. The existing image links were preserved. Choose the correct context or leave them unresolved.';
const EMPTY = 'Ambiguous images are added when a metadata scan or a new generation finds more than one sampler path.';
const OPTIONS = ['Sampler 11 · Golden dunes at dusk / Soft negative, blur', 'Sampler 13 · Harbor lights at night / Soft negative, blur',
'Leave unresolved'];
const OPTIONS = ['Sampler 11 · Golden dunes at dusk / Soft negative, blur', 'Sampler 13 · Harbor lights at night / Soft negative, blur'];
const leaveUnresolved = page => panel(page).getByRole('button', { name: 'Leave unresolved', exact: true });
const linkButton = page => panel(page).getByRole('button', { name: 'Link to a prompt…', exact: true });
const linkDialog = page => page.getByRole('dialog', { name: 'Link to a prompt', exact: true });
async function seed(request, items = 3, special = {}) {
const response = await request.post('/__test__/review/reset', { data: { items, ...special } });
@@ -44,7 +47,7 @@ const warning = page => panel(page).locator('.warning-note');
/** The page with its first item's sampler paths loaded. */
async function open(page) {
await page.goto(review);
await expect(context(page)).toBeEnabled();
await expect(leaveUnresolved(page)).not.toHaveAttribute('aria-disabled', 'true');
}
test.beforeEach(async ({ page, browserName }) => {
@@ -80,8 +83,6 @@ test('queue shows persisted items and the nav count', async ({ page, request })
await expect(panel(page).locator('.prompt-section.negative .prompt-text')).toHaveText('Soft negative, blur');
await context(page).selectOption({ label: OPTIONS[1] });
await expect(panel(page).locator('.prompt-section.positive .prompt-text')).toHaveText('Harbor lights at night');
await context(page).selectOption({ label: 'Leave unresolved' });
await expect(panel(page).locator('.prompt-section.positive .prompt-text')).toHaveText('Multiple possible prompts. Source could not be determined.');
// Info selects another item.
await cards(page).nth(1).getByRole('button', { name: 'Inspect review_0001.png', exact: true }).click();
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
@@ -105,7 +106,9 @@ test('apply candidate links the pair and the item leaves the queue and the count
await panel(page).getByRole('button', { name: 'Apply association', exact: true }).click();
expect((await decided).status()).toBe(200);
await expect(toast(page)).toHaveText('Association accepted.');
await expect(page).toHaveURL(/#\/images$/);
// #536: the page stays and the next item opens.
await expect(page).toHaveURL(/#\/review$/);
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
await expect(reviewRow(page)).toHaveText('Needs review1');
expect(await pending(request)).toBe(1);
// The first sampler path's pair is linked to that file, and only to it.
@@ -135,20 +138,20 @@ test('conflict leaves links unchanged and explains', async ({ page, request }) =
expect(await pending(request)).toBe(1);
await expect(reviewRow(page)).toHaveText('Needs review1');
// The file can still be left unresolved, which links nothing either.
await context(page).selectOption({ label: 'Leave unresolved' });
await apply(page).click();
await leaveUnresolved(page).click();
await expect(toast(page)).toHaveText('Left unresolved.');
expect(await links(request)).toEqual(before);
expect(await pending(request)).toBe(0);
});
test('a limited item and an unavailable file can only be left unresolved', async ({ page, request }) => {
test('a limited item can be linked or left unresolved; an unavailable file only left unresolved', async ({ page, request }) => {
await seed(request, 0, { limited: true, unavailable: true });
await open(page);
await expect(cards(page)).toHaveCount(2);
await expect(panel(page).locator('p.small')).toHaveText('limited.png');
await expect(context(page).locator('option')).toHaveText(['Leave unresolved']);
await expect(warning(page)).toHaveText('This file has too many sampler paths to list. You can leave it unresolved.');
await expect(context(page)).toBeHidden();
await expect(warning(page)).toHaveText('This file has too many sampler paths to list. Link it to a prompt or leave it unresolved.');
await expect(linkButton(page)).toHaveClass(/ca-primary/);
// The unavailable file: no current root lists it, so its media and Tag are inert.
const unavailable = cards(page).nth(1);
await expect(unavailable.locator('.image-open')).toHaveAttribute('aria-disabled', 'true');
@@ -166,7 +169,8 @@ test('a limited item and an unavailable file can only be left unresolved', async
await expect(panel(page).getByRole('button', { name: 'Tag image', exact: true })).toHaveAttribute('aria-disabled', 'true');
const observes = [];
page.on('request', item => { if (item.url().endsWith('/image-annotations/observe')) observes.push(item.url()); });
await apply(page).click();
await expect(linkButton(page)).toHaveAttribute('aria-disabled', 'true');
await leaveUnresolved(page).click();
await expect(toast(page)).toHaveText('Left unresolved.');
expect(observes).toEqual([]);
expect(await pending(request)).toBe(1);
@@ -175,20 +179,20 @@ test('a limited item and an unavailable file can only be left unresolved', async
test('leave unresolved removes the item from pending', async ({ page, request }) => {
await seed(request, 2);
await open(page);
await context(page).selectOption({ label: 'Leave unresolved' });
await panel(page).getByRole('button', { name: 'Apply association', exact: true }).click();
await leaveUnresolved(page).click();
await expect(toast(page)).toHaveText('Left unresolved.');
await expect(page).toHaveURL(/#\/images$/);
await expect(page).toHaveURL(/#\/review$/);
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
expect(await pending(request)).toBe(1);
});
test('leave for later writes nothing and goes to Images', async ({ page, request }) => {
test('leave for later writes nothing and opens the next item', async ({ page, request }) => {
await seed(request, 2);
await open(page);
const writes = [];
page.on('request', item => { if (item.method() !== 'GET') writes.push(item.url()); });
await panel(page).getByRole('button', { name: 'Leave for later', exact: true }).click();
await expect(page).toHaveURL(/#\/images$/);
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
expect(writes).toEqual([]);
expect(await pending(request)).toBe(2);
});
@@ -332,7 +336,7 @@ test('keyboard focus labels and touch targets', async ({ page, request }) => {
const order = await panel(page).evaluate(node => [...node.querySelectorAll('button, select')]
.filter(control => control.offsetParent !== null).map(control => control.getAttribute('aria-label') || control.textContent.trim()));
expect(order[0]).toBe('Tag image');
expect(order.slice(-2)).toEqual(['Leave for later', 'Apply association']);
expect(order.slice(-3)).toEqual(['Leave for later', 'Leave unresolved', 'Apply association']);
// Targets: the panel's controls are 40px on a coarse pointer (the concept's coarse buttons) and 24px otherwise (the
// WCAG 2.2 minimum); the card's buttons keep the concept's compact size, never under 24px (its ≤700px rule).
const coarse = await page.evaluate(() => matchMedia('(pointer: coarse)').matches);
@@ -350,10 +354,10 @@ test('keyboard focus labels and touch targets', async ({ page, request }) => {
expect(box.height, label).toBeGreaterThanOrEqual(minimum);
expect(box.width, label).toBeGreaterThanOrEqual(24);
}
// Enter on Leave for later returns to Images.
// Enter on Leave for later stays on the page; the only item stays open.
await panel(page).getByRole('button', { name: 'Leave for later', exact: true }).focus();
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/#\/images$/);
await expect(page).toHaveURL(/#\/review$/);
});
test('an open page shows items a scan adds and keeps the open choice', async ({ page, request }) => {
@@ -391,9 +395,8 @@ test('a decision in another tab leaves the open page and opens the next image',
await expect.poll(async () => (await streams(request)).streams).toBe(1);
const other = await browserContext.newPage();
await open(other);
await context(other).selectOption({ label: 'Leave unresolved' });
await apply(other).click();
await expect(other).toHaveURL(/#\/images$/);
await leaveUnresolved(other).click();
await expect(toast(other)).toHaveText('Left unresolved.');
await other.close();
await expect(cards(page)).toHaveCount(1);
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
@@ -613,3 +616,62 @@ test('teardown releases captures listeners and requests', async ({ page, request
await closedStreamsDropped(request);
await expect.poll(() => inFlight.size).toBe(0);
});
test('#536: nothing selectable says why and links through the picker, then the next item opens', async ({ page, request }) => {
await seed(request, 1, { unreadable: true });
await open(page);
await cards(page).nth(1).getByRole('button', { name: 'Inspect unreadable.png', exact: true }).click();
await expect(panel(page)).toContainText("None of this file's 2 sampler paths could be traced to a complete prompt");
await expect(panel(page)).toContainText('Unsupported latent ancestry (2 paths)');
await expect(context(page)).toBeHidden();
await expect(panel(page).getByRole('button', { name: 'Apply association', exact: true })).toBeHidden();
await expect(panel(page)).not.toContainText('Multiple possible prompts');
await expect(linkButton(page)).toHaveClass(/ca-primary/);
await linkButton(page).click();
const dialog = linkDialog(page);
await expect(dialog).toContainText("This records your choice; the file's metadata does not confirm it.");
const submit = dialog.getByRole('button', { name: 'Link image', exact: true });
await expect(submit).toHaveAttribute('aria-disabled', 'true');
await dialog.getByRole('searchbox').fill('harbor');
const harbor = dialog.getByRole('button', { name: 'Quiet harbor', exact: true });
await expect(harbor).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Golden dunes at dusk', exact: true })).toHaveCount(0);
await harbor.click();
// Several recipes: the reviewer must choose.
const negative = dialog.getByRole('combobox', { name: 'Negative prompt', exact: true });
await expect(negative.locator('option')).toHaveText(['Choose a negative…', 'washed out', 'grainy', 'No negative']);
await expect(submit).toHaveAttribute('aria-disabled', 'true');
await expect(submit).toHaveCSS('opacity', '0.55');
await expect(harbor).toHaveAttribute('aria-pressed', 'true');
await negative.selectOption({ label: 'grainy' });
const linked = page.waitForResponse(item => /\/link$/.test(item.url()));
await submit.click();
expect((await linked).status()).toBe(200);
await expect(toast(page)).toHaveText('Linked to Quiet harbor.');
await expect(dialog).toHaveCount(0);
await expect(page).toHaveURL(/#\/review$/);
await expect(panel(page).locator('p.small')).toHaveText('review_0000.png');
expect(await pending(request)).toBe(1);
const link = (await links(request)).find(row => row.filename === 'unreadable.png');
expect([link.positive, link.negative]).toEqual(['Quiet harbor', 'grainy']);
});
test('#536: a refused link stays in the dialog with the server\'s sentence', async ({ page, request }) => {
await seed(request, 0, { unreadable: true });
await open(page);
await linkButton(page).click();
const dialog = linkDialog(page);
await dialog.getByRole('button', { name: 'Golden dunes at dusk', exact: true }).click();
// One recipe: preselected and visible.
const negative = dialog.getByRole('combobox', { name: 'Negative prompt', exact: true });
await expect(negative).toHaveValue(/^[0-9]+$/);
await page.route(/\/link$/, route => route.fulfill({ status: 409, contentType: 'application/json',
body: JSON.stringify({ success: false, code: 'association-conflict', error: 'This image is already linked.' }) }));
await dialog.getByRole('button', { name: 'Link image', exact: true }).click();
await expect(dialog.locator('.warning-note')).toHaveText('This image is already linked.');
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog).toHaveCount(0);
expect(await pending(request)).toBe(1);
expect(await links(request)).toEqual([]);
});
+42 -1
View File
@@ -19,6 +19,10 @@ after the plain ones in this order:
recorder stores ``limited`` (more than 16 candidates, so none are stored).
An A1111 block with two negative lines is not queued any more: it offers
no candidate to choose (#284).
- ``unreadable`` (#536): an item whose stored candidates are all
``unresolved``, as items recorded before the choice rule are, written directly
(the recorder no longer stores one). The Library holds "Golden dunes at
dusk" with its negative, and "Quiet harbor" with two.
- ``unavailable``: a file recorded under the ``temp`` directory while it was a
gallery root; the root is then removed again, so no current root lists it.
@@ -41,6 +45,7 @@ page closed is dropped at such a write.
from __future__ import annotations
import hashlib
import json
import os
import threading
@@ -58,10 +63,11 @@ if TYPE_CHECKING:
from py.api import PromptManagerAPI
POSITIVES = ("Golden dunes at dusk", "Harbor lights at night")
UNREADABLE_REASON = "Unsupported latent ancestry"
NEGATIVE = "Soft negative, blur"
# The prompt the conflict item's file is linked to after it was queued.
FALLBACK = "Misty forest path"
SPECIAL = ("conflict", "limited", "unavailable")
SPECIAL = ("conflict", "limited", "unavailable", "unreadable")
# The concept's first three images (concept app.js `images`) and first prompt.
CONCEPT_IMAGES = (
("alpine_00001.png", (1536, 1152)),
@@ -215,6 +221,38 @@ def _unavailable(api: PromptManagerAPI, root: Path, path: Path) -> None:
GalleryConfig.MONITORING_DIRECTORIES = roots
def _unreadable(api: PromptManagerAPI, path: Path) -> None:
"""Queue ``path`` with candidates no reviewer can apply, and Library prompts."""
facts = path.stat()
spelled = os.path.join(os.path.realpath(path.parent), path.name)
candidates = [
{
"hash": hashlib.sha256(f"unreadable-{sampler}".encode()).hexdigest(),
"sampler_id": sampler,
"state": "unresolved",
"reason": UNREADABLE_REASON,
"positive": None,
"negative": None,
}
for sampler in ("11", "13")
]
api.db.record_review_item(
locator=os.path.normcase(spelled),
spelled_path=spelled,
sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
size=facts.st_size,
mtime_ns=facts.st_mtime_ns,
epoch=api.db.get_execution_epoch(),
resolver_version=1,
evidence={"reason": "", "resolver_version": 1, "candidates": candidates},
candidate_count=len(candidates),
limited=False,
)
api.db.save_prompt_pair(POSITIVES[0], NEGATIVE)
api.db.save_prompt_pair("Quiet harbor", "grainy")
api.db.save_prompt_pair("Quiet harbor", "washed out")
def seed_review(
api: PromptManagerAPI,
root: Path,
@@ -254,6 +292,9 @@ def seed_review(
path = (root / "temp" if kind == "unavailable" else output) / relative
if kind == "conflict":
_linked_after_queueing(api, path)
elif kind == "unreadable":
_png(path, (70, 40, 120))
_unreadable(api, path)
elif kind == "limited":
many = tuple(f"{POSITIVES[0]} {n}" for n in range(LIMITED_SAMPLERS))
_png(path, (90, 90, 90), graph=_graph(positives=many))
+1
View File
@@ -154,6 +154,7 @@
"POST /prompt_manager/restore/apply",
"POST /prompt_manager/restore/preview",
"POST /prompt_manager/review/items/{item_id}/decision",
"POST /prompt_manager/review/items/{item_id}/link",
"POST /prompt_manager/save",
"POST /prompt_manager/saved-views",
"POST /prompt_manager/scan",
+102 -24
View File
@@ -128,9 +128,7 @@ test('selecting a card loads detail and option labels, first workable preselecte
['Sampler 9 · No positive / Soft negative', true],
['Sampler 12 · Dunes / Soft negative', false],
['Sampler 28 · Dunes / Harsh negative', false],
['Leave unresolved', false],
]);
assert.equal(c.state.options.at(-1).value, 'unresolved');
assert.equal(c.state.choice, HASH_A);
assert.deepEqual(c.state.prompts, { positive: { status: 'resolved', text: 'golden dunes' }, negative: { status: 'resolved', text: 'blur' } });
c.choose(HASH_C);
@@ -138,16 +136,17 @@ test('selecting a card loads detail and option labels, first workable preselecte
c.choose(HASH_B);
assert.equal(c.state.prompts.negative.text, 'noise');
c.choose('unresolved');
assert.equal(c.state.prompts.positive.status, 'ambiguous');
assert.equal(c.state.choice, HASH_B, 'Leave unresolved is its own action');
await c.select('two');
await settle();
assert.equal(c.state.selectedId, 'two');
assert.deepEqual(c.state.options.map(option => option.label), ['Leave unresolved']);
assert.equal(c.state.choice, 'unresolved');
assert.deepEqual(c.state.options, []);
assert.equal(c.state.choice, null);
assert.equal(c.state.unreadable, null, 'a limited file keeps its own warning');
assert.match(c.state.warning, /too many sampler paths/i);
});
test('apply sends the selected candidate hash or unresolved with a fresh capture', async () => {
test('apply sends the selected candidate hash with a fresh capture and the next item opens', async () => {
const { c, h } = await loaded();
c.choose(HASH_B);
const done = await c.apply();
@@ -157,31 +156,105 @@ test('apply sends the selected candidate hash or unresolved with a fresh capture
const [decision] = h.of('POST', `${ITEMS}/one/decision`);
assert.deepEqual(decision.body, { expected_epoch: EPOCH, expected_revision: 0, capture_token: 'capture-1', sha256: SHA,
dependency_fingerprint: PRINT, choice: HASH_B });
assert.equal(c.state.phase, 'done');
assert.equal(c.state.total, 1);
assert.equal(c.state.phase, 'idle');
assert.equal(c.state.selectedId, 'two');
assert.equal(c.state.toast, 'Association accepted.');
assert.equal(c.state.decisions, 1);
// The server releases a used capture itself.
assert.equal(h.of('DELETE', CAPTURE).length, 0);
const again = await loaded();
again.c.choose('unresolved');
assert.equal(await again.c.apply(), true);
const [unresolved] = again.h.of('POST', `${ITEMS}/one/decision`);
assert.equal(unresolved.body.choice, 'unresolved');
assert.equal(unresolved.body.capture_token, 'capture-1');
assert.equal(again.c.state.toast, 'Left unresolved.');
});
test('an unavailable file can only be left unresolved, without a capture', async () => {
test('leave unresolved is its own action', async () => {
const { c, h } = await loaded();
assert.equal(await c.leaveUnresolved(), true);
const [unresolved] = h.of('POST', `${ITEMS}/one/decision`);
assert.equal(unresolved.body.choice, 'unresolved');
assert.equal(unresolved.body.capture_token, 'capture-1');
assert.equal(c.state.toast, 'Left unresolved.');
assert.equal(c.state.selectedId, 'two');
});
const unreadable = () => detailOf(QUEUE[0], { candidates: [
{ ...candidate(HASH_A, '9', 'No positive / No negative', null, null, 'unresolved'), reason: 'Unsupported latent ancestry' },
{ ...candidate(HASH_B, '12', 'No positive / No negative', null, null, 'unresolved'), reason: 'Unsupported latent ancestry' },
{ ...candidate(HASH_C, '14', 'No positive / No negative', null, null, 'unresolved'), reason: 'Unsupported node' },
] });
test('nothing selectable: the reasons are summed, there is no choice and apply sends nothing', async () => {
const { c, h } = await loaded({ [`GET ${ITEMS}/one`]: () => response(200, unreadable()) });
assert.equal(c.state.choice, null);
assert.equal(c.state.prompts, null);
assert.deepEqual(c.state.options.map(option => [option.label, option.disabled]), [
['Sampler 9 · No positive / No negative (Unsupported latent ancestry)', true],
['Sampler 12 · No positive / No negative (Unsupported latent ancestry)', true],
['Sampler 14 · No positive / No negative (Unsupported node)', true],
]);
assert.deepEqual(c.state.unreadable, { count: 3, reasons: [{ reason: 'Unsupported latent ancestry', count: 2 }, { reason: 'Unsupported node', count: 1 }] });
assert.equal(await c.apply(), false);
assert.equal(h.of('POST', '/').length, 0);
});
const linkRoute = (body = {}) => ({ [`POST ${ITEMS}/one/link`]: call => response(200, { success: true, library_epoch: EPOCH, total: 1,
item: { ...QUEUE[0], state: 'selected', revision: 1 }, pair: { id: call.body.pair_id ?? 7, image_id: 3, status: 'added' }, ...body }) });
test('link posts the chosen prompt and recipe with a fresh capture, then the next item opens', async () => {
const { c, h } = await loaded({ ...linkRoute(), [`GET ${ITEMS}/one`]: () => response(200, unreadable()) });
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: 9, title: 'Harbor' }), true);
const [link] = h.of('POST', `${ITEMS}/one/link`);
assert.deepEqual(link.body, { expected_epoch: EPOCH, expected_revision: 0, capture_token: 'capture-1', sha256: SHA,
dependency_fingerprint: PRINT, positive_id: 4, pair_id: 9 });
assert.equal(c.state.toast, 'Linked to Harbor.');
assert.equal(c.state.selectedId, 'two');
assert.equal(c.state.decisions, 1);
});
test('a refused link keeps the item, releases the capture and names the reason', async () => {
const { c, h } = await loaded({ [`POST ${ITEMS}/one/link`]: () => fail(409, 'association-conflict', 'This image is already linked') });
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: null, title: 'Harbor' }), false);
assert.equal(c.state.selectedId, 'one');
assert.equal(c.state.warning, 'This image is already linked');
assert.equal(c.state.decisions, 0);
assert.equal(h.of('DELETE', CAPTURE).length, 1);
});
test('an unanswered link resends the same body once; another choice takes a new capture', async () => {
let answers = 0;
const { c, h } = await loaded({ [`POST ${ITEMS}/one/link`]: () => {
answers += 1;
return answers === 1 ? Promise.reject(new TypeError('Failed to fetch')) : response(200, { success: true, total: 1 });
} });
assert.equal(await c.link({ itemId: 'two', positiveId: 4, pairId: 9, title: 'Harbor' }), false, 'not the open item');
assert.equal(h.of('POST', OBSERVE).length, 0);
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: 9, title: 'Harbor' }), false);
assert.match(c.state.warning, /Link image again/);
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: 9, title: 'Harbor' }), true);
const [first, second] = h.of('POST', `${ITEMS}/one/link`);
assert.deepEqual(first.body, second.body);
assert.equal(h.of('POST', OBSERVE).length, 1);
});
test('the last decision empties the page and a later page still comes back from the re-read', async () => {
let queue = [QUEUE[0]];
const { c } = await loaded({ [`GET ${ITEMS}`]: () => response(200, page(queue)) });
queue = [];
assert.equal(await c.leaveUnresolved(), true);
await settle();
assert.equal(c.state.selectedId, null);
assert.deepEqual(c.state.items, []);
assert.equal(c.state.warning, '');
});
test('an unavailable file can only be left unresolved, without a capture or a link', async () => {
const gone = item('one', { target: null, unavailable: true, url: null });
const { c, h } = await loaded({
[`GET ${ITEMS}`]: () => response(200, page([gone])),
[`GET ${ITEMS}/one`]: () => response(200, detailOf(gone)),
});
assert.ok(c.state.options.slice(0, -1).every(option => option.disabled));
assert.equal(c.state.choice, 'unresolved');
assert.ok(c.state.options.every(option => option.disabled));
assert.equal(c.state.choice, null);
assert.match(c.state.warning, /not under a current image source/i);
assert.equal(await c.apply(), true);
assert.equal(await c.link({ itemId: 'one', positiveId: 1, pairId: null, title: 'x' }), false);
assert.equal(await c.leaveUnresolved(), true);
assert.equal(h.of('POST', OBSERVE).length, 0);
const [decision] = h.of('POST', `${ITEMS}/one/decision`);
assert.equal(decision.body.capture_token, null);
@@ -275,11 +348,14 @@ test('a review-conflict on a still pending item reads its detail again', async (
assert.equal(h.of('POST', `${ITEMS}/one/decision`).at(-1).body.expected_revision, 1);
});
test('leave for later writes nothing', async () => {
test('leave for later writes nothing, opens the next item and wraps', async () => {
const { c, h } = await loaded();
c.leave();
assert.equal(c.state.phase, 'done');
await c.leave();
assert.equal(c.state.selectedId, 'two');
assert.equal(c.state.toast, null);
assert.equal(c.state.decisions, 0);
await c.leave();
assert.equal(c.state.selectedId, 'one');
assert.equal(h.of('POST', '/').length, 0);
assert.equal(h.of('DELETE', '/').length, 0);
});
@@ -781,7 +857,9 @@ test('M5: an unanswered decision that landed finishes as recorded, not as decide
queue = [QUEUE[1]]; // the server did record it: its change arrives
await c.refresh();
await settle();
assert.equal(c.state.phase, 'done');
assert.equal(c.state.phase, 'idle');
assert.equal(c.state.decisions, 1);
assert.equal(c.state.selectedId, 'two');
assert.equal(c.state.toast, 'Association accepted.');
assert.doesNotMatch(c.state.warning, /decided elsewhere/);
assert.equal(h.of('DELETE', CAPTURE).length, 0, 'the server used the capture');
+341
View File
@@ -0,0 +1,341 @@
"""Needs review: link an image to a library prompt by hand (#536).
The link is the reviewer's choice, never file evidence: it needs the same
capture of the same bytes as Apply, keeps the one-prompt-per-file rules, and
records provenance that cannot pose as what the file says.
"""
import json
try:
from tests.test_api_review import ITEMS_URL, ReviewRoutesTestCase
from tests.test_review import digest, unlinkable_graph, write_png
except ImportError: # discovered with tests/ as the top-level directory
from test_api_review import ITEMS_URL, ReviewRoutesTestCase
from test_review import digest, unlinkable_graph, write_png
from utils.hashing import generate_prompt_hash
class LinkTestCase(ReviewRoutesTestCase):
def _pair(self, positive="harbor", negative="blurry"):
pair_id = self.api.db.save_prompt_pair(positive, negative)
pair = self.api.db.get_prompt_pair(pair_id)
return pair["positive_prompt_id"], pair_id
def _link_body(self, detail, observed, positive_id, pair_id):
return {
"expected_epoch": observed["library_epoch"],
"expected_revision": detail["revision"],
"capture_token": observed["capture"]["token"],
"sha256": observed["capture"]["sha256"],
"dependency_fingerprint": detail["dependency_fingerprint"],
"positive_id": positive_id,
"pair_id": pair_id,
}
async def _link(self, item_id, body):
return await self._json("POST", f"{ITEMS_URL}/{item_id}/link", json=body)
async def _queued(self):
self._record(self._ambiguous("two.png"))
return await self._open()
async def _refused(self, item, body, status, code):
before = self._snapshot()
got, data = await self._link(item["id"], body)
self.assertEqual(got, status, data)
self.assertEqual(data.get("code"), code, data)
self.assertEqual(self._snapshot(), before)
return data
class TestLink(LinkTestCase):
async def test_links_the_chosen_recipe_and_records_a_manual_decision(self):
positive_id, pair_id = self._pair()
path = self.output_dir / "two.png"
item, detail, observed = await self._queued()
revision = self._review_revision()
status, data = await self._link(
item["id"], self._link_body(detail, observed, positive_id, pair_id)
)
self.assertEqual(status, 200, data)
self.assertEqual(data["total"], 0)
self.assertEqual(data["item"]["state"], "selected")
self.assertEqual(data["pair"]["id"], pair_id)
conn = self.api.db.model.get_connection()
image = conn.execute("SELECT id, prompt_id FROM generated_images").fetchone()
self.assertEqual(image[1], positive_id)
(link,) = self.api.db.get_image_prompt_pairs(image[0])
(decision,) = self._decisions()
self.assertEqual(link["pair_id"], pair_id)
self.assertEqual(
link["provenance"],
{
"kind": "manual-link",
"decision_id": decision["id"],
"reviewed_digest": digest(path),
"resolver_version": detail_version(self, item),
"positive_id": positive_id,
"pair_id": pair_id,
},
)
self.assertEqual(decision["outcome"], "selected")
self.assertRegex(decision["candidate_hash"], r"^[0-9a-f]{64}$")
self.assertNotIn(
decision["candidate_hash"], [c["hash"] for c in detail["candidates"]]
)
self.assertEqual(decision["pair_id"], pair_id)
evidence = json.loads(decision["evidence"])
self.assertEqual(evidence["kind"], "manual-link")
self.assertIsNone(evidence["candidate"])
self.assertEqual(
evidence["manual"], {"positive_id": positive_id, "pair_id": pair_id}
)
self.assertEqual(self._review_revision(), revision + 1)
self._no_paths()
async def test_a_manual_link_does_not_pose_as_file_evidence(self):
positive_id, pair_id = self._pair()
path = self.output_dir / "two.png"
item, detail, observed = await self._queued()
status, data = await self._link(
item["id"], self._link_body(detail, observed, positive_id, pair_id)
)
self.assertEqual(status, 200, data)
saved = self.api.db.get_image_prompt_info(str(path))
self.assertEqual(saved["text"], "harbor")
self.assertIsNone(saved.get("pair_context"))
async def test_no_negative_links_a_pair_without_one(self):
positive_id, _pair_id = self._pair()
item, detail, observed = await self._queued()
status, data = await self._link(
item["id"], self._link_body(detail, observed, positive_id, None)
)
self.assertEqual(status, 200, data)
pair = self.api.db.get_prompt_pair(data["pair"]["id"])
self.assertEqual(pair["positive_prompt_id"], positive_id)
self.assertIsNone(pair["negative_prompt_id"])
decision = self._decisions()[0]
self.assertEqual(
json.loads(decision["evidence"])["manual"]["pair_id"], data["pair"]["id"]
)
async def test_a_prompt_without_recipes_can_be_linked(self):
prompt_id = self.api.db.save_prompt(
"lone", prompt_hash=generate_prompt_hash("lone")
)
item, detail, observed = await self._queued()
status, data = await self._link(
item["id"], self._link_body(detail, observed, prompt_id, None)
)
self.assertEqual(status, 200, data)
async def test_an_existing_row_of_the_same_prompt_is_reused(self):
positive_id, pair_id = self._pair()
path = self._ambiguous("two.png")
image_id = self.api.db.link_image_to_prompt(positive_id, str(path))
self._record(path)
item, detail, observed = await self._open()
status, data = await self._link(
item["id"], self._link_body(detail, observed, positive_id, pair_id)
)
self.assertEqual(status, 200, data)
self.assertEqual(data["pair"]["image_id"], image_id)
async def test_a_candidates_own_item_can_still_be_linked_after_a_refusal(self):
positive_id, pair_id = self._pair()
item, detail, observed = await self._queued()
body = self._link_body(detail, observed, positive_id, pair_id)
await self._refused(
item, {**body, "pair_id": pair_id + 99}, 409, "prompt-missing"
)
status, data = await self._link(item["id"], body)
self.assertEqual(status, 200, data)
async def test_a_later_scan_leaves_the_link_and_does_not_queue_it(self):
positive_id, pair_id = self._pair()
path = self.output_dir / "two.png"
item, detail, observed = await self._queued()
status, data = await self._link(
item["id"], self._link_body(detail, observed, positive_id, pair_id)
)
self.assertEqual(status, 200, data)
before = self._snapshot()
self.assertIn(self._record_raw(path), ("decided", "duplicate"))
self.assertEqual(self._snapshot(), before)
self.assertEqual((await self._list())["total"], 0)
class TestLinkRefusals(LinkTestCase):
async def test_another_positive_on_the_file_is_an_association_conflict(self):
positive_id, pair_id = self._pair()
path = self._ambiguous("two.png")
other = self.api.db.save_prompt(
"other", prompt_hash=generate_prompt_hash("other")
)
self.api.db.link_image_to_prompt(other, str(path))
self._record(path)
item, detail, observed = await self._open()
body = self._link_body(detail, observed, positive_id, pair_id)
data = await self._refused(item, body, 409, "association-conflict")
self.assertIn("linked", data["error"])
async def test_a_prior_link_with_another_negative_is_an_association_conflict(self):
positive_id, pair_id = self._pair()
_same, other_pair = self._pair(negative="grainy")
path = self._ambiguous("two.png")
image_id = self.api.db.link_image_to_prompt(positive_id, str(path))
self.api.db.link_image_to_pair(other_pair, image_id, {"kind": "test"})
self._record(path)
item, detail, observed = await self._open()
body = self._link_body(detail, observed, positive_id, pair_id)
await self._refused(item, body, 409, "association-conflict")
async def test_changed_links_since_opening_are_refused(self):
positive_id, pair_id = self._pair()
path = self.output_dir / "two.png"
item, detail, observed = await self._queued()
other = self.api.db.save_prompt(
"other", prompt_hash=generate_prompt_hash("other")
)
self.api.db.link_image_to_prompt(other, str(path))
body = self._link_body(detail, observed, positive_id, pair_id)
await self._refused(item, body, 409, "association-conflict")
async def test_unusable_choices_are_refused_with_zero_writes(self):
positive_id, pair_id = self._pair()
foreign_positive, foreign_pair = self._pair("elsewhere", "bad")
negative_only = self.api.db.get_prompt_pair(pair_id)["negative_prompt_id"]
archived_positive, archived_pair = self._pair("old", "stale")
self.api.db.archive_prompt_pair(archived_pair)
item, detail, observed = await self._queued()
for chosen_positive, chosen_pair in (
(positive_id, foreign_pair),
(positive_id, pair_id + 99),
(positive_id + 99, None),
(negative_only, None),
(archived_positive, archived_pair),
):
body = self._link_body(detail, observed, chosen_positive, chosen_pair)
await self._refused(item, body, 409, "prompt-missing")
self.assertTrue(foreign_positive)
async def test_an_archived_prompt_is_refused(self):
positive_id, pair_id = self._pair()
conn = self.api.db.model.get_connection()
with conn:
conn.execute(
"INSERT INTO prompt_archive(prompt_id,archived_at) "
"VALUES(?,'2026-01-01T00:00:00Z')",
(positive_id,),
)
item, detail, observed = await self._queued()
body = self._link_body(detail, observed, positive_id, pair_id)
await self._refused(item, body, 409, "prompt-missing")
async def test_a_stale_stored_hash_never_links_another_row(self):
positive_id, _pair_id = self._pair()
other = self.api.db.save_prompt(
"other", prompt_hash=generate_prompt_hash("other")
)
conn = self.api.db.model.get_connection()
with conn:
conn.execute(
"UPDATE prompts SET hash=? WHERE id=?",
(generate_prompt_hash("elsewhere"), other),
)
conn.execute(
"UPDATE prompts SET hash=? WHERE id=?",
(generate_prompt_hash("other"), positive_id),
)
item, detail, observed = await self._queued()
body = self._link_body(detail, observed, other, None)
await self._refused(item, body, 409, "prompt-missing")
async def test_an_archived_bare_pair_is_refused(self):
positive_id, pair_id = self._pair()
bare = self.api.db.save_prompt_pair("harbor", "")
self.api.db.archive_prompt_pair(bare)
item, detail, observed = await self._queued()
body = self._link_body(detail, observed, positive_id, None)
await self._refused(item, body, 409, "prompt-missing")
self.assertTrue(pair_id)
async def test_body_validation(self):
positive_id, pair_id = self._pair()
item, detail, observed = await self._queued()
good = self._link_body(detail, observed, positive_id, pair_id)
before = self._snapshot()
for bad in (
{**good, "extra": 1},
{k: v for k, v in good.items() if k != "pair_id"},
{**good, "positive_id": "1"},
{**good, "positive_id": True},
{**good, "positive_id": 0},
{**good, "pair_id": -1},
{**good, "capture_token": None},
{**good, "expected_revision": -1},
{**good, "sha256": "x"},
):
status, data = await self._link(item["id"], bad)
self.assertEqual(status, 400, (bad, data))
self.assertEqual(self._snapshot(), before)
async def test_stale_revision_and_decided_item_are_refused(self):
positive_id, pair_id = self._pair()
item, detail, observed = await self._queued()
good = self._link_body(detail, observed, positive_id, pair_id)
await self._refused(
item, {**good, "expected_revision": 5}, 409, "review-conflict"
)
status, data = await self._link(item["id"], good)
self.assertEqual(status, 200, data)
again = await self._observe(item["target"])
await self._refused(
item,
{**good, "capture_token": again["capture"]["token"]},
409,
"review-conflict",
)
async def test_missing_item_expired_capture_and_changed_file_are_refused(self):
positive_id, pair_id = self._pair()
item, detail, observed = await self._queued()
good = self._link_body(detail, observed, positive_id, pair_id)
status, data = await self._link("00000000-0000-4000-8000-000000000000", good)
self.assertEqual((status, data["code"]), (404, "review-missing"))
await self._refused(
item, {**good, "capture_token": "a" * 43}, 409, "capture-expired"
)
write_png(
self.output_dir / "two.png", prompt=unlinkable_graph(), color=(1, 2, 3)
)
with_fresh = await self._observe(item["target"])
await self._refused(
item,
{
**good,
"capture_token": with_fresh["capture"]["token"],
"sha256": with_fresh["capture"]["sha256"],
},
409,
"file-changed",
)
async def test_restored_storage_is_refused(self):
positive_id, pair_id = self._pair()
item, detail, observed = await self._queued()
body = {
**self._link_body(detail, observed, positive_id, pair_id),
"expected_epoch": "00000000-0000-4000-8000-000000000000",
}
await self._refused(item, body, 409, "storage-changed")
def detail_version(case, item):
conn = case.api.db.model.get_connection()
return conn.execute(
"SELECT resolver_version FROM media_review_items WHERE id=?", (item["id"],)
).fetchone()[0]
+45
View File
@@ -285,6 +285,51 @@ class DryRunTests(RepairFixture):
self.assertIn("would lose every link", markdown)
class ManualLinkTests(RepairFixture):
"""A prompt the reviewer named in Needs review (#536) is not ours to undo."""
def mark_manual(self, key):
row = self.rows[key]
((prompt,),) = self.query(
"SELECT prompt_id FROM generated_images WHERE id=?", (row,)
)
self.query_write(
"INSERT OR IGNORE INTO prompt_pairs(positive_prompt_id,created_at,"
"updated_at) VALUES(?,'2026-10-30T00:00:00Z','2026-10-30T00:00:00Z')",
(prompt,),
)
((pair,),) = self.query(
"SELECT id FROM prompt_pairs WHERE positive_prompt_id=? "
"AND negative_prompt_id IS NULL",
(prompt,),
)
self.query_write(
"INSERT INTO image_prompt_pairs(pair_id,image_id,provenance,created_at) "
"VALUES(?,?,?,'2026-10-30T00:00:00Z')",
(pair, row, '{"kind":"manual-link"}'),
)
def test_a_manual_link_is_skipped_in_the_plan(self):
self.mark_manual("A")
code, out = self.dry_run()
self.assertEqual(code, 0, out)
items = {item["row_id"]: item for item in self.plan()["rows"]}
item = items[self.rows["A"]]
self.assertEqual(item["action"], "skip")
self.assertEqual(item["reason"], "linked by hand in Needs review")
self.assertEqual(items[self.rows["B"]]["action"], "relink")
def test_a_manual_link_made_after_the_plan_refuses_the_apply(self):
code, out = self.dry_run()
self.assertEqual(code, 0, out)
self.mark_manual("A")
before = self.links()
code, out = self.apply()
self.assertNotEqual(code, 0, out)
self.assertIn("action changed", out)
self.assertEqual(self.links(), before)
class DryRunReviewTests(RepairFixture):
def test_plan_records_its_library_and_fingerprint(self):
self.dry_run()
+15
View File
@@ -104,6 +104,21 @@ class ReviewToolTests(ToolFixture):
sorted(os.listdir(self.tmp)), ["output", "prompts.db"], "no side files"
)
def test_a_manual_link_is_not_ours_to_undo(self):
db = PromptDatabase(self.db_path)
try:
pair = db.save_prompt_pair("a woman holding a coffee cup", "")
db.link_image_to_pair(pair, self.guessed[0], {"kind": "manual-link"})
finally:
db.close_all()
before = self.links()
code, out = self.run_tool("--ids", str(self.guessed[0]), "--json")
self.assertEqual(code, 0)
(verdict,) = json.loads(out.split("--apply, for")[0])
self.assertEqual(verdict["verdict"], "manual")
code, _out = self.run_tool("--ids", str(self.guessed[0]), "--apply")
self.assertEqual(self.links(), before)
def test_prompt_selection_and_text_report(self):
code, out = self.run_tool("--prompt", str(self.coffee))
self.assertEqual(code, 0)