feat(review): link an image to a library prompt from Needs review (#536)
Images whose sampler paths could not be traced offered nothing to choose. Add Link to a prompt: search the Library and record the reviewer's choice under Apply's capture guards and the one-prompt-per-file rules, with provenance that names a manual choice and never reads as file evidence. Disabled options name the server's reason, nothing selectable says why and leads with the picker, Leave unresolved is its own action, and each decision opens the next item instead of returning to Images (overturns ruling R7).
This commit is contained in:
@@ -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??''),
|
||||
|
||||
@@ -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,60 @@ 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 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([]);
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user