diff --git a/TESTING.md b/TESTING.md index d9bf971..3ac99b4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -657,12 +657,18 @@ Recorded deviations on this page (spec §8): `Host` and `Origin`. Such a client can open a folder of a listed file on the host's screen, at most once a second, and learn whether that file exists (which the listing routes already tell it). Operators who expose ComfyUI that way accept this. -- **D39**: a recipe-linked image shows its recipe's own category and tags (#224), not - its positive's. The concept shows the prompt's category and tags for every image; - the concept fixture's recipes have neither, so their images show no category chip - and no tag row. `positive_links` on `/__test__/concept/reset` links the newest - images to their positive alone, so the inspector comparison sees the positive's - category ("Positive prompt category: " hidden prefix, D21) and tag filters. +- **D39** (ruled 2026-10-21: fallback): a recipe-linked image shows its recipe's own + category when set, otherwise the linked positive prompt's, and its recipe's own tags + when it has any, otherwise the positive's; each field falls back on its own and the + two tag sets are never merged. Set category (#259) writes the positive's category and + the Images category and Tags filters match the positive's, so the panel now agrees + with them whenever the recipe adds nothing. The inspection response names each + field's source (`library.category_scope`, `library.tags_scope`: `pair`, `positive` or + null); the category chip's visually hidden prefix (D21) says "Recipe category: " or + "Positive prompt category: " from it, and tag chips are Tags filter buttons only when + they are the positive's (#260 D2). History's media inspector uses the same rule. The + concept shows the prompt's category and tags for every image and the concept + fixture's recipes have neither, so the inspector comparison needs no mapping for it. - **D42**: the concept never shows a toast and a page alert together. Production keeps the concept's fixed toast (centred, 22px from the bottom, 12px at 700px and below) and, when an Import → Rescan toast leaves the Images discovery alert diff --git a/database/history_gallery.py b/database/history_gallery.py index 08d8324..2e2b9e0 100644 --- a/database/history_gallery.py +++ b/database/history_gallery.py @@ -4,9 +4,17 @@ import os import sqlite3 try: - from ..utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection + from ..utils.inspection_response import ( + MAX_LIBRARY_BYTES, + _library_projection, + library_metadata, + ) except ImportError: - from utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection + from utils.inspection_response import ( + MAX_LIBRARY_BYTES, + _library_projection, + library_metadata, + ) def _display_bytes(value: bytes | None) -> str | None: @@ -52,15 +60,21 @@ def _saved_projection(conn: sqlite3.Connection, row: sqlite3.Row) -> tuple[dict, return saved, _library_projection({"status": "limited"}) pair = pairs[0] if pairs else None title = pair["title"] if pair is not None else None - category = pair["category"] if pair is not None else row["category"] - tags, limited = _tags( - conn, - pair["id"] if pair is not None else row["prompt_id"], - pair=pair is not None, + recipe_tags, limited = ( + _tags(conn, pair["id"], pair=True) if pair is not None else ([], False) + ) + positive_tags = [] + if not recipe_tags and not limited: + # D39: only read the positive's tags when they are what the panel shows. + positive_tags, limited = _tags(conn, row["prompt_id"], pair=False) + metadata = library_metadata( + row["category"], + positive_tags, + (pair["category"], recipe_tags) if pair is not None else None, ) limited = limited or any( value is not None and len(value) > MAX_LIBRARY_BYTES - for value in (title, category) + for value in (title, metadata["category"]) ) if pair is not None: saved["pair"] = { @@ -73,9 +87,8 @@ def _saved_projection(conn: sqlite3.Connection, row: sqlite3.Row) -> tuple[dict, "positive_id": row["prompt_id"], "pair_id": pair["id"] if pair is not None else None, "title": _display_bytes(title), - "category": _display_bytes(category), - "tags": tags, - "metadata_scope": "pair" if pair is not None else "positive", + **metadata, + "category": _display_bytes(metadata["category"]), } ) if limited: diff --git a/py/api/images.py b/py/api/images.py index aef3656..1ed0642 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -36,7 +36,10 @@ try: from ...database.models import normalize_image_path from ...utils.card_seed import card_seed, header_params, parsed_positive from ...utils.parallel import map_parallel - from ...utils.inspection_response import encode_inspection_response + from ...utils.inspection_response import ( + encode_inspection_response, + library_metadata, + ) from ...utils.prompt_inspector import MetadataReadError, read_inspection_file from ...utils.file_reveal import ( OpenerUnavailable, @@ -52,7 +55,10 @@ except ImportError: from database.models import normalize_image_path from utils.card_seed import card_seed, header_params, parsed_positive from utils.parallel import map_parallel - from utils.inspection_response import encode_inspection_response + from utils.inspection_response import ( + encode_inspection_response, + library_metadata, + ) from utils.prompt_inspector import MetadataReadError, read_inspection_file from utils.file_reveal import ( OpenerUnavailable, @@ -2464,7 +2470,8 @@ class ImageRoutesMixin: "title": None, "category": None, "tags": [], - "metadata_scope": None, + "category_scope": None, + "tags_scope": None, } if not isinstance(saved, dict) or not saved: return result @@ -2472,23 +2479,19 @@ class ImageRoutesMixin: result["status"] = "ambiguous" return result pair = saved.get("pair") - if isinstance(pair, dict): - result.update( - status="linked", - positive_id=pair.get("positive_prompt_id"), - pair_id=pair.get("id"), - category=pair.get("category"), - tags=pair.get("tags"), - metadata_scope="pair", - ) - else: - result.update( - status="linked", - positive_id=saved.get("prompt_id"), - category=saved.get("category"), - tags=saved.get("tags"), - metadata_scope="positive", - ) + recipe = isinstance(pair, dict) + result.update( + status="linked", + positive_id=( + pair.get("positive_prompt_id") if recipe else saved.get("prompt_id") + ), + pair_id=pair.get("id") if recipe else None, + **library_metadata( + saved.get("category"), + saved.get("tags"), + (pair.get("category"), pair.get("tags")) if recipe else None, + ), + ) return result async def get_image_prompt(self, request): diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index bca64e5..6c6e2f9 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -75,7 +75,7 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => { expect(result.metadata.prompt_graph.value['3'].inputs.cfg).toBe('6.5'); expect(result.metadata.record_formats).toEqual({ prompt: 'original-text', workflow: 'original-text' }); expect(result.file).toMatchObject({ observation: 'current-file', format: 'PNG', width: 720, height: 480 }); - expect(result.library).toMatchObject({ status: 'linked', metadata_scope: 'pair', title: POSITIVE_TITLE, category: 'Inspection', tags: ['fixture', 'geometric'] }); + expect(result.library).toMatchObject({ status: 'linked', category_scope: 'pair', tags_scope: 'pair', title: POSITIVE_TITLE, category: 'Inspection', tags: ['fixture', 'geometric'] }); expect((await inspect(collision)).prompts.positive.text).toBe('Input collision'); const gallery = await (await request.get(`/prompt_manager/library/${result.library.positive_id}/images?pair_id=${result.library.pair_id}`)).json(); expect(gallery.images[0].root_scope).toBe(complete.root_scope); @@ -163,10 +163,20 @@ test('panel matches the concept order and copy',async({page,request})=>{ inner:['div.panel-heading','p.small','section.prompt-section.positive','section.prompt-section.negative','dl.meta-grid']}); await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded')); // Ambiguous saved association: the same honest absence. - reads.patch=data=>{data.library={...data.library,status:'ambiguous',positive_id:null,pair_id:null,title:null,category:null,tags:[],metadata_scope:null};}; + reads.patch=data=>{data.library={...data.library,status:'ambiguous',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null};}; await selectFile(page,'workflow-only.png');await expect(panel.locator('.panel-heading h2')).toHaveText('workflow-only.png'); await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0); await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0); + // D39 mixed scopes: each field follows its own source, so the prefix and the tag chips can disagree. + const tagRow=panel.locator('.detail-inner > .tags').last(); + reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'pair',tags_scope:'positive',tags:['alpha','beta']};}; + await selectFile(page,'complete.png'); + await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: '); + await expect(tagRow.getByRole('button')).toHaveText(['alpha','beta']); + reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'positive',tags_scope:'pair',tags:['alpha','beta']};}; + await selectFile(page,'workflow-only.png'); + await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Positive prompt category: '); + await expect(tagRow.locator('span.chip')).toHaveText(['alpha','beta']);await expect(tagRow.getByRole('button')).toHaveCount(0); // Loading and error states render in `.detail-inner` as `.small` text with the existing buttons. reads.patch=null;await page.unroute('**/images/inspect/**'); await page.route('**/images/inspect/**',route=>route.fulfill({status:500,json:{success:false,error:'broken'}})); diff --git a/tests/browser/inspector-concept.spec.mjs b/tests/browser/inspector-concept.spec.mjs index 6090796..6a7f32f 100644 --- a/tests/browser/inspector-concept.spec.mjs +++ b/tests/browser/inspector-concept.spec.mjs @@ -5,16 +5,15 @@ import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInve // Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i, // true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes. -// Both documents select the first listed image, alpine_00001.png (D19: the first load selects it), which the fixture -// links to its positive prompt alone (`positive_links`, D39), so its panel shows the positive's own category and tags -// as the concept's does. The panel's box in the gallery layout is the Images spec's. Paired captures are artifacts only; -// there are no PNG baselines. +// Both documents select the first listed image, alpine_00001.png (D19: the first load selects it). The fixture links it +// to a recipe without a category or tags, so (D39, ruled 2026-10-21) its panel falls back to the positive's own category +// and tags, as the concept's does. The panel's box in the gallery layout is the Images spec's. Paired captures are +// artifacts only; there are no PNG baselines. test.use({locale:'en-US',timezoneId:'UTC'}); const CARDS='.gallery-body > .image-grid > .media-card'; const TITLE='Alpine observatory'; const FIXTURE_IMAGES=24; -const SEED={positive_links:1}; // The concept's sticky panel never scrolls; production's scrolls inside the window above 700px (D20/D21 side panel). const PANEL_INSET=40; // D34: coarse pointers keep 40px targets for the viewer details' disclosure summaries and "Download original" (the @@ -39,15 +38,15 @@ function withFocusablePrompts(inventory) { return {...inventory,controls,sections:inventory.sections.map(section=>({...section,text:{...section.text,tabIndex:0}}))}; } -/** D21: the category chip names its scope in a visually hidden prefix; here the linked positive's. */ +/** D21: the category chip names its scope in a visually hidden prefix; here the linked positive's (D39 fallback). */ function withCategoryScope(inventory) { const [badge,category,...rest]=inventory.badges.chips; return {...inventory,badges:{...inventory.badges,chips:[badge,{...category,hidden:'Positive prompt category: '},...rest]}}; } /** - * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38 and D39 through the fixture's - * `positive_links`); everything else must equal production. `reference.viewerPlain` is the concept's own full details + * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34 and D38); everything else must equal + * production. `reference.viewerPlain` is the concept's own full details * with its tag chips made plain (D20) and, under a coarse pointer, D34's targets, both at runtime: the browser lays out * what follows them, as the Images spec's removed toolbar items do. */ @@ -232,7 +231,7 @@ const historyOf=async page=>{ return page.evaluate(()=>location.hash); }; -test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request,SEED);}); +test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);}); for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector panel geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page,context,browserName},testInfo)=>{ test.setTimeout(180_000); @@ -303,15 +302,20 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector pane expect.soft(production.history,'history').toBe(`#/library/${manifest.prompts[0].positive_id}`); }); -// D39: a recipe-linked image shows its recipe's own category and tags (#224), which the concept fixture's recipes do -// not have, so its panel has neither (the concept shows the prompt's for every image). -test('a recipe-linked image shows only its recipe metadata (D39)',async({page})=>{ +// D39 (ruled 2026-10-21): a recipe-linked image shows its recipe's own category and tags when set, otherwise its +// positive's, field by field. The concept fixture's recipes have neither, so this recipe-linked image shows its +// positive's, as the concept does, and its tag chips are Tags filter buttons again (D21: the positive's own tags). +test('a recipe-linked image without recipe metadata shows its positive prompt metadata (D39)',async({page})=>{ await page.setViewportSize({width:1600,height:1100}); await openProductionPanel(page,'dark'); await page.locator(CARDS).nth(1).locator('[aria-label^="Inspect"]').click(); await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry'); - await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(1); - await expect(panelOf(page).locator('.detail-inner > .tags .chip')).toHaveText(['Execution recorded']); + const rows=panelOf(page).locator('.detail-inner > .tags'); + await expect(rows).toHaveCount(2); + await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture']); + await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: '); + await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']); + await expect(rows.nth(1).locator('span.chip')).toHaveCount(0); await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']); }); diff --git a/tests/browser/server.py b/tests/browser/server.py index 53333bc..efecf55 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -17,7 +17,6 @@ from PIL.PngImagePlugin import PngInfo from tests.browser.workspace_concept_fixture import ( MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES, MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES, - MAX_POSITIVE_LINKS as CONCEPT_MAX_POSITIVE_LINKS, seed_workspace_concept, ) from tests.browser.workspace_visual_fixture import seed_workspace_visual @@ -443,7 +442,6 @@ def create_app(root: Path) -> web.Application: limits = { "extra_images": CONCEPT_MAX_EXTRA_IMAGES, "seedless_images": CONCEPT_MAX_SEEDLESS_IMAGES, - "positive_links": CONCEPT_MAX_POSITIVE_LINKS, } if ( request.query @@ -467,7 +465,6 @@ def create_app(root: Path) -> web.Application: root, extra_images=data.get("extra_images", 0), seedless_images=data.get("seedless_images", 0), - positive_links=data.get("positive_links", 0), ) ) return web.json_response({"success": True, **manifest}) diff --git a/tests/browser/workspace_concept_fixture.py b/tests/browser/workspace_concept_fixture.py index ef9d4a5..66c3da5 100644 --- a/tests/browser/workspace_concept_fixture.py +++ b/tests/browser/workspace_concept_fixture.py @@ -157,9 +157,6 @@ CONCEPT_MODEL = "illustrative-model.safetensors" MAX_EXTRA_IMAGES = 120 # Unlinked, seedless images (D11 "W × H · PNG") newer than the concept's, for D3 checks. MAX_SEEDLESS_IMAGES = 2 -# The newest images linked to their positive alone (no recipe), for the inspector's -# positive-scope category and tag chips (D39). -MAX_POSITIVE_LINKS = 6 SEEDLESS_SIZE = (1536, 1152) SESSION = "5c1f9a7e-3f0b-4a55-9d0e-2b8c4f6a7d10" _TABLES = ( @@ -344,7 +341,6 @@ def seed_workspace_concept( *, extra_images: int = 0, seedless_images: int = 0, - positive_links: int = 0, ) -> dict: """Replace this server's disposable rows and files with the concept's data. @@ -354,7 +350,6 @@ def seed_workspace_concept( """ _bounded("extra_images", extra_images, MAX_EXTRA_IMAGES) _bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES) - _bounded("positive_links", positive_links, MAX_POSITIVE_LINKS) _clear(api, root) db = api.db epoch = db.get_execution_epoch() @@ -410,9 +405,8 @@ def seed_workspace_concept( os.utime(path, (stamp, stamp)) owner = prompts[prompt_index] image_id = db.link_image_to_prompt(owner["positive_id"], str(path)) - pair_id = None if index < positive_links else owner["pair_ids"][variant] - if pair_id is not None: - db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"}) + pair_id = owner["pair_ids"][variant] + db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"}) if concept_starred(prompt_index, round_index): db.set_image_favorite( favorite_root, _favorite_relative_key(f"concepts/{name}") diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js index 3a01d7a..16d5a17 100644 --- a/tests/js/workspace-file-inspector.test.js +++ b/tests/js/workspace-file-inspector.test.js @@ -5,7 +5,7 @@ const modulePath='../../web/js/workspace/file-inspector.mjs'; const load=()=>import(modulePath); const rawWorkflow='{ "nodes": [], "seed": 9007199254740993, "cfg": 1.0000000000000000001 }'; function record() { - return {identity:{kind:'rooted-file',key:'2:a.png',root_index:2,path:'a.png',root_scope:'a'.repeat(64)},file:{format:'PNG'},prompts:{positive:{status:'resolved',text:'a\n猫'}},generation:{seed:{status:'known',value:'9007199254740993',source:'workflow',evidence:'configuration'}},library:{metadata_scope:'pair',tags:['saved']},download:{filename:'a.png',url:'/prompt_manager/images/serve/a.png?root=2'},metadata:{status:'available',omitted_reason:null,raw:{workflow:rawWorkflow,prompt:'{broken',parameters:'literal '},record_formats:{workflow:'original-text',prompt:'original-text',parameters:'original-text'},workflow_graph:{status:'parsed',value:{seed:'9007199254740993'}},prompt_graph:{status:'malformed',value:null},parameters:'literal '}}; + return {identity:{kind:'rooted-file',key:'2:a.png',root_index:2,path:'a.png',root_scope:'a'.repeat(64)},file:{format:'PNG'},prompts:{positive:{status:'resolved',text:'a\n猫'}},generation:{seed:{status:'known',value:'9007199254740993',source:'workflow',evidence:'configuration'}},library:{category_scope:'pair',tags_scope:'pair',tags:['saved']},download:{filename:'a.png',url:'/prompt_manager/images/serve/a.png?root=2'},metadata:{status:'available',omitted_reason:null,raw:{workflow:rawWorkflow,prompt:'{broken',parameters:'literal '},record_formats:{workflow:'original-text',prompt:'original-text',parameters:'original-text'},workflow_graph:{status:'parsed',value:{seed:'9007199254740993'}},prompt_graph:{status:'malformed',value:null},parameters:'literal '}}; } test('metadata export retains literal raw and scoped evidence',async()=>{ diff --git a/tests/js/workspace-history-media.test.js b/tests/js/workspace-history-media.test.js index fdc908c..6a95a37 100644 --- a/tests/js/workspace-history-media.test.js +++ b/tests/js/workspace-history-media.test.js @@ -8,7 +8,7 @@ const m=await import('../../web/js/workspace/history-media.mjs').catch(e=>{if(e. const epoch='11111111-1111-1111-1111-111111111111',run='22222222-2222-2222-2222-222222222222',scope='a'.repeat(64),location='b'.repeat(64); function media(node='1',ordinal=0,key=location) {const occurrence={run_id:run,output_node_id:node,image_ordinal:ordinal},reference={folder_type:'output',subfolder:'folder',filename:'a #%.png',path_style:'posix',root_key:'c'.repeat(64)},base=`/prompt_manager/history/runs/${run}/output/current`,query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope});return {kind:'reported-location',occurrence,reference,history_epoch:epoch,current_scope:scope,location_key:key,filename:reference.filename,is_video:false,url:`${base}/media?${query}`,inspection_url:`${base}/inspection?${query}`,thumbnail_url:`${base}/thumbnail?${query}`};} const receipt=value=>({occurrence:value.occurrence,reference:value.reference,file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false,current_candidate:{status:'available',reason:null,evidence_class:'locator_candidate',current_scope:value.current_scope,location_key:value.location_key,relative_path:'folder/a #%.png',source_label:'output',gallery_match:'none',gallery_image_id:null,media:value}}); -function envelope(target=media()) {return {success:true,inspection_version:1,history_version:1,history_epoch:epoch,current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:epoch,current_scope:scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},prompts:{positive:{status:'resolved',text:'current metadata only',source:'execution'},negative:{status:'unresolved',text:null,source:null}},file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',tags:[],metadata_scope:null,positive_id:null,pair_id:null,title:null,category:null}};} +function envelope(target=media()) {return {success:true,inspection_version:1,history_version:1,history_epoch:epoch,current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:epoch,current_scope:scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},prompts:{positive:{status:'resolved',text:'current metadata only',source:'execution'},negative:{status:'unresolved',text:null,source:null}},file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',tags:[],category_scope:null,tags_scope:null,positive_id:null,pair_id:null,title:null,category:null}};} function adapter(){assert.ok(m.reportedLocationAdapter,'reported location adapter is exported');return m.reportedLocationAdapter;} test('reported location uses explicit occurrence routes and never widens rooted defaults',()=>{const target=media(),a=adapter();assert.doesNotThrow(()=>a.validate(target));assert.equal(a.key(target),location);assert.equal(a.scope(target),`${epoch}:${scope}`);assert.equal(a.inspectionPath(target),target.inspection_url);assert.deepEqual(a.originalDownload(target),{url:target.url,filename:'a #%.png'});assert.throws(()=>originalMediaDownload(target),TypeError);assert.throws(()=>createViewerSession({items:[target],initialKey:location}),TypeError);}); diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js index 2ea7976..92bc176 100644 --- a/tests/js/workspace-inspection-state.test.js +++ b/tests/js/workspace-inspection-state.test.js @@ -27,7 +27,7 @@ function envelope(entry = image()) { workflow_graph: { status: 'absent', value: null }, parameters: null, }, resolution: { state: 'ambiguous', sampler_id: null, reason: 'unknown negative' }, - library: { status: 'linked', scope: 'saved-library', positive_id: 7, pair_id: 8, title: 'Recipe', category: 'Subject', tags: ['cat'], metadata_scope: 'pair' }, + library: { status: 'linked', scope: 'saved-library', positive_id: 7, pair_id: 8, title: 'Recipe', category: 'Subject', tags: ['cat'], category_scope: 'pair', tags_scope: 'pair' }, }; } function pendingApi() { @@ -276,6 +276,10 @@ test('record rejects root path media and invalid versioned fields with retryable data => { data.file.size = -1; }, data => { data.library.positive_id = 9007199254740992; }, data => { data.metadata.prompt_graph = { status: 'limited', value: {} }; }, data => { data.resolution.sampler_id = {}; }, + data => { data.library.tags_scope = 'recipe'; }, + data => { data.library.category_scope = 'recipe'; }, + data => { delete data.library.category_scope; }, + data => { delete data.library.tags_scope; }, ]) { const data = envelope(); mutate(data); assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error instanceof TypeError && !error.refreshRequired); diff --git a/tests/js/workspace-media-viewer.test.js b/tests/js/workspace-media-viewer.test.js index 216ee7a..6fe3d91 100644 --- a/tests/js/workspace-media-viewer.test.js +++ b/tests/js/workspace-media-viewer.test.js @@ -119,7 +119,7 @@ function currentInspectionEnvelope(target) { file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1}, prompts:{positive:{status:'resolved',text:'current positive',source:'execution'},negative:{status:'resolved',text:'',source:'execution'}}, metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null}, - resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',positive_id:null,pair_id:null,title:null,category:null,tags:[],metadata_scope:null}}; + resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null}}; } test('real inspection navigation preserves slideshow across two advances and deferred metadata loads',async()=>{ diff --git a/tests/test_api_library.py b/tests/test_api_library.py index ae0a9c6..80597a5 100644 --- a/tests/test_api_library.py +++ b/tests/test_api_library.py @@ -375,7 +375,9 @@ class TestLibraryAPI(ImageAPITestCase): result["image"], {"root_index": 0, "path": relative, "filename": "猫 % #.png"}, ) - self.assertEqual(result["library"]["metadata_scope"], "pair") + # D39: the recipe has no category or tags, so both fall back. + self.assertEqual(result["library"]["category_scope"], "positive") + self.assertEqual(result["library"]["tags_scope"], "positive") self.assertEqual(result["library"]["pair_id"], pair) self.assertEqual(result["library"]["positive_id"], positive) # D10/D20: the linked positive's title; "Pair title" names the variant. diff --git a/tests/test_api_prompt_inspector.py b/tests/test_api_prompt_inspector.py index cc41e1b..ae9a082 100644 --- a/tests/test_api_prompt_inspector.py +++ b/tests/test_api_prompt_inspector.py @@ -1302,6 +1302,64 @@ class TestPromptInspector(ImageAPITestCase): ) roots.assert_called_once_with() + async def test_recipe_link_falls_back_to_positive_category_and_tags_per_field( + self, + ): + # D39 ruling (2026-10-21): a recipe's own category and tags win when set; + # otherwise each field falls back to the linked positive's, separately, and + # the scopes say where each value came from (no merging of tag sets). + cases = ( + ("Recipe cat", ["recipe"], "Recipe cat", "pair", ["recipe"], "pair"), + (None, [], "Positive cat", "positive", ["positive"], "positive"), + (" ", [], "Positive cat", "positive", ["positive"], "positive"), + ("Recipe cat", [], "Recipe cat", "pair", ["positive"], "positive"), + (None, ["recipe"], "Positive cat", "positive", ["recipe"], "pair"), + ) + for index, case in enumerate(cases): + recipe_category, recipe_tags = case[:2] + category, category_scope, tags, tags_scope = case[2:] + with self.subTest(case=index): + text = f"Fallback owner {index}" + positive = self._save_prompt( + text, category="Positive cat", tags=["positive"] + ) + pair = self.api.db.save_prompt_pair( + text, + f"Fallback noise {index}", + category=recipe_category, + tags=recipe_tags, + ) + self.assertEqual( + self.api.db.get_prompt_pair(pair)["positive_prompt_id"], positive + ) + filename = f"fallback-{index}.png" + path = png(self.output_dir / filename) + image = self.api.db.link_image_to_prompt(positive, str(path)) + self.api.db.link_image_to_pair(pair, image, {"source": "fixture"}) + response, result = await self.inspect(filename) + self.assertEqual(response.status, 200) + library = result["library"] + self.assertEqual(library["status"], "linked") + self.assertEqual(library["pair_id"], pair) + self.assertEqual(library["category"], category) + self.assertEqual(library["category_scope"], category_scope) + self.assertEqual(library["tags"], tags) + self.assertEqual(library["tags_scope"], tags_scope) + self.assertNotIn("metadata_scope", library) + + async def test_positive_only_link_keeps_positive_scopes(self): + positive = self._save_prompt("Solo owner", tags=["solo"]) + path = png(self.output_dir / "solo.png") + self.api.db.link_image_to_prompt(positive, str(path)) + response, result = await self.inspect("solo.png") + self.assertEqual(response.status, 200) + library = result["library"] + self.assertEqual(library["pair_id"], None) + self.assertEqual(library["category"], None) + self.assertEqual(library["category_scope"], "positive") + self.assertEqual(library["tags"], ["solo"]) + self.assertEqual(library["tags_scope"], "positive") + async def test_exact_saved_association_ambiguity_preserves_physical_file_evidence( self, ): @@ -1340,8 +1398,9 @@ class TestPromptInspector(ImageAPITestCase): if count == 1: self.assertEqual(result["library"]["positive_id"], positive) self.assertEqual( - result["library"]["metadata_scope"], "positive" + result["library"]["category_scope"], "positive" ) + self.assertEqual(result["library"]["tags_scope"], "positive") self.assertEqual(result["library"]["category"], "Category 1") self.assertEqual(result["library"]["tags"], ["owner-1"]) if not readable: @@ -1361,7 +1420,8 @@ class TestPromptInspector(ImageAPITestCase): "title": None, "category": None, "tags": [], - "metadata_scope": None, + "category_scope": None, + "tags_scope": None, }, ) for field in ( diff --git a/tests/test_history_current.py b/tests/test_history_current.py index d9836eb..8d5c379 100644 --- a/tests/test_history_current.py +++ b/tests/test_history_current.py @@ -16,6 +16,7 @@ from tests.test_api_images import make_png from database.operations import PromptDatabase from py.config import GalleryConfig from utils.execution_outputs import snapshot_output_roots +from utils.hashing import generate_prompt_hash class CurrentLocationTests(unittest.TestCase): @@ -331,6 +332,93 @@ class CurrentLocationTests(unittest.TestCase): self.assertEqual(result[unique_locator]["library"]["status"], "limited") self.assertIsNone(result[unique_locator]["library"].get("pair_id")) + def test_gallery_recipe_display_falls_back_to_positive_per_field(self): + # D39 ruling (2026-10-21), shared with the Images inspector. + mod = self.module("database.history_gallery") + db = PromptDatabase(str(self.base / "gallery.db")) + self.addCleanup(db.close_all) + cases = ( + ("Recipe cat", ["recipe"], ("Recipe cat", "pair", ["recipe"], "pair")), + (None, [], ("Positive cat", "positive", ["positive"], "positive")), + ("Recipe cat", [], ("Recipe cat", "pair", ["positive"], "positive")), + ("", ["recipe"], ("Positive cat", "positive", ["recipe"], "pair")), + ) + for index, (recipe_category, recipe_tags, expected) in enumerate(cases): + with self.subTest(case=index): + text = f"owner {index}" + positive = db.save_prompt( + text, + prompt_hash=generate_prompt_hash(text), + category="Positive cat", + ) + db.set_prompt_tags(positive, ["positive"]) + pair = db.save_prompt_pair( + text, "noise", category=recipe_category, tags=recipe_tags + ) + path = make_png(self.output / f"fallback-{index}.png") + locator = os.path.normcase(str(path)) + image = db.link_image_to_prompt(positive, str(path)) + db.link_image_to_pair(pair, image, {"source": "fixture"}) + conn = db.model.get_connection() + with db.recorded_history_snapshot(): + result = mod.read_history_gallery(conn, (locator,)) + library = result[locator]["library"] + self.assertEqual(library["pair_id"], pair) + self.assertEqual( + ( + library["category"], + library["category_scope"], + library["tags"], + library["tags_scope"], + ), + expected, + ) + + def test_gallery_blank_recipe_category_and_skipped_positive_tags_query(self): + mod = self.module("database.history_gallery") + db = PromptDatabase(str(self.base / "blank.db")) + self.addCleanup(db.close_all) + cases = ( + (" ", ["recipe"], 0), + ("\u3000", [], 1), + ("\u00a0 ", ["recipe"], 0), + ) + for index, (blank, recipe_tags, positive_tag_queries) in enumerate(cases): + with self.subTest(case=index): + text = f"blank owner {index}" + positive = db.save_prompt( + text, + prompt_hash=generate_prompt_hash(text), + category="Positive cat", + ) + db.set_prompt_tags(positive, ["positive"]) + pair = db.save_prompt_pair(text, "noise", tags=recipe_tags) + path = make_png(self.output / f"blank-{index}.png") + locator = os.path.normcase(str(path)) + image = db.link_image_to_prompt(positive, str(path)) + db.link_image_to_pair(pair, image, {"source": "fixture"}) + conn = db.model.get_connection() + # Legacy rows can hold a blank-looking category the writers now trim. + conn.execute( + "UPDATE prompt_pairs SET category=? WHERE id=?", (blank, pair) + ) + conn.commit() + statements = [] + conn.set_trace_callback(statements.append) + self.addCleanup(conn.set_trace_callback, None) + with db.recorded_history_snapshot(): + result = mod.read_history_gallery(conn, (locator,)) + conn.set_trace_callback(None) + library = result[locator]["library"] + self.assertEqual(library["category"], "Positive cat") + self.assertEqual(library["category_scope"], "positive") + tag_queries = [ + statement + for statement in statements + if "FROM prompt_tags pt" in statement + ] + self.assertEqual(len(tag_queries), positive_tag_queries, statements) + def test_host_root_failure_and_removal_do_not_search_another_root(self): make_png(self.output / "image.png") make_png(self.temp / "image.png") diff --git a/tests/test_inspection_parity_facts.py b/tests/test_inspection_parity_facts.py index 469c1fa..392897b 100644 --- a/tests/test_inspection_parity_facts.py +++ b/tests/test_inspection_parity_facts.py @@ -36,7 +36,8 @@ LIBRARY = { "title": None, "category": None, "tags": [], - "metadata_scope": None, + "category_scope": None, + "tags_scope": None, } NODE_TIMEOUT_SECONDS = 120 diff --git a/tests/test_inspection_response.py b/tests/test_inspection_response.py index 382dadb..37de619 100644 --- a/tests/test_inspection_response.py +++ b/tests/test_inspection_response.py @@ -77,7 +77,8 @@ def encode(read, *, prompts=None, file=None, library=None): "title": None, "category": None, "tags": [], - "metadata_scope": None, + "category_scope": None, + "tags_scope": None, }, ) @@ -663,6 +664,21 @@ class TestInspectionResponse(unittest.TestCase): for key in ("positive_id", "pair_id", "category", "title"): self.assertNotIn(key, result["library"]) self.assertEqual(result["library"]["tags"], ["valid"]) + # An unknown or non-string scope is dropped, never passed through. + for bad in ("recipe", 3, ["pair"]): + result = json.loads( + encode( + None, + library={ + "status": "linked", + "category_scope": bad, + "tags_scope": bad, + "tags": [], + }, + ) + ) + self.assertNotIn("category_scope", result["library"]) + self.assertNotIn("tags_scope", result["library"]) result = json.loads( encode(inspection({}), library={"status": "linked", "tags": ["猫" * 12000]}) ) diff --git a/utils/inspection_response.py b/utils/inspection_response.py index 8dffb4e..84d4530 100644 --- a/utils/inspection_response.py +++ b/utils/inspection_response.py @@ -289,6 +289,39 @@ def _file_projection(file: dict, inspection: "InspectionRead | None") -> dict: return result +def _blank(value: str | bytes | None) -> bool: + if isinstance(value, bytes): + value = value.decode("utf-8", "replace") + return value is None or not value.strip() + + +def library_metadata( + positive_category: str | bytes | None, + positive_tags: list, + recipe: tuple | None = None, +) -> dict: + """The panel's saved category and tags, each with the scope it came from. + + D39 (maintainer ruling, 2026-10-21): a recipe-linked image shows the recipe's + own category and tags when set; otherwise each field separately falls back to + the linked positive prompt's, the values Set category writes and the Images + filters match. Tag sets are never merged. ``recipe`` is (category, tags). + """ + category, category_scope = positive_category, "positive" + tags, tags_scope = positive_tags, "positive" + if recipe is not None: + if not _blank(recipe[0]): + category, category_scope = recipe[0], "pair" + if recipe[1]: + tags, tags_scope = recipe[1], "pair" + return { + "category": category, + "category_scope": category_scope, + "tags": tags, + "tags_scope": tags_scope, + } + + def _library_projection(library: dict) -> dict: status = library.get("status") result = { @@ -307,9 +340,10 @@ def _library_projection(library: dict) -> dict: value = library.get(key) if value is None or isinstance(value, str): result[key] = value - scope = library.get("metadata_scope") - if scope in ("pair", "positive", None): - result["metadata_scope"] = scope + for key in ("category_scope", "tags_scope"): + scope = library.get(key) + if scope in ("pair", "positive", None): + result[key] = scope tags = library.get("tags") result["tags"] = ( [tag for tag in tags if isinstance(tag, str)] if isinstance(tags, list) else [] @@ -323,7 +357,8 @@ def _library_projection(library: dict) -> dict: "title": None, "category": None, "tags": [], - "metadata_scope": None, + "category_scope": None, + "tags_scope": None, } return result diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 7e694c2..b7b592d 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -206,15 +206,16 @@ export function mountFileInspector(container, {api, sender, parentScope, variant inner.append(heading, el('p', filename, 'small')); const badges = el('div', undefined, 'tags'), badge = evidenceBadge(record.prompts); if (badge) badges.append(el('span', badge, 'chip blue')); - if (linked && library.category) badges.append(prefixedChip(library.metadata_scope === 'pair' ? 'Recipe category: ' : 'Positive prompt category: ', library.category)); + if (linked && library.category) badges.append(prefixedChip(library.category_scope === 'pair' ? 'Recipe category: ' : 'Positive prompt category: ', library.category)); if (badges.childNodes.length) inner.append(badges); const prompts = el('div'); inner.append(prompts); const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext, title}); blocks.setState({prompts:record.prompts}); contentScope.cleanup(blocks.dispose); if (linked && library.tags.length) { // D21: a chip sets the Images Tags filter, which matches the linked positive's tags; a recipe's own tags are - // not what it matches, so they (and chips where no Tags filter exists, as in the viewer) stay plain. - const filterable = onTagFilter && library.metadata_scope === 'positive'; + // not what it matches, so they (and chips where no Tags filter exists, as in the viewer) stay plain. With the + // D39 fallback a recipe without tags shows its positive's, which are filter buttons again. + const filterable = onTagFilter && library.tags_scope === 'positive'; const tags = el('div', undefined, 'tags'); for (const tag of library.tags) { if (!filterable) {tags.append(el('span', tag, 'chip')); continue;} diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs index 920f1f9..98fb521 100644 --- a/web/js/workspace/inspection-state.mjs +++ b/web/js/workspace/inspection-state.mjs @@ -153,8 +153,10 @@ function resolutionFor(value) { function libraryFor(value) { if (!object(value) || !['linked', 'none', 'ambiguous', 'unavailable', 'limited'].includes(value.status) || value.scope !== 'saved-library' || - !Array.isArray(value.tags) || value.tags.some(tag => typeof tag !== 'string') || ![null, 'pair', 'positive'].includes(value.metadata_scope)) throw new TypeError('Invalid inspection library'); - const library = { status: value.status, scope: value.scope, tags: [...value.tags], metadata_scope: value.metadata_scope }; + !Array.isArray(value.tags) || value.tags.some(tag => typeof tag !== 'string') || + ![value.category_scope, value.tags_scope].every(scope => [null, 'pair', 'positive'].includes(scope))) throw new TypeError('Invalid inspection library'); + // D39: each field names its own source, so a recipe's category can sit beside its positive's tags. + const library = { status: value.status, scope: value.scope, tags: [...value.tags], category_scope: value.category_scope, tags_scope: value.tags_scope }; for (const key of ['positive_id', 'pair_id', 'title', 'category']) { const item = value[key] ?? null; if (item !== null && (key.endsWith('_id') ? !Number.isSafeInteger(item) || item <= 0 : typeof item !== 'string')) throw new TypeError('Invalid inspection library field');