Merge branch 'work/332-ui-concept' into feat/332-image-categories-pr3
This commit is contained in:
+128
-30
@@ -564,7 +564,7 @@ The shared inline and collection inspector reads the current rooted file's suppo
|
||||
embedded records, exact generation facts and recorded or inferred prompt evidence.
|
||||
It renders in two concept surfaces (#249 D20–D22, D38). The Images side panel shows
|
||||
only the concept content: cover, title, filename, the evidence badge with a category
|
||||
chip, the + and − prompt blocks with copy and send, tag chips (the file's own image
|
||||
chip naming its origin (D146), the + and − prompt blocks with copy and send, tag chips (the file's own image
|
||||
tags first, D88, then the linked tags; image tags and the linked positive's own tags
|
||||
filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
|
||||
"Prompt history" only when prompt roles resolved or a link is saved; the sticky panel
|
||||
@@ -1256,22 +1256,17 @@ Recorded deviations on this page (spec §8):
|
||||
inspector's own "Download metadata JSON" envelopes in listing order); the button is
|
||||
`aria-disabled` and ignores a second press while it runs, the selection is kept, and
|
||||
the toast names the unread images (none read: no download); leaving Images aborts the
|
||||
reads with no download and no toast. Set category opens the
|
||||
concept's `simpleInput` dialog ("Categorize selected images", `input#action-input`
|
||||
labelled Category, Cancel / Apply) and posts the selection's distinct linked positive
|
||||
ids to `/prompt_manager/bulk/category` (images have no category; the filter matches
|
||||
the linked positive's). With no linked image checked it posts nothing and opens no
|
||||
dialog; an empty value keeps the dialog open; the field stops at the server's 100
|
||||
characters; success reloads the categories and the listing together (clearing the
|
||||
selection, focus to the heading, and, as any replaced collection does, the inspector
|
||||
moves to the first listed image); failure keeps the selection. The write carries no
|
||||
page signal: leaving Images while it runs lets it finish without a toast, and the return
|
||||
loads the collection afresh instead of the saved visit. Three deviations from the
|
||||
concept's `simpleInput`: the field starts empty (the concept pre-fills its demo
|
||||
"Landscapes"), the action reads "Apply" (not "Apply in prototype"), and the
|
||||
nothing-linked check runs on the Set category press, so no dialog opens. The route
|
||||
rejects anything but 1-1000 positive integer ids within SQLite's INTEGER range
|
||||
(deduplicated) and a category that fails `validate_category`. `images selection bar matches
|
||||
reads with no download and no toast. Set category (amended by D140, #332) sets the
|
||||
checked images' own categories: it opens the concept's `simpleInput` dialog
|
||||
("Categorize selected images", `input#action-input` labelled Category) and writes
|
||||
`PUT`/`DELETE /prompt_manager/images/category`, never a prompt (see D140). Prompt
|
||||
categories are changed in the Prompt library editor and on the legacy admin page's
|
||||
"Set Category"; `POST /prompt_manager/bulk/category` is unchanged and serves that
|
||||
legacy page only. The write carries no page signal: leaving Images while it runs lets
|
||||
it finish without a toast, and the return loads the collection afresh instead of the
|
||||
saved visit. Its success reloads the categories and the listing together (clearing
|
||||
the selection, focus to the heading, and, as any replaced collection does, the
|
||||
inspector moves to the first listed image); failure keeps the selection. `images selection bar matches
|
||||
the concept` compares the bar and the checked card with one card checked; the omitted
|
||||
actions flow again from the concept's own boxes, checked against the concept page with
|
||||
them removed at runtime.
|
||||
@@ -1338,6 +1333,9 @@ Recorded deviations on this page (spec §8):
|
||||
filters, while recipe, recorded-run and embedded tags stay plain `span.chip`.
|
||||
Amended by D88 (#249 PR C): the file's own current image tags lead the same row
|
||||
and are filters too; a linked tag already shown (case-insensitive) is left out.
|
||||
Amended by D146 (#332): the category chip also names its origin in visible text
|
||||
after the category (" · this image", " · prompt", " · recipe"), and an image's own
|
||||
category shows for an unlinked or ambiguous file too.
|
||||
- **D22**: an empty positive reads "No positive prompt (explicitly empty)" (the concept
|
||||
applies its negative wording to both roles), and a confirmed `p.prompt-text` is
|
||||
focusable (`tabindex=0`, marked focus ring) so long prompts stay keyboard-readable.
|
||||
@@ -1400,8 +1398,9 @@ Recorded deviations on this page (spec §8):
|
||||
The category filter matches the same inherited category as the panel (D142, #332), and
|
||||
an image's own category overrides both (D141, D146). 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
|
||||
null, or `image` for an own category, #332); the category chip's visually hidden
|
||||
prefix (D21) says "Recipe category: ", "Positive prompt category: " or "Image category: "
|
||||
from it, and its visible origin (D146) " · recipe", " · prompt" or " · this image", 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.
|
||||
@@ -1419,7 +1418,11 @@ Recorded deviations on this page (spec §8):
|
||||
files whose own category is the value by path, so their rating and positive count.
|
||||
`test_filter_budget` seeds 2,000 own categories on its 50,000-file fixture.
|
||||
The option list adds the image categories of listed files
|
||||
(`GET /prompt_manager/images/categories`, PR 3).
|
||||
(`GET /prompt_manager/images/categories`, PR 3), merged with the Library's and sorted
|
||||
(D36); if only that read fails, the prompt categories show and the categories alert is
|
||||
raised. `images category options and Set category dialog match the concept` sets one
|
||||
image's own category ("Portraits") through the real route and compares the toolbar
|
||||
with it sorted into the concept's options; unmapped, only the options differ.
|
||||
- **D142** (#332 MC4, amends D39's filter note): the inherited category is one rule,
|
||||
`inherited_category` in `utils/inspection_response.py`: a linked recipe's own
|
||||
category when it is not blank, else the linked positive's. Before #332 the filter
|
||||
@@ -1435,7 +1438,53 @@ Recorded deviations on this page (spec §8):
|
||||
output sources, as Images keys it. A temp output or a file no source holds has none;
|
||||
a failed read hides the chip (DC5). The outputs list and media requests read nothing
|
||||
new. The chip's hidden prefix reads "Image category: " for an own category; the
|
||||
visible origin text and its comparison are D146 (PR 3).
|
||||
visible origin text and its comparison are D146 (PR 3). `history media inspector
|
||||
category chip matches the concept` (history-concept.spec) sets an own category on the
|
||||
Quality baseline's newest output, opens its Recorded run and "View current file", and
|
||||
compares the viewer's chip with the concept viewer's category chip (app.js a402) laid
|
||||
out at runtime with the D146 origin and that category; its place is not compared (what
|
||||
precedes it differs), only its size, tokens, text, prefix and origin. Unmapped, the two
|
||||
differ only in the text, the prefix, the width and `span.chip-source`.
|
||||
- **D140** (#332, amends D8): Set category sets the checked images' own categories, so
|
||||
every checked file is a target (no nothing-linked check). More than 500 checked (or
|
||||
"all N matching" over 500) toasts "Select at most 500 images." and opens nothing.
|
||||
The dialog is the concept's `simpleInput` with three recorded differences: the field
|
||||
starts empty (the concept pre-fills its demo "Landscapes") and suggests the toolbar's
|
||||
current categories (`datalist#workspace-input-suggestions`, `openInputDialog`'s
|
||||
`suggestions`); "Use prompt category", a plain button between Cancel and Apply
|
||||
(`secondary`), closes it and opens the "Use prompt category" confirmation ("Remove the
|
||||
image category from the 1 selected image? / … the N selected images? Each one then
|
||||
shows its prompt's category, or none if it has no linked prompt. This cannot be
|
||||
undone.", Cancel and a danger Remove, built like saved views' delete confirmation);
|
||||
and the action reads "Apply" (D8). Apply sends `PUT /prompt_manager/images/category`
|
||||
`{items | filter, category}`, Remove `DELETE` `{items | filter}`; Cancel sends
|
||||
nothing. An empty value keeps the dialog open and the field stops at 100 characters.
|
||||
Toasts: "Category “X” set on 1 image." / "… on N images." (unchanged files count as
|
||||
set) plus " N could not be found." for unlisted files; "Removed the image category
|
||||
from N images." or "None of the selected images had its own category."; 404 "None of
|
||||
the selected images could be found. Refresh the images and try again."; 409 "The
|
||||
sources changed. Refresh the images and try again." (the listing reloads); anything
|
||||
else "Category could not be set. Try again.". `images category options and Set
|
||||
category dialog match the concept` (images-concept.spec) checks a card, opens the
|
||||
dialog in both documents and compares it (title, body tree, field, input, suggestions,
|
||||
actions and every button's box and tokens) with the concept's own dialog changed at
|
||||
runtime as recorded: field emptied, the datalist added after the field, a clone of
|
||||
its Cancel reading "Use prompt category" after Cancel, the primary renamed "Apply".
|
||||
Unmapped, the dialog differs only in the input's value and list, the body's tree, the
|
||||
suggestions and the actions.
|
||||
- **D146** (#332, amends D21 and D39): the category chip names its origin in visible
|
||||
text. It is `span.chip` > `span.ca-visually-hidden` (the D21 prefix) + the category +
|
||||
`span.chip-source` (" · this image", " · prompt" or " · recipe"; muted
|
||||
`--ca-muted`, `white-space: nowrap`, so at 390px the chip may wrap only before the
|
||||
origin). It shows when the inspection names a non-empty category and either the
|
||||
scope is `image` (any link status, so an unlinked or ambiguous file with an own
|
||||
category shows it) or the file is linked. The concept's chip shows the bare name
|
||||
(app.js a402). The inspector comparison (inspector-concept.spec) measures each chip's
|
||||
`span.chip-source` (text, font, color, `white-space`, box) and lays the concept's own
|
||||
chip out with the recorded rule and " · prompt" added at runtime (`withChipSource` in
|
||||
`concept-inventory.mjs`), in the side panel and the viewer's details; unmapped they
|
||||
differ only in the chip's text, width and `span.chip-source`. Its mutation check
|
||||
removes the origin. History's chip (D145) is compared the same way.
|
||||
- **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
|
||||
@@ -1460,7 +1509,8 @@ applies D3 (the `.file-inspector` class of the production-only rules), D20 (the
|
||||
viewer's plain tag chips, laid out by the concept page itself with its chips made
|
||||
plain at runtime), the D20/D21 side-panel scroll (`overflow: auto`,
|
||||
`max-height: calc(100dvh - 40px)` above 700px), D21 (the category's hidden scope
|
||||
prefix) and D22 (focusable prompt text). `inspector comparison detects mutations`
|
||||
prefix), D22 (focusable prompt text) and D146 (the chip's visible " · prompt", laid out
|
||||
by the concept page itself). `inspector comparison detects mutations`
|
||||
changes `.prompt-heading .role-label` letter spacing and removes `.detail-footer`.
|
||||
|
||||
Manual check (quick), after a browser refresh on Images: hover an image (it zooms);
|
||||
@@ -1795,11 +1845,13 @@ ranges include cards that bounded rendering has unmounted.
|
||||
Over the limit, the client toasts "Narrow the view to at most 10,000 images." and the
|
||||
server answers 400 `too-many-images`. No match is 400 `no-images`. Auto-tag in this
|
||||
mode does not offer Review each image, because the review walks images in the
|
||||
browser. Set category posts `{images_filter, category}` to `/prompt_manager/bulk/category`.
|
||||
The server sets the category on the distinct linked positives and answers
|
||||
`prompt_count` and `skipped`. A listing whose `positive_count` is 0 toasts the
|
||||
nothing-linked sentence and opens no dialog, and over 10,000 matches it toasts the
|
||||
limit sentence. Export metadata reads every image in
|
||||
browser. Set category (D140, #332) sends `{filter, category}` to
|
||||
`PUT /prompt_manager/images/category` (Use prompt category `{filter}` to `DELETE`),
|
||||
which sets or removes each matching image's own category; over 500 matches the client
|
||||
toasts "Select at most 500 images." and opens no dialog, and the server answers 400
|
||||
`too-many-images`. (`POST /prompt_manager/bulk/category` still accepts
|
||||
`{images_filter, category}` and sets the linked positives' category, but no workspace
|
||||
control sends it any more.) Export metadata reads every image in
|
||||
the browser, so it is `aria-disabled` in this mode, and its `title` says why.
|
||||
|
||||
Tests: `tests/browser/workspace-images.spec.mjs` ("Shift+click selects a range across
|
||||
@@ -4565,6 +4617,21 @@ category; without one it shows the category its link supplies.
|
||||
appears at the same path shows it. The real-host check is the last test in
|
||||
`tests/comfyui/image-attribution.spec.mjs`: PUT, list, DELETE on a generated
|
||||
output.
|
||||
- Interface (PR 3, a browser refresh; no restart beyond PR 1-2's): the Images
|
||||
selection bar's Set category writes these routes (D140, which amends D8); the toolbar
|
||||
category options add the listed images' own categories (D141); the inspector's
|
||||
category chip names its origin, " · this image", " · prompt" or " · recipe" (D146), in
|
||||
the side panel, the viewer and History's media inspector (D145); Tags & categories
|
||||
counts images on each category row and lists image-only values (D143), and Manage
|
||||
category previews and renames image rows too (D144). Prompt categories are changed in
|
||||
the Prompt library editor and on the legacy admin page's "Set Category". Concept
|
||||
comparisons, at 1600x1100 and 390x844 in both themes: `images-concept.spec.mjs` (the
|
||||
Set category dialog and the options), `inspector-concept.spec.mjs` (the chip),
|
||||
`history-concept.spec.mjs` (History's chip) and `vocabulary-visual.spec.mjs` (rows and
|
||||
preview, with `vocabulary-canonical-comparison.mjs`). Behavior: `workspace-images`,
|
||||
`full-inspector`, `workspace-history` and `workspace-vocabulary` specs, and
|
||||
`tests/js/workspace-images-category.test.js`, `workspace-file-inspector.test.js`,
|
||||
`workspace-vocabulary-state.test.js`, `workspace-toolbar.test.js`.
|
||||
|
||||
Image annotations schema (#249, `database/annotation_schema.py`): the group
|
||||
`media_annotation_metadata` (one row: schema version, `image_index_revision`),
|
||||
@@ -5543,13 +5610,18 @@ Deploying this backend needs a ComfyUI restart (Python and route changes).
|
||||
longer hands off to the legacy tag manager. That page stays reachable from Overview's
|
||||
"Open existing tag manager" card for tag search and sort, the untagged entry and
|
||||
multi-tag AND/OR browsing; bulk prompt actions (delete, tag, category) stay on the
|
||||
legacy admin page. The screen consumes only the `/prompt_manager/vocabulary/*` routes
|
||||
legacy admin page. A prompt's category is changed in the Prompt library editor or with
|
||||
the legacy admin page's "Set Category" (`POST /prompt_manager/bulk/category`, unchanged);
|
||||
the Images selection bar's Set category sets images' own categories only (D140, #332),
|
||||
and Manage category renames a value on prompts, recipes and images together (D144). The screen consumes only the `/prompt_manager/vocabulary/*` routes
|
||||
above and adds no route, so PR B needs no ComfyUI restart: a browser refresh is enough.
|
||||
|
||||
Screen: a `+ Create tag` heading action, a Tag vocabulary table (columns Tag, Prompts,
|
||||
Images counting the files that carry the tag (`image_count`, #249 C2), and Actions with Browse and
|
||||
Rename / merge), and a Categories panel whose category rows end with the Manage
|
||||
category row (Review). Lists load 100 rows at a time, append when scrolled into view
|
||||
category row (Review). A category row reads "N positive prompts", "N positive prompts ·
|
||||
M images" when images hold the value as their own category too, or "M images" for a
|
||||
value only images hold (D143, #332). Lists load 100 rows at a time, append when scrolled into view
|
||||
and keep `Load more tags` / `Load more categories` while more remain. Prompts counts
|
||||
are the Library Browse totals.
|
||||
|
||||
@@ -5592,7 +5664,33 @@ Recorded deviations from the concept (maintainer acceptance): bounded lists with
|
||||
Load more; title "Tags"; Manage categories adds a Category select and Preview before
|
||||
Apply; "Apply" instead of "Apply in prototype"; Delete tag… inside Rename or merge;
|
||||
Browse sets the exact filter; singular "1 positive prompt"; the production list appends
|
||||
fixture-only tags after the concept's sample tags. Paired concept/production captures
|
||||
fixture-only tags after the concept's sample tags.
|
||||
|
||||
- **D143** (#332): category rows count the images whose own category is the value
|
||||
(`image_count`, the stored rows, missing files included): "N positive prompts · M
|
||||
images" beside prompts, "M images" for an image-only value, which follows every
|
||||
prompt-backed row (by value) before Manage category. Browse on an image-only row opens
|
||||
Images with exactly that Category filter (its Library would be empty); every other
|
||||
row opens the Library as before. Manage category's select lists image-only values
|
||||
last, so past 1,000 categories they are the first left out (the dialog says the list is
|
||||
capped).
|
||||
- **D144** (#332): Manage category's preview counts the images holding the value ("“X”
|
||||
is used by N positive prompts and M images. Apply renames it to “Y”."; D9's sentence
|
||||
with "M images" added when above 0), an image-only value can be renamed, and Apply
|
||||
renames the image rows in the same transaction; an image row gaining or losing the
|
||||
value between Preview and Apply is 409 `vocabulary-conflict` with nothing written.
|
||||
- `category rows and the Manage category preview match the concept with image
|
||||
categories` (vocabulary-visual.spec, 1600x1100 and 390x844, both themes) seeds the
|
||||
fixture with `image_categories: true` ("Landscapes" on two images, "Portraits" on one
|
||||
image only, rows keyed as Images keys them) and compares the page with D143 mapped
|
||||
(`mapVocabularyConcept(reference, imageCategories)`, whose row model is checked against
|
||||
the concept page with those rows added at runtime) and the Manage dialog after
|
||||
Preview with D9 and D144 (`mapVocabularyDialog`'s `preview`). Before the preview is
|
||||
measured the pointer leaves the pressed Apply and focus returns to ×, the state the
|
||||
opening comparison in workspace-vocabulary.spec measures. Unmapped, the page differs
|
||||
in the Landscapes row's text and the added Portraits row, the dialog only in the
|
||||
sentence. The default fixture seeds no image category, so the other vocabulary
|
||||
comparisons are unchanged. Paired concept/production captures
|
||||
for both themes and widths are attached as test artifacts; no PNG baseline is added
|
||||
until acceptance. Not compared: hover states and the dialog focus ring (the page-level
|
||||
keyboard ring and link ring offsets are asserted). No coverage floor was lowered;
|
||||
|
||||
@@ -187,6 +187,33 @@ export async function mutate(page,{css=null,remove=[],clone=[]}={}) {
|
||||
},token);
|
||||
}
|
||||
|
||||
/**
|
||||
* #332 D146: the category chip names its origin after the category, in a muted `span.chip-source` that never splits
|
||||
* (workspace.css `.chip .chip-source`). The concept's chip shows the bare name (app.js a402), so the recorded rule and
|
||||
* the span are added to the concept page at runtime and the browser lays the chip out: the first `.tags` row's
|
||||
* non-badge chip under `root`. `category` also replaces the chip's text (an own category the concept never shows).
|
||||
* Returns the undo.
|
||||
*/
|
||||
export const D146_CHIP_SOURCE='.chip .chip-source { color: var(--ca-muted); white-space: nowrap; }';
|
||||
export async function withChipSource(page,root,origin,category=null) {
|
||||
const restoreRule=await mutate(page,{css:D146_CHIP_SOURCE});
|
||||
const token=await page.evaluate(({root,origin,category})=>{
|
||||
const chip=document.querySelector(root)?.querySelector('.tags')?.querySelector(':scope > .chip:not(.blue)');
|
||||
if(!chip)throw new Error(`withChipSource: no category chip under ${root}`);
|
||||
const text=chip.textContent,source=document.createElement('span');
|
||||
source.className='chip-source';source.textContent=` · ${origin}`;
|
||||
if(category!==null)chip.textContent=category;
|
||||
chip.append(source);
|
||||
const token=`chip-source-${Math.random().toString(36).slice(2)}`;
|
||||
(window.__chipSources??=new Map()).set(token,()=>{chip.textContent=text;});
|
||||
return token;
|
||||
},{root,origin,category});
|
||||
return async()=>{
|
||||
await page.evaluate(token=>{window.__chipSources.get(token)();window.__chipSources.delete(token);},token);
|
||||
await restoreRule();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library,
|
||||
* history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7)
|
||||
@@ -615,9 +642,12 @@ export function measureInspector(selector) {
|
||||
primary:node.classList.contains('ca-primary'),...box(node),...font(node),...edges(node),...layout(node),
|
||||
minHeight:style.minHeight,minWidth:style.minWidth,icon:iconOf(node)};
|
||||
}
|
||||
// #332 D146: a category chip's visible origin (`span.chip-source`), null on every other chip.
|
||||
function chipOf(node) {
|
||||
const source=node.querySelector(':scope > .chip-source');
|
||||
return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),hidden:hiddenOf(node),...box(node),...font(node),...edges(node),
|
||||
minHeight:getComputedStyle(node).minHeight};
|
||||
minHeight:getComputedStyle(node).minHeight,
|
||||
source:when(source,()=>({text:source.textContent,...font(source),...css(source,'whiteSpace'),...box(source)}))};
|
||||
}
|
||||
function rowOf(row) {
|
||||
return {...layout(row),...edges(row),...box(row),chips:[...row.children].map(chipOf)};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact} from './concept-inventory.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,measureInspector,withChipSource} from './concept-inventory.mjs';
|
||||
|
||||
// Task F5 of #249: the production Prompt history (#/library/<id>) against the immutable concept's (#prompt/alpine,
|
||||
// app.js renderHistory a587–622, the dialogs a1282–1304, style.css 1059–1179 and 1546–1567), on the concept-shaped real
|
||||
@@ -272,6 +272,62 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`history geomet
|
||||
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
/**
|
||||
* D145 (#332): the History media inspector (a recorded run's current file, `recorded-run.mjs`) shows the file's own
|
||||
* category with the chip rule of the Images side panel and viewer (D146). The concept has no recorded-run inspector, so
|
||||
* its chip is the concept viewer's own category chip (app.js a402, imageDetails(i, true)) with the recorded D146 origin
|
||||
* and the own category added at runtime (`withChipSource`). The chip's own place depends on what precedes it in either
|
||||
* inspector, so its box is compared by size only.
|
||||
*/
|
||||
const OWN_CATEGORY='Portraits';
|
||||
const chipOnly=chip=>{const {left,top,source,...rest}=chip;return {...rest,source:source&&(({left,top,...own})=>own)(source)};};
|
||||
const categoryChipOf=async(page,root)=>(await page.evaluate(measureInspector,root)).badges.chips.find(chip=>chip.source);
|
||||
|
||||
async function conceptViewerChip(page,theme) {
|
||||
await openConcept(page,'images',theme);await page.mouse.move(0,0);
|
||||
await page.locator('.gallery-body > .image-grid > .media-card').first().locator('.image-open').click();
|
||||
await expect(page.locator('#viewer-dialog[open] #viewer-details .panel-heading h2')).toHaveText(TITLE);
|
||||
const restore=await withChipSource(page,'#viewer-details','this image',OWN_CATEGORY);await settle(page);
|
||||
const chip=await categoryChipOf(page,'#viewer-details');
|
||||
await restore();
|
||||
return chip;
|
||||
}
|
||||
|
||||
/** Recorded run for the baseline's newest run, its one reported output opened in the viewer. */
|
||||
async function productionRunChip(page,theme,positiveId) {
|
||||
await openProductionHistory(page,theme,'no-preference',positiveId);
|
||||
await page.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}).click();
|
||||
const run=page.getByRole('dialog',{name:'Recorded run',exact:true});
|
||||
await expect(run).toBeVisible();
|
||||
const view=run.getByRole('button',{name:'View current file',exact:true});
|
||||
await expect(view).toBeEnabled();await view.click();
|
||||
const details=page.locator('#viewer-dialog[open] #viewer-details');
|
||||
await expect(details.locator('.chip-source')).toHaveText(' · this image');
|
||||
await page.mouse.move(0,0);await settle(page);
|
||||
return categoryChipOf(page,'#viewer-dialog[open] #viewer-details');
|
||||
}
|
||||
|
||||
async function setOwnCategory(request,path,category) {
|
||||
const listing=await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||
const root_scope=listing.images.find(item=>item.root_index===0).root_scope;
|
||||
const response=await request.put('/prompt_manager/images/category',{data:{items:[{root_index:0,root_scope,path}],category}});
|
||||
await expect(response).toBeOK();
|
||||
}
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`history media inspector category chip matches the concept ${width}x${height} ${theme} (D145)`,async({page,request},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const {manifest}=testInfo;
|
||||
// The baseline's newest image: the output of the run its dot opens (the fixture records one run per image).
|
||||
const baseline=manifest.images.find(image=>image.prompt==='alpine'&&image.variant===0);
|
||||
await setOwnCategory(request,baseline.path,OWN_CATEGORY);
|
||||
const reference=await conceptViewerChip(page,theme);
|
||||
const actual=await productionRunChip(page,theme,manifest.prompts[0].positive_id);
|
||||
await captureCanonicalState(page,testInfo,`production-history-run-chip-${width}-${theme}`,'#viewer-dialog[open]');
|
||||
expect.soft(reference,'concept chip').toMatchObject({tag:'span',className:'chip',text:`${OWN_CATEGORY} · this image`,hidden:null});
|
||||
// D21: the hidden prefix names the scope; D146 as above.
|
||||
expect.soft(diffInventory({...chipOnly(reference),hidden:'Image category: '},chipOnly(actual)),'chip differences after D21, D145, D146').toEqual([]);
|
||||
});
|
||||
|
||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the variant
|
||||
// dot's width) and one DOM mutation (Compare negatives removed), each applied after an exact match.
|
||||
test('history comparison detects mutations',async({page},testInfo)=>{
|
||||
|
||||
@@ -85,15 +85,17 @@ function listInfo(card,actionsWidth) {
|
||||
/** Recorded Images deviations only (spec §8); everything else in the concept inventory must equal production. */
|
||||
const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
|
||||
|
||||
export function mapImagesConcept(reference,{manifest,now=new Date(manifest.now*1000)}) {
|
||||
export function mapImagesConcept(reference,{manifest,now=new Date(manifest.now*1000),imageCategories=[]}) {
|
||||
const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
|
||||
// D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired).
|
||||
const toolbar={...reference.toolbar};
|
||||
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options,'Oldest first','Name Z–A']}:item);
|
||||
// D36: the category list is the Library's real list, sorted alphabetically (database/library.py `ORDER BY category
|
||||
// COLLATE NOCASE, category`); the concept lists its samples in prompt order (app.js 348). Only the order is mapped.
|
||||
// D141 (#332): the list also holds the listed images' own categories (`GET /prompt_manager/images/categories`),
|
||||
// sorted in with the Library's.
|
||||
toolbar.items=toolbar.items.map(item=>item.id==='category-filter'
|
||||
?{...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]}:item);
|
||||
?{...item,options:[item.options[0],...[...item.options.slice(1),...imageCategories].sort(nocase)]}:item);
|
||||
// D29: no "Illustrative previews" sample tag.
|
||||
const filterRow=withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag'));
|
||||
filterRow.items=filterRow.items.map(item=>{
|
||||
@@ -163,9 +165,9 @@ const pick=inventory=>stripExact(inventory);
|
||||
// The concept's own sample categories, in its first-appearance order (app.js 348): what D36 reorders.
|
||||
const CONCEPT_CATEGORIES=['All categories','Landscapes','Architecture','Environments','Illustration'];
|
||||
|
||||
function compare(reference,actual,manifest,sections=SECTIONS) {
|
||||
function compare(reference,actual,manifest,sections=SECTIONS,imageCategories=[]) {
|
||||
const only=inventory=>Object.fromEntries(sections.map(key=>[key,inventory[key]]));
|
||||
return diffInventory(pick(only(mapImagesConcept(reference,{manifest}))),pick(only(actual)));
|
||||
return diffInventory(pick(only(mapImagesConcept(reference,{manifest,imageCategories}))),pick(only(actual)));
|
||||
}
|
||||
|
||||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||||
@@ -469,6 +471,117 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
|
||||
expect(compare(oracle,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
|
||||
});
|
||||
|
||||
/** #332: an own category on one listed image, set through the real route (PUT /prompt_manager/images/category). */
|
||||
const IMAGE_CATEGORY='Portraits';
|
||||
async function setImageCategory(request,path,category) {
|
||||
const listing=await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||
const root_scope=listing.images.find(item=>item.root_index===0).root_scope;
|
||||
const response=await request.put('/prompt_manager/images/category',{data:{items:[{root_index:0,root_scope,path}],category}});
|
||||
await expect(response).toBeOK();
|
||||
expect(await response.json()).toMatchObject({success:true,category,updated:1});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs in the page: the open Set category dialog (the concept's `simpleInput`, app.js 1384-1390 and 1138): its title,
|
||||
* the body's tree, the field, the input and its suggestions (`datalist`), and the actions with every button's box,
|
||||
* all relative to the dialog, with the concept's geometry and tokens.
|
||||
*/
|
||||
function measureCategoryDialog() {
|
||||
const dialog=document.querySelector('dialog#modal[open]');
|
||||
const css=(node,...keys)=>node?Object.fromEntries(keys.map(key=>[key,getComputedStyle(node)[key]])):null;
|
||||
const name=node=>[node.localName,...[...node.classList].sort()].join('.');
|
||||
const text=node=>node?.textContent.trim()??null;
|
||||
const origin=dialog.getBoundingClientRect();
|
||||
const box=node=>{const rect=node.getBoundingClientRect();return {left:Math.round(rect.left-origin.left),top:Math.round(rect.top-origin.top),
|
||||
width:Math.round(rect.width),height:Math.round(rect.height)};};
|
||||
const body=dialog.querySelector('#modal-body');
|
||||
const field=body.querySelector(':scope > .field');
|
||||
const label=field?.querySelector(':scope > label'),input=field?.querySelector(':scope > input');
|
||||
const list=body.querySelector(':scope > datalist');
|
||||
const actions=body.querySelector(':scope > .dialog-actions');
|
||||
return {
|
||||
title:text(dialog.querySelector('#modal-title')),
|
||||
tree:[...body.children].map(name),
|
||||
field:field&&{tree:[...field.children].map(name),...css(field,'display','flexDirection','rowGap','marginBottom'),...box(field)},
|
||||
label:label&&{text:text(label),for:label.htmlFor,...css(label,'fontSize','fontWeight','color')},
|
||||
input:input&&{id:input.id,type:input.type,value:input.value,list:input.getAttribute('list'),
|
||||
...css(input,'height','paddingLeft','fontSize','color','backgroundColor','borderTopColor','borderTopLeftRadius'),...box(input)},
|
||||
suggestions:list&&{id:list.id,shown:list.getClientRects().length>0,options:[...list.options].map(option=>option.value)},
|
||||
actions:{tree:[...actions.children].map(name),...css(actions,'display','justifyContent','columnGap','rowGap','flexWrap','marginTop'),...box(actions),
|
||||
buttons:[...actions.children].map(button=>({text:text(button),className:button.className,
|
||||
...css(button,'backgroundColor','color','fontSize','paddingTop','paddingLeft','borderTopColor','minHeight'),...box(button)}))},
|
||||
width:Math.round(origin.width),height:Math.round(origin.height),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The concept's own open dialog as production records it, changed at runtime so the browser lays it out: D140 (#332)
|
||||
* empties the field (the concept pre-fills its demo "Landscapes"), lists the toolbar's categories as the field's
|
||||
* suggestions (`datalist#workspace-input-suggestions`) and adds "Use prompt category", a plain button of the concept's
|
||||
* own Cancel, between Cancel and the primary action; D8 names the primary action "Apply" ("Apply in prototype").
|
||||
*/
|
||||
async function categoryDialogAsRecorded(page,suggestions) {
|
||||
await mutate(page,{clone:[{selector:'dialog#modal[open] .dialog-actions > button:first-child',text:'Use prompt category'}]});
|
||||
await page.evaluate(suggestions=>{
|
||||
const body=document.querySelector('dialog#modal[open] #modal-body');
|
||||
const input=body.querySelector('#action-input');input.value='';input.setAttribute('list','workspace-input-suggestions');
|
||||
const list=document.createElement('datalist');list.id='workspace-input-suggestions';
|
||||
for(const value of suggestions){const option=document.createElement('option');option.value=value;list.append(option);}
|
||||
body.querySelector(':scope > .field').after(list);
|
||||
body.querySelector('.dialog-actions > .ca-primary').textContent='Apply';
|
||||
},suggestions);
|
||||
await settle(page);
|
||||
return page.evaluate(measureCategoryDialog);
|
||||
}
|
||||
|
||||
async function openCategoryDialog(page,scope) {
|
||||
await page.locator(scope).locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
|
||||
await expect(page.locator('#bulkbar > .bulkbar > strong')).toHaveText('1 selected');
|
||||
await page.locator('#bulkbar').getByRole('button',{name:'Set category',exact:true}).click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Categorize selected images');
|
||||
await page.mouse.move(0,0);await settle(page);
|
||||
return page.evaluate(measureCategoryDialog);
|
||||
}
|
||||
|
||||
// D140 and D141 (#332): with one image's own category set, the toolbar's category options and the Set category dialog
|
||||
// against the concept's (app.js 348 and 1384-1390). The dialog is compared with the concept page itself changed at
|
||||
// runtime as recorded (`categoryDialogAsRecorded`); unmapped, it differs only in the recorded parts.
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`images category options and Set category dialog match the concept ${width}x${height} ${theme}`,async({page,request},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const manifest=testInfo.manifest;
|
||||
await setImageCategory(request,manifest.images.at(-1).path,IMAGE_CATEGORY);
|
||||
await openConceptLoaded(page,theme,'no-preference');
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
const referenceDialog=await openCategoryDialog(page,'#main');
|
||||
await captureCanonicalState(page,testInfo,`concept-images-category-${width}-${theme}`,'dialog#modal');
|
||||
const suggestions=[...CONCEPT_CATEGORIES.slice(1),IMAGE_CATEGORY].sort(nocase);
|
||||
const oracle=await categoryDialogAsRecorded(page,suggestions);
|
||||
await page.keyboard.press('Escape');await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
|
||||
|
||||
await openProductionLoaded(page,theme,'no-preference');
|
||||
await expect(page.locator('#category-filter option')).toHaveText(['All categories',...suggestions]);
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
const actualDialog=await openCategoryDialog(page,'#workspace-main');
|
||||
await captureCanonicalState(page,testInfo,`production-images-category-${width}-${theme}`,'dialog#modal');
|
||||
const path=testInfo.outputPath('images-category-geometry.json');
|
||||
await writeFile(path,JSON.stringify({referenceDialog,oracle,actualDialog,reference:reference.toolbar,actual:actual.toolbar},null,2));
|
||||
await testInfo.attach('images-category-geometry',{path,contentType:'application/json'});
|
||||
|
||||
// The concept itself: its sample categories and its pre-filled simpleInput with Cancel / Apply in prototype.
|
||||
expect.soft(reference.toolbar.items.find(item=>item.id==='category-filter').options,'concept categories').toEqual(CONCEPT_CATEGORIES);
|
||||
expect.soft(referenceDialog,'concept dialog').toMatchObject({title:'Categorize selected images',label:{text:'Category'},
|
||||
input:{id:'action-input',value:'Landscapes',list:null},suggestions:null});
|
||||
expect.soft(referenceDialog.actions.buttons.map(button=>button.text),'concept actions').toEqual(['Cancel','Apply in prototype']);
|
||||
// D141: the toolbar options add the image's own category, sorted in (D36).
|
||||
expect.soft(compare(reference,actual,manifest,['toolbar'],[IMAGE_CATEGORY]),'toolbar differences after D14, D36, D141').toEqual([]);
|
||||
// D140 and D8: the dialog equals the concept page laid out as recorded.
|
||||
expect.soft(diffInventory(oracle,actualDialog),'dialog differences after D8, D140').toEqual([]);
|
||||
// Unmapped, the concept differs only in the field's value and suggestions, the body's datalist and the actions.
|
||||
const recorded=/^(input\.(value|list)|tree\.|suggestions|actions\.)/;
|
||||
expect.soft(diffInventory(referenceDialog,actualDialog).map(item=>item.path).filter(item=>!recorded.test(item)),'unrecorded dialog differences').toEqual([]);
|
||||
expect.soft(actualDialog.suggestions,'suggestions').toEqual({id:'workspace-input-suggestions',shown:false,options:suggestions});
|
||||
});
|
||||
|
||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the
|
||||
// `.media-info` padding) and one DOM mutation (the Inspect buttons removed), each applied after an exact match.
|
||||
test('images comparison detects mutations',async({page},testInfo)=>{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags} from './concept-inventory.mjs';
|
||||
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource} from './concept-inventory.mjs';
|
||||
|
||||
// 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.
|
||||
@@ -13,6 +13,8 @@ test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
const CARDS='.gallery-body > .image-grid > .media-card';
|
||||
const TITLE='Alpine observatory';
|
||||
// D146 (#332): alpine_00001.png's recipe has no category, so its chip shows the positive's, " · prompt" (D39).
|
||||
const ORIGIN='prompt';
|
||||
const FIXTURE_IMAGES=24;
|
||||
// The concept's sticky panel never scrolls; production's scrolls inside the window above 700px (D20/D21 side panel).
|
||||
const PANEL_INSET=40;
|
||||
@@ -38,14 +40,18 @@ 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 (D39 fallback). */
|
||||
/**
|
||||
* D21: the category chip names its scope in a visually hidden prefix; here the linked positive's (D39 fallback). Its
|
||||
* visible origin (D146, #332: " · prompt") is the concept page's own chip with `span.chip-source` added at runtime
|
||||
* (`withChipSource`), so `reference.panel` and `viewerPlain` already carry it.
|
||||
*/
|
||||
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 D117); everything else must
|
||||
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117 and D146); everything else must
|
||||
* equal production. `reference.viewerPlain` is the concept's own full details
|
||||
* with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at
|
||||
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do.
|
||||
@@ -112,6 +118,7 @@ async function openProductionPanel(page,theme) {
|
||||
*/
|
||||
async function plainViewerChips(page) {
|
||||
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
|
||||
const restoreSource=await withChipSource(page,'#viewer-details',ORIGIN);
|
||||
await page.evaluate(()=>{
|
||||
window.__plainChips=[...document.querySelectorAll('#viewer-details .tags > button.chip')].map(button=>{
|
||||
const span=document.createElement('span');span.className='chip';span.textContent=button.textContent;
|
||||
@@ -126,7 +133,7 @@ async function plainViewerChips(page) {
|
||||
return async()=>{
|
||||
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;
|
||||
window.__summaryButton.remove();delete window.__summaryButton;});
|
||||
await restoreTargets();
|
||||
await restoreSource();await restoreTargets();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,7 +169,11 @@ async function viewerOf(page,concept,mutation=null) {
|
||||
return {viewerDetails,viewerPlain,fileControls};
|
||||
}
|
||||
async function inventory(page,concept=false) {
|
||||
// D146: the concept panel's category chip with its visible origin, laid out by the concept page itself.
|
||||
const restoreSource=concept?await withChipSource(page,'#image-inspector',ORIGIN):null;
|
||||
if(concept)await settle(page);
|
||||
const {panel}=await inspectorOf(page);
|
||||
await restoreSource?.();
|
||||
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept);
|
||||
return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls};
|
||||
}
|
||||
@@ -287,7 +298,8 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector pane
|
||||
expect.soft(panel.sections.map(section=>section.label.color)[0],'concept role colors').not.toBe(panel.sections[1].label.color);
|
||||
expect.soft(panel.facts,'concept facts').toMatchObject({columns:2,columnGap:'12px',rowGap:'12px',fontSize:width>700?'11px':'12px'});
|
||||
expect.soft(panel.footer,'concept footer').toMatchObject({paddingTop:'13px',paddingRight:'17px',paddingBottom:'13px',paddingLeft:'17px'});
|
||||
expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes']);
|
||||
// The concept's bare "Landscapes" with the D146 origin added at runtime (`withChipSource`).
|
||||
expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes · prompt']);
|
||||
expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']);
|
||||
|
||||
expect.soft(diff,'differences after the recorded deviations').toEqual([]);
|
||||
@@ -321,8 +333,10 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me
|
||||
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry');
|
||||
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')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']);
|
||||
await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
|
||||
// D146 (#332): the visible origin names where the value came from.
|
||||
await expect(rows.first().locator('.chip .chip-source')).toHaveText(' · prompt');
|
||||
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']);
|
||||
@@ -380,6 +394,13 @@ test('inspector comparison detects mutations',async({page})=>{
|
||||
expect(()=>assertInspectorMatch(reference,withoutSummary,viewport)).toThrow(/viewerDetails\.structure\.inner/);
|
||||
assertInspectorMatch(reference,await viewerMutated(),viewport);
|
||||
|
||||
// D146: the visible origin is part of the mapped concept, so losing it is a difference.
|
||||
const restoreSource=await mutate(page,{remove:['#image-inspector .chip-source']});
|
||||
const sourceMutated=await measure();
|
||||
expect(()=>assertInspectorMatch(reference,sourceMutated,viewport)).toThrow(/panel\.badges\.chips\.1\.source/);
|
||||
await restoreSource();await settle(page);
|
||||
assertInspectorMatch(reference,await measure(),viewport);
|
||||
|
||||
await mutate(page,{remove:['#image-inspector > .detail-footer']});
|
||||
const domMutated=await measure();
|
||||
expect(()=>assertInspectorMatch(reference,domMutated,viewport)).toThrow(/panel\.footer/);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Concept-vs-production inventory for the native Tags & categories screen (concept renderTags, app.js:719-729).
|
||||
// pageInventoryOf runs in the page; dialogs reuse the editor's inventoryOf. The concept is never rewritten:
|
||||
// mapVocabularyConcept / mapVocabularyDialog apply only the recorded deviations, each named with its ruling.
|
||||
import {diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
export {inventoryOf, diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
export function pageInventoryOf(main) {
|
||||
@@ -61,23 +62,51 @@ export function pageInventoryOf(main) {
|
||||
};
|
||||
}
|
||||
|
||||
const countOf=(count,noun)=>`${count} ${noun}${count===1?'':'s'}`;
|
||||
|
||||
/**
|
||||
* D143 (#332): a category row counts the images whose own category it is: "N positive prompts · M images" when
|
||||
* prompts hold it too, "M images" for a value only images hold. Image-only rows follow every prompt-backed row, before
|
||||
* the closing Manage category row, each drawn as the concept's last category row with its own text (vocabulary-visual
|
||||
* checks this against the concept page with those rows added at runtime). `images` is `{value: count}`; `only` lists
|
||||
* the image-only values in their order.
|
||||
*/
|
||||
export function withImageCounts(operations,{images={},only=[]}={}) {
|
||||
const rows=operations.map(row=>images[row.h3.text]&&!only.includes(row.h3.text)
|
||||
?{...row,p:{...row.p,text:`${row.p.text} · ${countOf(images[row.h3.text],'image')}`}}:row);
|
||||
const manage=rows.at(-1),last=rows.at(-2);
|
||||
const added=only.map(value=>({...last,h3:{...last.h3,text:value},p:{...last.p,text:countOf(images[value],'image')}}));
|
||||
return [...rows.slice(0,-1),...added,manage];
|
||||
}
|
||||
|
||||
// Recorded deviations only (plan "Mock inventory" + decisions D1-D15 + ledger rulings). Everything else must be equal.
|
||||
export function mapVocabularyConcept(reference) {
|
||||
// `imageCategories` (#332 D143) is the fixture's own image categories, none by default.
|
||||
export function mapVocabularyConcept(reference,imageCategories={}) {
|
||||
const operations=reference.operations.map(row=>({...row,p:{...row.p,
|
||||
// Ledger ruling: "1 positive prompts" -> singular, consistent with the shared plural helper (#238/#243).
|
||||
text:row.p.text.replace(/^1 positive prompts$/,'1 positive prompt')}}));
|
||||
return {...reference,
|
||||
table:{...reference.table,rows:reference.table.rows.map(row=>({...row,
|
||||
// Data, not a deviation: the concept tags each sample image with its prompt's tags; the vocabulary fixture
|
||||
// tags no image, so every Images count is 0 (#249 C2 retired #245 D3).
|
||||
images:'0'}))},
|
||||
operations:reference.operations.map(row=>({...row,p:{...row.p,
|
||||
// Ledger ruling: "1 positive prompts" -> singular, consistent with the shared plural helper (#238/#243).
|
||||
text:row.p.text.replace(/^1 positive prompts$/,'1 positive prompt')}})),
|
||||
operations:withImageCounts(operations,imageCategories),
|
||||
};
|
||||
}
|
||||
|
||||
/** The page comparison: concept rows are the concept's sample tags; production appends fixture-only tags (D4). */
|
||||
export function compareVocabularyPage(reference,actual,imageCategories={}) {
|
||||
const expected=mapVocabularyConcept(reference,imageCategories);
|
||||
const shared=expected.table.rows.length;
|
||||
return {diff:diffInventory(expected,{...actual,table:{...actual.table,rows:actual.table.rows.slice(0,shared)}}),
|
||||
extra:actual.table.rows.slice(shared).map(row=>row.tag)};
|
||||
}
|
||||
|
||||
// Concept dialogs are simpleInput (app.js:1138-1143). donors are concept inventories for controls the concept
|
||||
// shows elsewhere: a `.field select` and a `p.small` (Filters dialog, app.js:1351) and a danger dialog action
|
||||
// (Archive… in the editor, app.js:976), so added controls are held to concept tokens, never to production values.
|
||||
export function mapVocabularyDialog(mode,reference,{filters,editor,categories}) {
|
||||
// `preview` is the Manage categories sentence once Preview has been pressed (D9; D144, #332, adds its images).
|
||||
export function mapVocabularyDialog(mode,reference,{filters,editor,categories,preview=null}) {
|
||||
const fields=reference.fields.map(item=>({...item,
|
||||
// Inventory rows 27-29: the name is `required` (blank Apply is blocked natively).
|
||||
required:true}));
|
||||
@@ -98,9 +127,9 @@ export function mapVocabularyDialog(mode,reference,{filters,editor,categories})
|
||||
// D9 / Task 8: the proposed name offers a category datalist (no trailing commas: one value).
|
||||
...fields.map(item=>({...item,listed:true,commaOptions:false}))],
|
||||
// D9: the preview sentence is a `p.small`, empty and hidden until Preview.
|
||||
small:[{...filters.small[0],text:''}],
|
||||
small:[{...filters.small[0],text:preview??''}],
|
||||
// D9: the primary is `Preview` until a preview is shown, then `Apply`.
|
||||
actions:{...reference.actions,buttons:buttons.map(item=>item.text==='Apply'?{...item,text:'Preview'}:item)}};
|
||||
actions:{...reference.actions,buttons:preview===null?buttons.map(item=>item.text==='Apply'?{...item,text:'Preview'}:item):buttons}};
|
||||
}
|
||||
return {...reference,fields,actions:{...reference.actions,buttons}};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog,compareVocabularyPage} from './vocabulary-canonical-comparison.mjs';
|
||||
|
||||
// Artifact-only captures of Tags & categories states that have no concept counterpart: the Manage categories
|
||||
// preview, loading and 1,000-category cap notes, tag conflict and restore notes, the Delete tag confirmation (D14),
|
||||
// empty lists (D12), a failed read with Retry, and Load more (D5). The page and the three concept dialogs are captured
|
||||
// as concept/production pairs by workspace-vocabulary.spec.mjs. There are deliberately no pixel assertions or
|
||||
// baselines: the maintainer accepts these captures visually before any baseline is added.
|
||||
// baselines: the maintainer accepts these captures visually before any baseline is added. The last test (#332 D143,
|
||||
// D144) compares the category rows with image counts and the Manage category preview against the concept.
|
||||
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
|
||||
const route='/prompt_manager/workspace#/tags';
|
||||
const main=page=>page.locator('#workspace-main');
|
||||
@@ -101,3 +104,87 @@ for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','
|
||||
await testInfo.attach(`production-load-more-${width}-${theme}`,{path,contentType:'image/png'});
|
||||
await expect(rows(page)).toHaveCount(100);
|
||||
});
|
||||
|
||||
// D143 and D144 (#332): with own image categories seeded (`image_categories`: "Landscapes" on two images, "Portraits"
|
||||
// on one image only), the Categories panel and the Manage category preview against the concept (renderTags, app.js
|
||||
// 719-729; the Manage dialog is its simpleInput, 1138-1143). The page is compared with D143 mapped, a model checked
|
||||
// against the concept page with those rows added at runtime; the preview with D9's sentence and D144's images.
|
||||
const concept='/__test__/canonical/workspace';
|
||||
const IMAGE_CATEGORIES=Object.freeze({images:{Landscapes:2,Portraits:1},only:['Portraits']});
|
||||
// D9's sentence; D144 adds the images holding the value ("and 2 images").
|
||||
const PREVIEW='“Landscapes” is used by 3 positive prompts and 2 images. Apply renames it to “Scenery”.';
|
||||
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf);
|
||||
async function openConceptRoute(page,route,theme) {
|
||||
await page.goto('about:blank');await page.goto(`${concept}#${route}`);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
}
|
||||
/** The concept page as D143 records it: image counts after the prompt counts, image-only rows before Manage category. */
|
||||
function conceptWithImageCounts(page) {
|
||||
return page.evaluate(({images,only})=>{
|
||||
const surface=document.querySelectorAll('#main .two-col > section.surface')[1];
|
||||
const rows=[...surface.querySelectorAll('.operation')];
|
||||
const count=value=>`${images[value]} image${images[value]===1?'':'s'}`;
|
||||
for(const row of rows) {
|
||||
const value=row.querySelector('h3').textContent;
|
||||
if(images[value]&&!only.includes(value))row.querySelector('p').textContent+=` · ${count(value)}`;
|
||||
}
|
||||
const manage=rows.at(-1),last=rows.at(-2);
|
||||
for(const value of only) {
|
||||
const row=last.cloneNode(true);row.querySelector('h3').textContent=value;row.querySelector('p').textContent=count(value);manage.before(row);
|
||||
}
|
||||
},IMAGE_CATEGORIES);
|
||||
}
|
||||
/** Concept controls the Manage dialog adds (as in workspace-vocabulary.spec.mjs): the Filters field select and p.small. */
|
||||
async function conceptDonors(page,theme,categories) {
|
||||
await openConceptRoute(page,'images',theme);
|
||||
await page.locator('#main').getByRole('button',{name:'Filters',exact:true}).click();await expect(page.locator('#modal')).toBeVisible();
|
||||
const filters=await page.locator('#modal').evaluate(inventoryOf);
|
||||
await openConceptRoute(page,'prompt/alpine',theme);
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(page.locator('#modal')).toBeVisible();
|
||||
const editor=await page.locator('#modal').evaluate(inventoryOf);
|
||||
return {filters,editor,categories};
|
||||
}
|
||||
|
||||
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`category rows and the Manage category preview match the concept with image categories ${width}x${height} ${theme} (D143, D144)`,async({page,request},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const manifest=await seed(request,{image_categories:true});
|
||||
expect(manifest.image_categories).toEqual(IMAGE_CATEGORIES.images);
|
||||
await openConceptRoute(page,'tags',theme);await expect(page.locator('#main h1')).toHaveText('Tags & categories');
|
||||
await page.mouse.move(0,0);
|
||||
const reference=await pageOf(page,false);
|
||||
await conceptWithImageCounts(page);
|
||||
const oracle=await pageOf(page,false);
|
||||
await captureCanonicalState(page,testInfo,`concept-image-categories-${width}-${theme}`,'#main');
|
||||
const donors=await conceptDonors(page,theme,['Landscapes','Architecture','Environments','Illustration','Portraits']);
|
||||
await openConceptRoute(page,'tags',theme);
|
||||
await page.locator('#main').getByRole('button',{name:'Review',exact:true}).click();await expect(page.locator('#modal')).toBeVisible();
|
||||
const referenceDialog=await page.locator('#modal').evaluate(inventoryOf);
|
||||
|
||||
await open(page,theme,manifest.tags.length);
|
||||
await expect(categoryOperation(page,'Portraits')).toHaveCount(1);
|
||||
// The pointer still rests where the concept's Review was pressed: park it so no row is hovered.
|
||||
await page.mouse.move(0,0);
|
||||
const actual=await pageOf(page,true);
|
||||
await captureCanonicalState(page,testInfo,`production-image-categories-${width}-${theme}`,'#workspace-main');
|
||||
const box=await openDialog(page,categoryOperation(page,'Manage category').getByRole('button',{name:'Review',exact:true}));
|
||||
await expect(box.getByLabel('Category',{exact:true})).toHaveValue('Landscapes');
|
||||
await box.getByLabel('Proposed new category name',{exact:true}).fill('Scenery');await button(box,'Preview').click();
|
||||
await expect(box.locator('p.small')).toHaveText(PREVIEW);
|
||||
// The pressed primary keeps the pointer and focus; the concept is measured as it opens (focus on ×, no hover),
|
||||
// which workspace-vocabulary.spec.mjs compares, so this state is restored before measuring the preview.
|
||||
await page.mouse.move(0,0);await box.locator('.dialog-header button').focus();
|
||||
const actualDialog=await box.evaluate(inventoryOf);
|
||||
await captureCanonicalState(page,testInfo,`production-image-categories-preview-${width}-${theme}`);
|
||||
const path=testInfo.outputPath('vocabulary-image-categories.json');
|
||||
await writeFile(path,JSON.stringify({reference,oracle,actual,referenceDialog,actualDialog},null,2));
|
||||
await testInfo.attach('vocabulary-image-categories',{path,contentType:'application/json'});
|
||||
|
||||
// The D143 model reproduces the concept page with the rows added at runtime.
|
||||
expect.soft(diffInventory(mapVocabularyConcept(reference,IMAGE_CATEGORIES).operations,mapVocabularyConcept(oracle).operations),'D143 model').toEqual([]);
|
||||
const {diff,extra}=compareVocabularyPage(reference,actual,IMAGE_CATEGORIES);
|
||||
expect.soft(actual.operations.map(row=>[row.h3.text,row.p.text]).slice(0,-1),'category rows').toEqual([['Landscapes','3 positive prompts · 2 images'],
|
||||
['Architecture','1 positive prompt'],['Environments','1 positive prompt'],['Illustration','1 positive prompt'],['Portraits','1 image']]);
|
||||
expect.soft(diff,'page differences after the recorded deviations and D143').toEqual([]);
|
||||
expect.soft(extra,'fixture-only rows').toEqual([manifest.pair_only_tag,manifest.archived_only_tag,manifest.negative_only_tag,manifest.unused_tag]);
|
||||
expect.soft(diffInventory(mapVocabularyDialog('manage',referenceDialog,{...donors,preview:PREVIEW}),actualDialog),'preview differences after D9 and D144').toEqual([]);
|
||||
});
|
||||
|
||||
@@ -72,6 +72,10 @@ CONCEPT_RECIPES = (
|
||||
"negative": "oversaturated, flat lighting, watermark, text",
|
||||
},
|
||||
)
|
||||
# #332 D143/D144 (opt-in, ``image_categories``): own image categories, one on
|
||||
# images only and one shared with prompts. Stored rows count whether or not the
|
||||
# files exist, so the keys name files the fixture never writes.
|
||||
IMAGE_CATEGORY_ROWS = (("Landscapes", 2), ("Portraits", 1))
|
||||
PAIR_ONLY_TAG = "pair_only_detail"
|
||||
ARCHIVED_ONLY_TAG = "archived_pair_detail"
|
||||
NEGATIVE_ONLY_TAG = "legacy_negative_detail"
|
||||
@@ -112,8 +116,26 @@ _SNAPSHOT = {
|
||||
}
|
||||
|
||||
|
||||
def _seed_image_categories(api: PromptManagerAPI) -> dict:
|
||||
"""Own image category rows keyed as Images keys them; ``{value: count}``."""
|
||||
from py.api.images import image_category_key
|
||||
|
||||
sources = list(api._get_all_output_dirs())
|
||||
for category, count in IMAGE_CATEGORY_ROWS:
|
||||
keys = [
|
||||
image_category_key(sources, str(Path(sources[0]) / f"{category}_{n}.png"))
|
||||
for n in range(count)
|
||||
]
|
||||
api.db.set_image_categories(keys, category)
|
||||
return dict(IMAGE_CATEGORY_ROWS)
|
||||
|
||||
|
||||
def seed_vocabulary(
|
||||
api: PromptManagerAPI, root: Path, extra_tags: int = 0, extra_categories: int = 0
|
||||
api: PromptManagerAPI,
|
||||
root: Path,
|
||||
extra_tags: int = 0,
|
||||
extra_categories: int = 0,
|
||||
image_categories: bool = False,
|
||||
) -> dict:
|
||||
"""Replace this server's disposable rows with the concept vocabulary."""
|
||||
db = api.db
|
||||
@@ -167,6 +189,7 @@ def seed_vocabulary(
|
||||
"category": f"bulk_category_{number:04}",
|
||||
},
|
||||
)
|
||||
images = _seed_image_categories(api) if image_categories else {}
|
||||
with db.model.get_connection() as conn:
|
||||
tags = [row[0] for row in conn.execute("SELECT name FROM tags ORDER BY id")]
|
||||
api.invalidate_gallery_cache()
|
||||
@@ -180,6 +203,7 @@ def seed_vocabulary(
|
||||
"archived_only_tag": ARCHIVED_ONLY_TAG,
|
||||
"negative_only_tag": NEGATIVE_ONLY_TAG,
|
||||
"unused_tag": UNUSED_TAG,
|
||||
"image_categories": images,
|
||||
}
|
||||
|
||||
|
||||
@@ -200,7 +224,14 @@ def register_vocabulary_commands(
|
||||
@routes.post("/__test__/vocabulary/reset")
|
||||
async def reset(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
if not isinstance(data, dict) or set(data) - {"extra_tags", "extra_categories"}:
|
||||
if not isinstance(data, dict) or set(data) - {
|
||||
"extra_tags",
|
||||
"extra_categories",
|
||||
"image_categories",
|
||||
}:
|
||||
raise web.HTTPBadRequest()
|
||||
images = data.get("image_categories", False)
|
||||
if not isinstance(images, bool):
|
||||
raise web.HTTPBadRequest()
|
||||
extra = data.get("extra_tags", 0)
|
||||
categories = data.get("extra_categories", 0)
|
||||
@@ -215,7 +246,7 @@ def register_vocabulary_commands(
|
||||
if request.query:
|
||||
raise web.HTTPBadRequest()
|
||||
manifest = await api._run_in_executor(
|
||||
seed_vocabulary, api, root, extra, categories
|
||||
seed_vocabulary, api, root, extra, categories, images
|
||||
)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user