Closes #536
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,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([]);
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
|
||||
@@ -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