Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
963c671747 | ||
|
|
4a5dade10d | ||
|
|
92da267172 | ||
|
|
8784a38e40 | ||
|
|
5b3896bf26 | ||
|
|
d7f1b9987f | ||
|
|
86d82186ee | ||
|
|
6c82098207 | ||
|
|
9d398f9862 | ||
|
|
a44cd27c2e | ||
|
|
673433b2ae | ||
|
|
36564b4767 | ||
|
|
d6eba05d21 | ||
|
|
6d2213ead5 |
@@ -8,6 +8,7 @@ on:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
# Superseded PR pushes cancel their older run. Every main push gets its own
|
||||
# group (run_id), so no main commit's run is ever cancelled or dropped as pending.
|
||||
@@ -146,6 +147,32 @@ jobs:
|
||||
python -m coverage combine
|
||||
python -m coverage report --fail-under=85
|
||||
|
||||
perf-budget:
|
||||
# The reference wall-clock measurement (TESTING.md "Performance budgets").
|
||||
# Shared-runner timing varies, so it never gates a pull request: it runs on
|
||||
# each push to main and on demand. A red run on main means fix the code.
|
||||
name: perf budget
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Workspace filter budget
|
||||
env:
|
||||
PROMPTMANAGER_PERF_BUDGET: "1"
|
||||
run: python -m unittest -v tests.test_api_workspace_images.TestWorkspaceImages.test_filter_budget
|
||||
|
||||
lint:
|
||||
name: black and flake8
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+467
-5
@@ -125,6 +125,37 @@ not certify every existing feature, arbitrary host versions,
|
||||
GPU/model changes, cross-pack installation or visual design changes. Those still
|
||||
need targeted validation.
|
||||
|
||||
## Performance budgets
|
||||
|
||||
A spec performance budget gets two tests:
|
||||
|
||||
- A structural test that always runs, on every OS/Python job and under the
|
||||
coverage tracer. It asserts counts, never time: SELECTs per request, the
|
||||
`EXPLAIN QUERY PLAN` of the hot query, cache builds on cold and warm requests,
|
||||
and calls of the sort key per cursor page. Example:
|
||||
`test_filter_work_is_bounded` in `tests/test_api_workspace_images.py`.
|
||||
- A wall-clock test that runs only with `PROMPTMANAGER_PERF_BUDGET=1` and
|
||||
without a tracer (`sys.gettrace()` or a `sys.monitoring` tool). It skips
|
||||
otherwise. It warms up once, then measures three rounds and asserts the best
|
||||
round's p95; the failure message lists every timing. Example:
|
||||
`test_filter_budget` (Images filters, spec D12: p95 < 150 ms at 50,000 files
|
||||
and 20,000 links).
|
||||
|
||||
The `perf budget` job in `.github/workflows/test.yml` is the reference
|
||||
measurement: `ubuntu-latest`, one Python, no coverage, only the wall-clock tests.
|
||||
It runs on every push to `main` and on `workflow_dispatch`, never on pull
|
||||
requests, because shared-runner timing must not block a merge. Dispatch it on a
|
||||
branch to measure before merging.
|
||||
Shared Windows and macOS runners do not run them; the structural tests cover
|
||||
portability. Run it locally with
|
||||
`PROMPTMANAGER_PERF_BUDGET=1 python -m unittest tests.test_api_workspace_images.TestWorkspaceImages.test_filter_budget`
|
||||
and quote the printed p95 in the PR body.
|
||||
|
||||
A red `perf budget` run on `main` means the code got slower: fix the code (or
|
||||
change the budget in the spec with a reason) in a follow-up PR. Never rerun it to
|
||||
green. It is not a required check, and it cannot be one while it skips pull
|
||||
requests (`.github/branch-protection.json`).
|
||||
|
||||
## Windows portability preflight
|
||||
|
||||
Before changing paths, SQLite, streams, restore or cleanup:
|
||||
@@ -196,7 +227,12 @@ The shared inline and collection inspector reads the current rooted file's suppo
|
||||
embedded records, exact generation facts and recorded or inferred prompt evidence.
|
||||
`tests/browser/full-inspector.spec.mjs` covers original/metadata/workflow downloads,
|
||||
response limits, source changes, nested sending, matched dark/light and mobile
|
||||
geometry, and the loaded-collection viewer. The viewer never fetches another page
|
||||
geometry, and the loaded-collection viewer. `viewer matches the concept shell and
|
||||
toolbar order` measures the concept's `#viewer-dialog` shell, header copy, toolbar
|
||||
order and frame geometry in the concept and in production at 1600 and 390 pixels;
|
||||
its #249 D34 mapping keeps production's Download link, the appended position and
|
||||
reason lines, coarse 40px targets and the toolbar inside `.viewer-left` (Viewer.js's
|
||||
own `.viewer-toolbar` rule lifts the concept's across the dialog). The viewer never fetches another page
|
||||
or preloads all originals. Its explicit slideshow waits 3.5 seconds after image
|
||||
readiness, pauses at native video without advancing on `ended`, and resumes on a
|
||||
manually selected image only while prior intent remains. Pause/Stop, inspector
|
||||
@@ -227,7 +263,55 @@ workflow graph and its facts become limited). The serialized
|
||||
response stays under 8 MiB by omitting, in order, the workflow graph, then the prompt
|
||||
graph, then all raw records atomically. Retry does not raise these limits. Ingestion
|
||||
(scan, live monitor, admin import) and queue-time run counting keep the default
|
||||
profile, including its 500-node gate.
|
||||
profile, including its 500-node gate. Only `INSPECTION_PROFILE` reads the node
|
||||
semantics table `web/js/inspection-semantics.json` (at most 1 MiB and eight nesting
|
||||
levels, loaded on first use). With it, `SamplerCustomAdvanced` follows its `guider`
|
||||
link (through Reroutes) to `CFGGuider` (positive and negative) or `BasicGuider`
|
||||
(positive only; the negative is `absent`, "Guider has no negative input"). Any
|
||||
other guider, such as `DualCFGGuider`, is `unsupported-node`. Conditioning is
|
||||
traced through the table's pass-throughs (FluxGuidance, ReferenceLatent, the
|
||||
ConditioningSet* nodes, ControlNet apply nodes, InpaintModelConditioning and
|
||||
WanImageToVideo by output slot). `ConditioningZeroOut` is an explicit empty text
|
||||
with source `zeroed-conditioning`. `easy ifElse` with a literal boolean follows that
|
||||
branch only; with a linked boolean both branches are traced, equal texts resolve and
|
||||
different texts make the sampler `ambiguous` with one candidate per choice (at most
|
||||
32, else `work-limit`). ConditioningCombine/Concat/Average resolve only when every
|
||||
part has the same text, otherwise `unresolved` with `composite` naming the node.
|
||||
Results that depend on a forced choice are never memoized. A `FaceDetailer`
|
||||
(`detailer-stage`) is a sampler stage: the samplers reached through its `image`
|
||||
ancestry are compared with it. Equal texts resolve with the detailer as the sampler;
|
||||
different texts are `ambiguous` with `multistage-differs` and one candidate per
|
||||
stage; an unreadable stage makes the chain `unresolved`. Unanchored, a sampler
|
||||
whose output only feeds detailers (or previews) is not a candidate of its own.
|
||||
A sampler's latent ancestry is walked through every link input of non-sampler
|
||||
nodes within the same visit and depth budgets: with no earlier sampler it is a
|
||||
single stage whatever the origin (VAEEncode, EmptyLatentBatch, EmptyFlux2LatentImage,
|
||||
EmptyHunyuanLatentVideo, SetLatentNoiseMask, InpaintModelConditioning or
|
||||
WanImageToVideo slot 2). An earlier sampler (img2img from a decode, hires fix) stays
|
||||
`unresolved` "Multistage sampler ancestry is ambiguous" until stage texts are
|
||||
compared; an unlisted node with sampler-shaped inputs (positive, negative, guider,
|
||||
latent_image, a pipe, video embeds, custom-sampler parts, or a `model` link together
|
||||
with conditioning, neg_conditioning, samples or latent) is `unsupported-node`
|
||||
naming it, here and in a detailer's image ancestry; a latent that is not a link or
|
||||
reaches its own sampler is `malformed`; running out of budget is `work-limit` or
|
||||
`depth-limit`, never single stage. Each sampler's walk runs once per resolution;
|
||||
selector branch runs share it and other candidates are charged its visit cost.
|
||||
Text encoders: `single` encoders (the Qwen image encoders, `TextEncodeQwenImage21`
|
||||
slot 0 `prompt` and slot 1 `negative_prompt`, `KikoTextEncode`) read one string
|
||||
input unchanged. `parts-equal` encoders (CLIPTextEncodeSDXL, Flux, SD3) resolve when
|
||||
every part is identical or exactly one part is non-empty (exact `""`, whitespace is
|
||||
text); otherwise `composite`. String sources reproduce the pinned KJNodes code from
|
||||
literal settings only: `StringConstant` is its `string`; `StringConstantMultiline`
|
||||
with a literal true `strip_newlines` is `replace("\n", "").strip()`;
|
||||
`JoinStringMulti` keeps `string_1` (even empty), appends `string_2`..`string_N`
|
||||
(`N` = `inputcount`) after the literal delimiter unless missing or exactly `""`, and
|
||||
ignores later inputs. A linked setting is `unsupported-node`; a list output
|
||||
(`return_list` true or linked) and `PromptSearchList` are `runtime-value` ("Text is
|
||||
decided at run time"), never one list item. Unverified text nodes (LoRAfromText,
|
||||
RegexReplace, XYZPrompt, ImpactWildcardProcessor) stay `unsupported-node`. Every
|
||||
intermediate text is length-checked and charged to the scanned-character budget. If
|
||||
the table cannot be loaded, graph inspection is `unresolved` ("Inspection node
|
||||
semantics are unavailable") and ingestion is unaffected, because it never imports the table.
|
||||
|
||||
`tools/inspection_corpus.py --root DIR --out JSONL [--workers N] [--baseline JSONL]
|
||||
[--summary TXT] [--code TREE]` measures a media folder read-only against the #240
|
||||
@@ -257,6 +341,384 @@ Use the disposable fixture and managed-host prerequisites above. A maintainer
|
||||
live check needs a ComfyUI restart after adding the Python route, then opening
|
||||
`/prompt_manager/workspace`; the mockup preview server is a separate design tool.
|
||||
|
||||
### Concept shell and comparison method (#249)
|
||||
|
||||
**CSS base and drift check.** `web/js/workspace/concept.css` is a byte-identical copy
|
||||
of the approved concept's `docs/concepts/workspace/style.css`. Never edit the copy;
|
||||
it is only rewritten from the concept with `npm run --prefix tools/vendor sync:concept`.
|
||||
`npm run --prefix tools/vendor check` runs `sync-concept-css.mjs --check`, which fails
|
||||
on a missing copy or names the first differing byte. `workspace.html` loads, in order,
|
||||
the toolkit controls, Viewer.js, `concept.css`, the page stylesheets that their own
|
||||
PRs retire, and the production overlay `workspace.css` last.
|
||||
`tests/vendor/concept-css.test.mjs` asserts that order, the drift check, and that no
|
||||
other page or canvas script loads workspace CSS.
|
||||
|
||||
**Overlay decision-marker lint.** `lintOverlay` in `tools/vendor/sync-concept-css.mjs`
|
||||
fails any `workspace.css` rule whose selector is also a concept selector unless the
|
||||
rule carries a `/* D<n> */` comment, before its selector or inside its block. The
|
||||
D-number must be one of this spec's (#249): a comment that cites any other issue, such
|
||||
as `D5 (#245)`, is that issue's ruling and is not a marker, so write `#245 decision 5`
|
||||
for those. The lint test fails if `workspace.css` is missing. The match is by exact
|
||||
selector, so a narrower selector over a concept rule (for example
|
||||
`.ca-tools dialog.prompt-sender`) passes the lint. Such rules, and the page-stylesheet
|
||||
leftovers such as `library.css` `.row-actions`, name the PR that retires them, by
|
||||
convention.
|
||||
|
||||
The lint also runs over every page stylesheet that loads before the overlay and is
|
||||
still kept (`images.css`, `library.css`, `inspector.css`; test `kept page stylesheets
|
||||
mark their concept-selector overrides`). After PR C, `images.css` holds only the side
|
||||
panel, the prompt blocks and the prompt sender, which PR D moves to the concept panel
|
||||
and deletes with the file. Its three remaining concept-selector overrides
|
||||
(`.prompt-text`, `.prompt-actions`, `.ca-tools .prompt-actions button`) are marked
|
||||
`#249 D22 interim` until then, and `images.css keeps no retired Images page rules`
|
||||
keeps the replaced card, grid, filter, toolbar, preview and facts rules out.
|
||||
|
||||
**Comparison method.** Production must equal the concept except for the recorded
|
||||
deviations in the #249 design (spec §8, D-numbers). It is never compared against
|
||||
blessed production screenshots.
|
||||
- The fixture is `tests/browser/workspace_concept_fixture.py`, reset through
|
||||
`POST /__test__/concept/reset`. It holds the concept's six prompts and pairs, and
|
||||
24 PNGs with real links, runs and the concept's inspector facts. The sidebar counts
|
||||
are therefore the concept's own: 24, 6 and 15.
|
||||
- The shared inventory is `tests/browser/concept-inventory.mjs`. One self-contained
|
||||
in-page measurer runs the same concept selectors in the immutable concept
|
||||
(`/__test__/canonical/workspace`) and in production. An absent element becomes
|
||||
`null`, so the diff names its path (for example `chrome.breadcrumbs`) instead of
|
||||
failing the measurement.
|
||||
- Each page's `map<Page>Concept(reference)` applies only the recorded deviations, each
|
||||
commented with its D-number. `diffInventory` must then report nothing.
|
||||
- Sizes, offsets and heights that follow from a removed element are derived from the
|
||||
concept's own measurements, never copied from production.
|
||||
- `mutate(page, {css, remove})` appends a stylesheet and detaches elements, and returns
|
||||
an undo. Each comparison has a mutation check proving it fails. For the shell, a
|
||||
`.sidebar nav a { min-height: 30px }` rule must fail at `chrome.navs.0.rows.0.minHeight`
|
||||
and `.height`, and removing `.breadcrumbs` must fail at `chrome.breadcrumbs`.
|
||||
- Paired concept/production captures are attached as test artifacts. There are no PNG
|
||||
baselines.
|
||||
|
||||
`tests/browser/shell-concept.spec.mjs` compares the shell: the sidebar, topbar, page
|
||||
heading and footer, at 1600x1100 and 390x844, in dark and light, under normal and
|
||||
reduced motion, on Images, Prompt library and Tags. The heading is compared in full on
|
||||
Tags; on Images and Library only its frame and h1 are compared, because their copy and
|
||||
actions belong to their own PRs. The spec also compares the hover state of the first
|
||||
non-current nav row, the Theme select and the ComfyUI link, and the keyboard rings of
|
||||
the first nav link and heading button.
|
||||
|
||||
Recorded shell deviations:
|
||||
- **D4**: a "Theme" select with Dark, Light and System. System follows the ComfyUI
|
||||
palette, then the OS.
|
||||
- **D5**: no Activity button or Activity row until an activity center exists.
|
||||
- **D28**: no SAVED VIEWS or COLLECTIONS, and no Design review. The bottom nav is
|
||||
System plus the production Overview hub (its own outline home glyph, not the concept's
|
||||
`activity` pulse, and no count).
|
||||
The connection reads "Your library, on your server" with a neutral muted dot.
|
||||
- **D29**: no prototype copy. There is no "Interactive concept" badge and no
|
||||
"What changes & why" button. The footer reads "ComfyAssets · Local tools. Your
|
||||
library.", and the title has no " concept" suffix.
|
||||
- **D31**: at `max-width: 950px` the overlay caps the one-column shell at
|
||||
`minmax(0, 1fr)`, so extreme zoom cannot widen it to the sidebar's min-content.
|
||||
- **#245 D2**: no "Auto-tag images" on the Tags heading until the image-annotation
|
||||
slice.
|
||||
- **Brand name**: not a deviation. The brand link's accessible name matches the
|
||||
concept's, mark included, because production's `.brand-mark` is not `aria-hidden`
|
||||
either. The inventory compares `brand.name` and `brand.mark.hidden`.
|
||||
|
||||
```sh
|
||||
npm run --prefix tools/vendor check
|
||||
npm test --prefix tools/vendor
|
||||
npm run --prefix tools/vendor test:workflows -- shell-concept.spec.mjs \
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
`tests/browser/images-concept.spec.mjs` compares the Images page (#249 C7): heading,
|
||||
toolbar, filter row, caption, grid, the first six cards, the continuation and the panel
|
||||
box, in Grid, List, Masonry and the Large and Compact sizes, at 1600x1100 and 390x844
|
||||
in both themes and motion settings; grid, cards and panel also at 1200x900 (2 columns,
|
||||
275px panel) and 1800x1100 (4 columns, 310px panel). The concept is compared once its
|
||||
own Load more has loaded all 24 fixture images (D37: it loads 12 per batch, production
|
||||
40); the Load more row is compared before the press in both documents. Behaviors: hover
|
||||
zoom, the 2nd card opens the viewer titled "<title> · <filename>", Escape returns focus
|
||||
to it (production only: the concept re-renders its grid and drops focus), Info on the
|
||||
3rd card moves `.selected`, searches for "alpine" and for one image, and Load more with
|
||||
its toast. `mapImagesConcept` applies D3, D8 (the selection bar's real actions only),
|
||||
D16 (follow-up PR: no Save view), D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
|
||||
sorted alphabetically). Removed toolbar and filter-row items flow again from the
|
||||
concept's own boxes (`reflow`), checked against the concept page with those items
|
||||
removed at runtime. Masonry tops and heights are compared unrounded, within one layout
|
||||
unit per card above in the column (D3). `one footer height for the collection (D3)`
|
||||
seeds two seedless files (`seedless_images` on `/__test__/concept/reset`) and asserts
|
||||
that every row reserves the tallest footer.
|
||||
|
||||
### Images page controls and recorded deviations (#249 PR C)
|
||||
|
||||
The Images page renders the concept's DOM with real data. Its controls, top to bottom:
|
||||
|
||||
- **Heading**: "Images" / "Your generations, their prompts, and the next idea.",
|
||||
`Import` (opens "Import & inspect") and the shell's `New prompt`.
|
||||
- **Toolbar** (`toolbar.mjs`, shared with the Library in PR E): `#search` "Search
|
||||
prompts, tags, filenames…" (debounced 250 ms; filename, linked positive text and
|
||||
Library title, and tag names), `#category-filter` "All categories" plus the
|
||||
Library's categories, `#sort` (Recently added, Name A–Z, Highest rated, then Oldest
|
||||
first and Name Z–A), and `Filters` (opens "Refine this view").
|
||||
- **Filter row**: Grid / List / Masonry (`.segmented`, group "Image layout"),
|
||||
"Thumbnail size" Comfortable / Large / Compact, the scope chip ("All output folders"
|
||||
or "<source> · <folder>"), and "Group by positive prompt →" (goes to the Library).
|
||||
- **Caption**: "N of M images · K positive prompts" (`#result-count`) and, under
|
||||
Recently added only, the newest result's day.
|
||||
- **Grid** (`#image-grid`): concept media cards. The image opens the viewer
|
||||
(click, Enter, Space); Info selects the card into the side panel; the star toggles
|
||||
the favorite; arrows, Home and End move between cards. Bounded rendering keeps the
|
||||
DOM small, and returning to Images restores filters, layout, size, selection and
|
||||
scroll.
|
||||
- **Continuation** (`#gallery-continuation`): scrolling near it loads the next page;
|
||||
`Load more images` and `Retry` do the same by hand, with a toast per batch.
|
||||
- **Viewer** (`dialog#viewer-dialog`): "<title> · <filename>", the concept toolbar
|
||||
(a "Media controls" group), the shared file inspector beside the stage, and Escape
|
||||
returns focus to the opening image.
|
||||
|
||||
How the earlier workspace Images filters moved (old → new). The legacy `/gallery`,
|
||||
`/admin` and `/metadata` pages are unchanged.
|
||||
|
||||
| Before PR C | Now |
|
||||
| --- | --- |
|
||||
| "Search filenames" (filename or folder) | toolbar search, which also matches linked prompt text, title and tags (D12) |
|
||||
| Source | "Refine this view" → Source (D14); shown in the scope chip |
|
||||
| Folder | "Refine this view" → Folder (D14); shown in the scope chip |
|
||||
| Media (All media / Images / Videos) | "Refine this view" → Media (D14) |
|
||||
| Sort: Newest first / Oldest first / Filename A–Z / Filename Z–A | toolbar sort: Recently added / Oldest first / Name A–Z / Name Z–A, plus Highest rated (D13, D14) |
|
||||
| (none) | toolbar category and "Refine this view" → Tags, through the linked positive (D13, D14) |
|
||||
| Refresh | Import → "Scan an output folder" → Rescan (D15) |
|
||||
| Grid / List / Masonry, Comfortable / Large / Compact | the same choices in the concept filter row |
|
||||
| Card image → side panel; preview icon → viewer | image → viewer; Info → side panel (D19) |
|
||||
| "All images" heading and count | the concept caption and count line (D17, D35) |
|
||||
|
||||
Recorded deviations on this page (spec §8):
|
||||
- **D3**: bounded rendering stays. Every grid row reserves the tallest measured card
|
||||
footer, masonry tops may differ by sub-pixel snapping (at most one layout unit per
|
||||
card above in the column), and a List row's info takes its natural height, centred
|
||||
beside the 160×95 art.
|
||||
- **D5**, **D29**: no Activity button and no prototype copy (shell).
|
||||
- **D7**: the star is a real persisted favorite with "Added to favorites." /
|
||||
"Removed from favorites." and a rollback toast on failure.
|
||||
- **D8**: every Images card has the concept's select checkbox before the star; the
|
||||
`#bulkbar` shows "N selected" and only the real actions in the concept's order (Set
|
||||
category, Export metadata, Clear). Add tags and Add to collection wait for image tags
|
||||
and collections; "Remove links…" is dropped. The selection survives Load more and
|
||||
bounded re-mounting and is cleared whenever the collection is replaced (search,
|
||||
category, sort, Filters, source change, rescan, reload). A return to Images restores
|
||||
the saved collection unreplaced and keeps its selection; a full page reload starts
|
||||
empty. The Library-embedded gallery has no checkbox and no bar. Export metadata reads
|
||||
each checked image's inspection (at most 4 at a time) and downloads one
|
||||
`prompt-manager-images-metadata.json` (`format`, `version: 1`, `exported_at`, and the
|
||||
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
|
||||
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.
|
||||
- **D9**: no Tag button until the image-annotations slice.
|
||||
- **D10**: the card title is the linked positive's display title, else the filename;
|
||||
the `h3` keeps the relative path in `title`.
|
||||
- **D11**: the subtitle is "W × H · EXT · Seed N" with unknown parts omitted.
|
||||
- **D14**: Source, Folder, Media and Tags live in "Refine this view" (no "Prompt
|
||||
evidence" field, no scope note); the primary action reads "Apply filters"; extra
|
||||
sorts come after the concept's three; long folder names wrap inside the scope chip.
|
||||
Folder lists only the chosen Source's folders (each workspace root lists its own),
|
||||
and an unchanged Apply or Clear reloads nothing.
|
||||
- **D15**: Import offers "Inspect a local image" (`#/inspect`) and "Scan an output
|
||||
folder" (Rescan, `/gallery/rescan`); no "Import portable recipes". The concept has
|
||||
no rescan copy: toasts say "Rescanning output folders…", then "Rescan finished. N
|
||||
files in M output folders." (the scan's `total` and `roots`) or "Rescan could not
|
||||
finish. Try again."; while it runs, a reopened Import shows an aria-disabled
|
||||
"Rescanning…".
|
||||
- **D16 (follow-up)**: no Save view in PR C; a follow-up PR adds real saved views.
|
||||
- **D17**: the caption date is the newest result's day under Recently added (", YYYY"
|
||||
outside the current year), hidden under every other sort.
|
||||
- **D18**: the concept hover zoom, with no sticky zoom on hover-less (touch) devices.
|
||||
- **D32**: the layout control may wrap, so the filter and continuation rows never
|
||||
overflow at 390px and 200% zoom.
|
||||
- **D33**: after Load more or Retry, focus moves to the first new image without
|
||||
scrolling, and only while focus is still on the pressed control or `body`.
|
||||
- **D34**: the viewer keeps its toolbar under the stage in `.viewer-left`, a flex
|
||||
column above 700px, `max-height: min(94dvh, 94%)`, the "Download original" link,
|
||||
the appended position and reason lines, full-stage video and failure overlay,
|
||||
40×40 coarse targets, production's control names and "Media controls" group, and
|
||||
`overscroll-behavior: contain`.
|
||||
- **D35**: counts pluralize ("1 positive prompt", "1 image", "1 more image loaded").
|
||||
- **D36**: the category list is the Library's, sorted alphabetically (case-insensitive)
|
||||
by the page, whatever order the server sends.
|
||||
- **D37**: production loads 40 images per page (the concept 12), so the continuation
|
||||
and toast use 40; after Escape production returns focus to the opening image.
|
||||
|
||||
Manual check (quick), after a browser refresh on Images: hover an image (it zooms);
|
||||
click it (the viewer reads "<title> · <filename>"; Escape closes it); press a card's
|
||||
Info (the side panel changes); search a word from a prompt (matching images remain);
|
||||
open Filters (Source, Folder and Media are there); Import → Rescan picks up a newly
|
||||
saved file.
|
||||
|
||||
```sh
|
||||
npm run --prefix tools/vendor test:workflows -- images-concept.spec.mjs \
|
||||
workspace-images.spec.mjs full-inspector.spec.mjs \
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
## Workspace data fields for the concept pages (#249)
|
||||
|
||||
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
|
||||
`mode=workspace`) and the legacy pages do not change, and existing workspace pages
|
||||
ignore the new keys.
|
||||
|
||||
Workspace image items (`/prompt_manager/images/output?mode=workspace`, and Library
|
||||
image galleries) gain three card facts. Library list previews carry none of them
|
||||
and skip the parse.
|
||||
|
||||
- `linked: {status, positive_id, title, title_source, category, rating}`. One
|
||||
`get_image_link_summaries` SELECT per 200 paths, matched exactly as the
|
||||
inspection route matches (exact or normalized `file_path`, no basename
|
||||
fallback). `status` is `linked`, `ambiguous` (several owners), `none`, or
|
||||
`unavailable` when the database read failed (logged, no error text). Treat
|
||||
anything but `linked` as the filename fallback. In pair scope (the image has
|
||||
exactly one distinct pair), `positive_id` is the pair's positive, and the title
|
||||
comes from that positive's Library title, then its text's first line, never
|
||||
from the pair's own title, which names the negative variant (#249 D10/D20). In
|
||||
positive scope it comes from the Library title, then the first line. A first line longer than 80 code points is cut to 80 plus `…`, like
|
||||
`library.mjs` `title()`. `title_source` is `stored`, `text` or null. `category`
|
||||
and `rating` come from the linked positive (D13), not from the pair. The title
|
||||
gate is shared with the inspector (`_verified_title`): a `stored` title always
|
||||
shows. A `text` title shows only when the file's own positive, parsed from its
|
||||
records by the shared parser, gives the same display title. Otherwise the title
|
||||
is null on the card and in the inspector header. Videos and unreadable files
|
||||
have no parsed positive, so their `text` titles are withheld.
|
||||
- `seed`: a decimal string, or null. Always a string, because `JSON.parse` rounds
|
||||
numbers past 2**53 and ComfyUI's randomized seeds are often that large. It is
|
||||
`extract_generation_params` from `utils/generation_params.py` (the inspector's
|
||||
legacy parser, #251), run on the `prompt`/`workflow` records read with the
|
||||
dimensions. A PNG is read by the inspector's own reader
|
||||
(`utils.prompt_inspector.read_inspection_file`): text after IDAT counts, the
|
||||
shared byte budgets apply, and a file with conflicting duplicate `prompt` or
|
||||
`workflow` chunks gets no card seed, as the inspector refuses it (the card
|
||||
keeps its dimensions). Other formats use Pillow's header `info`, as the
|
||||
inspector's header read does. The records go through the inspection
|
||||
projection first (`utils/inspection_response.project_graph`), which carries
|
||||
integers past 2**53 as their exact digits, so the card and the inspector read
|
||||
the same digits. The parse runs after the file handle closes. A record over
|
||||
2 MiB (UTF-8), a parser error, a recursion error, a placeholder, or a
|
||||
non-integral, boolean or non-finite seed gives null. A card never shows a seed
|
||||
that differs from the inspector's
|
||||
(`test_card_seed_equals_inspector_seed_when_both_present`, which `JSON.parse`s
|
||||
the listing in Node and compares `String(seed)` with the inspector's seed,
|
||||
including one past 2**53). The card shows no seed for A1111 `parameters` files
|
||||
or for a BasicGuider custom sampler, where the inspector does.
|
||||
- Header facts are cached. Dimensions, `seed` and the parsed positive's display
|
||||
title are read once per `(path, mtime)` of the current gallery scan and kept
|
||||
beside the scan's per-file values. A monitor invalidation or TTL rescan keeps
|
||||
the facts of unchanged files and drops the rest, and a roots change drops them
|
||||
all. A header that could not be read is not stored. Links and favorites are
|
||||
never cached (`test_warm_page_reuses_card_facts_per_scan`: a warm page reads
|
||||
and parses no header). Measured cold cost is about 3.4 ms per 50 KB record set
|
||||
and 25 ms per 400 KB workflow.
|
||||
- `favorite: bool`. One favorites SELECT per root per page. A read failure logs a
|
||||
warning and shows false.
|
||||
|
||||
Filters (`mode=workspace`; anything invalid is 400 "Invalid gallery filters"):
|
||||
|
||||
- `q` (at most 500 characters). An entry passes when the case-folded relative path
|
||||
contains it, or when its linked positive's text, Library title or a tag name
|
||||
contains it (SQLite `LIKE` with escaping, ASCII case folding only). A pair's own
|
||||
title names its negative variant, so it is not searched (#249 D12): a variant
|
||||
name alone matches nothing. Search does not apply the card title gate, and does
|
||||
not search pair tags.
|
||||
- `category` (exact and case-sensitive, at most 200 characters, stripped; blank
|
||||
means none) and `tags` (a comma list, blanks dropped, order-free, AND, at most 30
|
||||
tags of at most 200 characters) admit linked images only, through the linked
|
||||
positive.
|
||||
- Ambiguous files never match a linked filter, and they rank as unlinked.
|
||||
- `sort` is `newest | oldest | name | name-desc | rating`. `rating` orders by
|
||||
rating 5 to 1, then linked but unrated, then unlinked and ambiguous; ties break
|
||||
newest first. A signed cursor covers every filter and the sort, so reusing it
|
||||
with a change is 400.
|
||||
- One link read (`get_image_link_matches`, streamed and bounded to the listed
|
||||
paths) serves the filters, the rating sort and the count.
|
||||
|
||||
`positive_count` is the number of distinct linked positives over the full matching
|
||||
set, not the page:
|
||||
|
||||
- Present on every first page, and on every page of a filtered (`q`, `category`,
|
||||
`tags`) or `sort=rating` listing.
|
||||
- Absent on a plain listing's cursor pages, which read no links. The frontend
|
||||
keeps the first page's value.
|
||||
- Null when a plain first page's link read failed. The listing still returns 200,
|
||||
and consumers then hide "· K positive prompts". A `q` search alone also
|
||||
survives the failure: it matches filenames only, as before linked search, and
|
||||
every page's count is null (logged with the traceback). A failure under
|
||||
`category`, `tags` or the rating sort stays a safe 500.
|
||||
|
||||
Library parents (list, detail and command receipts) gain `run_count` (an int; the
|
||||
D23 migration estimate for rows from before 3.2.4) and `last_used_at`, a UTC ISO
|
||||
string. A naive value reads as UTC, and an unreadable or out-of-range value is
|
||||
null. The edit token ignores both. `variant_total` has two meanings. On the list
|
||||
response it counts the active variants over all filtered parents (the same
|
||||
snapshot as `total`, the same on every cursor page). On the detail response it
|
||||
counts one positive's variants in the chosen archive scope. The Library caption
|
||||
reads it from the list state.
|
||||
|
||||
Favorites store (D7 = A): `PUT` and `DELETE /prompt_manager/images/favorite` with
|
||||
`{root_index, path}`. Both are idempotent and answer
|
||||
`{success, favorite, root_index, path}`. The boundary returns 400 for an unknown
|
||||
root or a malformed body, and 403 for traversal, an absolute or drive path, NUL, a
|
||||
non-media name, or a key over the stored bound. PUT needs a listed file (404
|
||||
otherwise). DELETE also clears a path that is no longer listed. Send the DELETE
|
||||
body as `application/json` (`api.del(url, {body})` in the shared API client).
|
||||
The Images card star (#249 C5) toggles optimistically, allows one request per file
|
||||
at a time, and on any failure restores the previous state and announces it in the
|
||||
shared toast. The browser fixtures clear `image_favorites` on reset, and the
|
||||
concept fixture stars the concept's favorites (each other prompt's first image).
|
||||
|
||||
- The table is `image_favorites(root_key, relative_path, created_at)`. `root_key`
|
||||
is the root's real path (`os.path.realpath`), case-normalized with `/`
|
||||
separators. It is stored server-side only and never returned. Folder labels are
|
||||
not unique across roots, so they are not used as keys.
|
||||
- `relative_path` is `normcase(path)` with `/`. On Windows a case-only rename
|
||||
keeps the star. On POSIX the key is the exact spelling, so `A.png` and `a.png`
|
||||
stay separate (tests swap in `ntpath` through `py.api.images._path_flavor`).
|
||||
Moving the install or changing a root's real path orphans its rows, which are
|
||||
harmless and never shown.
|
||||
- Schema validation (`image_favorites_schema_error`) runs at startup and on
|
||||
restore. Old backups without the table restore, and backup plus restore keeps
|
||||
WAL-resident favorites and closes every handle (`tests/test_database_backup.py`).
|
||||
|
||||
Budgets: `test_filter_work_is_bounded` always runs. It asserts that a plain first
|
||||
page makes 1 link SELECT and a plain cursor page none, that every filter shape
|
||||
makes exactly 1, the covering-index plan, and that the per-file values are built
|
||||
once per scan and only for new files after a monitor invalidation. The opt-in
|
||||
`test_filter_budget` (`PROMPTMANAGER_PERF_BUDGET=1`, the `perf budget` job) times
|
||||
the D12 p95 < 150 ms budget; see "Performance budgets".
|
||||
|
||||
```sh
|
||||
python -m unittest tests.test_api_workspace_images tests.test_card_seed \
|
||||
tests.test_image_favorites tests.test_library tests.test_api_library \
|
||||
tests.test_database_backup -q
|
||||
```
|
||||
|
||||
Installing these fields needs a maintainer ComfyUI restart (Python, routes and
|
||||
schema). Automated tests use disposable databases and output folders.
|
||||
|
||||
## Prompt sending and cross-pack compatibility
|
||||
|
||||
`tests/js/prompt-bridge-contracts.test.js` runs the Toolkit's shared protocol
|
||||
@@ -534,9 +996,9 @@ page lifecycle recovery retain their separate behavioral tests.
|
||||
Initial production acceptance requires maintainer inspection of paired PNGs and
|
||||
geometry; never update snapshots automatically or mask content to bless a gap.
|
||||
Data-dependent text/images/counts and the clearly labeled saved/frozen/current
|
||||
distinctions are documented in the comparison helper. Broader Images card
|
||||
activation/Info/tag/favorite/select parity and shared Viewer stage/toolbar polish
|
||||
and shared role-block spacing/padding/glyph polish remain outside #209. The
|
||||
distinctions are documented in the comparison helper. The Images card tag
|
||||
button (#249 D9) and shared role-block
|
||||
spacing/padding/glyph polish remain outside #209. The
|
||||
responsive production Viewer keeps its toolbar in the media panel and prompt details readable rather than reproducing the concept's
|
||||
mobile toolbar collision. These are explicit remaining visual differences, not
|
||||
a whole-application or full-gallery/Viewer pixel-parity claim.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Persisted image favorites, keyed by gallery root + relative path.
|
||||
|
||||
The root key is the root's real, case-normalized path and the relative key is
|
||||
case-normalized the same way (see ``py/api/images.py``); both stay
|
||||
server-side. Folder names alone are not unique across roots, so a favorite
|
||||
follows the directory itself when the configured roots are reordered.
|
||||
Optional in old backups, required once a database is current (see
|
||||
``library_schema.py``).
|
||||
"""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Iterable
|
||||
|
||||
TABLE = "image_favorites"
|
||||
MAX_KEY_LENGTH = 4096
|
||||
_LOOKUP_CHUNK = 500
|
||||
_DDL = (
|
||||
f"CREATE TABLE {TABLE} (root_key TEXT NOT NULL, relative_path TEXT NOT NULL, "
|
||||
"created_at TEXT NOT NULL, PRIMARY KEY(root_key, relative_path))"
|
||||
)
|
||||
_AUTOINDEX = f"sqlite_autoindex_{TABLE}_1"
|
||||
_COLUMNS = (
|
||||
("root_key", "TEXT", 1, 1),
|
||||
("relative_path", "TEXT", 1, 2),
|
||||
("created_at", "TEXT", 1, 0),
|
||||
)
|
||||
|
||||
|
||||
def _compact(sql: str) -> str:
|
||||
return re.sub(r"\s+", "", sql).lower()
|
||||
|
||||
|
||||
def image_favorites_schema_error(
|
||||
conn: sqlite3.Connection, *, allow_missing: bool = True
|
||||
) -> str | None:
|
||||
"""Reject unsupported favorites schemas and invalid persisted values.
|
||||
|
||||
Reads only. A missing table is acceptable for backups that predate it
|
||||
unless ``allow_missing`` is false.
|
||||
"""
|
||||
objects = conn.execute(
|
||||
"SELECT type,name,sql FROM sqlite_master WHERE name=? "
|
||||
"OR (type IN ('index','trigger') AND tbl_name=?)",
|
||||
(TABLE, TABLE),
|
||||
).fetchall()
|
||||
if not objects:
|
||||
return None if allow_missing else "Missing image favorites schema"
|
||||
tables = [sql for kind, _name, sql in objects if kind == "table"]
|
||||
others = {(kind, name, sql) for kind, name, sql in objects if kind != "table"}
|
||||
if len(tables) != 1 or others != {("index", _AUTOINDEX, None)}:
|
||||
return "Image favorites name is reserved by an unsupported schema object"
|
||||
if _compact(tables[0] or "") != _compact(_DDL):
|
||||
return "Image favorites has an invalid table definition"
|
||||
columns = tuple(
|
||||
(row[1], row[2].upper(), row[3], row[5])
|
||||
for row in conn.execute(f"PRAGMA table_info({TABLE})")
|
||||
)
|
||||
if columns != _COLUMNS:
|
||||
return "Image favorites has invalid columns"
|
||||
# Bytes bound characters from above (UTF-8 is at most 4 bytes each).
|
||||
limit = MAX_KEY_LENGTH * 4
|
||||
if conn.execute(
|
||||
f"SELECT 1 FROM {TABLE} WHERE typeof(root_key) != 'text' "
|
||||
"OR typeof(relative_path) != 'text' OR typeof(created_at) != 'text' "
|
||||
f"OR length(CAST(root_key AS BLOB)) > {limit} "
|
||||
f"OR length(CAST(relative_path AS BLOB)) > {limit} "
|
||||
"OR length(CAST(created_at AS BLOB)) > 64 LIMIT 1"
|
||||
).fetchone():
|
||||
return "Image favorites contains invalid values"
|
||||
for root, path in conn.execute(
|
||||
f"SELECT CAST(root_key AS BLOB), CAST(relative_path AS BLOB) FROM {TABLE} "
|
||||
f"WHERE length(CAST(root_key AS BLOB)) > {MAX_KEY_LENGTH} "
|
||||
f"OR length(CAST(relative_path AS BLOB)) > {MAX_KEY_LENGTH}"
|
||||
):
|
||||
for raw in (root, path):
|
||||
if len(bytes(raw).decode("utf-8", "replace")) > MAX_KEY_LENGTH:
|
||||
return "Image favorites contains invalid values"
|
||||
return None
|
||||
|
||||
|
||||
def migrate_image_favorites(conn: sqlite3.Connection) -> None:
|
||||
"""Install the table atomically; validate before and after creating it."""
|
||||
conn.execute("SAVEPOINT image_favorites_schema")
|
||||
try:
|
||||
error = image_favorites_schema_error(conn)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
|
||||
error = image_favorites_schema_error(conn, allow_missing=False)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
conn.execute("RELEASE image_favorites_schema")
|
||||
except BaseException:
|
||||
conn.execute("ROLLBACK TO image_favorites_schema")
|
||||
conn.execute("RELEASE image_favorites_schema")
|
||||
raise
|
||||
|
||||
|
||||
class ImageFavoriteOperations:
|
||||
"""PromptDatabase mixin: set, clear and look up image favorites."""
|
||||
|
||||
def set_image_favorite(self, root_key: str, relative_path: str) -> bool:
|
||||
"""Mark one file as a favorite; repeating it is a no-op. Returns True."""
|
||||
from .models import utc_now_iso # models imports this module's schema
|
||||
|
||||
with self.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO image_favorites VALUES (?, ?, ?)",
|
||||
(root_key, relative_path, utc_now_iso()),
|
||||
)
|
||||
return True
|
||||
|
||||
def clear_image_favorite(self, root_key: str, relative_path: str) -> bool:
|
||||
"""Remove one favorite; True when a row was removed."""
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM image_favorites WHERE root_key=? AND relative_path=?",
|
||||
(root_key, relative_path),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def get_image_favorites(
|
||||
self, root_key: str, relative_paths: Iterable[str]
|
||||
) -> set[str]:
|
||||
"""The subset of *relative_paths* favorited under *root_key*."""
|
||||
wanted = list(dict.fromkeys(relative_paths))
|
||||
found: set[str] = set()
|
||||
conn = self.model.get_connection()
|
||||
for start in range(0, len(wanted), _LOOKUP_CHUNK):
|
||||
chunk = wanted[start : start + _LOOKUP_CHUNK]
|
||||
marks = ",".join("?" * len(chunk))
|
||||
rows = conn.execute( # only "?" placeholders are interpolated
|
||||
"SELECT relative_path FROM image_favorites WHERE root_key=? "
|
||||
f"AND relative_path IN ({marks})", # nosec B608
|
||||
(root_key, *chunk),
|
||||
)
|
||||
found.update(path for (path,) in rows)
|
||||
return found
|
||||
+88
-7
@@ -1,5 +1,6 @@
|
||||
"""Read-only positive-parent views over existing prompt and pair tables."""
|
||||
|
||||
import datetime
|
||||
import sqlite3
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
@@ -13,10 +14,79 @@ _ELIGIBLE = """(EXISTS(SELECT 1 FROM prompt_pairs ep WHERE ep.positive_prompt_id
|
||||
_FROM = "prompts p LEFT JOIN prompt_library_state ls ON ls.prompt_id=p.id"
|
||||
# The characters JavaScript's String.prototype.trim() removes, so a title the
|
||||
# display treats as blank also falls back to the prompt text for name sorting.
|
||||
_BLANK = (
|
||||
"char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,"
|
||||
"8200,8201,8202,8232,8233,8239,8287,12288,65279)"
|
||||
)
|
||||
_TRIM_CODES = (9, 10, 11, 12, 13, 32, 160, 5760, *range(8192, 8203))
|
||||
_TRIM_CODES += (8232, 8233, 8239, 8287, 12288, 65279)
|
||||
_BLANK = f"char({','.join(map(str, _TRIM_CODES))})"
|
||||
_JS_TRIM = "".join(map(chr, _TRIM_CODES))
|
||||
# The Library's display title: a stored title, else the prompt's first line.
|
||||
DISPLAY_TITLE_LIMIT = 80
|
||||
|
||||
|
||||
def _utf16_units(text: str) -> int:
|
||||
"""JavaScript ``length``: characters beyond the BMP count as two units."""
|
||||
return len(text) + sum(1 for ch in text if ord(ch) > 0xFFFF)
|
||||
|
||||
|
||||
def _utf16_prefix(text: str, units: int) -> str:
|
||||
"""The longest prefix within ``units`` UTF-16 units, never splitting a pair."""
|
||||
used = 0
|
||||
for index, ch in enumerate(text):
|
||||
used += 2 if ord(ch) > 0xFFFF else 1
|
||||
if used > units:
|
||||
return text[:index]
|
||||
return text
|
||||
|
||||
|
||||
def titled(stored_titles: tuple, text: object) -> tuple[str | None, str | None]:
|
||||
"""Return ``(title, source)``: a stored title, else the text's first line.
|
||||
|
||||
``source`` is ``"stored"`` for the first non-blank stored title (blank as
|
||||
in JavaScript's trim, like the Library view), ``"text"`` for the first line,
|
||||
and None when both are empty. The first line is trimmed and, past
|
||||
DISPLAY_TITLE_LIMIT UTF-16 units, cut with an ellipsis like the Library's
|
||||
``libraryTitle()`` (never inside a surrogate pair).
|
||||
"""
|
||||
for title in stored_titles:
|
||||
if isinstance(title, str) and title.strip(_JS_TRIM):
|
||||
return title, "stored"
|
||||
if not isinstance(text, str):
|
||||
return None, None
|
||||
first = text.split("\n")[0].strip(_JS_TRIM)
|
||||
if _utf16_units(first) > DISPLAY_TITLE_LIMIT:
|
||||
first = _utf16_prefix(first, DISPLAY_TITLE_LIMIT) + "\u2026"
|
||||
return (first, "text") if first else (None, None)
|
||||
|
||||
|
||||
def display_title(stored_titles: tuple, text: object) -> str | None:
|
||||
"""The Library display title of ``titled``, without its source."""
|
||||
return titled(stored_titles, text)[0]
|
||||
|
||||
|
||||
def utc_iso(value: object) -> str | None:
|
||||
"""A stored timestamp as UTC ISO text, or None when absent or unreadable.
|
||||
|
||||
Usage writes are already UTC ISO; a naive value (SQLite's CURRENT_TIMESTAMP
|
||||
form) is read as UTC, and an offset is converted to UTC. One bad row must
|
||||
not fail a whole Library read, so a value past the datetime range after
|
||||
conversion (OverflowError) is unreadable too. Python 3.11+ fromisoformat
|
||||
accepts more ISO forms than 3.10 (e.g. a 2-digit fraction); every form the
|
||||
app writes (utc_now_iso, the 3.2.4 backfill, CURRENT_TIMESTAMP, Z) parses
|
||||
the same on both, so only hand-edited values can differ by version.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
text = value.strip()
|
||||
if text.endswith(("Z", "z")):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
stamp = datetime.datetime.fromisoformat(text)
|
||||
if stamp.tzinfo is None:
|
||||
stamp = stamp.replace(tzinfo=datetime.timezone.utc)
|
||||
return stamp.astimezone(datetime.timezone.utc).isoformat()
|
||||
except (ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
_NAME = (
|
||||
f"LOWER(CASE WHEN TRIM(COALESCE(ls.title,''),{_BLANK})!='' "
|
||||
"THEN ls.title ELSE p.text END)"
|
||||
@@ -145,6 +215,7 @@ class LibraryOperations:
|
||||
) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT p.id,p.text,ls.title,p.category,p.rating,p.notes,p.created_at,"
|
||||
"p.run_count,p.last_used_at,"
|
||||
+ (f"{sort_expression} AS _sort," if sort_expression else "")
|
||||
+ "(SELECT COUNT(*) FROM prompt_pairs v WHERE v.positive_prompt_id=p.id "
|
||||
"AND v.archived=0) AS variant_count,"
|
||||
@@ -165,6 +236,9 @@ class LibraryOperations:
|
||||
):
|
||||
by_id[row["prompt_id"]]["tags"].append(row["name"])
|
||||
for row in result:
|
||||
# Usage is shown, never compared: the token hashes owner fields only.
|
||||
row["run_count"] = row["run_count"] if type(row["run_count"]) is int else 0
|
||||
row["last_used_at"] = utc_iso(row["last_used_at"])
|
||||
row["edit_token"] = library_edit_token(epoch, row)
|
||||
return result
|
||||
|
||||
@@ -196,9 +270,15 @@ class LibraryOperations:
|
||||
ceiling = after["ceiling"]
|
||||
where += " AND p.id<=?"
|
||||
params.append(ceiling)
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM " + _FROM + " WHERE " + where, params
|
||||
).fetchone()[0]
|
||||
# One SELECT counts the filtered parents and their active variants.
|
||||
total, variant_total = conn.execute(
|
||||
"SELECT COUNT(*),COALESCE(SUM((SELECT COUNT(*) FROM prompt_pairs v "
|
||||
"WHERE v.positive_prompt_id=p.id AND v.archived=0)),0) FROM "
|
||||
+ _FROM
|
||||
+ " WHERE "
|
||||
+ where,
|
||||
params,
|
||||
).fetchone()
|
||||
expression, direction = _ORDERS[sort]
|
||||
if after is not None:
|
||||
operator = ">" if direction == "ASC" else "<"
|
||||
@@ -230,6 +310,7 @@ class LibraryOperations:
|
||||
"library_epoch": epoch,
|
||||
"items": rows,
|
||||
"total": total,
|
||||
"variant_total": variant_total,
|
||||
"has_more": has_more,
|
||||
"next_key": next_key,
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ from pathlib import Path
|
||||
from typing import Optional, Set, Tuple
|
||||
|
||||
from .library_schema import library_schema_error, migrate_library_state
|
||||
from .image_favorites import image_favorites_schema_error, migrate_image_favorites
|
||||
from .execution_history_schema import (
|
||||
history_schema_error,
|
||||
migrate_execution_history,
|
||||
@@ -298,6 +299,7 @@ class PromptModel:
|
||||
self._migrate_prompt_pairs(conn)
|
||||
migrate_execution_history(conn)
|
||||
migrate_library_state(conn)
|
||||
migrate_image_favorites(conn)
|
||||
|
||||
def _migrate_prompt_pairs(self, conn: sqlite3.Connection) -> None:
|
||||
conn.execute("SAVEPOINT prompt_pair_schema")
|
||||
@@ -1016,6 +1018,9 @@ class PromptModel:
|
||||
|
||||
Args:
|
||||
path: File to inspect
|
||||
require_library_state: Also require the tables every current
|
||||
database has (Library state and image favorites), as a
|
||||
migrated restore stage must; old backups may lack them.
|
||||
|
||||
Returns:
|
||||
(ok, reason): reason is "ok" when the file is acceptable, otherwise
|
||||
@@ -1070,6 +1075,11 @@ class PromptModel:
|
||||
)
|
||||
if library_error:
|
||||
return False, library_error
|
||||
favorites_error = image_favorites_schema_error(
|
||||
conn, allow_missing=not require_library_state
|
||||
)
|
||||
if favorites_error:
|
||||
return False, favorites_error
|
||||
return True, "ok"
|
||||
except sqlite3.Error as e:
|
||||
return False, f"Invalid SQLite database: {e}"
|
||||
|
||||
+215
-2
@@ -6,11 +6,12 @@ import sqlite3
|
||||
import json
|
||||
import datetime
|
||||
import os
|
||||
from typing import Optional, List, Dict, Any
|
||||
from typing import Optional, List, Dict, Any, Set, Tuple
|
||||
|
||||
from .models import PromptModel, normalize_image_path, utc_now_iso
|
||||
from .pairs import PairDependencyError, PairOperations
|
||||
from .library import LibraryOperations
|
||||
from .library import LibraryOperations, titled
|
||||
from .image_favorites import ImageFavoriteOperations
|
||||
from .library_commands import LibraryCommandOperations
|
||||
from .vocabulary import VocabularyOperations
|
||||
from .execution_history import ExecutionHistoryOperations
|
||||
@@ -179,6 +180,122 @@ AND p.id NOT IN (SELECT prompt_id FROM live_hint_prompts)
|
||||
"""
|
||||
|
||||
|
||||
# One SELECT reads the link summaries of at most this many paths: three bound
|
||||
# parameters per path stays under SQLite's historical 999-variable limit.
|
||||
LINK_SUMMARY_BATCH = 200
|
||||
# A pair link counts only when an image has exactly one distinct pair, as in
|
||||
# get_image_prompt_info; its positive (text, Library title, category, rating)
|
||||
# then describes the card. The pair's own title names its negative variant, so
|
||||
# it is not read (D10).
|
||||
_LINK_SUMMARY_SQL = """SELECT gi.image_path, gi.file_path, gi.prompt_id,
|
||||
p.text, p.category, p.rating, ls.title,
|
||||
pp.positive_prompt_id, pt.text, pt.category, pt.rating, pls.title
|
||||
FROM generated_images gi
|
||||
JOIN prompts p ON gi.prompt_id = p.id
|
||||
LEFT JOIN prompt_library_state ls ON ls.prompt_id = p.id
|
||||
LEFT JOIN prompt_pairs pp ON pp.id = (
|
||||
SELECT CASE WHEN COUNT(DISTINCT ip.pair_id) = 1 THEN MIN(ip.pair_id) END
|
||||
FROM image_prompt_pairs ip WHERE ip.image_id = gi.id)
|
||||
LEFT JOIN prompts pt ON pt.id = pp.positive_prompt_id
|
||||
LEFT JOIN prompt_library_state pls ON pls.prompt_id = pp.positive_prompt_id
|
||||
WHERE gi.image_path IN ({marks}) OR gi.file_path IN ({marks}){fallback}"""
|
||||
# One image row describes a card only when no other row claims the same file
|
||||
# (as in get_image_link_summaries, several rows are ambiguous). The pair scope
|
||||
# and the linked positive follow _LINK_SUMMARY_SQL; category, rating, tags and
|
||||
# the searchable text and titles come from that positive (spec D12/D13). Every
|
||||
# writer since the 3.2.4 migration stores file_path, so it is the path key.
|
||||
# The prompt-level conditions are uncorrelated IN sets SQLite builds once, and
|
||||
# the CASE evaluates the per-row ambiguity probe only for admitted rows.
|
||||
_LINK_FILTER_SQL = """SELECT gi.file_path, p.rating, {hit}, p.id
|
||||
FROM generated_images gi
|
||||
LEFT JOIN prompt_pairs pp ON pp.id = (
|
||||
SELECT CASE WHEN COUNT(DISTINCT ip.pair_id) = 1 THEN MIN(ip.pair_id) END
|
||||
FROM image_prompt_pairs ip WHERE ip.image_id = gi.id)
|
||||
JOIN prompts p ON p.id = COALESCE(pp.positive_prompt_id, gi.prompt_id)
|
||||
WHERE gi.file_path IS NOT NULL AND CASE WHEN {admit} THEN NOT EXISTS (
|
||||
SELECT 1 FROM generated_images other
|
||||
WHERE other.file_path = gi.file_path AND other.id <> gi.id) ELSE 0 END"""
|
||||
# The positive's text, its Library title or one of its tag names contains
|
||||
# :pattern (LIKE, so ASCII case is ignored as in Library search). A pair's own
|
||||
# title names its negative variant and is never searched (#249 D12).
|
||||
_LINK_FILTER_HIT = """p.id IN (
|
||||
SELECT hp.id FROM prompts hp
|
||||
LEFT JOIN prompt_library_state hls ON hls.prompt_id = hp.id
|
||||
WHERE hp.text LIKE :pattern ESCAPE '\\' OR hls.title LIKE :pattern ESCAPE '\\'
|
||||
OR hp.id IN (SELECT ht.prompt_id FROM prompt_tags ht JOIN tags hn
|
||||
ON hn.id = ht.tag_id WHERE hn.name LIKE :pattern ESCAPE '\\'))"""
|
||||
_LINK_FILTER_TAG = (
|
||||
"p.id IN (SELECT ft.prompt_id FROM prompt_tags ft JOIN tags fn "
|
||||
"ON fn.id = ft.tag_id WHERE fn.name = :tag{index})"
|
||||
)
|
||||
|
||||
|
||||
def link_filter_query(
|
||||
q: str, category: Optional[str], tags: List[str], *, every_link: bool = False
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""The workspace filter SELECT and its named parameters (all bound).
|
||||
|
||||
Only code constants and enumerate indices are formatted into the SQL; see
|
||||
PromptDatabase.get_image_link_matches for the semantics.
|
||||
"""
|
||||
params: Dict[str, Any] = {}
|
||||
hit = "1"
|
||||
if q:
|
||||
hit = _LINK_FILTER_HIT
|
||||
params["pattern"] = f"%{escape_like(q)}%"
|
||||
admitted = []
|
||||
if category is not None:
|
||||
admitted.append("p.category = :category")
|
||||
params["category"] = category
|
||||
for index, tag in enumerate(tags):
|
||||
admitted.append(_LINK_FILTER_TAG.format(index=index))
|
||||
params[f"tag{index}"] = tag
|
||||
if q and not admitted and not every_link:
|
||||
admitted.append(hit)
|
||||
sql = _LINK_FILTER_SQL.format(hit=hit, admit=" AND ".join(admitted) or "1")
|
||||
return sql, params
|
||||
|
||||
|
||||
_ASCII_FOLD = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
|
||||
|
||||
|
||||
def _like_suffix(value: object, suffix: str) -> bool:
|
||||
"""Python form of SQLite's ``value LIKE '%' || suffix``: ASCII-only case."""
|
||||
return isinstance(value, str) and value.translate(_ASCII_FOLD).endswith(
|
||||
suffix.translate(_ASCII_FOLD)
|
||||
)
|
||||
|
||||
|
||||
def _empty_link_summary(status: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"status": status,
|
||||
"positive_id": None,
|
||||
"title": None,
|
||||
"title_source": None,
|
||||
"category": None,
|
||||
"rating": None,
|
||||
}
|
||||
|
||||
|
||||
def _link_summary(row: tuple) -> Dict[str, Any]:
|
||||
if row[7] is not None:
|
||||
positive, text, category, rating = row[7], row[8], row[9], row[10]
|
||||
# The linked positive's Library title (D10/D20).
|
||||
titles = (row[11],)
|
||||
else:
|
||||
positive, text, category, rating = row[2], row[3], row[4], row[5]
|
||||
titles = (row[6],)
|
||||
title, source = titled(titles, text)
|
||||
return {
|
||||
"status": "linked",
|
||||
"positive_id": positive,
|
||||
"title": title,
|
||||
"title_source": source,
|
||||
"category": category,
|
||||
"rating": rating,
|
||||
}
|
||||
|
||||
|
||||
class PromptDatabase(
|
||||
PairOperations,
|
||||
LibraryOperations,
|
||||
@@ -188,6 +305,7 @@ class PromptDatabase(
|
||||
ExecutionContextOperations,
|
||||
ExecutionOutputOperations,
|
||||
HistoryReadOperations,
|
||||
ImageFavoriteOperations,
|
||||
):
|
||||
"""Database operations class for managing prompts."""
|
||||
|
||||
@@ -2160,6 +2278,101 @@ class PromptDatabase(
|
||||
"parameters": row[8] if len(row) > 8 else None,
|
||||
}
|
||||
|
||||
def get_image_link_summaries(
|
||||
self, paths: List[str], allow_filename_fallback: bool = True
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""Summarize each path's linked saved positive for an image card.
|
||||
|
||||
Matching equals get_image_prompt_info: exact image_path or normalized
|
||||
file_path rows win; otherwise, with the fallback, rows whose image_path
|
||||
ends with the basename. One match is linked, several are ambiguous.
|
||||
One SELECT reads up to LINK_SUMMARY_BATCH paths.
|
||||
"""
|
||||
unique = list(dict.fromkeys(paths))
|
||||
summaries: Dict[str, Dict[str, Any]] = {}
|
||||
for start in range(0, len(unique), LINK_SUMMARY_BATCH):
|
||||
batch = unique[start : start + LINK_SUMMARY_BATCH]
|
||||
summaries.update(self._link_summary_batch(batch, allow_filename_fallback))
|
||||
return summaries
|
||||
|
||||
def _link_summary_batch(
|
||||
self, paths: List[str], allow_filename_fallback: bool
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
normalized = {path: normalize_image_path(path) for path in paths}
|
||||
basenames = {path: os.path.basename(path) for path in paths}
|
||||
marks = ",".join("?" for _ in paths)
|
||||
params: List[Any] = [*paths, *normalized.values()]
|
||||
fallback = ""
|
||||
if allow_filename_fallback:
|
||||
fallback = "".join(" OR gi.image_path LIKE ? ESCAPE '\\'" for _ in paths)
|
||||
params.extend(f"%{escape_like(basenames[path])}" for path in paths)
|
||||
sql = _LINK_SUMMARY_SQL.format(marks=marks, fallback=fallback)
|
||||
with self.model.get_connection() as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
summaries = {}
|
||||
for path in paths:
|
||||
exact = [
|
||||
row for row in rows if row[0] == path or row[1] == normalized[path]
|
||||
]
|
||||
matches = exact or (
|
||||
[row for row in rows if _like_suffix(row[0], basenames[path])]
|
||||
if allow_filename_fallback
|
||||
else []
|
||||
)
|
||||
if len(matches) == 1:
|
||||
summaries[path] = _link_summary(matches[0])
|
||||
else:
|
||||
summaries[path] = _empty_link_summary(
|
||||
"ambiguous" if matches else "none"
|
||||
)
|
||||
return summaries
|
||||
|
||||
def get_image_link_matches(
|
||||
self,
|
||||
q: str,
|
||||
category: Optional[str],
|
||||
tags: List[str],
|
||||
*,
|
||||
every_link: bool = False,
|
||||
wanted: Optional[Set[str]] = None,
|
||||
) -> Dict[str, Tuple[bool, Optional[int], int]]:
|
||||
"""Map normalized paths of unambiguously linked images to
|
||||
(hit, rating, positive id).
|
||||
|
||||
Rows are limited to images whose linked positive has *category* and
|
||||
every tag in *tags* (exact names). ``hit`` says whether *q* occurs in
|
||||
the positive's text, Library title or a tag name; a pair's own title
|
||||
names its negative variant and is not searched.
|
||||
Without category or tags only hits are returned, unless *every_link*
|
||||
asks for every linked image (the rating sort and the positive count).
|
||||
The positive id is the card's linked positive. One SELECT whose rows
|
||||
are streamed; with *wanted* (the listing's normalized paths) only those
|
||||
paths are kept, so memory is bounded by the listing, not the database.
|
||||
"""
|
||||
sql, params = link_filter_query(q, category, tags, every_link=every_link)
|
||||
matches: Dict[str, Tuple[bool, Optional[int], int]] = {}
|
||||
with self.model.get_connection() as conn:
|
||||
for path, rating, matched, positive in conn.execute(sql, params):
|
||||
if wanted is None or path in wanted:
|
||||
matches[path] = (bool(matched), rating, positive)
|
||||
return matches
|
||||
|
||||
def get_image_paths_matching(
|
||||
self, q: str, category: Optional[str], tags: List[str]
|
||||
) -> Set[str]:
|
||||
"""Normalized paths whose linked positive matches *q*, *category* and *tags*.
|
||||
|
||||
See get_image_link_matches; an empty *q* matches every linked image
|
||||
the category and tags admit.
|
||||
"""
|
||||
return {
|
||||
path
|
||||
for path, (hit, _rating, _positive) in self.get_image_link_matches(
|
||||
q, category, tags
|
||||
).items()
|
||||
if hit or not q
|
||||
}
|
||||
|
||||
def get_prompt_id_for_image(self, image_path: str) -> Optional[int]:
|
||||
"""Return the prompt_id linked to an image, or None."""
|
||||
with self.model.get_connection() as conn:
|
||||
|
||||
+24
-1
@@ -283,6 +283,16 @@ class PromptManagerAPI(
|
||||
self._html_cache = {} # Cached HTML file contents keyed by path
|
||||
self._gallery_cache = {} # dict: path_str -> (files, timestamp)
|
||||
self._gallery_cache_ttl = 30 # Cache TTL in seconds
|
||||
# Workspace listing values and sort orders derived from a cached scan
|
||||
# (py/api/images.py _workspace_listed/_workspace_orders).
|
||||
self._workspace_listed_cache = {}
|
||||
self._workspace_order_cache = {}
|
||||
# Card facts (dimensions, seed, positive title) per root and
|
||||
# (path, mtime), kept and pruned with the per-file values
|
||||
# (py/api/images.py _card_facts).
|
||||
self._workspace_card_cache = {}
|
||||
# Favorite keys of each root's cached scan (images._favorite_listing).
|
||||
self._favorite_listing_cache = {}
|
||||
# Single-flight locks for long jobs, keyed by job family; see
|
||||
# _acquire_long_job(). Created lazily so no lock binds to an event
|
||||
# loop before the server's loop exists.
|
||||
@@ -348,9 +358,22 @@ class PromptManagerAPI(
|
||||
def invalidate_gallery_cache(self):
|
||||
"""Invalidate the cached gallery file listing.
|
||||
|
||||
Called by the image monitor when new files are detected.
|
||||
Called by the image monitor when new files are detected. The workspace
|
||||
keeps its per-file values and card facts, so the next request rebuilds
|
||||
only new or changed files (see ImageRoutesMixin._workspace_listed and
|
||||
_card_facts); its sort orders
|
||||
are dropped, as the rescan replaces them anyway, and so is the
|
||||
favorites lookup, which holds the old scan list.
|
||||
"""
|
||||
self._gallery_cache = {}
|
||||
self._workspace_order_cache = {}
|
||||
self._favorite_listing_cache = {}
|
||||
|
||||
def reset_gallery_roots_cache(self):
|
||||
"""Drop every cached listing and derived workspace value (roots changed)."""
|
||||
self.invalidate_gallery_cache()
|
||||
self._workspace_listed_cache = {}
|
||||
self._workspace_card_cache = {}
|
||||
|
||||
def add_routes(self, routes):
|
||||
"""Register all API routes with the ComfyUI server.
|
||||
|
||||
+53
-17
@@ -27,10 +27,22 @@ except ImportError:
|
||||
try:
|
||||
from ...database.operations import PromptDatabase
|
||||
from ...database.pair_ingestion import ingest_resolved_pair
|
||||
from ...utils.generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
parse_js_json,
|
||||
)
|
||||
from ...utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
except ImportError:
|
||||
from database.operations import PromptDatabase
|
||||
from database.pair_ingestion import ingest_resolved_pair
|
||||
from utils.generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
parse_js_json,
|
||||
)
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
|
||||
|
||||
@@ -885,7 +897,7 @@ class AdminRoutesMixin:
|
||||
return False
|
||||
GalleryConfig.MONITORING_DIRECTORIES = roots
|
||||
self._cached_output_dir = None
|
||||
self._gallery_cache = {}
|
||||
self.reset_gallery_roots_cache()
|
||||
return True
|
||||
|
||||
def _persist_settings(self):
|
||||
@@ -1511,6 +1523,21 @@ class AdminRoutesMixin:
|
||||
counts["pair_links_added"] += outcome.get("links_added", 0)
|
||||
return counts
|
||||
|
||||
@staticmethod
|
||||
def _graph_prompt_text(metadata):
|
||||
"""The positive prompt the browser parser traces from the graphs, or None."""
|
||||
records = {}
|
||||
for key in ("prompt", "workflow"):
|
||||
value = metadata.get(key)
|
||||
records[key] = parse_js_json(value) if isinstance(value, str) else value
|
||||
try:
|
||||
positive = extract_generation_params(records)["positivePrompt"]
|
||||
except GenerationParamsError:
|
||||
return None
|
||||
if not isinstance(positive, str) or positive == PLACEHOLDERS["positivePrompt"]:
|
||||
return None
|
||||
return positive.strip() or None
|
||||
|
||||
def _ingest_scanned_file(self, media_file, metadata, detailed=False):
|
||||
"""Enrich exact pairs; retain legacy positive-only import for absent pairs."""
|
||||
if not metadata:
|
||||
@@ -1531,23 +1558,32 @@ class AdminRoutesMixin:
|
||||
self.db, str(media_file), canonical, resolution
|
||||
)
|
||||
return result if detailed else result["status"]
|
||||
if resolution["state"] == "ambiguous":
|
||||
return "ambiguous"
|
||||
if resolution["state"] == "unresolved":
|
||||
if canonical.get("prompt") or not isinstance(
|
||||
canonical.get("parameters"), str
|
||||
):
|
||||
if canonical.get("prompt") and resolution["state"] in (
|
||||
"ambiguous",
|
||||
"unresolved",
|
||||
):
|
||||
# The resolver could not decide: link the prompt the inspector and the
|
||||
# legacy pages show for this file (the browser parser, in Python).
|
||||
# Graphs without a recognized sampler keep the legacy extractor below.
|
||||
prompt_text = self._graph_prompt_text(metadata)
|
||||
if not prompt_text:
|
||||
return resolution["state"]
|
||||
parsed = self._parse_comfyui_prompt(metadata)
|
||||
if not (parsed.get("prompt") or parsed.get("parameters")):
|
||||
return None
|
||||
|
||||
prompt_text = self._extract_readable_prompt(parsed)
|
||||
if prompt_text is not None and not isinstance(prompt_text, str):
|
||||
prompt_text = str(prompt_text)
|
||||
if not (prompt_text and prompt_text.strip()):
|
||||
return "found"
|
||||
prompt_text = prompt_text.strip()
|
||||
elif resolution["state"] == "ambiguous":
|
||||
return "ambiguous"
|
||||
elif resolution["state"] == "unresolved" and not isinstance(
|
||||
canonical.get("parameters"), str
|
||||
):
|
||||
return resolution["state"]
|
||||
else:
|
||||
parsed = self._parse_comfyui_prompt(metadata)
|
||||
if not (parsed.get("prompt") or parsed.get("parameters")):
|
||||
return None
|
||||
prompt_text = self._extract_readable_prompt(parsed)
|
||||
if prompt_text is not None and not isinstance(prompt_text, str):
|
||||
prompt_text = str(prompt_text)
|
||||
if not (prompt_text and prompt_text.strip()):
|
||||
return "found"
|
||||
prompt_text = prompt_text.strip()
|
||||
|
||||
prompt_hash = generate_prompt_hash(prompt_text)
|
||||
existing = self.db.get_prompt_by_hash(prompt_hash)
|
||||
|
||||
+728
-123
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,11 @@ try:
|
||||
MetadataReadError,
|
||||
inspect_prompt_evidence,
|
||||
read_inspection_file,
|
||||
unreadable_evidence,
|
||||
)
|
||||
from ...utils.prompt_pair_resolver import (
|
||||
INSPECTION_PROFILE,
|
||||
PREVIEW_INSPECTION_PROFILE,
|
||||
)
|
||||
from ...utils.video_metadata import is_video_path
|
||||
except ImportError:
|
||||
@@ -16,6 +21,11 @@ except ImportError:
|
||||
MetadataReadError,
|
||||
inspect_prompt_evidence,
|
||||
read_inspection_file,
|
||||
unreadable_evidence,
|
||||
)
|
||||
from utils.prompt_pair_resolver import (
|
||||
INSPECTION_PROFILE,
|
||||
PREVIEW_INSPECTION_PROFILE,
|
||||
)
|
||||
from utils.video_metadata import is_video_path
|
||||
|
||||
@@ -60,6 +70,28 @@ def _current_file_facts(path: Path) -> os.stat_result:
|
||||
return observed
|
||||
|
||||
|
||||
def _in_host_temp(path: Path) -> bool:
|
||||
"""Whether a file lies in ComfyUI's temp folder, where previews are written.
|
||||
|
||||
An unknown temp folder reads as no: the file is then inspected as saved
|
||||
output.
|
||||
"""
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
temp = folder_paths.get_temp_directory()
|
||||
except (ImportError, AttributeError, OSError):
|
||||
return False
|
||||
if type(temp) is not str or not temp:
|
||||
return False
|
||||
try:
|
||||
folder = os.path.normcase(os.path.realpath(temp))
|
||||
target = os.path.normcase(os.path.realpath(path))
|
||||
return os.path.commonpath([folder, target]) == folder
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def inspect_current_file(
|
||||
path: Path, *, source_label: str, saved: dict | None, library: dict
|
||||
) -> dict:
|
||||
@@ -73,24 +105,18 @@ def inspect_current_file(
|
||||
if any(getattr(before, key) != getattr(after, key) for key in fields):
|
||||
raise CurrentFileChanged
|
||||
if inspection is None:
|
||||
evidence = {
|
||||
"prompts": {
|
||||
role: {"status": "unresolved", "text": None, "source": None}
|
||||
for role in ("positive", "negative")
|
||||
},
|
||||
"resolution": {
|
||||
"state": "unresolved",
|
||||
"sampler_id": None,
|
||||
"reason": "Media metadata could not be read",
|
||||
"reason_code": None,
|
||||
"node": None,
|
||||
},
|
||||
}
|
||||
evidence = unreadable_evidence()
|
||||
else:
|
||||
role_saved = (
|
||||
None if saved and saved.get("association_status") == "ambiguous" else saved
|
||||
)
|
||||
evidence = inspect_prompt_evidence(inspection.metadata, role_saved)
|
||||
# A preview may be the output of a stage that reaches only previews.
|
||||
profile = (
|
||||
PREVIEW_INSPECTION_PROFILE if _in_host_temp(path) else INSPECTION_PROFILE
|
||||
)
|
||||
evidence = inspect_prompt_evidence(
|
||||
inspection.metadata, role_saved, profile=profile
|
||||
)
|
||||
return {
|
||||
"file": {
|
||||
"observation": "current-file",
|
||||
|
||||
+8
-3
@@ -233,7 +233,9 @@ class LibraryRoutesMixin:
|
||||
self.add_library_negative
|
||||
)
|
||||
|
||||
def _library_media(self, records: list[dict], roots: list[Path]) -> list[dict]:
|
||||
def _library_media(
|
||||
self, records: list[dict], roots: list[Path], *, linked: bool = True
|
||||
) -> list[dict]:
|
||||
page = []
|
||||
for record in records:
|
||||
try:
|
||||
@@ -251,7 +253,7 @@ class LibraryRoutesMixin:
|
||||
break
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
continue
|
||||
return self._format_workspace_page(page)
|
||||
return self._format_workspace_page(page, linked=linked)
|
||||
|
||||
@pair_response
|
||||
async def get_library_options(self, request: web.Request) -> web.Response:
|
||||
@@ -296,7 +298,10 @@ class LibraryRoutesMixin:
|
||||
|
||||
def previews() -> None:
|
||||
for parent in result["items"]:
|
||||
parent["previews"] = self._library_media(records[parent["id"]], roots)
|
||||
# Previews belong to their parent; a per-parent link read is N+1.
|
||||
parent["previews"] = self._library_media(
|
||||
records[parent["id"]], roots, linked=False
|
||||
)
|
||||
|
||||
await self._run_in_executor(previews)
|
||||
return web.json_response({"success": True, **result})
|
||||
|
||||
+43
-5
@@ -22,6 +22,29 @@ MAX_PAGE_OFFSET = 10_000_000
|
||||
# for one giant result set (and silently truncating large libraries).
|
||||
EXPORT_PAGE_SIZE = 1000
|
||||
|
||||
# The most prompts one bulk category request may change.
|
||||
BULK_CATEGORY_MAX_PROMPTS = 1000
|
||||
|
||||
# The largest value SQLite's INTEGER can bind; a larger id raises OverflowError.
|
||||
MAX_SQLITE_INT = 2**63 - 1
|
||||
|
||||
|
||||
def parse_bulk_prompt_ids(value, cap=BULK_CATEGORY_MAX_PROMPTS):
|
||||
"""A bulk request's ``prompt_ids``: distinct positive ints, first-seen order.
|
||||
|
||||
Raises ``ValueError`` unless ``value`` is a non-empty list of at most
|
||||
``cap`` distinct positive integers no larger than SQLite's INTEGER
|
||||
(booleans are not integers here).
|
||||
"""
|
||||
if not isinstance(value, list) or not value:
|
||||
raise ValueError("prompt_ids must be a non-empty list")
|
||||
if any(type(pid) is not int or not 1 <= pid <= MAX_SQLITE_INT for pid in value):
|
||||
raise ValueError("prompt_ids must be positive integers")
|
||||
ids = list(dict.fromkeys(value))
|
||||
if len(ids) > cap:
|
||||
raise ValueError(f"At most {cap} prompts can be changed at once")
|
||||
return ids
|
||||
|
||||
|
||||
def parse_page_params(query, default_limit=50, max_limit=MAX_PAGE_LIMIT):
|
||||
"""Return ``(limit, offset)`` from a query mapping, clamped to safe bounds.
|
||||
@@ -1145,14 +1168,29 @@ class PromptRoutesMixin:
|
||||
async def bulk_set_category(self, request):
|
||||
"""Bulk set category for prompts."""
|
||||
try:
|
||||
data = await request.json()
|
||||
prompt_ids = data.get("prompt_ids", [])
|
||||
category = (data.get("category") or "").strip()
|
||||
|
||||
if not prompt_ids:
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Expected a JSON object"}, status=400
|
||||
)
|
||||
if not data.get("prompt_ids"):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "No prompt IDs provided"}, status=400
|
||||
)
|
||||
category = data.get("category")
|
||||
try:
|
||||
prompt_ids = parse_bulk_prompt_ids(data["prompt_ids"])
|
||||
validate_category(category)
|
||||
except ValueError as ve:
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(ve)}, status=400
|
||||
)
|
||||
category = (category or "").strip()
|
||||
|
||||
updated_count = await self._run_in_executor(
|
||||
self.db.bulk_set_category, prompt_ids, category
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
// Shared concept-vs-production inventory for the mock-true rebuild (#249, spec §7). One in-page measurer runs the
|
||||
// same concept selectors in the immutable concept (/__test__/canonical/workspace) and in production, which now renders
|
||||
// the concept's classes. The concept is never rewritten: each page's map<Page>Concept(reference) applies only the
|
||||
// recorded spec-§8 deviations, each commented with its D-number, and diffInventory must then report nothing.
|
||||
import {expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
export {diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
export const CONCEPT_URL='/__test__/canonical/workspace';
|
||||
export const PRODUCTION_URL='/prompt_manager/workspace';
|
||||
export const SIZES=Object.freeze([[1600,1100],[390,844]]);
|
||||
export const THEMES=Object.freeze(['dark','light']);
|
||||
export const MOTIONS=Object.freeze(['no-preference','reduce']);
|
||||
|
||||
/** D35: production uses the singular for exactly one; the concept's plurals are hard-coded (app.js 447, 489, 517-521). */
|
||||
export const countCopy=text=>text.replace(/(^|\D)1 positive prompts\b/g,'$11 positive prompt')
|
||||
.replace(/(^|\D)1 more images\b/g,'$11 more image').replace(/(^|\D)1 images\b/g,'$11 image');
|
||||
|
||||
/** Seed the concept-shaped real fixture (tests/browser/workspace_concept_fixture.py) and return its manifest. */
|
||||
export async function seedConcept(request,data={}) {
|
||||
const response=await request.post('/__test__/concept/reset',{data});
|
||||
await expect(response).toBeOK();
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function selectTheme(page,theme) {
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
}
|
||||
|
||||
/** The concept at `#<route>` with its own Theme select. */
|
||||
export async function openConcept(page,route,theme,motion='no-preference') {
|
||||
await page.emulateMedia({reducedMotion:motion});
|
||||
await page.goto('about:blank');await page.goto(`${CONCEPT_URL}#${route}`);
|
||||
await selectTheme(page,theme);
|
||||
await expect(page.locator('#main #page-title')).toBeVisible();
|
||||
}
|
||||
|
||||
/** Production at `#/<route>` with the D4 Theme select, once the sidebar's real counts (D27) have settled. */
|
||||
export async function openProduction(page,route,theme,motion='no-preference') {
|
||||
await page.emulateMedia({reducedMotion:motion});
|
||||
await page.goto('about:blank');await page.goto(`${PRODUCTION_URL}#/${route}`);
|
||||
await selectTheme(page,theme);
|
||||
await expect(page.locator('#workspace-main #page-title')).toBeVisible();
|
||||
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3);
|
||||
}
|
||||
|
||||
/** Computed outline of the focused element, after keyboard focus so `:focus-visible` applies. */
|
||||
export async function focusRingOf(locator) {
|
||||
return locator.evaluate(node=>{const style=getComputedStyle(node);return {focused:document.activeElement===node,
|
||||
visible:node.matches(':focus-visible'),outlineStyle:style.outlineStyle,outlineWidth:style.outlineWidth,
|
||||
outlineColor:style.outlineColor,outlineOffset:style.outlineOffset};});
|
||||
}
|
||||
|
||||
/** Computed colors of an element while the pointer rests on it; the pointer is parked again afterwards. */
|
||||
export async function hoverStateOf(page,locator) {
|
||||
await locator.hover();
|
||||
const state=await locator.evaluate(node=>{const style=getComputedStyle(node);return {hovered:node.matches(':hover'),
|
||||
color:style.color,backgroundColor:style.backgroundColor,borderTopColor:style.borderTopColor,
|
||||
textDecorationLine:style.textDecorationLine,boxShadow:style.boxShadow};});
|
||||
await page.mouse.move(0,0);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutate the open page so a comparison can be shown to notice a change (the A7 mutation check): `css` is appended as
|
||||
* the last stylesheet, so it wins over equally specific concept rules, and every element matching a `remove` selector
|
||||
* is detached. Returns an async undo that restores the stylesheet and the removed elements in place.
|
||||
*/
|
||||
export async function mutate(page,{css=null,remove=[]}={}) {
|
||||
const token=await page.evaluate(({css,remove})=>{
|
||||
const token=`mutation-${Math.random().toString(36).slice(2)}`;
|
||||
const undo=[];
|
||||
if(css) {
|
||||
const style=document.createElement('style');style.dataset.mutation=token;style.textContent=css;
|
||||
document.head.append(style);undo.push(()=>style.remove());
|
||||
}
|
||||
for(const selector of remove) {
|
||||
const nodes=[...document.querySelectorAll(selector)];
|
||||
if(!nodes.length)throw new Error(`mutate: nothing matches ${selector}`);
|
||||
for(const node of nodes) {
|
||||
const parent=node.parentNode,next=node.nextSibling;
|
||||
node.remove();undo.push(()=>parent.insertBefore(node,next));
|
||||
}
|
||||
}
|
||||
(window.__conceptMutations??=new Map()).set(token,undo);
|
||||
return token;
|
||||
},{css,remove});
|
||||
return ()=>page.evaluate(token=>{
|
||||
const undo=window.__conceptMutations?.get(token);
|
||||
if(!undo)throw new Error('mutate: this mutation was already undone or the page reloaded');
|
||||
undo.reverse().forEach(step=>step());window.__conceptMutations.delete(token);
|
||||
},token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* measure the concept's Images markup; `cards` holds the first six; `bulkbar` is the selection bar's place and, while
|
||||
* anything is checked, its `.bulkbar` row. Library and history stay null until PRs E and F.
|
||||
*/
|
||||
export function inventoryOf(page,scope='main') {
|
||||
return page.evaluate(measureInventory,scope);
|
||||
}
|
||||
|
||||
/** The inventory without its `exact` (unrounded) helper values, which only a re-layout reads; compare after this. */
|
||||
export function stripExact(value) {
|
||||
if(Array.isArray(value))return value.map(stripExact);
|
||||
if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).filter(([key])=>key!=='exact').map(([key,item])=>[key,stripExact(item)]));
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Runs in the page; self-contained. `scope` selects the page's main element. */
|
||||
export function measureInventory(scope) {
|
||||
const px=value=>Math.round(value);
|
||||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const shown=node=>Boolean(node)&&node.getClientRects().length>0&&getComputedStyle(node).visibility!=='hidden';
|
||||
const rect=node=>node.getBoundingClientRect();
|
||||
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color','textTransform');
|
||||
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginTop','marginRight','marginBottom','marginLeft',
|
||||
'borderTopWidth','borderTopStyle','borderTopColor','borderRightWidth','borderRightStyle','borderRightColor',
|
||||
'borderBottomWidth','borderBottomStyle','borderBottomColor','borderTopLeftRadius','backgroundColor');
|
||||
const layout=node=>css(node,'display','flexDirection','flexWrap','justifyContent','alignItems','columnGap','rowGap','position');
|
||||
// Text of a node without its descendants matching `skip` (counts, nested controls), whitespace collapsed.
|
||||
const textOf=(node,skip)=>{const copy=node.cloneNode(true);if(skip)copy.querySelectorAll(skip).forEach(item=>item.remove());
|
||||
return copy.textContent.replace(/\s+/g,' ').trim();};
|
||||
const relative=(node,origin)=>{const a=rect(node),b=rect(origin);return {left:px(a.left-b.left),top:px(a.top-b.top),width:px(a.width),height:px(a.height)};};
|
||||
const main=document.querySelector(scope);
|
||||
const sidebar=document.querySelector('aside.sidebar');
|
||||
const workspace=document.querySelector('.workspace');
|
||||
|
||||
function iconOf(node) {
|
||||
const svg=node.querySelector(':scope > svg.icon');
|
||||
if(!svg)return null;
|
||||
const box=rect(svg);
|
||||
return {width:px(box.width),height:px(box.height),viewBox:svg.getAttribute('viewBox'),hidden:svg.getAttribute('aria-hidden'),
|
||||
d:svg.querySelector('path')?.getAttribute('d')??null,...css(svg,'strokeWidth','color','verticalAlign','flexShrink')};
|
||||
}
|
||||
function navRow(link,index,links) {
|
||||
const box=rect(link),count=link.querySelector('.nav-count'),icon=link.querySelector(':scope > svg.icon');
|
||||
const style=getComputedStyle(link);
|
||||
const content=(icon?rect(icon).width+parseFloat(style.columnGap||0):0)+(()=>{
|
||||
const range=document.createRange();const text=[...link.childNodes].find(item=>item.nodeType===Node.TEXT_NODE&&item.textContent.trim());
|
||||
if(!text)return 0;range.selectNodeContents(text);return range.getBoundingClientRect().width;})();
|
||||
const next=links[index+1];let gapAfter=null;
|
||||
if(next){const other=rect(next);gapAfter=px(other.top)===px(box.top)?{axis:'x',size:px(other.left-box.right)}:{axis:'y',size:px(other.top-box.bottom)};}
|
||||
return {text:textOf(link,'.nav-count'),current:link.getAttribute('aria-current'),
|
||||
...layout(link),...edges(link),...font(link),minHeight:style.minHeight,height:px(box.height),
|
||||
fullWidth:px(box.width)===px(rect(link.parentElement).width),
|
||||
hugs:Math.abs(box.width-content-parseFloat(style.paddingLeft)-parseFloat(style.paddingRight))<=1.5,
|
||||
gapAfter,icon:iconOf(link),
|
||||
count:count?{text:count.textContent,shown:shown(count),...font(count),...css(count,'marginInlineStart')}:null};
|
||||
}
|
||||
// Every subsection is null when its element is missing, so a removed node is reported by its path
|
||||
// (for example `chrome.breadcrumbs`) instead of failing the measurement.
|
||||
const when=(node,measure)=>node?measure(node):null;
|
||||
// The accessible text of a node: its text without aria-hidden descendants.
|
||||
const accessibleText=node=>textOf(node,'[aria-hidden="true"]');
|
||||
function itemOf(node,actions) {
|
||||
const base={tag:node.tagName.toLowerCase(),className:node.className,shown:shown(node)};
|
||||
if(!base.shown)return base;
|
||||
const box=relative(node,actions);
|
||||
return {...base,text:textOf(node,'select, .count'),...font(node),...edges(node),...layout(node),
|
||||
left:box.left,top:box.top,width:box.width,height:box.height,
|
||||
count:node.querySelector('.count')?.textContent??null,
|
||||
select:when(node.querySelector('select'),select=>({options:[...select.options].map(option=>[option.value,option.textContent]),
|
||||
label:select.getAttribute('aria-label'),labelled:select.labels?.[0]===node,...font(select),...edges(select),
|
||||
minHeight:getComputedStyle(select).minHeight,width:px(rect(select).width),height:px(rect(select).height)})),
|
||||
hiddenSuffix:[...node.querySelectorAll('[aria-hidden="true"]')].map(child=>child.textContent).join('')||null,
|
||||
whiteSpace:getComputedStyle(node).whiteSpace};
|
||||
}
|
||||
function sidebarOf() {
|
||||
return when(sidebar,()=>({...layout(sidebar),...edges(sidebar),
|
||||
...css(sidebar,'top','overflowY','borderInlineEndWidth'),width:px(rect(sidebar).width),
|
||||
fillsViewport:Math.abs(rect(sidebar).height-innerHeight)<=1}));
|
||||
}
|
||||
function bottomOf(bottom) {
|
||||
// In the column sidebar the bottom group is pinned to the end by `margin-top: auto`, whose used value is only
|
||||
// the free space above it, so the pin (its inset from the sidebar's padding edge) is recorded instead.
|
||||
const sidebarStyle=getComputedStyle(sidebar);
|
||||
const pinned=sidebarStyle.display==='flex';
|
||||
const {marginTop,...rest}=edges(bottom);
|
||||
return {...rest,...css(bottom,'display'),marginTop:pinned?'auto':marginTop,
|
||||
bottomInset:pinned?px(rect(sidebar).bottom-rect(bottom).bottom-parseFloat(sidebarStyle.paddingBottom)):null};
|
||||
}
|
||||
function topActionsOf(actions,topbar) {
|
||||
const style=getComputedStyle(topbar);
|
||||
return {...layout(actions),...edges(actions),width:px(rect(actions).width),height:px(rect(actions).height),
|
||||
fullWidth:Math.abs(rect(actions).width-(rect(topbar).width-parseFloat(style.paddingLeft)-parseFloat(style.paddingRight)))<=1,
|
||||
rightInset:px(rect(topbar).right-rect(actions).right),
|
||||
items:[...actions.children].map(node=>itemOf(node,actions))};
|
||||
}
|
||||
function chromeOf() {
|
||||
if(!sidebar&&!workspace)return null;
|
||||
const shell=document.querySelector('.app-shell');
|
||||
const brand=sidebar?.querySelector('.brand');
|
||||
const topbar=workspace?.querySelector('.topbar');
|
||||
const footer=workspace?.querySelector('.app-footer');
|
||||
return {
|
||||
title:document.title,
|
||||
skip:when(document.querySelector('a.skip'),skip=>({text:skip.textContent.trim(),...css(skip,'position','top','left')})),
|
||||
body:{...font(document.body),backgroundColor:getComputedStyle(document.body).backgroundColor,
|
||||
tokens:Object.fromEntries(['--ca-text','--ca-muted','--ca-focus','--ca-accent','--ca-field','--ca-surface','--ca-line',
|
||||
'--line','--page','--sidebar','--soft-blue','--green'].map(name=>[name,getComputedStyle(document.body).getPropertyValue(name).trim()]))},
|
||||
shell:when(shell,()=>{const columns=getComputedStyle(shell).gridTemplateColumns.split(' ');
|
||||
return {display:getComputedStyle(shell).display,columns:columns.length,sidebarColumn:columns.length>1?columns[0]:null,
|
||||
minHeight:getComputedStyle(shell).minHeight};}),
|
||||
sidebar:sidebarOf(),
|
||||
brand:when(brand,()=>({text:textOf(brand),name:accessibleText(brand),href:brand.getAttribute('href')?.replace(/^#\/?/,''),
|
||||
...relative(brand,sidebar),...font(brand),...layout(brand),...edges(brand),
|
||||
mark:when(brand.querySelector('.brand-mark'),mark=>({text:textOf(mark),hidden:mark.getAttribute('aria-hidden'),...font(mark),
|
||||
width:px(rect(mark).width),height:px(rect(mark).height)})),
|
||||
small:when(brand.querySelector('small'),small=>({text:small.textContent,...font(small),...css(small,'display','marginTop')}))})),
|
||||
labels:sidebar?[...sidebar.querySelectorAll('.sidebar-label')].map(node=>({text:textOf(node),shown:shown(node),...font(node),...layout(node),...edges(node)})):null,
|
||||
navs:sidebar?[...sidebar.querySelectorAll('nav')].map(nav=>{const links=[...nav.querySelectorAll(':scope > a')];return {
|
||||
id:nav.id,label:nav.getAttribute('aria-label'),shown:shown(nav),...layout(nav),...edges(nav),
|
||||
width:px(rect(nav).width),rows:links.map(navRow)};}):null,
|
||||
bottom:when(sidebar?.querySelector('.sidebar-bottom'),bottomOf),
|
||||
connection:when(sidebar?.querySelector('.connection'),connection=>({shown:shown(connection),text:textOf(connection,'small'),
|
||||
small:connection.querySelector('small')?.textContent??null,...layout(connection),...edges(connection),...font(connection),
|
||||
smallFont:when(connection.querySelector('small'),font),
|
||||
dot:when(connection.querySelector('i'),dot=>css(dot,'width','height','borderTopLeftRadius','backgroundColor','marginTop'))})),
|
||||
topbar:when(topbar,()=>({...layout(topbar),...edges(topbar),...css(topbar,'height','minHeight'),...relative(topbar,workspace),
|
||||
fullWidth:px(rect(topbar).width)===px(rect(workspace).width)})),
|
||||
breadcrumbs:when(topbar?.querySelector('.breadcrumbs'),crumbs=>({shown:shown(crumbs),text:textOf(crumbs),...layout(crumbs),...font(crumbs),
|
||||
parts:[...crumbs.querySelectorAll('span')].map(span=>({text:span.textContent,...font(span)})),
|
||||
...(shown(crumbs)?{left:relative(crumbs,topbar).left,middle:px(rect(crumbs).top+rect(crumbs).height/2-rect(topbar).top)}:{})})),
|
||||
topActions:when(topbar?.querySelector('.top-actions'),actions=>topActionsOf(actions,topbar)),
|
||||
main:when(main,()=>({tag:main.tagName.toLowerCase(),tabIndex:main.tabIndex,
|
||||
...css(main,'paddingTop','paddingRight','paddingBottom','paddingLeft','maxWidth','marginLeft','marginRight')})),
|
||||
footer:when(footer,()=>({...layout(footer),...edges(footer),...font(footer),...css(footer,'height'),width:px(rect(footer).width),
|
||||
fullWidth:px(rect(footer).width)===px(rect(workspace).width),
|
||||
spans:[...footer.querySelectorAll(':scope > span')].map(span=>({text:span.textContent.replace(/\s+/g,' ').trim(),...font(span),height:px(rect(span).height)})),
|
||||
buttons:[...footer.querySelectorAll('button')].map(button=>({text:button.textContent.trim(),...font(button),...edges(button),
|
||||
minHeight:getComputedStyle(button).minHeight,height:px(rect(button).height)}))})),
|
||||
};
|
||||
}
|
||||
function headingOf() {
|
||||
const heading=main?.querySelector(':scope > .heading');
|
||||
if(!heading)return null;
|
||||
const copy=heading.firstElementChild,h1=copy?.querySelector('h1'),description=copy?.querySelector('p'),actions=heading.querySelector('.heading-actions');
|
||||
return {...layout(heading),...edges(heading),height:px(rect(heading).height),
|
||||
copy:when(copy,()=>({tag:copy.tagName.toLowerCase(),...layout(copy)})),
|
||||
h1:when(h1,()=>({id:h1.id,tabIndex:h1.tabIndex,text:h1.textContent,...font(h1),...edges(h1)})),
|
||||
description:when(description,()=>({className:description.className,text:description.textContent,...font(description),...edges(description)})),
|
||||
actions:when(actions,()=>({...layout(actions),...edges(actions),
|
||||
fullWidth:px(rect(actions).width)===px(rect(heading).width),stacked:copy?rect(actions).top>=rect(copy).bottom-0.5:null,
|
||||
buttons:[...actions.querySelectorAll('button')].map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'),
|
||||
...font(node),...edges(node),minHeight:getComputedStyle(node).minHeight,height:px(rect(node).height)}))}))};
|
||||
}
|
||||
// ---- Images (#249 C7). Every box is relative to its section, every section to `main` (vertical rhythm). ----
|
||||
const classes=node=>[...node.classList].sort().join(' ');
|
||||
const sized=node=>({width:px(rect(node).width),height:px(rect(node).height)});
|
||||
/** One control or label in a toolbar-like row: what it is, says and offers, and its box in `origin`. */
|
||||
function controlOf(node,origin) {
|
||||
const base={tag:node.tagName.toLowerCase(),className:classes(node),id:node.id||null,shown:shown(node)};
|
||||
if(!base.shown)return base;
|
||||
const style=getComputedStyle(node),input=node.querySelector('input');
|
||||
// A group (the layout `.segmented`) is recorded button by button: its joined text says nothing about either.
|
||||
const buttons=[...node.querySelectorAll(':scope > button')];
|
||||
return {...base,text:buttons.length?null:textOf(node,'select, option'),label:node.getAttribute('aria-label'),pressed:node.getAttribute('aria-pressed'),
|
||||
role:buttons.length?node.getAttribute('role'):null,buttons:buttons.length?buttons.map(button=>controlOf(button,node)):null,
|
||||
primary:node.classList.contains('ca-primary'),htmlFor:node.htmlFor||null,...relative(node,origin),
|
||||
...font(node),...edges(node),...layout(node),minHeight:style.minHeight,minWidth:style.minWidth,flexGrow:style.flexGrow,flexBasis:style.flexBasis,
|
||||
icon:iconOf(node),
|
||||
options:node.tagName==='SELECT'?[...node.options].map(option=>option.textContent):null,
|
||||
input:when(input,()=>({type:input.type,id:input.id,placeholder:input.placeholder,label:input.getAttribute('aria-label'),
|
||||
...font(input),...edges(input),minHeight:getComputedStyle(input).minHeight,...relative(input,node),exact:{width:rect(input).width}})),
|
||||
// Unrounded place, for a comparison that lays a row out again; `stripExact` removes it before any diff.
|
||||
exact:{left:rect(node).left-rect(origin).left,width:rect(node).width}};
|
||||
}
|
||||
function rowOf(row) {
|
||||
return {...layout(row),...edges(row),...relative(row,main),exact:{width:rect(row).width},items:[...row.children].map(node=>controlOf(node,row))};
|
||||
}
|
||||
const gallery=main?.querySelector(':scope > .gallery-layout');
|
||||
const body=gallery?.querySelector(':scope > .gallery-body');
|
||||
const grid=body?.querySelector(':scope > .image-grid');
|
||||
function captionOf(caption) {
|
||||
return {...layout(caption),...edges(caption),...font(caption),...relative(caption,body),
|
||||
// A span's width follows its text, so only its line box is recorded (the D17 date's text differs).
|
||||
spans:[...caption.children].filter(shown).map(span=>({tag:span.tagName.toLowerCase(),id:span.id||null,text:textOf(span),...font(span),
|
||||
top:relative(span,caption).top,height:relative(span,caption).height}))};
|
||||
}
|
||||
function gridOf() {
|
||||
const style=getComputedStyle(grid);
|
||||
return {id:grid.id||null,className:classes(grid),columns:style.gridTemplateColumns.split(/\s+/).length,
|
||||
...css(grid,'columnGap','rowGap'),...relative(grid,body),cardCount:grid.querySelectorAll(':scope > .media-card').length};
|
||||
}
|
||||
function cardOf(card) {
|
||||
const art=card.querySelector(':scope > .media-art'),open=art?.querySelector('.image-open'),img=open?.querySelector('img');
|
||||
const overlays=art?.querySelector('.image-overlays'),info=card.querySelector(':scope > .media-info');
|
||||
const copy=info?.querySelector(':scope > div:not(.image-card-actions)'),actions=info?.querySelector('.image-card-actions');
|
||||
const h3=copy?.querySelector('h3'),p=copy?.querySelector('p');
|
||||
return {className:classes(card),ratio:card.style.getPropertyValue('--media-ratio').replace(/\s/g,''),...relative(card,grid),
|
||||
exact:{left:rect(card).left-rect(grid).left,top:rect(card).top-rect(grid).top,height:rect(card).height},
|
||||
...edges(card),...css(card,'boxShadow','overflow','display'),
|
||||
art:when(art,()=>({...relative(art,card),...css(art,'position')})),
|
||||
open:when(open,()=>({label:open.getAttribute('aria-label'),...relative(open,card),...edges(open),...css(open,'display','overflow')})),
|
||||
img:when(img,()=>({alt:img.alt,width:img.getAttribute('width'),height:img.getAttribute('height'),...sized(img),
|
||||
...css(img,'objectFit','aspectRatio','transform','transitionProperty','transitionDuration')})),
|
||||
// An overlay item's horizontal margin may be `auto`, whose used value is the free space beside it: it is kept
|
||||
// to the pixel, and the item's place is also recorded as its inset from the overlay's right edge.
|
||||
overlays:when(overlays,()=>!shown(overlays)?{display:getComputedStyle(overlays).display,shown:false}:({shown:true,...layout(overlays),
|
||||
...css(overlays,'display','pointerEvents'),...relative(overlays,card),
|
||||
items:[...overlays.children].map(node=>{const item=controlOf(node,card);
|
||||
return item.shown?{...item,marginLeft:`${px(parseFloat(item.marginLeft))}px`,marginRight:`${px(parseFloat(item.marginRight))}px`,
|
||||
rightInset:px(rect(overlays).right-rect(node).right)}:item;})})),
|
||||
info:when(info,()=>({...layout(info),...edges(info),...relative(info,card),
|
||||
// The text and the actions are placed in the card (what is seen), so a centred list row compares by place.
|
||||
// The text's heights are the unrounded flow (the same CSS lays them out alike in both documents), so lines
|
||||
// of 16.5px and 13.5px add up exactly.
|
||||
copy:when(copy,()=>({left:relative(copy,card).left,top:relative(copy,card).top,height:rect(copy).height,...css(copy,'minWidth')})),
|
||||
h3:when(h3,()=>({text:h3.textContent,title:h3.getAttribute('title'),...font(h3),...edges(h3),
|
||||
...css(h3,'overflowX','textOverflow','whiteSpace'),height:rect(h3).height})),
|
||||
// `maxContent` is the subtitle's one-line width (an unwrapped hidden copy beside it), so a wider text column can
|
||||
// be shown to fit it on one line.
|
||||
p:when(p,()=>({text:p.textContent,...font(p),...edges(p),height:rect(p).height,maxContent:(()=>{
|
||||
const probe=p.cloneNode(true);probe.style.cssText='position:absolute;visibility:hidden;white-space:nowrap;width:max-content';
|
||||
p.after(probe);const width=rect(probe).width;probe.remove();return px(width);})()})),
|
||||
actions:when(actions,()=>({...layout(actions),...relative(actions,card),...css(actions,'flexShrink','minWidth'),
|
||||
buttons:[...actions.children].map(node=>controlOf(node,actions))}))}))};
|
||||
}
|
||||
function continuationOf(continuation) {
|
||||
return {id:continuation.id||null,...layout(continuation),...edges(continuation),...relative(continuation,body),
|
||||
items:[...continuation.children].filter(shown).map(node=>controlOf(node,continuation))};
|
||||
}
|
||||
function panelOf() {
|
||||
const panel=gallery.children[1];
|
||||
// The panel's content (and so the layout's height) is the inspector's, PR D; its box is compared here.
|
||||
const {height,...place}=relative(gallery,main);
|
||||
return {...css(gallery,'display','gridTemplateColumns','columnGap','rowGap','alignItems'),...place,
|
||||
box:when(panel,()=>{const box=relative(panel,gallery);return {left:box.left,top:box.top,width:box.width,...css(panel,'position','top')};})};
|
||||
}
|
||||
const continuation=body?.querySelector(':scope > .infinite-continuation');
|
||||
function bulkbarOf(bar) {
|
||||
return {id:bar.id||null,...relative(bar,main),children:bar.children.length,bar:when(bar.querySelector(':scope > .bulkbar'),rowOf)};
|
||||
}
|
||||
return {chrome:chromeOf(),heading:headingOf(),
|
||||
toolbar:when(main?.querySelector(':scope > search.toolbar'),rowOf),
|
||||
filterRow:when(main?.querySelector(':scope > .filter-row'),rowOf),
|
||||
bulkbar:when(main?.querySelector(':scope > #bulkbar'),bulkbarOf),
|
||||
caption:when(body?.querySelector(':scope > .section-caption'),captionOf),
|
||||
grid:when(grid,gridOf),
|
||||
cards:grid?[...grid.querySelectorAll(':scope > .media-card')].slice(0,6).map(cardOf):null,
|
||||
continuation:when(continuation,continuationOf),
|
||||
panel:when(gallery,panelOf),
|
||||
library:null,history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { seedConcept, openConcept, openProduction, SIZES } from './concept-inventory.mjs';
|
||||
// D10/D20: the heading is the linked positive's title (its confirmed first line here), never the pair's
|
||||
// "Inspector recipe", which names the negative variant.
|
||||
const POSITIVE_TITLE='Inspector portrait 猫 😀';
|
||||
|
||||
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
|
||||
test.beforeEach(async({page,browserName})=>{await startCoverage(page,browserName);page.inspectorErrors=[];page.on('pageerror',error=>page.inspectorErrors.push(error.message));});
|
||||
@@ -26,7 +30,10 @@ test('inspection facts survive actual API to browser transport', async ({ page,
|
||||
const complete = result.complete;
|
||||
expect(Object.values(complete.generation).map(fact => fact.value)).toEqual(['9007199254740993', '28', '6.5', 'dpmpp_2m', 'karras', 'fixture-model.safetensors']);
|
||||
expect(Object.values(complete.generation).every(fact => fact.source === 'api-graph' && fact.evidence === 'configuration')).toBe(true);
|
||||
expect(Object.values(result.ambiguous.generation).every(fact => fact.status === 'ambiguous' && fact.value === null)).toBe(true);
|
||||
// Differing branches show what the legacy parser shows: the first sampler's seed.
|
||||
expect(Object.values(result.ambiguous.generation).map(fact => [fact.status, fact.value])).toEqual([
|
||||
['known', '9007199254740993'], ['known', '28'], ['known', '6.5'], ['known', 'dpmpp_2m'], ['known', 'karras'], ['known', 'fixture-model.safetensors']]);
|
||||
expect(Object.values(result.ambiguous.generation).every(fact => fact.sampler_id === null)).toBe(true);
|
||||
expect(result['workflow-only'].generation.seed).toMatchObject({ status: 'known', value: '9007199254740993', source: 'workflow', evidence: 'configuration' });
|
||||
expect(result['workflow-only'].prompts.positive.source).toBe('saved-association');
|
||||
expect(Object.values(result['control-heavy'].generation).every(fact => fact.status === 'unresolved' && fact.value === null && fact.reason === 'projection-limited')).toBe(true);
|
||||
@@ -68,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: 'Inspector recipe', category: 'Inspection', tags: ['fixture', 'geometric'] });
|
||||
expect(result.library).toMatchObject({ status: 'linked', metadata_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);
|
||||
@@ -83,7 +90,7 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
|
||||
});
|
||||
|
||||
const inspector=page=>page.locator('.image-details');
|
||||
const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-open`).click();
|
||||
const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-card-actions button`).click();
|
||||
async function fullFixture(page,request) {
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
|
||||
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'complete.png');
|
||||
@@ -95,7 +102,7 @@ async function downloadText(page,action) {
|
||||
|
||||
test('full inspector shows six facts and scoped provenance',async({page,request},testInfo)=>{
|
||||
await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);const panel=inspector(page);
|
||||
await expect(panel.getByRole('heading',{name:'Inspector recipe',exact:true})).toBeVisible();await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.locator('.file-name')).toHaveText('complete.png');
|
||||
await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.locator('.file-name')).toHaveText('complete.png');
|
||||
await expect(panel.locator('.file-evidence')).toHaveText('Workflow prompt evidence');
|
||||
expect(await panel.evaluate(node=>node.getBoundingClientRect().width)).toBe(310);
|
||||
expect(await panel.locator('.file-inspector-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px');
|
||||
@@ -171,7 +178,7 @@ test('full inspector labels a limited workflow record without hiding prompt fact
|
||||
test('full inspector rejects stale shared action state',async({page,request})=>{
|
||||
let reads=0,stale=false;await page.route('**/images/inspect/**',route=>{reads++;return stale?route.fulfill({status:409,json:{success:false,code:'source-changed',error:'Source changed'}}):route.continue();});
|
||||
await fullFixture(page,request);const panel=inspector(page);await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();const before=reads;
|
||||
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const preview=page.getByRole('dialog',{name:'Image preview',exact:true});await expect(preview.locator('.generation-facts dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close preview',exact:true}).click();
|
||||
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const preview=page.locator('dialog#viewer-dialog');await expect(preview.locator('.generation-facts dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
stale=true;await selectFile(page,'complete.png',1);await expect(panel).toContainText('The source changed. Refresh the image collection before inspecting this file.');
|
||||
await expect(panel.getByRole('button',{name:/Retry/})).toHaveCount(0);await expect(panel.locator('[data-copy-role],[data-send-role],a[download]')).toHaveCount(0);await expect(panel.getByRole('button',{name:/Download/})).toHaveCount(0);
|
||||
stale=false;const refreshed=page.waitForResponse(response=>response.url().includes('/images/output?'));await panel.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await selectFile(page,'complete.png',1);await expect(panel).toContainText('Input collision');
|
||||
@@ -191,7 +198,7 @@ test('full inspector keeps long literal content reachable on mobile',async({page
|
||||
|
||||
test('embedded gallery uses the same full inspector',async({page,request})=>{
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
|
||||
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:'Inspector recipe',exact:true})).toBeVisible();await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel).toContainText('Recipe tags');await panel.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('.image-preview .file-inspector')).toHaveCount(1);
|
||||
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel).toContainText('Recipe tags');await panel.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('full inspector labels container representations and releases action URLs',async({page,request})=>{
|
||||
@@ -224,7 +231,7 @@ test('full inspector labels container representations and releases action URLs',
|
||||
test('full inspector distinguishes missing, limited and legacy unknown generation facts',async({page,request})=>{
|
||||
await fullFixture(page,request);const panel=inspector(page);
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-open').click();await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button').click();await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await selectFile(page,'control-heavy.png');
|
||||
const explanation='Configuration exceeds inspection limits; download the original file to inspect it locally.';
|
||||
await expect(panel.getByText(explanation,{exact:true})).toHaveCount(1);await expect(panel.getByText(explanation,{exact:true})).toBeVisible();
|
||||
@@ -259,9 +266,9 @@ test.describe('full inspector coarse pointer',()=>{
|
||||
}
|
||||
await expect(panel.locator('.prompt-positive pre')).toHaveText(fullText);
|
||||
}
|
||||
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});
|
||||
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.locator('dialog#viewer-dialog');
|
||||
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
|
||||
for(const target of await viewer.locator('.image-preview-controls > :is(button,a),.image-preview-header > button').all()) {
|
||||
for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-header > button').all()) {
|
||||
const box=await target.evaluate(node=>{const {width,height}=node.getBoundingClientRect();return {width,height,name:node.getAttribute('aria-label')};});
|
||||
expect(box.width,box.name).toBeGreaterThanOrEqual(80);expect(box.height,box.name).toBeGreaterThanOrEqual(80);
|
||||
}
|
||||
@@ -269,55 +276,130 @@ test.describe('full inspector coarse pointer',()=>{
|
||||
});
|
||||
});
|
||||
|
||||
const mediaDialog=page=>page.getByRole('dialog',{name:'Image preview',exact:true});
|
||||
const mediaDialog=page=>page.locator('dialog#viewer-dialog');
|
||||
const mediaStage=page=>mediaDialog(page).getByRole('region',{name:'Media preview',exact:true});
|
||||
const position=page=>mediaDialog(page).locator('.image-preview-position');
|
||||
const position=page=>mediaDialog(page).locator('.media-position');
|
||||
async function openCollection(page) {
|
||||
await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
|
||||
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
}
|
||||
|
||||
// #249 C6: the concept viewer shell (h84–103, a889–926, s1008–1058, ≤700 s1456–1474), measured by one function in the
|
||||
// concept and in production. #viewer-stage and #viewer-details content is not part of the shell (C8 owns the details).
|
||||
const VIEWER_SHELL=()=>{
|
||||
const dialog=document.querySelector('dialog#viewer-dialog');
|
||||
const describe=node=>`${node.tagName.toLowerCase()}${node.id?`#${node.id}`:''}${[...node.classList].sort().map(value=>`.${value}`).join('')}`;
|
||||
const paths=[];
|
||||
const walk=(node,prefix)=>{for(const child of node.children){const path=`${prefix} > ${describe(child)}`;paths.push(path);if(!child.matches('#viewer-stage,#viewer-toolbar,#viewer-details'))walk(child,path);}};
|
||||
walk(dialog,describe(dialog));
|
||||
const box=node=>{const bounds=node.getBoundingClientRect();return [bounds.x,bounds.y,bounds.width,bounds.height].map(Math.round);};
|
||||
const toolbar=dialog.querySelector('#viewer-toolbar');
|
||||
const part=selector=>dialog.querySelector(selector);
|
||||
return {open:dialog.open,labelledBy:dialog.getAttribute('aria-labelledby'),paths,
|
||||
title:part('#viewer-title').textContent.trim(),subtitle:part('.dialog-header > div > p').textContent.trim(),
|
||||
close:{text:part('#viewer-close').textContent.trim(),label:part('#viewer-close').getAttribute('aria-label')},
|
||||
details:part('#viewer-details').getAttribute('aria-label'),
|
||||
toolbar:[...toolbar.children].map(node=>({tag:node.tagName.toLowerCase(),text:node.textContent.trim(),size:box(node).slice(2)})),
|
||||
geometry:Object.fromEntries(['dialog','.dialog-header','#viewer-title','#viewer-close','.viewer-layout','.viewer-left','#viewer-details']
|
||||
.map(selector=>[selector,box(selector==='dialog'?dialog:part(selector))])),
|
||||
toolbarBox:box(toolbar),stageBox:box(part('#viewer-stage'))};
|
||||
};
|
||||
|
||||
test('viewer matches the concept shell and toolbar order',async({page,request})=>{
|
||||
await seedConcept(request);
|
||||
for(const [width,height] of SIZES) {
|
||||
await page.setViewportSize({width,height});
|
||||
await openConcept(page,'images','dark');
|
||||
const card=page.locator('#image-grid > article.media-card').nth(1).locator('.image-open');
|
||||
const filename=(await card.getAttribute('aria-label')).replace(/^Open /,'');
|
||||
await card.click();await expect(page.locator('#viewer-dialog .viewer-canvas img')).toBeVisible();
|
||||
const concept=await page.evaluate(VIEWER_SHELL);
|
||||
expect(concept.toolbar.map(control=>control.text)).toEqual(['← Previous','−','+','Fit','Rotate','Flip','Play','Download','Next →']);
|
||||
await openProduction(page,'images','dark');
|
||||
const opener=page.locator(`article[data-image-key] .image-open[aria-label="Open ${filename}"]`);await opener.click();
|
||||
const viewer=page.locator('dialog#viewer-dialog');
|
||||
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();await expect(viewer.locator('#viewer-title')).toHaveText(concept.title);
|
||||
await expect(viewer.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
// The dialog is named by its title, as the concept's aria-labelledby does.
|
||||
await expect(page.getByRole('dialog',{name:concept.title,exact:true})).toBeVisible();
|
||||
const production=await page.evaluate(VIEWER_SHELL);
|
||||
// C8 rebuilds the details content; until then the shared file inspector marks the aside it renders into.
|
||||
production.paths=production.paths.map(path=>path.replace('aside#viewer-details.file-inspector','aside#viewer-details'));
|
||||
// #249 D34: after Next → production appends its own status lines (position, then the transform and slideshow
|
||||
// reasons), and Download stays the original-file link; every other control keeps the concept's text and order.
|
||||
const appended=production.toolbar.slice(9);
|
||||
expect(appended.map(control=>control.tag)).toEqual(['p','p','p']);
|
||||
await expect(viewer.locator('#viewer-toolbar > p[role="status"]')).toHaveText(new RegExp(`^\\d+ of \\d+ loaded: ${filename.replace(/[\\^$.*+?()[\]{}|]/g,'\\$&')}$`));
|
||||
expect(production.toolbar[7].tag).toBe('a');
|
||||
production.toolbar=production.toolbar.slice(0,9).map((control,index)=>index===7?{...control,tag:'button'}:control);
|
||||
// #249 D34: Viewer.js's own `.viewer-toolbar` rule makes the concept's toolbar absolute across the dialog's
|
||||
// bottom edge, over the details; production keeps it in `.viewer-left` under the stage.
|
||||
const [leftX,leftY,leftWidth,leftHeight]=production.geometry['.viewer-left'],[barX,barY,barWidth,barHeight]=production.toolbarBox;
|
||||
// Inside the left box's 1px border (boxes are rounded to whole pixels).
|
||||
for(const [actual,expected] of [[barX,leftX+1],[barWidth,leftWidth-2],[barY+barHeight,leftY+leftHeight-1],[production.stageBox[1]+production.stageBox[3],barY]])
|
||||
expect(Math.abs(actual-expected),`${width}x${height} toolbar inside .viewer-left`).toBeLessThanOrEqual(1);
|
||||
expect(concept.toolbarBox[2]).toBeGreaterThan(concept.geometry['.viewer-left'][2]);
|
||||
for(const shell of [concept,production]) {
|
||||
delete shell.toolbarBox;delete shell.stageBox;
|
||||
// At 700px and below the details and the stacked layout grow with the details content, which C8 rebuilds.
|
||||
if(width<=700) for(const selector of ['#viewer-details','.viewer-layout']) shell.geometry[selector]=shell.geometry[selector].slice(0,3);
|
||||
}
|
||||
if(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
|
||||
// #249 D34: coarse pointers keep production's 40px square targets, wider than the concept's ×, "−" and "+" (the title beside × narrows by the difference).
|
||||
for(const shell of [concept,production]) {shell.toolbar=shell.toolbar.map(({tag,text})=>({tag,text}));delete shell.geometry['#viewer-close'];shell.geometry['#viewer-title'].splice(2,1);}
|
||||
expect(production.close).toEqual(concept.close);
|
||||
}
|
||||
expect(production,`${width}x${height}`).toEqual(concept);
|
||||
await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(opener).toBeFocused();
|
||||
}
|
||||
});
|
||||
|
||||
test('collection viewer follows filtered loaded order beyond mounted cards',async({page,request})=>{
|
||||
await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true,extra_images:200}})).toBeOK();
|
||||
await page.goto('/prompt_manager/workspace#/images');
|
||||
await page.getByRole('combobox',{name:'Source',exact:true}).selectOption('0');
|
||||
await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector');
|
||||
await page.getByRole('combobox',{name:'Sort',exact:true}).selectOption('name');
|
||||
// #249 C2: Source and Folder live in the concept's "Refine this view"; sort is the toolbar's.
|
||||
await page.getByRole('button',{name:'Filters',exact:true}).click();
|
||||
const refine=page.getByRole('dialog',{name:'Refine this view',exact:true});
|
||||
await refine.getByLabel('Source',{exact:true}).selectOption('0');await refine.getByLabel('Folder',{exact:true}).selectOption('inspector');
|
||||
await refine.getByRole('button',{name:'Apply filters',exact:true}).click();
|
||||
await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('name');
|
||||
const more=page.getByRole('button',{name:'Load more images',exact:true});
|
||||
await expect(page.locator('.image-toolbar')).toContainText('40 of');
|
||||
for(const n of [80,120,160]) {await more.click();await expect(page.locator('.image-toolbar')).toContainText(`${n} of`);}
|
||||
await expect(page.locator('.section-caption')).toContainText('40 of');
|
||||
for(const n of [80,120,160]) {await more.click();await expect(page.locator('.section-caption')).toContainText(`${n} of`);}
|
||||
const listed=await(await request.get('/prompt_manager/images/output?mode=workspace&root=0&subfolder=inspector&sort=name&limit=200')).json();
|
||||
const loaded=listed.images.slice(0,160);expect(loaded).toHaveLength(160);
|
||||
await page.evaluate(()=>scrollTo(0,0));await expect.poll(()=>page.locator('article[data-image-key]').count()).toBeLessThan(120);
|
||||
let release,started;const held=new Promise(resolve=>{release=resolve;});const hit=new Promise(resolve=>{started=resolve;});
|
||||
await page.route('**/images/output?*',async route=>{started();await held;await route.continue();},{times:1});
|
||||
// A grid page, not the sidebar's `limit=1` Images count.
|
||||
await page.route(url=>url.pathname.endsWith('/images/output') && url.searchParams.get('limit')!=='1',async route=>{started();await held;await route.continue();},{times:1});
|
||||
await more.click();await hit;await page.evaluate(()=>scrollTo(0,0));
|
||||
const opener=page.locator(`article[data-image-key="0:${loaded[0].path}"] .image-card-footer button`);
|
||||
await opener.click();release();await expect(page.locator('.image-toolbar')).toContainText('200 of');
|
||||
const opener=page.locator(`article[data-image-key="0:${loaded[0].path}"] .image-open`);
|
||||
await opener.click();release();await expect(page.locator('.section-caption')).toContainText('200 of');
|
||||
const modal=mediaDialog(page);await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`);
|
||||
let pages=0;page.on('request',req=>{if(req.url().includes('/images/output?'))pages++;});
|
||||
await modal.getByRole('button',{name:'Previous media',exact:true}).click();
|
||||
await expect(position(page)).toHaveText(`160 of 160 loaded: ${loaded[159].filename}`);
|
||||
await expect(modal.locator('.file-name')).toHaveText(loaded[159].filename);
|
||||
await expect(modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',loaded[159].url);
|
||||
await expect(modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',loaded[159].url);
|
||||
await expect(page.locator(`article[data-image-key="0:${loaded[159].path}"]`)).toHaveCount(0);
|
||||
await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`);
|
||||
await mediaStage(page).focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(`2 of 160 loaded: ${loaded[1].filename}`);
|
||||
expect(pages).toBe(0);await modal.getByRole('button',{name:'Close preview',exact:true}).click();
|
||||
expect(pages).toBe(0);await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
await expect(opener).toBeFocused();
|
||||
await page.getByRole('combobox',{name:'Source',exact:true}).selectOption('1');
|
||||
await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector');
|
||||
await page.getByRole('button',{name:'Filters',exact:true}).click();
|
||||
await refine.getByLabel('Source',{exact:true}).selectOption('1');await refine.getByLabel('Folder',{exact:true}).selectOption('inspector');
|
||||
await refine.getByRole('button',{name:'Apply filters',exact:true}).click();
|
||||
await selectFile(page,'complete.png',1);await openCollection(page);await expect(position(page)).toHaveText('1 of 1 loaded: complete.png');
|
||||
for(const name of ['Previous media','Next media'])await expect(mediaDialog(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
const onePlay=mediaDialog(page).getByRole('button',{name:'Play slideshow',exact:true});
|
||||
await expect(onePlay).toHaveAccessibleDescription('Slideshow requires at least two loaded media entries.');await onePlay.dispatchEvent('click');await expect(onePlay).toHaveText('Play');
|
||||
await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click();
|
||||
await mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
const library=await(await request.get('/prompt_manager/library')).json();const id=library.items.find(item=>item.text.startsWith('Inspector portrait')).id;
|
||||
await page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page);
|
||||
await expect(position(page)).toContainText('of 1 loaded: complete.png');
|
||||
await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click();
|
||||
await mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
await selectFile(page,'complete.png');await openCollection(page);await expect(position(page)).toContainText('of 2 loaded:');
|
||||
});
|
||||
@@ -330,8 +412,8 @@ test('collection viewer uses real public transforms and resets on navigation',as
|
||||
for(const method of ['zoom','reset','rotate','scaleX']) {const original=Native.prototype[method];Native.prototype[method]=function(...args){window.viewerCalls.push([method,...args]);return original.apply(this,args);};}
|
||||
window.Viewer=class extends Native {constructor(...args){super(...args);window.viewerInstances.push(this);}};
|
||||
});
|
||||
await openCollection(page);const modal=mediaDialog(page),controls=modal.getByRole('group',{name:'Media controls',exact:true});
|
||||
await expect(modal.locator('.viewer-container')).toHaveCount(1);await expect(modal.locator(':is(.viewer-toolbar,.viewer-navbar,.viewer-title,.viewer-button):visible')).toHaveCount(0);
|
||||
await openCollection(page);const modal=mediaDialog(page),controls=modal.locator('#viewer-toolbar');
|
||||
await expect(modal.locator('.viewer-container')).toHaveCount(1);await expect(modal.locator('.viewer-container :is(.viewer-toolbar,.viewer-navbar,.viewer-title,.viewer-button):visible')).toHaveCount(0);
|
||||
const fitted=await page.evaluate(()=>({...window.viewerInstances.at(-1).imageData}));
|
||||
await controls.getByRole('button',{name:'Zoom in',exact:true}).click();expect(await page.evaluate(()=>window.viewerInstances.at(-1).imageData.width)).toBeGreaterThan(fitted.width);
|
||||
await controls.getByRole('button',{name:'Zoom out',exact:true}).click();
|
||||
@@ -374,7 +456,7 @@ async function slideshowFixture(page,request,preferences={videoAutoplay:false,vi
|
||||
await expect(mediaDialog(page).locator('.file-name')).toHaveText('complete.png');
|
||||
await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000));
|
||||
}
|
||||
const slideshowButton=page=>mediaDialog(page).getByRole('group',{name:'Media controls',exact:true}).getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/});
|
||||
const slideshowButton=page=>mediaDialog(page).locator('#viewer-toolbar').getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/});
|
||||
const timerCount=page=>page.evaluate(()=>window.slideshowTimers.size);
|
||||
const mediaStep=async(page,direction)=>mediaDialog(page).getByRole('button',{name:`${direction} media`,exact:true}).click();
|
||||
async function readyClockImage(page) {
|
||||
@@ -403,7 +485,7 @@ test('slideshow exposes exact state labels and respects video playback',async({p
|
||||
await button.click();await mediaStep(page,'Previous');await expect(button).toHaveText('Stop');
|
||||
await mediaStep(page,'Previous');await expect(button).toHaveText('Pause');await expect(position(page)).toContainText('Slideshow waiting for image.');await readyClockImage(page);await expect.poll(()=>timerCount(page)).toBe(1);
|
||||
expect(new Set(originals.map(url=>new URL(url).pathname)).size).toBe(3);
|
||||
await button.click();await modal.locator('.image-preview-details').evaluate(node=>{node.scrollTop=node.scrollHeight;});await page.clock.runFor(50);
|
||||
await button.click();await modal.locator('#viewer-details').evaluate(node=>{node.scrollTop=node.scrollHeight;});await page.clock.runFor(50);
|
||||
await button.click();await mediaStep(page,'Previous');await readyClockImage(page);await expect(button).toHaveText('Pause');expect(await timerCount(page)).toBe(1);
|
||||
});
|
||||
|
||||
@@ -434,7 +516,7 @@ test('slideshow production query and route disposal cancel the active deadline',
|
||||
await openCollection(page);await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
await page.clock.pauseAt(new Date(Date.now()+1000));await slideshowButton(page).click();expect(await timerCount(page)).toBe(1);
|
||||
await page.clock.runFor(3499);
|
||||
const pendingAtReset=await page.evaluate(()=>{const folder=document.getElementById('image-folder');folder.value='inspector';folder.dispatchEvent(new Event('change'));return window.slideshowTimers.size;});
|
||||
const pendingAtReset=await page.evaluate(()=>{const sort=document.getElementById('sort');sort.value='name';sort.dispatchEvent(new Event('change'));return window.slideshowTimers.size;});
|
||||
expect(pendingAtReset).toBe(0);await page.clock.runFor(1);
|
||||
await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0);
|
||||
await selectFile(page,'complete.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await readyClockImage(page);await expect(slideshowButton(page)).toHaveText('Play');
|
||||
@@ -446,18 +528,18 @@ test('slideshow interaction visibility sender and disposal clear intent',async({
|
||||
await slideshowFixture(page,request);const button=slideshowButton(page),modal=mediaDialog(page);
|
||||
for(const action of [
|
||||
()=>modal.locator('.prompt-positive pre').click(),
|
||||
()=>modal.locator('.image-preview-details').dispatchEvent('wheel',{deltaY:100}),
|
||||
()=>modal.locator('#viewer-details').dispatchEvent('wheel',{deltaY:100}),
|
||||
()=>modal.getByRole('button',{name:'Copy positive prompt',exact:true}).click(),
|
||||
async()=>{await modal.getByText('Workflow & raw metadata',{exact:true}).click();await downloadText(page,modal.getByRole('button',{name:'Download metadata JSON',exact:true}));},
|
||||
async()=>{await page.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,value:true});document.dispatchEvent(new Event('visibilitychange'));Object.defineProperty(document,'hidden',{configurable:true,value:false});document.dispatchEvent(new Event('visibilitychange'));});},
|
||||
async()=>{const trigger=modal.getByRole('button',{name:'Send prompt pair',exact:true});await trigger.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(trigger).toBeFocused();},
|
||||
()=>modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true}).click(),
|
||||
()=>modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true}).click(),
|
||||
()=>page.evaluate(()=>window.slideshowFixture.view.stopSlideshow()),
|
||||
]) {
|
||||
await button.click();expect(await timerCount(page)).toBe(1);await action();await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);await page.clock.runFor(3500);await expect(position(page)).toContainText('1 of 3 loaded');
|
||||
}
|
||||
for(let mount=0;mount<3;mount++) {
|
||||
await button.click();await modal.getByRole('button',{name:'Close preview',exact:true}).click();await expect(modal).toHaveCount(0);expect(await timerCount(page)).toBe(0);
|
||||
await button.click();await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await expect(modal).toHaveCount(0);expect(await timerCount(page)).toBe(0);
|
||||
await page.evaluate(()=>window.slideshowFixture.open());await expect(button).toHaveText('Play');
|
||||
await readyClockImage(page);
|
||||
}
|
||||
@@ -488,12 +570,12 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
|
||||
async function capture(name,viewer=false) {
|
||||
await page.evaluate(()=>document.fonts.ready);
|
||||
if(viewer) {
|
||||
const modal=mediaDialog(page),rail=modal.locator('.image-preview-details');
|
||||
const modal=mediaDialog(page),rail=modal.locator('#viewer-details');
|
||||
expect(await modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
|
||||
measurements.push({name,...await modal.evaluate(node=>{
|
||||
const box=element=>{const bounds=element.getBoundingClientRect();return{x:bounds.x,y:bounds.y,width:bounds.width,height:bounds.height};};
|
||||
return{frame:box(node),header:box(node.querySelector('.image-preview-header')),stage:box(node.querySelector('.image-preview-stage')),
|
||||
toolbar:box(node.querySelector('.image-preview-toolbar')),rail:box(node.querySelector('.image-preview-details')),
|
||||
return{frame:box(node),header:box(node.querySelector('.dialog-header')),stage:box(node.querySelector('#viewer-stage')),
|
||||
toolbar:box(node.querySelector('#viewer-toolbar')),rail:box(node.querySelector('#viewer-details')),
|
||||
footer:node.querySelector('.file-inspector-footer')?box(node.querySelector('.file-inspector-footer')):null};
|
||||
})});
|
||||
if(page.viewportSize().width===1600)expect(await rail.evaluate(node=>node.getBoundingClientRect().width)).toBe(340);
|
||||
@@ -503,23 +585,23 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
|
||||
}
|
||||
for(const [width,height,theme]of [[1600,1100,'dark'],[1600,1100,'light'],[390,844,'dark'],[390,844,'light']]) {
|
||||
const label=`${theme}-${width}`;await page.setViewportSize({width,height});
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await selectFile(page,'complete.png');await expect(inspector(page).locator('.generation-facts dd').first()).toHaveText('9007199254740993');
|
||||
if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.file-cover').evaluate(node=>node.scrollIntoView({block:'start'}));
|
||||
await capture(`inline-${label}`);
|
||||
await openCollection(page);const modal=mediaDialog(page),rail=modal.locator('.image-preview-details');
|
||||
await openCollection(page);const modal=mediaDialog(page),rail=modal.locator('#viewer-details');
|
||||
await expect(modal.locator('.viewer-canvas img')).toBeVisible();await expect.poll(()=>modal.locator('.viewer-canvas img').evaluate(node=>node.complete&&node.naturalWidth>0)).toBe(true);
|
||||
await modal.evaluate(node=>{node.scrollTop=0;});await capture(`viewer-${label}`,true);
|
||||
await expect(rail.locator('.prompt-negative')).toContainText('blurred edges, watermark');await rail.locator('.generation-facts').scrollIntoViewIfNeeded();await capture(`facts-${label}`,true);
|
||||
await rail.getByText('File & provenance',{exact:true}).click();await rail.locator('.file-facts').scrollIntoViewIfNeeded();await capture(`provenance-${label}`,true);
|
||||
await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await raw.scrollIntoViewIfNeeded();await expect(raw).toBeInViewport();await capture(`raw-${label}`,true);
|
||||
const send=rail.getByRole('button',{name:'Send prompt pair',exact:true});await send.focus();await expect(send).toBeInViewport();await send.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await capture(`sender-${label}`);await page.keyboard.press('Escape');await expect(send).toBeFocused();
|
||||
await modal.getByRole('button',{name:'Close preview',exact:true}).click();await selectFile(page,'clip.webm');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
await expect.poll(()=>modal.locator('video').evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);await capture(`video-${label}`,true);await modal.getByRole('button',{name:'Close preview',exact:true}).click();
|
||||
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await selectFile(page,'clip.webm');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
await expect.poll(()=>modal.locator('video').evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);await capture(`video-${label}`,true);await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
await selectFile(page,'control-heavy.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
await expect(modal).toContainText('Unavailable within inspection limits');await modal.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
const limitedDownload=modal.getByRole('button',{name:'Download metadata JSON',exact:true});await limitedDownload.scrollIntoViewIfNeeded();await expect(limitedDownload).toHaveAttribute('aria-disabled','true');await capture(`limited-${label}`,true);
|
||||
await modal.getByRole('button',{name:'Close preview',exact:true}).click();
|
||||
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
}
|
||||
const fs=await import('node:fs/promises'),geometryPath=testInfo.outputPath('inspector-geometry.json');
|
||||
await fs.writeFile(geometryPath,JSON.stringify(measurements,null,2));await testInfo.attach('inspector-geometry',{path:geometryPath,contentType:'application/json'});
|
||||
@@ -530,12 +612,12 @@ test('collection viewer preserves original download and rooted retries',async({p
|
||||
await page.route('**/images/serve/inspector/complete.png?*',route=>{originals.push(route.request().url());return deny?route.fulfill({status:404,body:'Missing'}):route.continue();});
|
||||
await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();const modal=mediaDialog(page);
|
||||
await expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.generation-facts dd').first()).toHaveText('9007199254740993');
|
||||
await expect(modal.locator('.image-preview-position')).toContainText('Media could not be loaded.');
|
||||
const download=modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true});const original=await download.getAttribute('href');
|
||||
await expect(modal.locator('.media-position')).toContainText('Media could not be loaded.');
|
||||
const download=modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true});const original=await download.getAttribute('href');
|
||||
await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
const denied=page.waitForResponse(response=>response.url().includes('/images/serve/inspector/complete.png?')&&response.status()===404);
|
||||
await modal.getByRole('button',{name:'Retry media',exact:true}).click();await denied;
|
||||
await expect.poll(()=>mediaStage(page).locator('.image-preview-media > img').evaluate(node=>node.complete&&node.naturalWidth===0)).toBe(true);
|
||||
await expect.poll(()=>mediaStage(page).locator(':scope > img').evaluate(node=>node.complete&&node.naturalWidth===0)).toBe(true);
|
||||
await expect(modal.getByRole('button',{name:'Retry media',exact:true})).toBeVisible();
|
||||
deny=false;await modal.getByRole('button',{name:'Retry media',exact:true}).click();await expect(modal.locator('.viewer-canvas img')).toBeVisible();
|
||||
await expect(download).toHaveAttribute('href',original);
|
||||
@@ -543,10 +625,10 @@ test('collection viewer preserves original download and rooted retries',async({p
|
||||
expect(new Set(stages.map(url=>url.searchParams.get('_pm_viewer'))).size).toBe(3);
|
||||
for(const value of originals) {const actual=new URL(value),expected=new URL(original,'http://127.0.0.1');expect(actual.pathname).toBe(expected.pathname);for(const key of ['root','root_scope','v'])expect(actual.searchParams.get(key)).toBe(expected.searchParams.get(key));expect(actual.searchParams.getAll('_pm_viewer').length).toBeLessThanOrEqual(1);expect([...actual.searchParams.keys()].filter(key=>!expected.searchParams.has(key))).toEqual(actual.searchParams.has('_pm_viewer')?['_pm_viewer']:[]);}
|
||||
const [saved]=await Promise.all([page.waitForEvent('download'),download.click()]);expect(saved.suggestedFilename()).toBe('complete.png');
|
||||
await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{window.availableViewer=window.Viewer;window.Viewer=undefined;});
|
||||
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await page.evaluate(()=>{window.availableViewer=window.Viewer;window.Viewer=undefined;});
|
||||
await page.route('**/images/inspect/inspector/explicit-empty.png?*',route=>route.fulfill({status:503,json:{success:false,error:'Unavailable'}}),{times:1});
|
||||
await selectFile(page,'explicit-empty.png');await expect(inspector(page)).toContainText('Could not read prompts from this file.');
|
||||
await page.locator('article[data-image-key="0:inspector/explicit-empty.png"] .image-card-footer button').click();
|
||||
await page.locator('article[data-image-key="0:inspector/explicit-empty.png"] .image-open').click();
|
||||
await expect(mediaStage(page).locator('img')).toBeVisible();await expect(mediaStage(page).locator('img')).toHaveJSProperty('naturalWidth',720);
|
||||
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAccessibleDescription('Image transforms are unavailable because Viewer.js could not load.');
|
||||
await page.evaluate(()=>{window.Viewer=window.availableViewer;});await mediaDialog(page).getByRole('button',{name:'Retry prompt details',exact:true}).click();
|
||||
@@ -561,20 +643,20 @@ test('collection viewer keeps nested dialog focus and route cleanup',async({page
|
||||
const text=modal.locator('.prompt-positive pre');await text.focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before);
|
||||
await modal.getByText('Workflow & raw metadata',{exact:true}).focus();await page.keyboard.press('ArrowLeft');await expect(position(page)).toHaveText(before);
|
||||
await modal.getByRole('button',{name:'Next media',exact:true}).click();const key=(await position(page).textContent()).split('loaded: ')[1];
|
||||
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('.image-open[aria-pressed="true"]')).toBeFocused();await expect(inspector(page).locator('.file-name')).toHaveText(key);
|
||||
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('.media-card.selected .image-open')).toBeFocused();await expect(inspector(page).locator('.file-name')).toHaveText(key);
|
||||
await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await page.evaluate(()=>{location.hash='/overview';});
|
||||
await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,.image-preview')).toHaveCount(0);
|
||||
await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('collection viewer matches approved responsive frame',async({page,request},testInfo)=>{
|
||||
await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);await openCollection(page);
|
||||
const modal=mediaDialog(page),body=modal.locator('.image-preview-body'),rail=modal.locator('.image-preview-details');
|
||||
const modal=mediaDialog(page),body=modal.locator('.viewer-layout'),rail=modal.locator('#viewer-details');
|
||||
// Absolute gallery cards relayout through ResizeObserver and rAF after resize or CSS zoom.
|
||||
const settleViewport=width=>expect.poll(()=>page.evaluate(()=>({inner:innerWidth,client:document.documentElement.clientWidth,scroll:document.documentElement.scrollWidth})),{message:`Images gallery relayout settles the ${width}px viewport`}).toEqual({inner:width,client:width,scroll:width});
|
||||
await settleViewport(1600);
|
||||
for(const theme of ['dark','light']) {
|
||||
await page.evaluate(theme=>{const preference=document.getElementById('workspace-appearance');preference.value=theme;preference.dispatchEvent(new Event('change'));},theme);
|
||||
const geometry=await modal.evaluate(node=>{const style=getComputedStyle(node),box=node.getBoundingClientRect(),rail=node.querySelector('.image-preview-details').getBoundingClientRect();return{width:box.width,height:box.height,padding:style.padding,radius:style.borderRadius,rail:rail.width,gap:getComputedStyle(node.querySelector('.image-preview-body')).columnGap};});
|
||||
await page.evaluate(theme=>{const preference=document.getElementById('theme');preference.value=theme;preference.dispatchEvent(new Event('change'));},theme);
|
||||
const geometry=await modal.evaluate(node=>{const style=getComputedStyle(node),box=node.getBoundingClientRect(),rail=node.querySelector('#viewer-details').getBoundingClientRect();return{width:box.width,height:box.height,padding:style.padding,radius:style.borderRadius,rail:rail.width,gap:getComputedStyle(node.querySelector('.viewer-layout')).columnGap};});
|
||||
expect(geometry).toEqual({width:1500,height:940,padding:'18px',radius:'9px',rail:340,gap:'20px'});
|
||||
await page.screenshot({path:testInfo.outputPath(`viewer-${theme}-1600.png`)});
|
||||
}
|
||||
@@ -590,7 +672,7 @@ test('collection viewer matches approved responsive frame',async({page,request},
|
||||
expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(zoom===1?356:322,1);
|
||||
expect(await modal.evaluate(node=>{const box=node.getBoundingClientRect();return box.top>=0&&box.bottom<=innerHeight+1;})).toBe(true);
|
||||
await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
|
||||
await expect(rail).toHaveCSS('overflow-y','visible');expect(await body.evaluate(node=>getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1);
|
||||
await expect(rail).toHaveCSS('overflow-y','visible');expect(await body.evaluate(node=>{const style=getComputedStyle(node);return [style.display,style.flexDirection];})).toEqual(['flex','column']);
|
||||
await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await expect(raw).toBeVisible();
|
||||
await rail.getByRole('button',{name:'Send prompt pair',exact:true}).focus();await expect(rail.getByRole('button',{name:'Send prompt pair',exact:true})).toBeInViewport();
|
||||
await page.screenshot({path:testInfo.outputPath(`viewer-mobile-${zoom}x.png`)});await rail.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
@@ -604,16 +686,16 @@ test('collection viewer matches approved responsive frame',async({page,request},
|
||||
expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(322,1);await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
|
||||
await testInfo.attach('wider-font-footer-geometry',{body:JSON.stringify({footer,modal:await modal.evaluate(node=>({width:node.getBoundingClientRect().width,scrollWidth:node.scrollWidth,clientWidth:node.clientWidth}))}),contentType:'application/json'});
|
||||
await page.screenshot({path:testInfo.outputPath('viewer-mobile-wider-font-2x.png')});
|
||||
await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';});await settleViewport(390);
|
||||
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';});await settleViewport(390);
|
||||
const longFiles=await(await request.get('/prompt_manager/images/output?mode=workspace&q=a-very-long&limit=40')).json();const long=longFiles.images[0];
|
||||
await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-open`).click();await openCollection(page);
|
||||
await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-card-actions button`).click();await openCollection(page);
|
||||
await page.evaluate(()=>{document.documentElement.style.zoom='2';});await settleViewport(390);await modal.evaluate(node=>{node.scrollTop=0;});
|
||||
await expect(modal.getByRole('button',{name:'Close preview',exact:true})).toBeInViewport();
|
||||
await expect(modal.getByRole('button',{name:'Close image viewer',exact:true})).toBeInViewport();
|
||||
});
|
||||
|
||||
async function currentStageFit(page) {
|
||||
return page.evaluate(()=>{
|
||||
const instance=window.fitInstances.at(-1),stage=document.querySelector('.image-preview-stage');
|
||||
const instance=window.fitInstances.at(-1),stage=document.querySelector('#viewer-stage');
|
||||
const bounds=stage.getBoundingClientRect(),image=instance.image.getBoundingClientRect();
|
||||
return {stage:{width:stage.offsetWidth,height:stage.offsetHeight},viewer:{...instance.viewerData},
|
||||
image:{width:image.width,height:image.height},
|
||||
@@ -651,7 +733,7 @@ async function openStageFitFixture(page,request) {
|
||||
window.stageFitFixture={scope,controller,view};view.open({items:[tall,next],selectedKey:`${tall.root_index}:${tall.path}`,trigger:document.activeElement});
|
||||
});
|
||||
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
await expect(mediaDialog(page).locator('.image-preview-reason')).toBeHidden();
|
||||
await expect(mediaDialog(page).locator('.media-reason')).toBeHidden();
|
||||
return release;
|
||||
}
|
||||
async function attachStageFit(page,testInfo,name) {
|
||||
@@ -675,7 +757,7 @@ test('collection viewer refits a late wrapped title without replacing current me
|
||||
const modal=mediaDialog(page),fit=modal.getByRole('button',{name:'Fit image',exact:true});
|
||||
await page.evaluate(()=>{window.fitImage=document.querySelector('.viewer-canvas img');window.fitControl=document.querySelector('[aria-label="Fit image"]');});
|
||||
await fit.focus();const initial=await attachStageFit(page,testInfo,'before-late-title');release();
|
||||
await expect(modal.locator('.image-preview-header h2')).toHaveText(`${'W'.repeat(220)} · study-02.png`);
|
||||
await expect(modal.locator('#viewer-title')).toHaveText(`${'W'.repeat(220)} · study-02.png`);
|
||||
await expect(fit).toBeFocused();await fit.click();await attachStageFit(page,testInfo,'late-title-fit');
|
||||
await expect.poll(async()=>{const data=await currentStageFit(page);return Math.abs(data.viewer.height-data.stage.height);}).toBeLessThanOrEqual(1);
|
||||
const current=await currentStageFit(page);expect(current.stage.height).toBeLessThan(initial.stage.height);expect(Math.abs(current.center.y)).toBeLessThanOrEqual(1);expect(current.contained).toBe(true);
|
||||
@@ -688,7 +770,7 @@ test('collection viewer refits a late wrapped title without replacing current me
|
||||
await fit.click();const fitted=await currentStageFit(page);expect(Math.abs(fitted.center.y)).toBeLessThanOrEqual(1);expect(fitted.contained).toBe(true);
|
||||
await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
await page.evaluate(()=>{for(const probe of window.fitObservers.filter(probe=>probe.disconnected))probe.callback([],probe.observer);});
|
||||
await modal.getByRole('button',{name:'Close preview',exact:true}).click();await expect(modal).toHaveCount(0);
|
||||
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await expect(modal).toHaveCount(0);
|
||||
await page.evaluate(async()=>{for(const probe of window.fitObservers)probe.callback([],probe.observer);await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));});
|
||||
expect(await page.evaluate(()=>window.fitObservers.every(probe=>probe.disconnected))).toBe(true);
|
||||
expect(await page.evaluate(()=>window.fitResizeCalls.filter(call=>call.destroyed||!call.connected))).toEqual([]);
|
||||
|
||||
@@ -15,8 +15,7 @@ export const canonicalDifferences = {
|
||||
'Current-file candidates disclose unknown identity and context membership',
|
||||
],
|
||||
remainingApplicationPolish: [
|
||||
'Gallery thumbnail selects details before Open full preview; direct Viewer/Info/tag/favorite/select parity remains outside #209',
|
||||
'Shared Viewer stage background and toolbar alignment remain outside #209',
|
||||
'Gallery card select checkbox (#249 D8 follow-up) and tag button (#249 D9, image-annotations slice) remain outside #209',
|
||||
'Shared role-block spacing, padding and glyph styling remain outside #209; exact role order/actions and evidence disclosure are covered',
|
||||
'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced',
|
||||
],
|
||||
@@ -106,7 +105,7 @@ export async function exerciseCurrentPairSend(page, roles) {
|
||||
apply: roles => { window.canonicalApplied.push(roles);return { targetId: target.id, values: roles }; },
|
||||
} });
|
||||
});
|
||||
const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });
|
||||
const viewer = page.locator('dialog#viewer-dialog');
|
||||
const send = viewer.getByRole('button', { name: 'Send prompt pair', exact: true });await send.click();
|
||||
const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });await expect(sender).toBeVisible();
|
||||
await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Canonical ComfyUI' });
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy} from './concept-inventory.mjs';
|
||||
|
||||
// Task C7 of #249: the production Images page against the immutable concept, on the concept-shaped real fixture (24
|
||||
// images of the concept's six prompts, its seeds, sizes and favorites). Heading, toolbar, filter row, caption, grid,
|
||||
// the first six cards, the continuation and the panel box are compared at 1600x1100 and 390x844 in both themes, and the
|
||||
// page's behaviors under normal and reduced motion; grid, cards and panel also at 1200x900 and 1800x1100 (2 and 4
|
||||
// columns, the 275px and 310px panel). The topbar and sidebar (D5, D28, D29) are the shell spec's.
|
||||
// Paired captures are artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
// D37: the concept loads 12 images per batch (app.js imageBatchSize) and production one listing page (40), so both are
|
||||
// compared once the 24 fixture images are all loaded: the concept after its own Load more press.
|
||||
const FIXTURE_IMAGES=24;
|
||||
const CONCEPT_BATCH=12;
|
||||
const PRODUCTION_BATCH=40;
|
||||
// Load more in production needs more than one listing page: the fixture continues the concept's rounds (two pages).
|
||||
const LOAD_MORE_EXTRA=2*PRODUCTION_BATCH-FIXTURE_IMAGES;
|
||||
const CARDS='.gallery-body > .image-grid > .media-card';
|
||||
const WIDE_SIZES=Object.freeze([[1200,900],[1800,1100]]);
|
||||
|
||||
/** D17: "<Today|Yesterday|weekday> · <Month D>" of the newest file (the fixture's own mtime), in the test's UTC zone. */
|
||||
function captionDay(seconds,now=new Date()) {
|
||||
const day=value=>Date.UTC(value.getUTCFullYear(),value.getUTCMonth(),value.getUTCDate());
|
||||
const date=new Date(seconds*1000),days=Math.round((day(now)-day(date))/86400000);
|
||||
const name=days===0?'Today':days===1?'Yesterday':date.toLocaleDateString('en-US',{weekday:'long',timeZone:'UTC'});
|
||||
const options={month:'long',day:'numeric',timeZone:'UTC',...(date.getUTCFullYear()===now.getUTCFullYear()?{}:{year:'numeric'})};
|
||||
return `${name} · ${date.toLocaleDateString('en-US',options)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's flex lines laid out again from the concept's own item boxes, in order: an item breaks to a new line when its
|
||||
* hypothetical size (`flex-basis: 100%` → the row; a growing item → its min-width; otherwise its box) no longer fits
|
||||
* after the line's items and gaps; a growing item (the search field, `flex: 1`) takes its line's free space; items pack
|
||||
* from the start and centre in their line (`align-items: center`). The test proves this model on the concept rows first.
|
||||
*/
|
||||
export function reflow(row,items) {
|
||||
const gap=parseFloat(row.columnGap)||0,rowGap=parseFloat(row.rowGap)||0;
|
||||
// The unrounded boxes (`exact`); rounding happens once, on the result.
|
||||
const rowWidth=row.exact?.width??row.width;
|
||||
const exact=item=>item.exact??{left:item.left,width:item.width};
|
||||
const grows=item=>parseFloat(item.flexGrow)>0;
|
||||
const basis=item=>item.flexBasis==='100%'?rowWidth:grows(item)?parseFloat(item.minWidth)||0:exact(item).width;
|
||||
const lines=[];
|
||||
const order=row.items.filter(entry=>entry.shown);
|
||||
for(const item of items.filter(entry=>entry.shown)) {
|
||||
const line=lines.at(-1),used=line?line.reduce((sum,entry)=>sum+basis(entry)+gap,0):0;
|
||||
// The slack is one layout unit (1/60px in Firefox), which float sums of the browser's boxes may exceed.
|
||||
if(line&&used+basis(item)<=rowWidth+1/60)line.push(item);else lines.push([item]);
|
||||
}
|
||||
const placed=new Map();let top=0;
|
||||
const sameLine=(a,b)=>a.top<b.top+b.height&&b.top<a.top+a.height;
|
||||
const freeOf=line=>rowWidth-line.reduce((sum,item)=>sum+basis(item),0)-gap*(line.length-1);
|
||||
for(const line of lines) {
|
||||
const free=freeOf(line);
|
||||
const growing=line.filter(grows);
|
||||
const height=Math.max(...line.map(item=>item.height));
|
||||
let x=0,previous=null;
|
||||
for(const item of line) {
|
||||
// A growing item keeps its concept width plus the space its line gained.
|
||||
const width=growing.includes(item)?exact(item).width+(free-freeOf(order.filter(entry=>sameLine(entry,item))))/growing.length:exact(item).width;
|
||||
// An item that followed the same neighbour on the same line in the concept keeps the concept's own spacing,
|
||||
// moved by however much that neighbour grew; otherwise it follows the previous box and the gap.
|
||||
const adjacent=previous&&order.indexOf(item)===order.indexOf(previous)+1&&sameLine(item,previous);
|
||||
const before=previous&&placed.get(previous);
|
||||
const left=line.indexOf(item)===0?0:adjacent?before.left+exact(item).left-exact(previous).left+before.width-exact(previous).width:x;
|
||||
placed.set(item,{left,width,top:Math.round(top+(height-item.height)/2)});
|
||||
x=left+width+gap;previous=item;
|
||||
}
|
||||
top+=height+rowGap;
|
||||
}
|
||||
const moved=item=>{
|
||||
const box=placed.get(item);
|
||||
if(!box)return item;
|
||||
const grown=box.width-exact(item).width;
|
||||
return {...item,left:Math.round(box.left),top:box.top,width:Math.round(box.width),
|
||||
input:item.input&&{...item.input,width:Math.round((item.input.exact?.width??item.input.width)+grown)}};
|
||||
};
|
||||
return {...row,height:Math.round(top-rowGap),items:items.map(moved)};
|
||||
}
|
||||
|
||||
/** A flex row without the items `isRemoved` picks; the remaining items flow again from the concept's boxes. */
|
||||
function withoutItems(row,isRemoved) {
|
||||
return reflow(row,row.items.filter(item=>!isRemoved(item)));
|
||||
}
|
||||
|
||||
/** D8: the selection bar's real actions only; Add tags, Add to collection and Remove links… are left out. */
|
||||
const OMITTED_BULK=Object.freeze(['Add tags','Add to collection','Remove links…']);
|
||||
const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-tags','bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
|
||||
|
||||
/**
|
||||
* `withoutItems` for a row with padding and a border (the `.bulkbar`): its items flow again inside the content box,
|
||||
* measured from the border box as the inventory records them. The concept's border is one width on every side
|
||||
* (`border: 1px solid`), so the right border stands for the left one.
|
||||
*/
|
||||
function withoutPaddedItems(row,isRemoved) {
|
||||
const [left,right,top,bottom]=[['paddingLeft','borderRightWidth'],['paddingRight','borderRightWidth'],
|
||||
['paddingTop','borderTopWidth'],['paddingBottom','borderBottomWidth']].map(keys=>keys.reduce((sum,key)=>sum+parseFloat(row[key]),0));
|
||||
const shift=(item,x,y)=>item.left===undefined?item:{...item,left:item.left+x,top:item.top+y,
|
||||
exact:item.exact&&{...item.exact,left:item.exact.left+x}};
|
||||
const inner={...row,width:row.width-left-right,exact:{width:row.exact.width-left-right},items:row.items.map(item=>shift(item,-left,-top))};
|
||||
const flowed=withoutItems(inner,isRemoved);
|
||||
return {...flowed,width:row.width,exact:row.exact,height:flowed.height+top+bottom,items:flowed.items.map(item=>shift(item,left,top))};
|
||||
}
|
||||
|
||||
/** D8: the bar without the omitted actions, laid out again from the concept's boxes; what follows moves up by as much. */
|
||||
function mapBulkbar(bulkbar,rise) {
|
||||
if(!bulkbar)return {bulkbar,shrink:0};
|
||||
if(!bulkbar.bar)return {bulkbar:{...bulkbar,top:bulkbar.top-rise},shrink:0};
|
||||
const bar=withoutPaddedItems(bulkbar.bar,item=>OMITTED_BULK.includes(item.text));
|
||||
const shrink=bulkbar.bar.height-bar.height;
|
||||
return {bulkbar:{...bulkbar,top:bulkbar.top-rise,height:bulkbar.height-shrink,bar:{...bar,top:bar.top-rise}},shrink};
|
||||
}
|
||||
|
||||
/**
|
||||
* A list row's info once D9 narrows its actions to `actionsWidth`, from the concept's boxes:
|
||||
* - D3 (the measurable list row, `.list-grid .media-info` in the overlay): the info fills the row beside the art and may
|
||||
* shrink below a long nowrap title (`min-width: 0`), so it is the card's inner width less the art, and the actions
|
||||
* end at its right padding; it keeps its own height (text or actions plus padding), centred in the row the concept
|
||||
* stretches it across, so the text, centred in either, moves only by half the lines it lost;
|
||||
* - D9: the wider text column fits a subtitle the concept wrapped when its one-line width (`maxContent`) now fits.
|
||||
*/
|
||||
function listInfo(card,actionsWidth) {
|
||||
const {info}=card,{copy,p}=info;
|
||||
const width=card.width-2*parseFloat(card.borderTopWidth)-card.art.width;
|
||||
const column=width-parseFloat(info.paddingLeft)-parseFloat(info.paddingRight)-parseFloat(info.columnGap)-actionsWidth;
|
||||
const line=parseFloat(p.lineHeight);
|
||||
const pHeight=p.height>line&&p.maxContent<=column?line:p.height;
|
||||
const copyHeight=copy.height-(p.height-pHeight);
|
||||
const natural=Math.max(copyHeight,info.actions.height)+parseFloat(info.paddingTop)+parseFloat(info.paddingBottom);
|
||||
// The row's inner box is the stretched concept info: its top is the card border, its height the art's.
|
||||
return {info:{width,top:Math.round(info.top+(info.height-natural)/2),height:Math.round(natural)},
|
||||
copy:{height:copyHeight,top:Math.round(info.top+(info.height-copyHeight)/2)},p:{height:pHeight},
|
||||
actionsLeft:info.left+width-parseFloat(info.paddingRight)-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()}) {
|
||||
const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
|
||||
const toolbar=withoutItems(reference.toolbar,item=>item.text==='Save view');
|
||||
// D16 (follow-up PR): no Save view until the saved-views store ships. D14: the sort appends production's two sorts.
|
||||
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.
|
||||
toolbar.items=toolbar.items.map(item=>item.id==='category-filter'
|
||||
?{...item,options:[item.options[0],...item.options.slice(1).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=>{
|
||||
// D32: the layout buttons may wrap at extreme zoom (they never do while they fit).
|
||||
if(item.className==='segmented')return {...item,flexWrap:'wrap'};
|
||||
// D14: the scope chip may shrink and wrap a long source or folder name inside itself.
|
||||
if(item.tag==='span'&&item.className==='chip')return {...item,minWidth:'0px'};
|
||||
return item;
|
||||
});
|
||||
// A shorter toolbar or filter row moves what follows it up by as much.
|
||||
const toolbarRise=reference.toolbar.height-toolbar.height;
|
||||
filterRow.top-=toolbarRise;
|
||||
const rise=toolbarRise+reference.filterRow.height-filterRow.height;
|
||||
const list=reference.grid.className.split(' ').includes('list-grid');
|
||||
const cards=reference.cards.map(card=>{
|
||||
const filename=card.open.label.replace(/^Open /,'');
|
||||
const actions=card.info.actions;
|
||||
const tag=actions.buttons.find(button=>button.label?.startsWith('Tag or auto-tag '));
|
||||
// D9: no Tag button until the image-annotations slice; the actions group narrows by its box and gap.
|
||||
const freed=tag?tag.width+parseFloat(actions.columnGap):0;
|
||||
// D3: a list row's info keeps its own height (text or actions plus its padding) so bounded rendering can measure
|
||||
// it, centred in the row the concept stretches it across; the text and the actions keep their place.
|
||||
const actionsWidth=actions.width-freed;
|
||||
const listRow=list?listInfo(card,actionsWidth):{};
|
||||
return {...card,
|
||||
// D29: the concept's alt adds its demo-only " · sample illustration N" to the card title.
|
||||
img:{...card.img,alt:card.img.alt.replace(/ · sample illustration \d+$/,'')},
|
||||
info:{...card.info,...listRow.info,copy:{...card.info.copy,...listRow.copy},p:{...card.info.p,...listRow.p},
|
||||
// D10: the title keeps the file's relative path (the fixture manifest's) in `title`.
|
||||
h3:{...card.info.h3,title:paths.get(filename)??null},
|
||||
actions:{...actions,width:actionsWidth,left:listRow.actionsLeft??actions.left+freed,
|
||||
buttons:actions.buttons.filter(button=>button!==tag)}}};
|
||||
});
|
||||
// D17: the right-hand date is the newest result's day under Recently added, here the fixture's newest mtime.
|
||||
// D35: the count line uses the singular for one.
|
||||
const caption={...reference.caption,spans:reference.caption.spans.map((span,index)=>index===1
|
||||
?{...span,text:captionDay(manifest.newest_modified,now)}:{...span,text:countCopy(span.text)})};
|
||||
const continuation=mapContinuation(reference.continuation);
|
||||
const {bulkbar,shrink}=mapBulkbar(reference.bulkbar,rise);
|
||||
return {...reference,toolbar,filterRow,bulkbar,cards,caption,continuation,panel:{...reference.panel,top:reference.panel.top-rise-shrink}};
|
||||
}
|
||||
|
||||
/** D32: the continuation's text and Load more may wrap at extreme zoom; D35: its count uses the singular for one. */
|
||||
function mapContinuation(continuation,numbers=null) {
|
||||
return {...continuation,flexWrap:'wrap',items:continuation.items.map(item=>item.tag!=='p'?item:{...item,
|
||||
// D37: a row with more to load counts production's own page ("N of M"), not the concept's 12-image batch.
|
||||
text:countCopy(numbers?item.text.replace(/^\d+ of \d+/,`${numbers.loaded} of ${numbers.total}`):item.text)})};
|
||||
}
|
||||
|
||||
/** D37: the Load more row before its press, the concept's at 12 of 24 against production's at its own page. */
|
||||
function compareLoadingRow(reference,actual,count) {
|
||||
const [,loaded,total]=count.match(/^(\d+) of (\d+)/);
|
||||
// The row's place follows the grid above it, whose height is the batch's (D37).
|
||||
const unplaced=({top,...row})=>stripExact(row);
|
||||
return diffInventory(unplaced(mapContinuation(reference,{loaded,total})),unplaced(actual));
|
||||
}
|
||||
|
||||
/** D15: the Import dialog's two real operations, with the ruled intro and the Rescan wording. */
|
||||
function mapImportConcept(reference) {
|
||||
return {...reference,
|
||||
intro:{...reference.intro,text:'Image inspection and output scanning have different effects. Choose the workflow you want.'},
|
||||
rows:reference.rows.filter(row=>row.title!=='Import portable recipes').map(row=>row.title==='Scan an output folder'
|
||||
?{...row,description:'Find files added or removed in the output folders.',buttons:['Rescan']}:row)};
|
||||
}
|
||||
|
||||
const SECTIONS=['heading','toolbar','filterRow','bulkbar','caption','grid','cards','continuation','panel','noPageScroll'];
|
||||
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) {
|
||||
const only=inventory=>Object.fromEntries(sections.map(key=>[key,inventory[key]]));
|
||||
return diffInventory(pick(only(mapImagesConcept(reference,{manifest}))),pick(only(actual)));
|
||||
}
|
||||
|
||||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||||
function assertImagesMatch(reference,actual,manifest) {
|
||||
const diff=compare(reference,actual,manifest);
|
||||
if(diff.length)throw new Error(`the Images page differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 (masonry sub-pixel snapping): bounded rendering places masonry cards from the layout arithmetic (media width over
|
||||
* the file's ratio), while the concept's flow snaps each box to the browser's layout unit (1/64px in Chromium and
|
||||
* WebKit, 1/60px in Firefox) before measuring the next. So masonry tops and heights are compared unrounded instead,
|
||||
* within one layout unit per card above in the column (the card's own included); the rounded fields are skipped there.
|
||||
*/
|
||||
const MASONRY_ROUNDED=/^cards\.\d+\.(top|height)$/;
|
||||
function masonryDrift(reference,actual) {
|
||||
return reference.cards.flatMap((card,index)=>{
|
||||
const above=reference.cards.slice(0,index).filter(other=>Math.abs(other.exact.left-card.exact.left)<1).length;
|
||||
const bound=(above+1)/60+1e-6,other=actual.cards[index];
|
||||
return ['top','height'].filter(key=>!(Math.abs(card.exact[key]-other.exact[key])<=bound))
|
||||
.map(key=>({path:`cards.${index}.exact.${key}`,reference:card.exact[key],actual:other.exact[key],bound}));
|
||||
});
|
||||
}
|
||||
function compareLayout(layout,reference,mapped,actual) {
|
||||
const diff=diffInventory(stripExact({grid:mapped.grid,cards:mapped.cards}),stripExact(actual));
|
||||
return layout==='Masonry'?[...diff.filter(item=>!MASONRY_ROUNDED.test(item.path)),...masonryDrift(reference,actual)]:diff;
|
||||
}
|
||||
|
||||
// Two frames, then every running transition (a card image zooming back after the pointer left it) finished.
|
||||
const settle=page=>page.evaluate(async()=>{
|
||||
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
|
||||
await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
|
||||
new Promise(resolve=>setTimeout(resolve,2000))]);
|
||||
});
|
||||
const countText=page=>page.locator('#result-count').textContent();
|
||||
const toastText=page=>page.locator('.toast').textContent();
|
||||
|
||||
/** The concept with every fixture image loaded through its own Load more button; returns what the press did. */
|
||||
async function openConceptLoaded(page,theme,motion) {
|
||||
await openConcept(page,'images',theme,motion);await page.mouse.move(0,0);await settle(page);
|
||||
const before=await page.locator(CARDS).count();
|
||||
const loadingRow=(await inventoryOf(page,'#main')).continuation;
|
||||
// A dispatched press: scrolling the button into view would let the concept's own observer load the batch first.
|
||||
// Where the first batch leaves the row in view (1800px, four columns), that observer has already loaded it.
|
||||
if(before<FIXTURE_IMAGES)await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click');
|
||||
await expect(page.locator(CARDS)).toHaveCount(FIXTURE_IMAGES);
|
||||
const load={before,after:await page.locator(CARDS).count(),toast:await toastText(page),count:await countText(page),loadingRow};
|
||||
await page.evaluate(()=>{document.activeElement?.blur();window.scrollTo(0,0);});await settle(page);
|
||||
return load;
|
||||
}
|
||||
|
||||
async function openProductionLoaded(page,theme,motion,total=FIXTURE_IMAGES) {
|
||||
await openProduction(page,'images',theme,motion);await page.mouse.move(0,0);
|
||||
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${Math.min(total,PRODUCTION_BATCH)} of ${total} images`));
|
||||
await expect(page.locator(CARDS)).toHaveCount(Math.min(total,PRODUCTION_BATCH));
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** The Import dialog's intro and operation rows (D15). */
|
||||
async function importDialogOf(page) {
|
||||
await page.locator('.heading-actions').getByRole('button',{name:'Import',exact:true}).click();
|
||||
const dialog=page.locator('dialog#modal[open]');
|
||||
await expect(dialog).toBeVisible();
|
||||
const inventory=await dialog.evaluate(node=>{
|
||||
const css=(element,...keys)=>{const style=getComputedStyle(element);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const font=element=>css(element,'fontSize','fontWeight','lineHeight','color');
|
||||
const intro=node.querySelector('#modal-body > p.small');
|
||||
return {title:node.querySelector('#modal-title').textContent,
|
||||
intro:intro&&{text:intro.textContent,...font(intro)},
|
||||
...css(node.querySelector('.operation-list'),'display','rowGap'),
|
||||
rows:[...node.querySelectorAll('.operation-list > .operation')].map(row=>({title:row.querySelector('h3').textContent,
|
||||
description:row.querySelector('p').textContent,buttons:[...row.querySelectorAll('button')].map(button=>button.textContent.trim()),
|
||||
titleFont:font(row.querySelector('h3')),descriptionFont:font(row.querySelector('p')),...css(row,'display','alignItems')}))};
|
||||
});
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
return inventory;
|
||||
}
|
||||
|
||||
/** The hovered first card image after its transition, then the pointer parked again. */
|
||||
async function hoverOf(page) {
|
||||
const open=page.locator(CARDS).first().locator('.image-open');
|
||||
await open.hover();
|
||||
// The concept's 0.2s transition (instant under reduced motion) has started and finished.
|
||||
await settle(page);
|
||||
const state=await open.locator('img').evaluate(img=>({hovered:img.closest('.image-open').matches(':hover'),
|
||||
transform:getComputedStyle(img).transform,transitionDuration:getComputedStyle(img).transitionDuration,
|
||||
transitionProperty:getComputedStyle(img).transitionProperty,hoverNone:matchMedia('(hover: none)').matches}));
|
||||
await page.mouse.move(0,0);
|
||||
return state;
|
||||
}
|
||||
|
||||
const activeOf=page=>page.evaluate(()=>{const node=document.activeElement;
|
||||
return {tag:node?.tagName.toLowerCase()??null,label:node?.getAttribute('aria-label')??null,
|
||||
card:node?.closest('.media-card')?[...node.closest('.image-grid').children].indexOf(node.closest('.media-card')):null};});
|
||||
const selectedOf=page=>page.locator(CARDS).evaluateAll(nodes=>nodes.flatMap((node,index)=>node.classList.contains('selected')?[index]:[]));
|
||||
|
||||
const LAYOUTS=['List','Masonry','Grid','Large','Compact'];
|
||||
/** The card boxes in each layout: Grid → List → Masonry → Grid, then the Large and Compact thumbnail sizes. */
|
||||
async function layoutsOf(page,scope) {
|
||||
const result={};
|
||||
for(const layout of LAYOUTS) {
|
||||
if(['Large','Compact'].includes(layout))await page.locator('#image-size').selectOption(layout.toLowerCase());
|
||||
else {
|
||||
await page.locator('.filter-row .segmented').getByRole('button',{name:layout,exact:true}).click();
|
||||
await expect(page.locator('.filter-row .segmented').getByRole('button',{name:layout,exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
}
|
||||
await page.mouse.move(0,0);await settle(page);await settle(page);
|
||||
const inventory=await inventoryOf(page,scope);
|
||||
result[layout]={grid:inventory.grid,cards:inventory.cards};
|
||||
}
|
||||
await page.locator('#image-size').selectOption('comfortable');await settle(page);
|
||||
return result;
|
||||
}
|
||||
|
||||
const continuationText=page=>page.locator('.infinite-continuation > p').first().textContent();
|
||||
/** A search: the count line, the continuation and the cards it leaves, then the search cleared again. */
|
||||
async function searchOf(page,term) {
|
||||
const before=await countText(page);
|
||||
await page.locator('#search').fill(term);
|
||||
await expect.poll(()=>countText(page)).not.toBe(before);
|
||||
await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(FIXTURE_IMAGES);
|
||||
await settle(page);
|
||||
const result={count:await countText(page),continuation:await continuationText(page),cards:await page.locator(`${CARDS} .image-open`).evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))};
|
||||
await page.locator('#search').fill('');
|
||||
await expect(page.locator('#result-count')).toHaveText(before);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The behaviors §7 lists, run with identical selectors in both documents. */
|
||||
async function behaviorsOf(page,scope) {
|
||||
const hover=await hoverOf(page);
|
||||
const second=page.locator(CARDS).nth(1).locator('.image-open');
|
||||
const opened=await second.getAttribute('aria-label');
|
||||
await second.click();
|
||||
const viewer=page.locator('#viewer-dialog[open]');
|
||||
await expect(viewer).toBeVisible();
|
||||
const viewerTitle=await page.locator('#viewer-title').textContent();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer).toHaveCount(0);
|
||||
await settle(page);
|
||||
const focusAfterViewer=await activeOf(page);
|
||||
await page.locator(CARDS).nth(2).locator('[aria-label^="Inspect"]').click();
|
||||
await expect.poll(()=>selectedOf(page)).toEqual([2]);
|
||||
const selected=await selectedOf(page);
|
||||
const layouts=await layoutsOf(page,scope);
|
||||
const search=await searchOf(page,'alpine');
|
||||
// Exactly one image (a unique filename stem): both counted nouns in the singular (D35).
|
||||
const single=await searchOf(page,'alpine_00003');
|
||||
return {hover,opened,viewerTitle,focusAfterViewer,selected,layouts,search,single};
|
||||
}
|
||||
|
||||
test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);});
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,request},testInfo)=>{
|
||||
test.setTimeout(240_000);
|
||||
await page.setViewportSize({width,height});
|
||||
const manifest=testInfo.manifest;const result={};
|
||||
for(const motion of MOTIONS) {
|
||||
const capture=motion==='no-preference';
|
||||
const conceptLoad=await openConceptLoaded(page,theme,motion);
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
if(capture)await captureCanonicalState(page,testInfo,`concept-images-${width}-${theme}`);
|
||||
// The browser as the re-layout's oracle: the concept page itself without Save view, then without the sample tag.
|
||||
const removed={};
|
||||
for(const [key,selector] of [['toolbar','.toolbar > [data-action="save-view"]'],['filterRow','.filter-row > .sample-tag']]) {
|
||||
const restore=await mutate(page,{remove:[selector]});
|
||||
removed[key]=(await inventoryOf(page,'#main'))[key];
|
||||
await restore();
|
||||
}
|
||||
const referenceImport=await importDialogOf(page);
|
||||
const referenceBehaviors=await behaviorsOf(page,'#main');
|
||||
|
||||
await openProductionLoaded(page,theme,motion);
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
if(capture)await captureCanonicalState(page,testInfo,`production-images-${width}-${theme}`);
|
||||
const actualImport=await importDialogOf(page);
|
||||
const actualBehaviors=await behaviorsOf(page,'#workspace-main');
|
||||
result[motion]={conceptLoad,reference,removed,actual,diff:compare(reference,actual,manifest),referenceImport,actualImport,
|
||||
importDiff:diffInventory(mapImportConcept(referenceImport),actualImport),referenceBehaviors,actualBehaviors};
|
||||
}
|
||||
// Load more in production: a fixture past one listing page appends one batch and shows the concept's toast.
|
||||
testInfo.manifest=await seedConcept(request,{extra_images:LOAD_MORE_EXTRA});
|
||||
for(const motion of MOTIONS) {
|
||||
const total=FIXTURE_IMAGES+LOAD_MORE_EXTRA;
|
||||
await openProductionLoaded(page,theme,motion,total);
|
||||
const before=await page.locator(CARDS).count();
|
||||
const loadingRow=(await inventoryOf(page,'#workspace-main')).continuation,loadingCount=await countText(page);
|
||||
await page.getByRole('button',{name:'Load more images',exact:true}).click();
|
||||
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`));
|
||||
result[motion].productionLoad={before,after:await page.locator(CARDS).count(),toast:await toastText(page),count:await countText(page)};
|
||||
result[motion].loadingDiff=compareLoadingRow(result[motion].conceptLoad.loadingRow,loadingRow,loadingCount);
|
||||
}
|
||||
const path=testInfo.outputPath('images-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
||||
await testInfo.attach('images-geometry',{path,contentType:'application/json'});
|
||||
|
||||
for(const [motion,entry] of Object.entries(result)) {
|
||||
const {conceptLoad:{loadingRow,...conceptLoad},productionLoad,loadingDiff,reference,removed,actual,diff,referenceImport,importDiff,
|
||||
referenceBehaviors:concept,actualBehaviors:production}=entry;
|
||||
// The concept itself is what this comparison assumes: its first batch, its sample card and its copy.
|
||||
expect.soft(conceptLoad,`${motion} concept Load more`).toEqual({before:CONCEPT_BATCH,after:FIXTURE_IMAGES,
|
||||
toast:`${CONCEPT_BATCH} more images loaded. ${FIXTURE_IMAGES} of ${FIXTURE_IMAGES}.`,count:`${FIXTURE_IMAGES} of ${FIXTURE_IMAGES} images · 6 positive prompts`});
|
||||
// D37: production appends one listing page, with the concept's toast and count copy (D35 for one).
|
||||
expect.soft(productionLoad,`${motion} production Load more`).toEqual({before:PRODUCTION_BATCH,after:2*PRODUCTION_BATCH,
|
||||
toast:countCopy(`${PRODUCTION_BATCH} more images loaded. ${2*PRODUCTION_BATCH} of ${2*PRODUCTION_BATCH}.`),
|
||||
count:countCopy(`${2*PRODUCTION_BATCH} of ${2*PRODUCTION_BATCH} images · 6 positive prompts`)});
|
||||
// D37: the row with more to load, before its press, in both documents.
|
||||
expect.soft(loadingRow.items.map(item=>item.text),`${motion} concept loading row`).toEqual([`${CONCEPT_BATCH} of ${FIXTURE_IMAGES} images · more load as you scroll`,'Load more images']);
|
||||
expect.soft(loadingDiff,`${motion} loading row differences after D32, D35, D37`).toEqual([]);
|
||||
// D11: the concept's subtitle is the fixture's own "W × H · PNG · Seed N".
|
||||
const first=manifest.images[0];
|
||||
expect.soft(reference.cards[0].info.p.text,`${motion} concept subtitle`).toBe(`${first.width} × ${first.height} · PNG · Seed ${first.seed}`);
|
||||
expect.soft(reference.cards[0].info.h3.fontSize,`${motion} concept h3 size`).toBe('11px');
|
||||
expect.soft(referenceImport.rows.map(row=>row.title),`${motion} concept Import rows`).toEqual(['Inspect a local image','Scan an output folder','Import portable recipes']);
|
||||
// The row model reproduces the concept's own rows, and the concept page itself once Save view (D16) or the
|
||||
// sample tag (D29) is removed from it at runtime: the browser is the oracle for the remainders the mapping uses.
|
||||
expect.soft(diffInventory(stripExact(reflow(reference.toolbar,reference.toolbar.items)),stripExact(reference.toolbar)),`${motion} toolbar model`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(reflow(reference.filterRow,reference.filterRow.items)),stripExact(reference.filterRow)),`${motion} filter row model`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(withoutItems(reference.toolbar,item=>item.text==='Save view')),stripExact(removed.toolbar)),
|
||||
`${motion} toolbar without Save view`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag'))),stripExact(removed.filterRow)),
|
||||
`${motion} filter row without the sample tag`).toEqual([]);
|
||||
expect.soft(reference.toolbar.items.find(item=>item.id==='category-filter').options,`${motion} concept categories`).toEqual(CONCEPT_CATEGORIES);
|
||||
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
|
||||
expect.soft(importDiff,`${motion} Import dialog differences after D15`).toEqual([]);
|
||||
expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true);
|
||||
// Hover (C4): the concept's 1.03 zoom over 0.2s, instant under reduced motion; D18: none where there is no hover.
|
||||
expect.soft(concept.hover,`${motion} concept hover`).toMatchObject({hovered:true,transform:'matrix(1.03, 0, 0, 1.03, 0, 0)',
|
||||
transitionDuration:motion==='reduce'?'0s':'0.2s'});
|
||||
expect.soft(production.hover,`${motion} hover`).toEqual(production.hover.hoverNone?{...concept.hover,transform:'none'}:concept.hover);
|
||||
// The 2nd card's image opens the viewer titled "<title> · <filename>".
|
||||
expect.soft(concept.viewerTitle,`${motion} concept viewer title`).toBe(`${reference.cards[1].info.h3.text} · ${concept.opened.replace(/^Open /,'')}`);
|
||||
expect.soft(production.viewerTitle,`${motion} viewer title`).toBe(concept.viewerTitle);
|
||||
// Escape returns focus to the image that opened the viewer.
|
||||
expect.soft(production.focusAfterViewer,`${motion} focus after the viewer`).toEqual({tag:'button',label:concept.opened,card:1});
|
||||
// The concept re-renders its grid when the viewer closes (app.js 1737-1743), so its focus falls to the body;
|
||||
// §7 and D19 keep production's restore, which is what is asserted above.
|
||||
// Info on the 3rd card moves `.media-card.selected` there.
|
||||
expect.soft(production.selected,`${motion} selection after Info`).toEqual(concept.selected);
|
||||
// List (160×95 art) and Masonry (--media-ratio media) as the concept lays them out.
|
||||
expect.soft(concept.layouts.List.cards[0].img,`${motion} concept list art`).toMatchObject({width:160,height:95});
|
||||
for(const layout of LAYOUTS) {
|
||||
const mapped=mapImagesConcept({...reference,...concept.layouts[layout]},{manifest});
|
||||
expect.soft(compareLayout(layout,concept.layouts[layout],mapped,production.layouts[layout]),`${motion} ${layout} layout`).toEqual([]);
|
||||
}
|
||||
for(const card of concept.layouts.Masonry.cards) {
|
||||
const [w,h]=card.ratio.split('/').map(Number);
|
||||
expect.soft(Math.abs(card.img.width/card.img.height-w/h),`${motion} concept masonry ratio ${card.open.label}`).toBeLessThan(0.02);
|
||||
}
|
||||
// Searches leave the same images, count line and continuation in both documents (D35 for one).
|
||||
const mappedSearch=search=>({...search,count:countCopy(search.count),continuation:countCopy(search.continuation)});
|
||||
expect.soft(production.search,`${motion} search alpine`).toEqual(mappedSearch(concept.search));
|
||||
expect.soft(concept.single,`${motion} concept single result`).toMatchObject({count:'1 of 1 images · 1 positive prompts',cards:['Open alpine_00003.png']});
|
||||
expect.soft(production.single,`${motion} search for one image`).toEqual(mappedSearch(concept.single));
|
||||
expect.soft(production.single.count,`${motion} singular count line`).toBe('1 of 1 image · 1 positive prompt');
|
||||
}
|
||||
});
|
||||
|
||||
// D8 follow-up (image selection, Task 1): with the first card checked, the concept's bar (app.js 430-434) and the
|
||||
// checked card, against production's bar of real actions. The concept page itself, without the omitted actions, is the
|
||||
// oracle for the re-laid-out bar.
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selection bar matches the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const manifest=testInfo.manifest;
|
||||
const check=async()=>{
|
||||
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
|
||||
await expect(page.locator('#bulkbar > .bulkbar > strong')).toHaveText('1 selected');
|
||||
await page.evaluate(()=>document.activeElement?.blur());await page.mouse.move(0,0);await settle(page);
|
||||
};
|
||||
await openConceptLoaded(page,theme,'no-preference');
|
||||
const empty=await inventoryOf(page,'#main');
|
||||
await check();
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
const restore=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS]});
|
||||
const removed=(await inventoryOf(page,'#main')).bulkbar;
|
||||
await restore();
|
||||
await captureCanonicalState(page,testInfo,`concept-images-selected-${width}-${theme}`);
|
||||
await openProductionLoaded(page,theme,'no-preference');
|
||||
const emptyActual=await inventoryOf(page,'#workspace-main');
|
||||
await check();
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
await captureCanonicalState(page,testInfo,`production-images-selected-${width}-${theme}`);
|
||||
// The concept itself: an empty bar, then "1 selected" and its six actions.
|
||||
expect.soft(empty.bulkbar,'concept empty bar').toMatchObject({id:'bulkbar',height:0,children:0,bar:null});
|
||||
expect.soft(reference.bulkbar.bar.items.map(item=>item.text),'concept bar').toEqual(['1 selected','Add tags','Set category',
|
||||
'Add to collection','Export metadata','Remove links…','Clear']);
|
||||
expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]);
|
||||
expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Set category','Export metadata','Clear']);
|
||||
expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
|
||||
expect.soft(compare(reference,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]);
|
||||
// The bar comparison notices a change to the production bar.
|
||||
await mutate(page,{css:'.bulkbar { padding: 4px }'});
|
||||
expect(compare(reference,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
|
||||
});
|
||||
|
||||
// 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)=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
const manifest=testInfo.manifest;
|
||||
await openConceptLoaded(page,'dark','no-preference');
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
await openProductionLoaded(page,'dark','no-preference');
|
||||
const measure=()=>inventoryOf(page,'#workspace-main');
|
||||
assertImagesMatch(reference,await measure(),manifest);
|
||||
|
||||
const restoreCss=await mutate(page,{css:'.media-info { padding: 4px }'});
|
||||
const cssMutated=await measure();
|
||||
expect(()=>assertImagesMatch(reference,cssMutated,manifest)).toThrow(/cards\.0\.info\.paddingTop/);
|
||||
await restoreCss();await settle(page);
|
||||
assertImagesMatch(reference,await measure(),manifest);
|
||||
|
||||
await mutate(page,{remove:['[aria-label^="Inspect"]']});
|
||||
const domMutated=await measure();
|
||||
expect(()=>assertImagesMatch(reference,domMutated,manifest)).toThrow(/cards\.0\.info\.actions\.buttons\.length/);
|
||||
});
|
||||
|
||||
// Grid, cards and panel at the other concept breakpoints: 2 columns and the 275px panel at 1200, 4 columns and the
|
||||
// 310px panel at 1800.
|
||||
for(const [width,height] of WIDE_SIZES)for(const theme of THEMES)test(`images grid, cards and panel match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const manifest=testInfo.manifest;
|
||||
await openConceptLoaded(page,theme,'no-preference');
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
await captureCanonicalState(page,testInfo,`concept-images-${width}-${theme}`);
|
||||
await openProductionLoaded(page,theme,'no-preference');
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
await captureCanonicalState(page,testInfo,`production-images-${width}-${theme}`);
|
||||
expect(reference.grid.columns,'concept columns').toBe(width<=1250?2:4);
|
||||
expect(reference.panel.box.width,'concept panel').toBe(width<=1250?275:310);
|
||||
expect(compare(reference,actual,manifest,['grid','cards','panel'])).toEqual([]);
|
||||
});
|
||||
|
||||
// D35 for the toast and the continuation: production's Load more appending exactly one image, against the concept's
|
||||
// own toast and row copy (app.js 489, 517-521) with production's numbers (D37).
|
||||
test('one appended image reads in the singular',async({page,request})=>{
|
||||
// A second fixture seed (dozens of PNGs and their thumbnails) runs inside the test.
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
const total=PRODUCTION_BATCH+1;
|
||||
await seedConcept(request,{extra_images:total-FIXTURE_IMAGES});
|
||||
await openProductionLoaded(page,'dark','no-preference',total);
|
||||
await expect(page.locator('.infinite-continuation > p').first()).toHaveText(countCopy(`${PRODUCTION_BATCH} of ${total} images · more load as you scroll`));
|
||||
// A dispatched press: scrolling the button into view lets the page's own observer load the one image first.
|
||||
await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click');
|
||||
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`));
|
||||
await expect(page.locator('.toast')).toHaveText(countCopy(`1 more images loaded. ${total} of ${total}.`));
|
||||
expect(countCopy('1 more images loaded. 41 of 41.')).toBe('1 more image loaded. 41 of 41.');
|
||||
await expect(page.locator('.infinite-continuation > p').first()).toHaveText(`You’re all caught up · ${total} images`);
|
||||
});
|
||||
|
||||
// The same singular copy when the page's own observer loads the one image: the user only scrolls (no press).
|
||||
test('one image appended by scrolling reads in the singular',async({page,request})=>{
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
const total=PRODUCTION_BATCH+1;
|
||||
await seedConcept(request,{extra_images:total-FIXTURE_IMAGES});
|
||||
await openProductionLoaded(page,'dark','no-preference',total);
|
||||
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${PRODUCTION_BATCH} of ${total} images`));
|
||||
await page.locator('.infinite-continuation').scrollIntoViewIfNeeded();
|
||||
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`));
|
||||
await expect(page.locator('.toast')).toHaveText(countCopy(`1 more images loaded. ${total} of ${total}.`));
|
||||
await expect(page.locator('.infinite-continuation > p').first()).toHaveText(`You’re all caught up · ${total} images`);
|
||||
});
|
||||
|
||||
// D3 (one footer height for the collection): bounded rendering cannot measure unrendered cards, so every row reserves
|
||||
// the tallest measured footer. Two seedless files (D11 "W × H · PNG", one line) form the first row at 390px, where
|
||||
// every concept subtitle wraps: their row is as tall as the wrapped rows, and every concept row moves down by one.
|
||||
test('one footer height for the collection (D3)',async({page,request})=>{
|
||||
// A second fixture seed (dozens of PNGs and their thumbnails) runs inside the test.
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await openConceptLoaded(page,'dark','no-preference');
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
expect(reference.grid.columns).toBe(2);
|
||||
expect(reference.cards.every(card=>card.info.p.height>parseFloat(card.info.p.lineHeight))).toBe(true);
|
||||
const manifest=await seedConcept(request,{seedless_images:2});
|
||||
await openProductionLoaded(page,'dark','no-preference',FIXTURE_IMAGES+2);
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
const box=card=>({left:card.left,top:card.top,width:card.width,height:card.height});
|
||||
const pitch=reference.cards[2].top-reference.cards[0].top;
|
||||
expect(actual.cards.map(box)).toEqual([box(reference.cards[0]),box(reference.cards[1]),
|
||||
...reference.cards.slice(0,4).map(card=>({...box(card),top:card.top+pitch}))]);
|
||||
const seedless=actual.cards.slice(0,2);
|
||||
expect(seedless.map(card=>card.open.label)).toEqual(manifest.seedless.map(image=>`Open ${image.filename}`));
|
||||
expect(seedless.map(card=>card.info.p.text)).toEqual(['1536 × 1152 · PNG','1536 × 1152 · PNG']);
|
||||
// Their own footer is one line shorter than the row it reserves.
|
||||
for(const card of seedless)expect(card.info.height).toBeLessThan(reference.cards[0].info.height);
|
||||
});
|
||||
@@ -18,7 +18,7 @@ test.beforeEach(async({page,request})=>{
|
||||
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`editor state captures for maintainer acceptance ${width} ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});const f=page.fixture;
|
||||
await page.goto(`${workspace}#/library/${f.positive_id}`);await expect(variants(page)).toBeVisible();
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-variants':'dialog.library-editor');
|
||||
|
||||
await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click();
|
||||
|
||||
@@ -4,7 +4,7 @@ import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/inde
|
||||
const workspace='/prompt_manager/workspace#/images';
|
||||
const details=page=>page.getByRole('region',{name:'Image details',exact:true});
|
||||
const role=(scope,name)=>scope.getByRole('region',{name:`${name} prompt`,exact:true});
|
||||
const select=async(page,key)=>page.locator(`article[data-image-key="${key}"]`).getByRole('button',{name:/^Open /}).click();
|
||||
const select=async(page,key)=>page.locator(`article[data-image-key="${key}"]`).getByRole('button',{name:/^Inspect /}).click();
|
||||
const dialog=page=>page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
|
||||
test.beforeEach(async({page,request,browserName})=>{
|
||||
@@ -45,10 +45,10 @@ test('collection viewer rejects stale source and sends only refreshed API eviden
|
||||
await expect(await request.post('/prompt_manager/settings',{data:{gallery_root_paths:['input','output']}})).toBeOK();
|
||||
const response=page.waitForResponse(result=>result.url().includes('/images/inspect/shared/same.png?root=0'));
|
||||
await select(page,'0:shared/same.png');const changed=await response;expect(changed.status()).toBe(409);expect((await changed.json()).code).toBe('source-changed');
|
||||
await page.locator('article[data-image-key="0:shared/same.png"] .image-card-footer button').click();const preview=page.getByRole('dialog',{name:'Image preview',exact:true});
|
||||
await page.locator('article[data-image-key="0:shared/same.png"] .image-open').click();const preview=page.locator('dialog#viewer-dialog');
|
||||
const message='The source changed. Refresh the image collection before inspecting this file.';
|
||||
await expect(preview).toContainText(message);await expect(preview.locator('[data-copy-role],[data-send-role],.file-inspector a[download]')).toHaveCount(0);
|
||||
const original=preview.getByRole('group',{name:'Media controls',exact:true}).getByLabel('Download original',{exact:true});
|
||||
const original=preview.locator('#viewer-toolbar').getByLabel('Download original',{exact:true});
|
||||
await expect(original).toHaveAttribute('aria-disabled','true');await expect(original).not.toHaveAttribute('href',/./);
|
||||
let downloads=0;page.on('download',()=>downloads++);await original.focus();await page.keyboard.press('Enter');expect(downloads).toBe(0);
|
||||
await expect(preview.getByRole('button',{name:'Retry media',exact:true})).toBeHidden();
|
||||
@@ -111,17 +111,49 @@ test('sender requires explicit destination, previews full changes and acknowledg
|
||||
await chosen.getByRole('button',{name:'Close',exact:true}).click();
|
||||
await page.evaluate(()=>scrollTo(0,0));
|
||||
await page.screenshot({path:testInfo.outputPath('inspector-dark.png'),fullPage:true});
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption('light');
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption('light');
|
||||
await page.screenshot({path:testInfo.outputPath('inspector-light.png'),fullPage:true});
|
||||
await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
|
||||
await page.screenshot({path:testInfo.outputPath('viewer-prompts.png')});
|
||||
await page.getByRole('button',{name:'Close preview',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
await page.setViewportSize({width:390,height:844});await details(page).scrollIntoViewIfNeeded();
|
||||
await page.screenshot({path:testInfo.outputPath('inspector-mobile.png')});
|
||||
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
// D6: ComfyUI ↗ is a link to the canvas; on Images with a selected file whose two roles resolved, a plain click opens
|
||||
// the acknowledged pair send instead (concept app.js 1750-1757).
|
||||
test('ComfyUI ↗ opens the pair send for a selected resolved image, else follows the link',async({page,context})=>{
|
||||
const host=await fakeHost(context,page);
|
||||
try {
|
||||
await page.goto('/prompt_manager/workspace#/overview');
|
||||
const link=page.locator('header.topbar').getByRole('link',{name:'ComfyUI',exact:true});
|
||||
await expect(link).toHaveAttribute('href','/');
|
||||
// Record whether the link's own handler let the navigation through, then stop it so the page stays.
|
||||
await page.evaluate(()=>{window.followed=[];document.addEventListener('click',event=>{if(!event.target.closest?.('.canvas-link'))return;window.followed.push(!event.defaultPrevented);event.preventDefault();});});
|
||||
await link.click();await expect(dialog(page)).toHaveCount(0);
|
||||
await page.locator('nav [data-route="images"]').click();await expect(page.locator('article[data-image-key]').first()).toBeVisible();
|
||||
await select(page,'0:uncertain.png');await expect(details(page)).toContainText('Not recorded in this file.');
|
||||
await link.click();await expect(dialog(page)).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>window.followed)).toEqual([true,true]);
|
||||
await select(page,'0:shared/same.png');await expect(details(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeEnabled();
|
||||
await link.click();
|
||||
const sender=await chooseDestination(page);
|
||||
expect(await page.evaluate(()=>window.followed)).toEqual([true,true,false]);
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
for(const text of ['Cat portrait','blurry, watermark'])await expect(sender).toContainText(text);
|
||||
expect(await host.evaluate(()=>window.applied)).toEqual([]);
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.applied)).toEqual([{positive:'Cat portrait',negative:'blurry, watermark'}]);
|
||||
await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(dialog(page)).toHaveCount(0);await expect(link).toBeFocused();
|
||||
// Another page has no image selection, so the link is a plain link again.
|
||||
await page.locator('nav [data-route="library"]').click();await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await link.click();await expect(dialog(page)).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>window.followed)).toEqual([true,true,false,true]);
|
||||
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
test('host apply rejection retains the current text and requires a new preview',async({page,context})=>{
|
||||
const host=await fakeHost(context,page,{rejectApply:true});
|
||||
try {
|
||||
@@ -195,8 +227,9 @@ test('filtering away a selected image aborts its pending metadata request',async
|
||||
try {
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');await requested;
|
||||
const baseline=await page.evaluate(()=>window.inspectionAborts);
|
||||
await page.getByRole('searchbox',{name:'Search filenames',exact:true}).fill('no-such-file');
|
||||
await expect(page.getByText('No matching images or media. Try changing the filters.',{exact:true})).toBeVisible();
|
||||
await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('no-such-file');
|
||||
await expect(page.getByText('No matching images · 0 images',{exact:true})).toBeVisible();
|
||||
await expect(page.locator('.gallery-body > .empty')).toHaveText('No matching images. Try another search or category.');
|
||||
await expect.poll(()=>page.evaluate(()=>window.inspectionAborts),{timeout:1500}).toBeGreaterThan(baseline);
|
||||
} finally {release();}
|
||||
});
|
||||
@@ -215,10 +248,10 @@ test('responsive gallery defers observed size writes and still fits the new view
|
||||
};
|
||||
});
|
||||
await page.setViewportSize({width:1280,height:900});await page.goto(workspace);
|
||||
await expect(page.locator('.image-card')).toHaveCount(4);
|
||||
await expect(page.locator('.media-card')).toHaveCount(4);
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await expect.poll(()=>page.locator('.image-card').first().evaluate(node=>node.getBoundingClientRect().width)).toBeLessThan(390);
|
||||
await expect.poll(()=>page.locator('.image-grid').evaluate(node=>Number.parseFloat(node.style.height))).toBeCloseTo(383.75,0);
|
||||
await expect.poll(()=>page.locator('.media-card').first().evaluate(node=>node.getBoundingClientRect().width)).toBeLessThan(390);
|
||||
await expect.poll(()=>page.locator('.image-grid').evaluate(node=>Number.parseFloat(node.style.height))).toBeCloseTo(384.4,0); // the concept's 1.33 media plus the measured info row
|
||||
expect(await page.evaluate(()=>window.inlineResizeWrites)).toBe(0);
|
||||
});
|
||||
|
||||
@@ -232,6 +265,17 @@ test('unverified saved associations explain uncertainty and cannot be sent',asyn
|
||||
await expect(details(page).getByRole('button',{name:/^Send /})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('unresolved roles name the blocking node as text and offer no prompt actions',async({page})=>{
|
||||
const line='Prompt uses a node the inspector does not support (<img src=x onerror="window.injected=1">).';
|
||||
await page.route('**/images/inspect/uncertain.png?root=0*',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),image:{root_index:0,path:'uncertain.png',filename:'uncertain.png'},prompts:{
|
||||
positive:{status:'unresolved',text:null,source:null,message:line},negative:{status:'unresolved',text:null,source:null,message:line},
|
||||
}}}));
|
||||
await page.goto(workspace);await select(page,'0:uncertain.png');
|
||||
for (const name of ['Positive','Negative']) await expect(role(details(page),name)).toContainText(line);
|
||||
await expect(details(page).locator('img[onerror]')).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>window.injected)).toBeUndefined();
|
||||
await expect(details(page).getByRole('button',{name:/^(Send|Copy) /})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('prompt role headings contain compact real actions and honest source evidence',async({page})=>{
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
|
||||
@@ -14,6 +14,11 @@ from aiohttp import web
|
||||
from PIL import Image
|
||||
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,
|
||||
seed_workspace_concept,
|
||||
)
|
||||
from tests.browser.workspace_visual_fixture import seed_workspace_visual
|
||||
from tests.browser.workspace_inspector_fixture import seed_workspace_inspector
|
||||
from tests.browser.workspace_history_fixture import (
|
||||
@@ -128,6 +133,7 @@ def create_app(root: Path) -> web.Application:
|
||||
) -> None:
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
@@ -379,6 +385,40 @@ def create_app(root: Path) -> web.Application:
|
||||
)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/concept/reset")
|
||||
async def concept_reset(request: web.Request) -> web.Response:
|
||||
"""Seed the concept-shaped fixture (D30); only a bounded image count."""
|
||||
nonlocal history_fixture
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
limits = {
|
||||
"extra_images": CONCEPT_MAX_EXTRA_IMAGES,
|
||||
"seedless_images": CONCEPT_MAX_SEEDLESS_IMAGES,
|
||||
}
|
||||
if (
|
||||
request.query
|
||||
or not isinstance(data, dict)
|
||||
or set(data) - set(limits)
|
||||
or any(
|
||||
isinstance(data.get(key, 0), bool)
|
||||
or not isinstance(data.get(key, 0), int)
|
||||
or not 0 <= data.get(key, 0) <= limit
|
||||
for key, limit in limits.items()
|
||||
)
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
history_commands.reset()
|
||||
library_editor_commands.reset()
|
||||
history_fixture = None
|
||||
manifest = await api._run_in_executor(
|
||||
lambda: seed_workspace_concept(
|
||||
api,
|
||||
root,
|
||||
extra_images=data.get("extra_images", 0),
|
||||
seedless_images=data.get("seedless_images", 0),
|
||||
)
|
||||
)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
|
||||
@routes.post("/__test__/add-nested-image")
|
||||
async def add_nested_image(request: web.Request) -> web.Response:
|
||||
destination = root / "output" / "project" / "day" / "new-output.png"
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,focusRingOf,hoverStateOf,mutate} from './concept-inventory.mjs';
|
||||
|
||||
// Task A6 of #249: the production shell (sidebar, topbar, heading, footer) against the immutable concept, with the
|
||||
// concept-shaped real fixture so the sidebar counts are the concept's own (24 images, 6 prompts, 15 tags). Both themes,
|
||||
// 1600x1100 and 390x844, normal and reduced motion. Paired captures are artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
// Routes whose shell is compared. The concept and production share route names for these three pages.
|
||||
const ROUTES=Object.freeze([['images','Images'],['library','Prompt library'],['tags','Tags']]);
|
||||
// The heading's content (description copy, actions) belongs to each page's own PR (C1, E1, F1) and is compared there;
|
||||
// Tags & categories is already rebuilt (#245), so its heading is compared in full.
|
||||
const FULL_HEADING=new Set(['tags']);
|
||||
|
||||
const rowNamed=(nav,text)=>nav.rows.find(row=>row.text===text);
|
||||
|
||||
const removedItem=item=>String(item.className).includes('concept-badge')||(item.tag==='button'&&item.text.startsWith('Activity'));
|
||||
|
||||
/** Left offsets of flex items packed along a row of `width` by its `justify-content` and `gap`. */
|
||||
function packedLefts(items,width,justify,gap) {
|
||||
const total=items.reduce((sum,item)=>sum+item.width,0)+gap*(items.length-1);
|
||||
let start=0,spacing=gap;
|
||||
if(justify==='flex-end'||justify==='end')start=width-total;
|
||||
else if(justify==='center')start=(width-total)/2;
|
||||
else if(justify==='space-between'&&items.length>1)spacing=gap+(width-total)/(items.length-1);
|
||||
let x=start;
|
||||
return items.map(item=>{const left=Math.round(x);x+=item.width+spacing;return left;});
|
||||
}
|
||||
|
||||
/** The top-actions row once removed items are gone: a centered flex row, so its size and item offsets follow. */
|
||||
function remainingTopActions(actions) {
|
||||
const kept=actions.items.filter(item=>!removedItem(item));
|
||||
const shown=kept.filter(item=>item.shown);
|
||||
const height=Math.max(...shown.map(item=>item.height));
|
||||
const gap=parseFloat(actions.columnGap);
|
||||
// A full-width row (<=700px) keeps its width; otherwise it shrinks to its remaining items.
|
||||
const width=actions.fullWidth?actions.width:shown.reduce((sum,item)=>sum+item.width,0)+gap*(shown.length-1);
|
||||
// At <=700px the concept spreads three items with `space-between`, so its Theme select sits in the middle; with
|
||||
// Activity removed (D5) the same rule puts Theme at the row's start. Offsets come from the concept's own sizes.
|
||||
const lefts=new Map(shown.map((item,index)=>[item,packedLefts(shown,width,actions.justifyContent,gap)[index]]));
|
||||
return {...actions,height,width,
|
||||
items:kept.map(item=>item.shown?{...item,top:Math.round((height-item.height)/2),left:lefts.get(item)}:item)};
|
||||
}
|
||||
|
||||
/** The footer once its button is gone: paddings plus the remaining spans along the footer's flex direction. */
|
||||
function remainingFooter(footer,spans) {
|
||||
const content=footer.flexDirection==='column'
|
||||
?spans.reduce((sum,span)=>sum+span.height,0)+parseFloat(footer.rowGap)*(spans.length-1)
|
||||
:Math.max(...spans.map(span=>span.height));
|
||||
return `${parseFloat(footer.paddingTop)+parseFloat(footer.paddingBottom)+content}px`;
|
||||
}
|
||||
|
||||
// D28 ruling (final review A, Minor 1): Overview gets a distinct outline home glyph. The concept has no home icon, and
|
||||
// its `activity` pulse stays reserved for the Activity row D5 omits, so the two entries never share one glyph.
|
||||
const OVERVIEW_GLYPH='M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6';
|
||||
|
||||
/** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */
|
||||
function mapShellConcept(reference) {
|
||||
const {chrome}=reference;
|
||||
const navs=chrome.navs
|
||||
// D28: SAVED VIEWS and COLLECTIONS are proposals with no persisted store, so their navs are not rendered.
|
||||
.filter(nav=>nav.id!=='saved-nav'&&nav.id!=='collections-nav')
|
||||
.map(nav=>{
|
||||
if(nav.id!=='system-nav')return nav;
|
||||
const system=rowNamed(nav,'System');
|
||||
return {...nav,rows:[
|
||||
// D5: no Activity row until an activity center exists. D28: no Design review (prototype page).
|
||||
system,
|
||||
// D28: the production Overview hub follows System, as a concept nav row without a count. Its icon is
|
||||
// the ruling's own outline home glyph (OVERVIEW_GLYPH), never the concept's `activity` pulse.
|
||||
{...system,text:'Overview',current:null,gapAfter:null,icon:{...system.icon,d:OVERVIEW_GLYPH}},
|
||||
]};
|
||||
});
|
||||
return {...reference,chrome:{...chrome,
|
||||
// D29: no prototype suffix in the document title.
|
||||
title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'),
|
||||
// D28: only the WORKSPACE group label remains.
|
||||
labels:chrome.labels.filter(label=>label.text==='WORKSPACE'),
|
||||
navs,
|
||||
connection:{...chrome.connection,
|
||||
// D28: truthful copy instead of "Demo connection · Classic / Nodes 2.0".
|
||||
small:'Your library, on your server',
|
||||
// D28: no fake connection state, so the dot is the neutral muted token (the concept's label color).
|
||||
dot:{...chrome.connection.dot,backgroundColor:chrome.labels[0].color}},
|
||||
// D29: no "Interactive concept" badge. D5: no Activity button. The row's size and the remaining items'
|
||||
// centered offsets follow from the concept's own item sizes.
|
||||
topActions:remainingTopActions(chrome.topActions),
|
||||
footer:(()=>{
|
||||
// D29: production footer copy (one line, as the concept's own copy is), and no "What changes & why"
|
||||
// (a prototype review page); the footer then holds only its span.
|
||||
const spans=chrome.footer.spans.map(span=>({...span,text:'ComfyAssets · Local tools. Your library.'}));
|
||||
return {...chrome.footer,spans,buttons:[],height:remainingFooter(chrome.footer,spans)};
|
||||
})(),
|
||||
},
|
||||
heading:reference.heading&&{...reference.heading,actions:reference.heading.actions&&{...reference.heading.actions,
|
||||
// #245 vocabulary ruling D2 (mapVocabularyConcept): Auto-tag images is omitted until the image-annotation slice.
|
||||
buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}}};
|
||||
}
|
||||
|
||||
/** The heading without its page-owned description copy and actions; the h1 text is the shell's and stays compared. */
|
||||
function headingFrame(heading) {
|
||||
if(!heading)return heading;
|
||||
const {height,description,actions,...frame}=heading;
|
||||
const {text:copy,...descriptionTokens}=description??{};
|
||||
const {buttons,stacked,...actionTokens}=actions??{};
|
||||
return {...frame,description:description?descriptionTokens:null,actions:actions?actionTokens:null};
|
||||
}
|
||||
|
||||
function compare(route,reference,actual) {
|
||||
const expected=mapShellConcept(reference);
|
||||
const heading=FULL_HEADING.has(route);
|
||||
return [
|
||||
...diffInventory({chrome:expected.chrome,noPageScroll:expected.noPageScroll},{chrome:actual.chrome,noPageScroll:actual.noPageScroll}),
|
||||
...diffInventory(heading?expected.heading:headingFrame(expected.heading),heading?actual.heading:headingFrame(actual.heading))
|
||||
.map(item=>({...item,path:`heading.${item.path}`})),
|
||||
];
|
||||
}
|
||||
|
||||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||||
function assertShellMatches(route,reference,actual) {
|
||||
const diff=compare(route,reference,actual);
|
||||
if(diff.length)throw new Error(`the shell differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
|
||||
}
|
||||
|
||||
/** Keyboard focus rings on the first WORKSPACE nav link and the first heading button. */
|
||||
async function ringsOf(page,mainSelector) {
|
||||
// Any key press makes the next programmatic focus keyboard-modal (`:focus-visible`); WebKit does not Tab to links.
|
||||
await page.locator('#page-title').focus();await page.keyboard.press('Tab');
|
||||
const button=page.locator(`${mainSelector} .heading-actions button`).first();
|
||||
await expect(button).toBeFocused();
|
||||
const headingButton=await focusRingOf(button);
|
||||
const link=page.locator('#primary-nav a').first();await link.focus();
|
||||
const navLink=await focusRingOf(link);
|
||||
await link.blur();
|
||||
return {navLink,headingButton};
|
||||
}
|
||||
|
||||
/** Hover colors of the first non-current WORKSPACE row, the Theme select and the ComfyUI link. */
|
||||
async function hoversOf(page) {
|
||||
return {
|
||||
navRow:await hoverStateOf(page,page.locator('#primary-nav a:not([aria-current])').first()),
|
||||
theme:await hoverStateOf(page,page.getByRole('combobox',{name:'Theme',exact:true})),
|
||||
canvasLink:await hoverStateOf(page,page.locator('.topbar .canvas-link')),
|
||||
};
|
||||
}
|
||||
|
||||
/** A viewport capture with the footer (and, at 1600, the sidebar's bottom group) in view. */
|
||||
async function captureFooter(page,testInfo,name) {
|
||||
await page.locator('.app-footer').scrollIntoViewIfNeeded();
|
||||
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
await captureCanonicalState(page,testInfo,name);
|
||||
}
|
||||
|
||||
test.beforeEach(async({request})=>{await seedConcept(request);});
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`shell geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});const result={};
|
||||
for(const motion of MOTIONS)for(const [route,breadcrumb] of ROUTES) {
|
||||
await openConcept(page,route,theme,motion);await page.mouse.move(0,0);
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`concept-shell-${route}-${width}-${theme}`);
|
||||
const referenceRings=FULL_HEADING.has(route)?await ringsOf(page,'#main'):null;
|
||||
const referenceHovers=await hoversOf(page);
|
||||
if(motion==='no-preference')await captureFooter(page,testInfo,`concept-footer-${route}-${width}-${theme}`);
|
||||
await openProduction(page,route,theme,motion);await page.mouse.move(0,0);
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`production-shell-${route}-${width}-${theme}`);
|
||||
const actualRings=FULL_HEADING.has(route)?await ringsOf(page,'#workspace-main'):null;
|
||||
const actualHovers=await hoversOf(page);
|
||||
if(motion==='no-preference')await captureFooter(page,testInfo,`production-footer-${route}-${width}-${theme}`);
|
||||
result[`${motion} ${route}`]={route,breadcrumb,reference,actual,diff:compare(route,reference,actual),referenceRings,actualRings,
|
||||
referenceHovers,actualHovers};
|
||||
}
|
||||
const path=testInfo.outputPath('shell-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
||||
await testInfo.attach('shell-geometry',{path,contentType:'application/json'});
|
||||
for(const [key,{route,breadcrumb,reference,actual,diff,referenceRings,actualRings,referenceHovers,actualHovers}] of Object.entries(result)) {
|
||||
// The concept itself is what this comparison assumes: its sample counts and its shell controls.
|
||||
expect.soft(reference.chrome.navs[0].rows.map(row=>[row.text,row.count?.text??null]),`${key} concept WORKSPACE rows`)
|
||||
.toEqual([['Images','24'],['Prompt library','6'],['Tags & categories','15'],['Inspect a file',null]]);
|
||||
expect.soft(reference.chrome.topActions.items.map(item=>item.className||item.tag),`${key} concept top actions`)
|
||||
.toEqual(['concept-badge','button','theme-label','canvas-link']);
|
||||
expect.soft(reference.chrome.breadcrumbs.parts.at(-1).text,`${key} concept breadcrumb`).toBe(breadcrumb);
|
||||
expect.soft(reference.chrome.title,`${key} concept title`).toBe(`${breadcrumb} · Prompt Manager concept`);
|
||||
expect.soft(actual.chrome.title,`${key} production title`).toBe(`${breadcrumb} · Prompt Manager`);
|
||||
expect.soft(reference.heading.h1.text,`${key} concept page title`).toBe(route==='tags'?'Tags & categories':breadcrumb);
|
||||
// The concept's own nav-row hover (style.css:183-186) is what production is held to.
|
||||
expect.soft(referenceHovers.navRow,`${key} concept nav-row hover`).toMatchObject({hovered:true,color:reference.chrome.body.color});
|
||||
expect.soft(actualHovers,`${key} hover states`).toEqual(referenceHovers);
|
||||
expect.soft(diff,`${key} differences after the recorded deviations`).toEqual([]);
|
||||
expect.soft(actual.noPageScroll,`${key} no page-level horizontal scroll`).toBe(true);
|
||||
if(referenceRings) {
|
||||
// The toolkit's button ring (2px offset) wins over the concept's 3px for buttons, in the concept too (#245).
|
||||
expect.soft(referenceRings.headingButton,`${key} concept heading-button ring`).toMatchObject({focused:true,visible:true,outlineStyle:'solid',outlineWidth:'2px'});
|
||||
expect.soft(referenceRings.navLink,`${key} concept nav-link ring`).toMatchObject({focused:true,outlineStyle:'solid',outlineWidth:'2px',outlineOffset:'3px'});
|
||||
expect.soft(actualRings,`${key} focus rings`).toEqual(referenceRings);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the inventory reports a missing shell element by its path instead of failing',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openConcept(page,'tags','dark');await page.mouse.move(0,0);
|
||||
const reference=mapShellConcept(await inventoryOf(page,'#main'));
|
||||
await openProduction(page,'tags','dark');await page.mouse.move(0,0);
|
||||
expect(diffInventory(reference.chrome,(await inventoryOf(page,'#workspace-main')).chrome)).toEqual([]);
|
||||
await page.locator('.breadcrumbs').evaluate(node=>node.remove());
|
||||
await page.locator('.connection small').evaluate(node=>node.remove());
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
expect(actual.chrome.breadcrumbs).toBeNull();
|
||||
expect(actual.chrome.connection.smallFont).toBeNull();
|
||||
const paths=diffInventory(reference.chrome,actual.chrome).map(item=>item.path);
|
||||
expect(paths).toContain('breadcrumbs');
|
||||
expect(paths).toContain('connection.smallFont');
|
||||
expect(paths).toContain('connection.small');
|
||||
});
|
||||
|
||||
test('the concept fixture renders the concept inspector facts',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openProduction(page,'images','dark');
|
||||
// The first image is the concept's first sample, selected into the panel on first load (D19).
|
||||
const facts=page.locator('#workspace-main .generation-facts');
|
||||
await expect(facts).toBeVisible();
|
||||
const pairs=await facts.evaluate(list=>Object.fromEntries([...list.querySelectorAll('dt')].map(term=>[term.textContent.trim(),term.nextElementSibling.textContent.trim()])));
|
||||
// app.js:404-408: the concept's facts for alpine_00001.png.
|
||||
expect(pairs).toEqual({Seed:'42001',Steps:'28',CFG:'6.5',Sampler:'dpmpp_2m',Scheduler:'karras',Model:'illustrative-model.safetensors'});
|
||||
// D21 maps execution evidence to "Execution recorded" in PR D; today's panel names the same source this way.
|
||||
await expect(page.locator('#workspace-main').getByText('Execution prompt evidence',{exact:true})).toBeVisible();
|
||||
});
|
||||
|
||||
// Task A7: the comparison is only worth something if it notices a change. One CSS mutation (a nav-row height the
|
||||
// concept never uses) and one DOM mutation (the breadcrumbs removed) are applied to production after an exact match,
|
||||
// and each must make the comparison fail with the path of what changed.
|
||||
for(const [width,height] of SIZES)test(`shell comparison detects mutations ${width}x${height}`,async({page})=>{
|
||||
await page.setViewportSize({width,height});
|
||||
await openConcept(page,'tags','dark');await page.mouse.move(0,0);
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
await openProduction(page,'tags','dark');await page.mouse.move(0,0);
|
||||
const measure=()=>inventoryOf(page,'#workspace-main');
|
||||
const clean=await measure();
|
||||
assertShellMatches('tags',reference,clean);
|
||||
|
||||
const restoreCss=await mutate(page,{css:'.sidebar nav a { min-height: 30px }'});
|
||||
const cssMutated=await measure();
|
||||
expect(()=>assertShellMatches('tags',reference,cssMutated)).toThrow(/navs\.0\.rows\.0\.minHeight/);
|
||||
expect(()=>assertShellMatches('tags',reference,cssMutated)).toThrow(/navs\.0\.rows\.0\.height/);
|
||||
await restoreCss();
|
||||
assertShellMatches('tags',reference,await measure());
|
||||
|
||||
await mutate(page,{remove:['.breadcrumbs']});
|
||||
const domMutated=await measure();
|
||||
expect(domMutated.chrome.breadcrumbs).toBeNull();
|
||||
// The missing section is reported by its own path (A6: absent sections become null), not by a measuring error.
|
||||
expect(()=>assertShellMatches('tags',reference,domMutated)).toThrow(/(at |, )chrome\.breadcrumbs(,|$)/);
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
// mapVocabularyConcept / mapVocabularyDialog apply only the recorded deviations, each named with its ruling.
|
||||
export {inventoryOf, diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
export function pageInventoryOf(main, production) {
|
||||
export function pageInventoryOf(main) {
|
||||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const px=value=>Math.round(value);
|
||||
const visible=node=>node.getClientRects().length>0;
|
||||
@@ -18,8 +18,8 @@ export function pageInventoryOf(main, production) {
|
||||
|
||||
// Rendered line count of a cell's text, so a wrapped short name is reported even when the row height is unchanged.
|
||||
const lines=node=>{const range=document.createRange();range.selectNodeContents(node);return new Set([...range.getClientRects()].map(rect=>px(rect.top))).size;};
|
||||
const heading=main.querySelector(production?':scope > .workspace-heading':':scope > .heading');
|
||||
const copy=production?heading.querySelector('.workspace-heading-copy'):heading.firstElementChild;
|
||||
const heading=main.querySelector(':scope > .heading');
|
||||
const copy=heading.firstElementChild;
|
||||
const h1=copy.querySelector('h1'),subtitle=copy.querySelector('p'),actions=heading.querySelector('.heading-actions');
|
||||
const grid=main.querySelector(':scope > .two-col'),gridBox=grid.getBoundingClientRect();
|
||||
const surfaces=[...grid.querySelectorAll(':scope > section.surface')];
|
||||
|
||||
@@ -19,7 +19,7 @@ const categoryOperation=(page,name)=>main(page).locator('.operation').filter({ha
|
||||
async function seed(request,data={}) {const response=await request.post('/__test__/vocabulary/reset',{data});await expect(response).toBeOK();return response.json();}
|
||||
async function open(page,theme,count) {
|
||||
await page.goto('about:blank');await page.goto(route);
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
if(count!==undefined)await expect(rows(page)).toHaveCount(count);
|
||||
}
|
||||
async function openDialog(page,trigger) {await trigger.click();await expect(dialog(page)).toBeVisible();return dialog(page);}
|
||||
|
||||
@@ -224,7 +224,7 @@ test('every ordered receipt remains selected through distinct current locations,
|
||||
await expect(receipts.nth(3)).toContainText('missing');await expect(receipts.nth(4)).toContainText('Transient');
|
||||
await expect(dialog(page)).toContainText('Context membership unknown');
|
||||
const loadedReads=outputReads;const view=receipts.nth(1).getByRole('button',{name:'View current file',exact:true});await view.click();
|
||||
const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});await expect(viewer).toBeVisible();
|
||||
const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
|
||||
await expect(viewer).toContainText('1 of 2 loaded');await expect(viewer.getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText('same visible negative');
|
||||
await viewer.getByRole('button',{name:'Next media',exact:true}).click();await expect(viewer).toContainText('2 of 2 loaded');
|
||||
await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(dialog(page)).toBeVisible();
|
||||
@@ -245,7 +245,7 @@ test('inspection first discovers a real restore and immediately retires every su
|
||||
const inspectionRead=page.waitForResponse(response=>new URL(response.url()).pathname.endsWith('/output/current/inspection'));
|
||||
try {
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
|
||||
await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
|
||||
await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true);
|
||||
await page.evaluate(()=>{
|
||||
window.recordedRecoveryStates=[];
|
||||
@@ -259,7 +259,7 @@ test('inspection first discovers a real restore and immediately retires every su
|
||||
} finally {await command(request,{action:'release'});}
|
||||
const response=await inspectionRead;expect(response.status()).toBe(409);expect((await response.json()).code).toBe('history-changed');
|
||||
await expect(dialog(page).locator('.recorded-run-status')).toContainText('Recorded evidence changed');
|
||||
const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});
|
||||
const viewer=page.locator('dialog#viewer-dialog');
|
||||
if(await viewer.count())await page.keyboard.press('Escape');
|
||||
await expect(dialog(page)).toBeVisible();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(0);
|
||||
await expect(dialog(page).locator('[data-send-role]:enabled:visible,[data-copy-role]:enabled:visible,[data-view-receipt]:enabled:visible')).toHaveCount(0);
|
||||
@@ -297,7 +297,7 @@ test('unavailable exact receipt selection and targeted retry observe a newly ava
|
||||
await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
await expect(dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).locator('[aria-pressed="true"]')).toContainText('z');
|
||||
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await missing.getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');
|
||||
await missing.getByRole('button',{name:'View current file',exact:true}).click();await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
|
||||
await expect(missing).toHaveAttribute('aria-current','true');await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(keys);expect(writes).toBe(0);
|
||||
@@ -353,7 +353,7 @@ test('route reentry restores older timeline range and both scroll axes after 61
|
||||
|
||||
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`final fix review states ${width} ${theme}`,async({page,request},testInfo)=>{
|
||||
test.setTimeout(60000);await page.setViewportSize({width,height});await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
|
||||
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await marker(page,f.runs.shared,'z').click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
const missing=dialog(page).locator('[data-output-node-id="2"][data-image-ordinal="1"]');await missing.getByRole('button',{name:'Select reported output',exact:true}).click();
|
||||
await expect(missing).toHaveAttribute('aria-current','true');await expect(missing.getByRole('button',{name:'Retry current availability',exact:true})).toBeVisible();
|
||||
@@ -458,9 +458,49 @@ test('frozen Effective pair uses the shared acknowledged sender and Escape close
|
||||
} finally {await host.evaluate(()=>window.historyHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
test('the inline current file names its prompts apart from the frozen run fields',async({page})=>{
|
||||
// The current file resolves its own prompts, as a real host's saved PNG does, so the dialog holds two prompt texts per role.
|
||||
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
|
||||
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
|
||||
await route.fulfill({response,json:body});});
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
|
||||
const frozen=dialog(page).getByRole('region',{name:'Frozen run prompt texts',exact:true});
|
||||
const before=await exact(frozen.getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
|
||||
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});await expect(current).toBeVisible();
|
||||
await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('pre')).toHaveText('current file positive');
|
||||
await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('pre')).toHaveText('current file negative');
|
||||
await expect(current.getByRole('button',{name:'Copy current file positive prompt',exact:true})).toBeVisible();
|
||||
for(const role of ['Positive','Negative'])await expect(dialog(page).getByRole('region',{name:`${role} prompt`,exact:true})).toHaveCount(1);
|
||||
await expect(current.getByRole('region',{name:/^(Positive|Negative) prompt$/})).toHaveCount(0);
|
||||
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'))).toBe(before);
|
||||
});
|
||||
|
||||
test('the recorded run dialog with a resolved current file has no duplicate button or region names',async({page})=>{
|
||||
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
|
||||
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
|
||||
await route.fulfill({response,json:body});});
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
|
||||
await expect(dialog(page).getByRole('region',{name:'Current reported file',exact:true})).toBeVisible();
|
||||
await expect(dialog(page).getByRole('region',{name:'Current file positive prompt',exact:true})).toBeVisible();
|
||||
// Receipt rows repeat per-row actions by design; every other control and region in the dialog must be uniquely named.
|
||||
const snapshot=await dialog(page).ariaSnapshot();const counts=new Map();
|
||||
for(const line of snapshot.split('\n')){const match=/^\s*- (button|region|link) "((?:[^"\\]|\\.)*)"/.exec(line);if(!match)continue;
|
||||
const key=`${match[1]} ${match[2]}`;counts.set(key,(counts.get(key)||0)+1);}
|
||||
expect(counts.size).toBeGreaterThan(10);
|
||||
const receiptNames=await dialog(page).locator('[data-receipt]').first().evaluate(row=>[...row.querySelectorAll('button,a,[role="region"]')].map(node=>node.getAttribute('aria-label')||node.textContent.trim()));
|
||||
const repeated=[...counts].filter(([key,n])=>n>1&&!receiptNames.some(name=>key.endsWith(` ${name}`)));
|
||||
expect(repeated).toEqual([]);
|
||||
});
|
||||
|
||||
test('pagehide tears down dialogs and persisted reentry revalidates before enabling recorded actions',async({page})=>{
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
|
||||
await page.evaluate(()=>dispatchEvent(new PageTransitionEvent('pagehide',{persisted:true})));await expect(page.locator('dialog[open],.viewer-container')).toHaveCount(0);
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});let checks=0;
|
||||
await page.route(/\/history\/runs\/[^/]+\?/,async route=>{checks++;await held;await route.continue().catch(()=>{});});
|
||||
|
||||
+1319
-118
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@ test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);
|
||||
|
||||
async function mountEditor(page,theme) {
|
||||
await page.goto(`${workspace}#/overview`);await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
if(theme)await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
if(theme)await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await page.evaluate(async()=>{
|
||||
const trigger=document.createElement('button');trigger.type='button';trigger.id='editor-trigger';trigger.textContent='Open editor';document.body.append(trigger);
|
||||
window.editorEvents={committed:[],reconciled:[]};
|
||||
@@ -433,7 +433,7 @@ for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','
|
||||
|
||||
// Real entry actions: Library, Images and prompt history headings, and the Negative variants panel.
|
||||
const historyRegion=page=>page.getByRole('region',{name:'Prompt history',exact:true});
|
||||
const headingActions=page=>page.locator('#workspace-main .workspace-heading .heading-actions');
|
||||
const headingActions=page=>page.locator('#workspace-main > .heading > .heading-actions');
|
||||
const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants',exact:true});
|
||||
const savedTexts=page=>historyRegion(page).getByRole('region',{name:'Saved prompt texts',exact:true});
|
||||
const toast=page=>page.locator('#workspace-toast');
|
||||
@@ -478,8 +478,9 @@ test('library heading offers Export then + New prompt, and New opens the saved p
|
||||
|
||||
test('new from Images cancel preserves browse',async({page})=>{
|
||||
await page.goto(`${workspace}#/images`);await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
const actions=headingActions(page).locator('button');expect(await visibleNames(actions)).toEqual(['New prompt']);
|
||||
const trigger=actions.first();await expect(trigger).toHaveClass(/\bca-primary\b/);await expect(trigger.locator('svg')).toHaveCount(1);
|
||||
// #249 C2: the concept's Images heading puts Import before New prompt.
|
||||
const actions=headingActions(page).locator('button');expect(await visibleNames(actions)).toEqual(['Import','New prompt']);
|
||||
const trigger=actions.last();await expect(trigger).toHaveClass(/\bca-primary\b/);await expect(trigger.locator('svg')).toHaveCount(1);
|
||||
await trigger.click();let dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
|
||||
await field(dialog,'Title').fill('Discarded');await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();await expect(page).toHaveURL(/#\/images$/);await expect(page.locator('.library-shelf, .library-history')).toHaveCount(0);
|
||||
@@ -679,6 +680,9 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
|
||||
await lane.locator('.library-variant-dot').click();await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1);
|
||||
// #249 D8 ruling (a): the Library-embedded gallery has no select checkbox and no selection bar.
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('.image-overlays input[type="checkbox"]')).toHaveCount(0);
|
||||
await expect(page.locator('#bulkbar, .bulkbar')).toHaveCount(0);
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).getByText('1 loaded · 1 linked image',{exact:true})).toBeVisible();
|
||||
expect(galleryReads.at(-1).get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.pair_id));
|
||||
await expect(headingActions(page).getByRole('button',{name:/^Send prompt pair to ComfyUI/})).toBeEnabled();
|
||||
@@ -866,3 +870,25 @@ test('long and error states retain footer focus and reflow',async({page})=>{
|
||||
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
|
||||
});
|
||||
|
||||
// Task A2: the editor dialogs keep matching the concept on the concept base stylesheet, with library-editor.css retired.
|
||||
const CONCEPT_BASE=['js/shared/toolkit/controls.css','lib/viewerjs/viewer.min.css','js/workspace/concept.css',
|
||||
'js/workspace/images.css','js/workspace/library.css','js/workspace/inspector.css','js/workspace/workspace.css'];
|
||||
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`concept base keeps the tags screen and editor dialogs equal to the concept ${width} ${theme}`,async({page})=>{
|
||||
await page.setViewportSize({width,height});const f=page.fixture;const references={};
|
||||
for(const mode of Object.keys(conceptModes)) {
|
||||
const [hash,openMode]=conceptModes[mode];
|
||||
await page.goto('about:blank');await page.goto(`/__test__/canonical/workspace#${hash}`);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);await openMode(page);await expect(page.locator('#modal')).toBeVisible();
|
||||
if(mode==='revision')await page.locator('#modal-close').focus();
|
||||
references[mode]=mapConcept(await page.locator('#modal').evaluate(inventoryOf));
|
||||
}
|
||||
await mountEditor(page,theme);
|
||||
expect(await page.evaluate(()=>[...document.styleSheets].filter(sheet=>sheet.href).map(sheet=>new URL(sheet.href).pathname.replace(/^\/prompt_manager\//,'')))).toEqual(CONCEPT_BASE);
|
||||
const options={new:{mode:'new'},metadata:{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id},revision:{mode:'revision',positiveId:f.positive_id,pairId:f.pair_id},negative:{mode:'negative',positiveId:f.positive_id}};
|
||||
for(const mode of Object.keys(conceptModes)) {
|
||||
const dialog=await openEditor(page,options[mode]);await ready(dialog);
|
||||
expect(diffInventory(references[mode],await dialog.evaluate(inventoryOf)),`${mode} dialog`).toEqual([]);
|
||||
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -129,7 +129,7 @@ test('returning from history restores library context and mobile content stays i
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -140,9 +140,9 @@ test('empty shelf visits restore saved scroll without inventing a parent anchor'
|
||||
await page.evaluate(()=>scrollTo(0,document.documentElement.scrollHeight));
|
||||
await expect.poll(()=>page.evaluate(()=>scrollY)).toBeGreaterThan(0);
|
||||
const position=await page.evaluate(()=>scrollY);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).evaluate(node=>node.click());
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).evaluate(node=>node.click());
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Prompt library',exact:true}).evaluate(node=>node.click());
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Prompt library(?:\s*[\d,]+)?$/}).evaluate(node=>node.click());
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('No matching saved positive');
|
||||
await expect(cards(page)).toHaveCount(0);
|
||||
await expect.poll(()=>page.evaluate(expected=>Math.abs(scrollY-expected),position)).toBeLessThanOrEqual(1);
|
||||
@@ -188,7 +188,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
|
||||
const recipe=await(await request.get(`/prompt_manager/library/${parentId}`)).json();
|
||||
await openHistory(page);await selectVariant(page,recipe.variants.findIndex(variant=>variant.negative_text==='blurry, watermark'));
|
||||
const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent();
|
||||
await page.locator('.workspace-heading').getByRole('button',{name:'Send prompt pair to ComfyUI: Cat portrait',exact:true}).click();
|
||||
await page.locator('#workspace-main > .heading').getByRole('button',{name:'Send prompt pair to ComfyUI: Cat portrait',exact:true}).click();
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||
await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({label:'Library ComfyUI'});
|
||||
@@ -197,13 +197,13 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.libraryApplied)).toEqual([{positive:'Cat portrait',negative:chosenNegative}]);
|
||||
await sender.getByRole('button',{name:'Close',exact:true}).click();
|
||||
await media(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();
|
||||
await media(page).first().getByRole('button',{name:'Inspect same.png',exact:true}).click();
|
||||
const inspector=page.getByRole('region',{name:'Image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
|
||||
await expect(inspector.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
|
||||
await expect(inspector.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
|
||||
await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
|
||||
await page.keyboard.press('Escape');await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();await expect(page.locator('.viewer-container')).toHaveCount(0);
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();await expect(page.locator('.viewer-container')).toHaveCount(0);
|
||||
} finally {await host.evaluate(()=>window.libraryHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
@@ -279,10 +279,10 @@ test('Library natural rows retain long prompt content without overflow',async({p
|
||||
for(const [width,height] of [[1600,1100],[390,844]]) {
|
||||
await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(6);
|
||||
expect(await cards(page).filter({hasText:'A study in very long creative context'}).evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560);
|
||||
await expect(page.locator('.workspace-heading-copy > .intro')).toContainText('One positive.');
|
||||
await expect(page.locator('.library-shelf > .intro')).toHaveCount(0);
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toContainText('One positive.');
|
||||
await expect(page.locator('#workspace-main p').filter({hasText:'One positive. Reusable negatives.'})).toHaveCount(1);
|
||||
for(const theme of ['dark','light']) {
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await expect(page.locator('.prompt-positive h3').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)');
|
||||
}
|
||||
await expect(page.locator('.library-card-art').first()).toHaveCSS('height',width===1600?'170px':'150px');
|
||||
|
||||
@@ -8,14 +8,14 @@ import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/i
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const primary = ['images', 'library', 'tags', 'inspect'];
|
||||
const tools = ['overview', 'system'];
|
||||
const tools = ['system', 'overview'];
|
||||
|
||||
async function noOverflow(page) {
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
async function groupedNavigation(page) {
|
||||
for (const [group, routes] of [['primary', primary], ['tools', tools]]) {
|
||||
const region = page.locator(`.workspace-nav-${group}`);
|
||||
for (const [group, routes] of [['primary', primary], ['system', tools]]) {
|
||||
const region = page.locator(`#${group}-nav`);
|
||||
await expect(region).toHaveCount(1);
|
||||
expect(await region.locator('[data-route]').evaluateAll(nodes => nodes.map(node => node.dataset.route))).toEqual(routes);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ async function attachEvidence(page, testInfo, name, fullPage = true) {
|
||||
const screenshotPath = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: screenshotPath, fullPage });
|
||||
await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' });
|
||||
const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.workspace-heading', 'h1', '.image-workspace', '.image-grid', '.image-card', '.image-details', '.prompt-block-heading', '.library-toolbar', '.library-grid', '.library-card', '.library-card-art', '.library-card-content'].map(selector => {
|
||||
const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.heading', 'h1', '.image-workspace', '.image-grid', '.media-card', '.image-details', '.prompt-block-heading', '.library-toolbar', '.library-grid', '.library-card', '.library-card-art', '.library-card-content'].map(selector => {
|
||||
const node = document.querySelector(selector);
|
||||
if (!node) return [selector, null];
|
||||
const box = node.getBoundingClientRect(), style = getComputedStyle(node);
|
||||
@@ -92,7 +92,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
|
||||
|
||||
const reset = await request.post('/__test__/reset', { data: { chronological_history: true } });await expect(reset).toBeOK();const fixture = await reset.json();
|
||||
await page.goto(`${workspace}#/library/${fixture.canonical_id}`);
|
||||
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||||
const details = await (await request.get(`/prompt_manager/library/${fixture.canonical_id}`)).json();
|
||||
const index = details.variants.findIndex(variant => variant.id === fixture.canonical_pair);
|
||||
const choice = page.getByRole('button', { name: `Select negative variant ${index + 1}`, exact: true });
|
||||
@@ -104,12 +104,12 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
|
||||
for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt']) await expect(roles.getByRole('button', { name, exact: true })).toBeVisible();
|
||||
// As in the concept, the selected pair's send sits in the history heading, not among the saved texts.
|
||||
await expect(roles.getByRole('button', { name: 'Send prompt pair', exact: true })).toHaveCount(0);
|
||||
await expect(page.locator('.workspace-heading button.send')).toBeEnabled();
|
||||
await expect(page.locator('#workspace-main > .heading button.send')).toBeEnabled();
|
||||
await expect(page.locator('.library-shared-gallery article[data-image-key]')).toHaveCount(2);
|
||||
await page.locator('.library-shared-gallery .image-open').first().click();
|
||||
await page.locator('.library-shared-gallery .image-card-actions button').first().click();
|
||||
await expect(page.getByRole('region', { name: 'Image details', exact: true })).toContainText('canonical-alt.png');
|
||||
await page.getByRole('button', { name: 'Open full preview', exact: true }).click();
|
||||
await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toBeVisible();await page.keyboard.press('Escape');
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
|
||||
for (const [name, selector] of [['saved', '.library-surface'], ['branches', '.library-variants'], ['gallery', '.library-shared-gallery'], ['chronology', '.recorded-history']]) {
|
||||
await captureCanonicalState(page, testInfo, `production-${name}-${width}-${theme}`, selector);
|
||||
}
|
||||
@@ -122,7 +122,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
|
||||
for (const name of ['Raw', 'Effective', 'Close recorded run', 'Send prompt pair']) await expect(runDialog.getByRole('button', { name, exact: true })).toBeVisible();
|
||||
await captureCanonicalState(page, testInfo, `production-run-${width}-${theme}`, '.recorded-run-dialog');
|
||||
const view = runDialog.getByRole('button', { name: 'View current file', exact: true }).first();await view.click();
|
||||
const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toBeVisible();
|
||||
const viewer = page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
|
||||
await expect(viewer.getByRole('region', { name: 'Positive prompt', exact: true })).toContainText(fixture.canonical_positive);
|
||||
await expect(viewer.getByRole('region', { name: 'Negative prompt', exact: true })).toContainText(fixture.canonical_negative);
|
||||
for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt', 'Send prompt pair']) await expect(viewer.getByRole('button', { name, exact: true })).toBeVisible();
|
||||
@@ -131,7 +131,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
|
||||
await currentPositive.getByRole('button', { name: 'Copy positive prompt', exact: true }).click();
|
||||
await expect(currentPositive.getByRole('status')).toContainText(/Copied\.|Copy unavailable/);
|
||||
await exerciseCurrentPairSend(page, { positive: fixture.canonical_positive, negative: fixture.canonical_negative });
|
||||
await captureCanonicalState(page, testInfo, `production-viewer-${width}-${theme}`, 'dialog.image-preview');
|
||||
await captureCanonicalState(page, testInfo, `production-viewer-${width}-${theme}`, 'dialog#viewer-dialog');
|
||||
await page.keyboard.press('Escape');await expect(view).toBeFocused();await page.keyboard.press('Escape');await expect(run).toBeFocused();
|
||||
await compareCanonicalHistory(reference, actual, testInfo);
|
||||
|
||||
@@ -197,26 +197,26 @@ test('shell matches approved stable geometry in both themes', async ({ page, req
|
||||
for (const route of ['images', 'library']) {
|
||||
await page.goto(`${workspace}#/${route}`);
|
||||
if (width === 1600) await expect(page.locator('.sidebar')).toHaveCSS('width', '218px');
|
||||
await expect(page.locator('.workspace-heading > .workspace-heading-copy > h1')).toBeVisible();
|
||||
await expect(page.locator('#workspace-main > .heading > div > h1#page-title')).toBeVisible();
|
||||
await groupedNavigation(page);
|
||||
await expect(page.locator(route === 'images' ? 'article[data-image-key]' : 'article[data-prompt-id]').first()).toBeVisible();
|
||||
await expect(page.locator('.brand')).toHaveAttribute('href', '#/images');
|
||||
await expect(page.locator('#workspace-main > .eyebrow')).toHaveCount(0);
|
||||
for (const [theme, active] of [['dark', 'rgb(20, 54, 77)'], ['light', 'rgb(228, 241, 251)']]) {
|
||||
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||||
await expect(page.locator('nav [aria-current="page"]')).toHaveCSS('background-color', active);
|
||||
await expect(page.locator('h1')).toHaveCSS('font-weight', '620');
|
||||
await expect(page.locator('h1')).toHaveCSS('letter-spacing', '-0.7px');
|
||||
await expect(page.locator('.workspace-heading')).toHaveCSS('margin-block-end', '22px');
|
||||
await expect(page.locator('#workspace-main > .heading')).toHaveCSS('margin-block-end', '22px');
|
||||
await expect(page.locator('h1')).toHaveCSS('font-size', width === 1600 ? '28px' : '25px');
|
||||
await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCSS('line-height', '18px');
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveCSS('line-height', '18px');
|
||||
await expect(page.locator('#workspace-main')).toHaveCSS('padding-inline-start', width === 1600 ? '30px' : '14px');
|
||||
if (width === 1600) {
|
||||
await expect(page.locator('.sidebar')).toHaveCSS('width', '218px');
|
||||
await expect(page.locator('.topbar')).toHaveCSS('height', '68px');
|
||||
} else {
|
||||
await expect(page.locator('.sidebar')).toHaveCSS('position', 'static');
|
||||
expect(await page.locator('.workspace-shell').evaluate(node => getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1);
|
||||
expect(await page.locator('.app-shell').evaluate(node => getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1);
|
||||
}
|
||||
await noOverflow(page);
|
||||
await attachEvidence(page, testInfo, `${route}-${width}-${theme}`);
|
||||
@@ -236,8 +236,8 @@ test('long content and zoom keep grouped navigation reachable', async ({ page, r
|
||||
const link = page.locator(`nav [data-route="${route}"]`);
|
||||
await link.focus(); await expect(link).toBeFocused();
|
||||
}
|
||||
await page.getByRole('combobox', { name: 'Appearance', exact: true }).focus();
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toBeFocused();
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).focus();
|
||||
await expect(page.getByRole('combobox', { name: 'Theme', exact: true })).toBeFocused();
|
||||
const text = page.locator('.prompt-text').filter({ hasText: 'A study in very long creative context' });
|
||||
await text.scrollIntoViewIfNeeded();
|
||||
expect((await text.textContent()).length).toBeGreaterThan(1500);
|
||||
@@ -257,17 +257,17 @@ test('long content and zoom keep grouped navigation reachable', async ({ page, r
|
||||
}
|
||||
});
|
||||
|
||||
test('Appearance label and control fit the narrow shell at 200 percent zoom', async ({ page, request }, testInfo) => {
|
||||
test('Theme label and control fit the narrow shell at 200 percent zoom', async ({ page, request }, testInfo) => {
|
||||
await request.post('/__test__/reset', { data: { workspace_visual: true } });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(page.locator('article[data-image-key]')).toHaveCount(24);
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
|
||||
const preference = page.getByRole('combobox', { name: 'Appearance', exact: true });
|
||||
const preference = page.getByRole('combobox', { name: 'Theme', exact: true });
|
||||
const bounds = await preference.evaluate(select => {
|
||||
const field = select.closest('label');
|
||||
return { intendedWrapper: field.classList.contains('workspace-appearance'),
|
||||
boxes: [field, field.querySelector('.ca-field-label'), select].map(node => {
|
||||
return { intendedWrapper: field.classList.contains('theme-label') && field.parentElement.classList.contains('top-actions'),
|
||||
boxes: [field, select].map(node => {
|
||||
const box = node.getBoundingClientRect(); return { left: box.left, right: box.right, width: box.width };
|
||||
}), width: document.documentElement.clientWidth };
|
||||
});
|
||||
@@ -277,7 +277,7 @@ test('Appearance label and control fit the narrow shell at 200 percent zoom', as
|
||||
expect(box.right).toBeLessThanOrEqual(bounds.width + 1);
|
||||
}
|
||||
expect(bounds.intendedWrapper).toBe(true);
|
||||
for (const theme of ['follow', 'light', 'dark']) {
|
||||
for (const theme of ['system', 'light', 'dark']) {
|
||||
await preference.selectOption(theme); await expect(preference).toHaveValue(theme);
|
||||
}
|
||||
await preference.focus(); await expect(preference).toBeFocused();
|
||||
@@ -290,18 +290,18 @@ async function containedImageContent(page) {
|
||||
const failures = [];
|
||||
for (const card of nodes) {
|
||||
const outer = card.getBoundingClientRect();
|
||||
for (const selector of ['.image-card-footer', 'strong', 'small', '.image-card-footer button']) {
|
||||
for (const selector of ['.media-info', '.media-info h3', '.media-info p', '.image-card-actions button']) {
|
||||
const node = card.querySelector(selector), box = node.getBoundingClientRect();
|
||||
if (box.width <= 0 || box.height <= 0 || box.left < outer.left - 1 || box.right > outer.right + 1 ||
|
||||
box.top < outer.top - 1 || box.bottom > outer.bottom + 1 ||
|
||||
(selector === 'small' && node.scrollHeight > node.clientHeight + 1)) {
|
||||
(selector === '.media-info p' && node.scrollHeight > node.clientHeight + 1)) {
|
||||
failures.push({ key: card.dataset.imageKey, selector,
|
||||
card: { left: outer.left, right: outer.right, top: outer.top, bottom: outer.bottom },
|
||||
content: { left: box.left, right: box.right, top: box.top, bottom: box.bottom } });
|
||||
}
|
||||
}
|
||||
const action = card.querySelector('.image-card-footer button').getBoundingClientRect();
|
||||
const range = document.createRange(); range.selectNodeContents(card.querySelector('small'));
|
||||
const action = card.querySelector('.image-card-actions button').getBoundingClientRect();
|
||||
const range = document.createRange(); range.selectNodeContents(card.querySelector('.media-info p'));
|
||||
for (const text of range.getClientRects()) {
|
||||
if (Math.min(text.right, action.right) - Math.max(text.left, action.left) > .5 &&
|
||||
Math.min(text.bottom, action.bottom) - Math.max(text.top, action.top) > .5) {
|
||||
@@ -341,18 +341,30 @@ for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of [
|
||||
await page.keyboard.press('ArrowUp'); await expect(cards.first().locator('.image-open')).toBeFocused();
|
||||
expect(await cards.first().getAttribute('data-image-key')).toBe(firstKey);
|
||||
}
|
||||
const longCard = cards.filter({ hasText: 'a-very-long-geometric-composition-' });
|
||||
const filename = await longCard.locator('strong').textContent();
|
||||
const longCard = cards.filter({ has: page.locator('[aria-label^="Open a-very-long-geometric-composition-"]') });
|
||||
const filename = (await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
|
||||
expect(filename).toMatch(/layered-context-.*\.png/);
|
||||
await longCard.locator('.image-open').focus(); await page.keyboard.press('Enter');
|
||||
// D19: Info selects into the panel; the image itself opens the viewer.
|
||||
await longCard.locator('.image-card-actions button').focus(); await page.keyboard.press('Enter');
|
||||
await expect(page.locator('.image-details h2')).toHaveText(filename);
|
||||
const fullPreview = page.getByRole('button', { name: 'Open full preview', exact: true });
|
||||
await fullPreview.focus(); await expect(fullPreview).toBeFocused(); await page.keyboard.press('Enter');
|
||||
await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.image-preview > header h2')).toHaveText(filename);
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
|
||||
await expect(page.locator('#viewer-title')).toHaveText(filename);
|
||||
await page.keyboard.press('Escape');
|
||||
await longCard.locator('.image-open').focus(); await page.keyboard.press('Tab');
|
||||
await expect(longCard.getByRole('button', { name: `Preview ${filename}`, exact: true })).toBeFocused();
|
||||
// The concept's card order (app.js mediaCard): image, the D8 checkbox, the D7 star, then Inspect. List hides
|
||||
// the overlays as the concept does (`.list-grid .image-overlays`), so there the image is followed by Inspect.
|
||||
const box = longCard.getByRole('checkbox', { name: `Select ${filename}`, exact: true });
|
||||
const star = longCard.getByRole('button', { name: `Favorite ${filename}`, exact: true });
|
||||
const overlays = layout === 'List' ? [] : [box, star];
|
||||
if (layout === 'List') { await expect(box).toBeHidden(); await expect(star).toBeHidden(); }
|
||||
await longCard.locator('.image-open').focus();
|
||||
for (const control of overlays) { await page.keyboard.press('Tab'); await expect(control).toBeFocused(); }
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(longCard.getByRole('button', { name: `Inspect ${filename}`, exact: true })).toBeFocused();
|
||||
for (const control of [...overlays].reverse()) { await page.keyboard.press('Shift+Tab'); await expect(control).toBeFocused(); }
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(longCard.locator('.image-open')).toBeFocused();
|
||||
await page.keyboard.press('Enter'); await expect(page.locator('dialog[open]')).toHaveCount(1);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect.poll(() => containedImageContent(page)).toEqual([]);
|
||||
@@ -374,7 +386,7 @@ test('zoomed virtual Images keeps the visible real media mounted during native s
|
||||
const stride = await cards.first().evaluate(node => node.getBoundingClientRect().height + 26);
|
||||
const more = page.getByRole('button', { name: 'Load more images', exact: true });
|
||||
for (const loaded of [80, 120, 160, 200, 202]) {
|
||||
await more.click(); await expect(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`);
|
||||
await more.click(); await expect(page.locator('.section-caption')).toContainText(`${loaded} of 202 images`);
|
||||
}
|
||||
await expect.poll(() => cards.count()).toBeLessThan(120);
|
||||
await page.evaluate(stride => {
|
||||
@@ -401,33 +413,32 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
|
||||
await expect.poll(() => cards.nth(1).evaluate(node => node.offsetLeft)).toBe(0);
|
||||
const selected = cards.nth(16), key = await selected.getAttribute('data-image-key');
|
||||
await selected.locator('.image-open').click(); await selected.locator('.image-open').focus();
|
||||
const filename = await selected.locator('strong').textContent();
|
||||
await selected.locator('.image-card-actions button').click(); await selected.locator('.image-open').focus();
|
||||
const filename = (await selected.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
|
||||
await selected.evaluate(node => scrollBy(0, node.getBoundingClientRect().top - 120));
|
||||
await page.evaluate(() => {
|
||||
const grid = document.querySelector('.image-grid'), footers = [...grid.querySelectorAll('.image-card-footer')];
|
||||
const nativeHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight').get;
|
||||
const descriptors = footers.map(node => [node, Object.getOwnPropertyDescriptor(node, 'offsetHeight')]);
|
||||
const restore = () => { for (const [node, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(node, 'offsetHeight', descriptor); else delete node.offsetHeight;
|
||||
} };
|
||||
const grid = document.querySelector('.image-grid'), footers = [...grid.querySelectorAll('.media-info')];
|
||||
// Production measures each footer's fractional height (`getBoundingClientRect().height / scale`, #249 C7), so the
|
||||
// probe records the visible anchor at the first measurement after the zoom, before the grid relays out.
|
||||
const nativeRect = Element.prototype.getBoundingClientRect;
|
||||
const restore = () => { for (const node of footers) delete node.getBoundingClientRect; };
|
||||
window.footerMeasurementProbe = { before: null, restore };
|
||||
for (const footer of footers) Object.defineProperty(footer, 'offsetHeight', { configurable: true, get() {
|
||||
const height = nativeHeight.call(this);
|
||||
for (const footer of footers) Object.defineProperty(footer, 'getBoundingClientRect', { configurable: true, writable: true, value() {
|
||||
const rect = nativeRect.call(this);
|
||||
if (document.documentElement.style.zoom === '4' && grid.style.getPropertyValue('--image-footer-height') === '54px' &&
|
||||
Math.abs(Number.parseFloat(this.closest('.image-card').style.width) - Number.parseFloat(getComputedStyle(grid).width)) < .01) {
|
||||
Math.abs(Number.parseFloat(this.closest('.media-card').style.width) - Number.parseFloat(getComputedStyle(grid).width)) < .01) {
|
||||
const top = visualViewport?.offsetTop || 0, bottom = top + (visualViewport?.height || innerHeight);
|
||||
const first = [...grid.children].find(node => { const box = node.getBoundingClientRect(); return box.bottom > top && box.top < bottom; });
|
||||
window.footerMeasurementProbe.before = first ? { index: Number(first.dataset.index), key: first.dataset.imageKey,
|
||||
top: first.getBoundingClientRect().top - top } : null;
|
||||
restore();
|
||||
}
|
||||
return height;
|
||||
return rect;
|
||||
} });
|
||||
document.documentElement.style.zoom = '4';
|
||||
const box = grid.getBoundingClientRect();
|
||||
// The approved initial reservation is 54px with 4:3 media and a 13px row gap.
|
||||
const stride = (box.width / 4 - 2) * .75 + 54 + 2 + 13;
|
||||
// The approved initial reservation is 54px under the concept's 1.33 media and a 13px row gap.
|
||||
const stride = (box.width / 4 - 2) / 1.33 + 54 + 2 + 13;
|
||||
// Place row eight inside the 52px scaled gap so the preceding row is not visible.
|
||||
const index = 8;
|
||||
scrollTo(0, box.top + scrollY + index * stride * 4 - 16);
|
||||
@@ -454,10 +465,10 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
|
||||
await page.getByRole('button', { name: 'List', exact: true }).click();
|
||||
await expect.poll(() => containedImageContent(page)).toEqual([]);
|
||||
expect(await cards.first().evaluate(node => node.offsetHeight)).toBeGreaterThan(97);
|
||||
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('no-real-media-match');
|
||||
await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('no-real-media-match');
|
||||
await expect(cards).toHaveCount(0);
|
||||
expect(await grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBe(54);
|
||||
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('');
|
||||
await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('');
|
||||
await expect(cards).toHaveCount(24);
|
||||
await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54);
|
||||
const extremeFooter = await grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')));
|
||||
@@ -479,7 +490,7 @@ test('supplemental wrapped real footer reservation survives append without movin
|
||||
await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`);
|
||||
const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid');
|
||||
await expect(cards).toHaveCount(40);
|
||||
await cards.first().locator('.image-open').click();
|
||||
await cards.first().locator('.image-card-actions button').click();
|
||||
const selectedKey = await cards.first().getAttribute('data-image-key');
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = '4'; });
|
||||
await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54);
|
||||
@@ -491,18 +502,23 @@ test('supplemental wrapped real footer reservation survives append without movin
|
||||
});
|
||||
try {
|
||||
const more = page.getByRole('button', { name: 'Load more images', exact: true });
|
||||
await more.click(); await expect.poll(() => pending).toBe(true);
|
||||
// At 4x zoom the concept continuation (copy + Load more) fills the viewport, so keep the last cards in view and
|
||||
// press Load more from the keyboard without scrolling to it.
|
||||
await cards.last().scrollIntoViewIfNeeded();
|
||||
await more.evaluate(node => node.focus({ preventScroll: true })); await page.keyboard.press('Enter');
|
||||
await expect.poll(() => pending).toBe(true);
|
||||
const anchor = await cards.evaluateAll(nodes => {
|
||||
const top = visualViewport?.offsetTop || 0, bottom = top + (visualViewport?.height || innerHeight);
|
||||
const node = nodes.find(node => { const box = node.getBoundingClientRect(); return box.bottom > top && box.top < bottom; });
|
||||
return node ? { key: node.dataset.imageKey, top: node.getBoundingClientRect().top - top } : null;
|
||||
});
|
||||
expect(anchor).not.toBeNull();
|
||||
release(); await expect(page.locator('.image-toolbar')).toContainText('80 of 202 images');
|
||||
release(); await expect(page.locator('.section-caption')).toContainText('80 of 202 images');
|
||||
await expect(cards).toHaveCount(80);
|
||||
await expect.poll(async () => Math.abs(await page.locator(`article[data-image-key="${anchor.key}"]`).evaluate(node => node.getBoundingClientRect().top - (visualViewport?.offsetTop || 0)) - anchor.top)).toBeLessThanOrEqual(1);
|
||||
await expect(page.locator(`article[data-image-key="${selectedKey}"]`)).toHaveClass(/selected/);
|
||||
await expect(more).toBeFocused();
|
||||
// The concept focuses the first appended image after a Load more button press.
|
||||
await expect(page.locator('article[aria-posinset="41"] .image-open')).toBeFocused();
|
||||
await expect.poll(() => containedImageContent(page)).toEqual([]);
|
||||
} finally { release(); }
|
||||
});
|
||||
@@ -514,15 +530,15 @@ test('Library visual evidence keeps natural cards and returns from real history
|
||||
const page=await context.newPage();const errors=[];page.on('pageerror',error=>errors.push(error.message));await startCoverage(page,browserName);
|
||||
try {
|
||||
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await attachEvidence(page,testInfo,`after-library-${width}-${theme}`,false);
|
||||
await noOverflow(page);
|
||||
await page.getByRole('link',{name:'Explore history',exact:true}).first().click();
|
||||
await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible();
|
||||
await expect(page.locator('.library-history > div > .intro')).toHaveCount(1);
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.');
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
|
||||
await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(1);
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive. Reusable negatives. Every experiment stays connected.');
|
||||
expect(errors).toEqual([]);
|
||||
} finally {await saveCoverage(page,browserName);await context.close();}
|
||||
}
|
||||
@@ -533,11 +549,11 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as
|
||||
await page.goto(`${workspace}#/images`);
|
||||
const icons = await page.evaluate(async () => {
|
||||
const { createWorkspaceIcon } = await import('/prompt_manager/js/workspace/icons.mjs');
|
||||
const names = ['images', 'library', 'tags', 'upload', 'overview', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch'];
|
||||
const names = ['images', 'library', 'tags', 'upload', 'overview', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch', 'star', 'tagImage', 'folder', 'history', 'review'];
|
||||
const otherDocument = document.implementation.createHTMLDocument();
|
||||
const rendered = names.map(name => {
|
||||
const icon = createWorkspaceIcon(name, otherDocument);
|
||||
return { name, svg: icon instanceof SVGSVGElement, owner: icon.ownerDocument === otherDocument,
|
||||
return { name, svg: icon instanceof SVGSVGElement, className: icon.getAttribute('class'), owner: icon.ownerDocument === otherDocument,
|
||||
viewBox: icon.getAttribute('viewBox'), hidden: icon.getAttribute('aria-hidden'), focusable: icon.getAttribute('focusable'),
|
||||
fill: icon.getAttribute('fill'), stroke: icon.getAttribute('stroke'), width: icon.getAttribute('stroke-width'),
|
||||
path: icon.querySelector('path').getAttribute('d'), children: icon.childElementCount };
|
||||
@@ -549,10 +565,12 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as
|
||||
});
|
||||
expect(icons.unknown).toEqual([true, true, true, true, true]);
|
||||
for (const icon of icons.rendered) {
|
||||
expect(icon).toMatchObject({ svg: true, owner: true, viewBox: '0 0 24 24', hidden: 'true', focusable: 'false', fill: 'none', stroke: 'currentColor', width: '1.65', children: 1 });
|
||||
expect(icon).toMatchObject({ svg: true, className: 'icon', owner: true, viewBox: '0 0 24 24', hidden: 'true', focusable: 'false', fill: 'none', stroke: 'currentColor', width: '1.65', children: 1 });
|
||||
expect(icon.path.length).toBeGreaterThan(10);
|
||||
}
|
||||
expect(icons.rendered.find(icon => icon.name === 'overview').path).toBe('M3 12h4l3-8 4 16 3-8h4');
|
||||
// D28 ruling: Overview's own outline home glyph, not the concept's `activity` pulse ('M3 12h4l3-8 4 16 3-8h4').
|
||||
expect(icons.rendered.find(icon => icon.name === 'overview').path).toBe('M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6');
|
||||
expect(new Set(icons.rendered.map(icon => icon.path)).size).toBe(icons.rendered.length);
|
||||
});
|
||||
|
||||
|
||||
@@ -575,33 +593,34 @@ test('Images visual layouts retain real delayed media long filenames and loaded
|
||||
await page.setViewportSize({width:1600,height:1100});await page.goto(`${workspace}#/images`,{waitUntil:'domcontentloaded'});
|
||||
await expect(page.locator('article[data-image-key]')).toHaveCount(24);
|
||||
await expect.poll(()=>mediaRequests).toBeGreaterThan(0);
|
||||
expect(await page.locator('.image-card img').first().evaluate(image=>image.complete)).toBe(false);
|
||||
await expect(page.locator('.image-card').first()).toHaveCSS('border-radius','7px');
|
||||
expect(await page.locator('.media-card img').first().evaluate(image=>image.complete)).toBe(false);
|
||||
await expect(page.locator('.media-card').first()).toHaveCSS('border-radius','7px');
|
||||
await orderedMedia(page);
|
||||
} finally {release();}
|
||||
for (const [width,height] of [[1600,1100],[390,844]]) {
|
||||
await page.setViewportSize({width,height});
|
||||
for (const theme of ['dark','light']) {
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await page.locator('.image-card .image-open').first().click();
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await page.locator('.media-card .image-card-actions button').first().click();
|
||||
await expect(page.locator('.image-details h2')).toHaveText('study-01.png');
|
||||
for (const layout of ['Grid','List','Masonry']) {
|
||||
await page.getByRole('button',{name:layout,exact:true}).click();await page.evaluate(()=>scrollTo(0,0));
|
||||
if(layout==='List') await expect(page.locator('.image-card').first()).toHaveCSS('height','97px');
|
||||
if(layout==='List') await expect(page.locator('.media-card').first()).toHaveCSS('height','97px');
|
||||
await orderedMedia(page);
|
||||
await attachEvidence(page,testInfo,`after-images-${layout.toLowerCase()}-${width}-${theme}`,false);
|
||||
}
|
||||
await page.locator('article[data-image-key]').last().scrollIntoViewIfNeeded();
|
||||
const longCard=page.locator('article[data-image-key]').filter({hasText:'a-very-long-geometric-composition-'});
|
||||
await expect(longCard.locator('strong')).toHaveText(/layered-context-.*\.png/);
|
||||
await longCard.locator('.image-open').click();
|
||||
await expect(page.locator('.image-details h2')).toHaveText(await longCard.locator('strong').textContent());
|
||||
const longCard=page.locator('article[data-image-key]').filter({has:page.locator('[aria-label^="Open a-very-long-geometric-composition-"]')});
|
||||
const longName=(await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /,'');
|
||||
expect(longName).toMatch(/layered-context-.*\.png/);
|
||||
await longCard.locator('.image-card-actions button').click();
|
||||
await expect(page.locator('.image-details h2')).toHaveText(longName);
|
||||
await orderedMedia(page);
|
||||
await attachEvidence(page,testInfo,`after-images-loaded-scroll-${width}-${theme}`,false);
|
||||
}
|
||||
}
|
||||
await page.evaluate(()=>{location.hash='#/overview';});
|
||||
await expect(page.locator('.image-card,.image-details,.viewer-container,dialog[open]')).toHaveCount(0);
|
||||
await expect(page.locator('.media-card,.image-details,.viewer-container,dialog[open]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('embedded history gallery keeps the history heading and disposes its layouts',async({page,request},testInfo)=>{
|
||||
@@ -618,10 +637,10 @@ test('embedded history gallery keeps the history heading and disposes its layout
|
||||
for (const layout of ['List','Masonry','Grid']) {
|
||||
await page.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page);
|
||||
await expect(page.locator('h1')).toHaveText(title);
|
||||
await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(0);
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.');
|
||||
}
|
||||
await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false);
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.locator('.image-card,.image-details,dialog[open]')).toHaveCount(0);
|
||||
await expect(page.locator('.media-card,.image-details,dialog[open]')).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -43,8 +43,8 @@ test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);
|
||||
test('tags route renders the concept composition without a legacy handoff',async({page})=>{
|
||||
await page.goto(route);
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
|
||||
await expect(main(page).locator('.workspace-heading-copy > p')).toHaveText('Organize once. Reuse everywhere.');
|
||||
const actions=main(page).locator('.workspace-heading .heading-actions button');
|
||||
await expect(main(page).locator(':scope > .heading p.muted')).toHaveText('Organize once. Reuse everywhere.');
|
||||
const actions=main(page).locator(':scope > .heading > .heading-actions button');
|
||||
await expect(actions).toHaveText(['+ Create tag']);
|
||||
await expect(actions.first()).not.toHaveClass(/ca-primary/);
|
||||
await expect(main(page).getByRole('button',{name:'Auto-tag images'})).toHaveCount(0);
|
||||
@@ -89,7 +89,7 @@ test('category Browse sets only the category constraint',async({page})=>{
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});
|
||||
await search.fill('observatory');await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Tags & categories',exact:true}).click();
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Tags & categories(?:\s*[\d,]+)?$/}).click();
|
||||
const row=categoryRow(page,'Landscapes');await expect(row.locator('p')).toHaveText('3 positive prompts');
|
||||
await row.getByRole('button',{name:'Browse',exact:true}).click();
|
||||
await expectLibrary(page,{category:'Landscapes',count:3});
|
||||
@@ -127,7 +127,8 @@ test('long vocabularies append and Load more is keyboard reachable',async({page,
|
||||
|
||||
test('read failure shows inline error with Retry and no zero counts',async({page})=>{
|
||||
let failures=0;
|
||||
await page.route('**/prompt_manager/vocabulary/tags?*',route=>{
|
||||
// Tag list pages only: the sidebar's Tags count is its own `limit=1` read.
|
||||
await page.route(url=>url.pathname==='/prompt_manager/vocabulary/tags' && url.searchParams.get('limit')!=='1',route=>{
|
||||
if(failures++) return route.continue();
|
||||
return route.fulfill({status:500,json:{success:false,error:'Database is unavailable'}});
|
||||
});
|
||||
@@ -149,7 +150,7 @@ test('breadcrumb and document title are Tags',async({page})=>{
|
||||
await page.goto(route);
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Tags');
|
||||
await expect(page).toHaveTitle('Tags · Prompt Manager');
|
||||
await expect(page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Tags & categories',exact:true})).toHaveAttribute('aria-current','page');
|
||||
await expect(page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Tags & categories(?:\s*[\d,]+)?$/})).toHaveAttribute('aria-current','page');
|
||||
await page.reload();
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Tags');
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
@@ -161,7 +162,8 @@ test('route disposal aborts requests and removes observers',async({page})=>{
|
||||
await page.addInitScript(()=>{
|
||||
window.__vocabularySignals=[];window.__vocabularyObservers=[];
|
||||
const fetch=window.fetch;
|
||||
window.fetch=(input,init={})=>{if(String(input).includes('/vocabulary/') && init.signal) window.__vocabularySignals.push(init.signal);return fetch(input,init);};
|
||||
// The page's own reads; the sidebar's `limit=1` Tags count belongs to the shell.
|
||||
window.fetch=(input,init={})=>{if(String(input).includes('/vocabulary/') && !/[?&]limit=1(?:&|$)/.test(String(input)) && init.signal) window.__vocabularySignals.push(init.signal);return fetch(input,init);};
|
||||
const Observer=window.IntersectionObserver;
|
||||
window.IntersectionObserver=class extends Observer {
|
||||
constructor(...args) {super(...args);this.record={targets:[],disconnected:false};window.__vocabularyObservers.push(this.record);}
|
||||
@@ -175,7 +177,7 @@ test('route disposal aborts requests and removes observers',async({page})=>{
|
||||
await expect.poll(()=>page.evaluate(()=>window.__vocabularySignals.length)).toBe(2);
|
||||
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).length)).toBe(2);
|
||||
await expect(main(page).getByText('Loading…',{exact:true})).toHaveCount(2);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Prompt library',exact:true}).click();
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Prompt library(?:\s*[\d,]+)?$/}).click();
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
|
||||
expect(await page.evaluate(()=>window.__vocabularySignals.map(signal=>signal.aborted))).toEqual([true,true]);
|
||||
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).map(item=>item.disconnected))).toEqual([true,true]);
|
||||
@@ -193,10 +195,10 @@ test('390px layout stacks without horizontal page scroll; table scrolls locally'
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
const layout=await main(page).evaluate(node=>{
|
||||
const grid=node.querySelector('.two-col'),[left,right]=grid.querySelectorAll(':scope > .surface'),wrap=node.querySelector('.table-wrap');
|
||||
const heading=node.querySelector('.workspace-heading'),operation=node.querySelector('.operation');
|
||||
const heading=node.querySelector(':scope > .heading'),operation=node.querySelector('.operation');
|
||||
return {tracks:getComputedStyle(grid).gridTemplateColumns.split(' ').length,stacked:right.getBoundingClientRect().top>=left.getBoundingClientRect().bottom,
|
||||
overflow:getComputedStyle(wrap).overflowX,heading:getComputedStyle(heading).flexDirection,operation:getComputedStyle(operation).flexDirection,
|
||||
actions:node.querySelector('.workspace-heading .heading-actions').getBoundingClientRect().width===heading.getBoundingClientRect().width};
|
||||
actions:node.querySelector(':scope > .heading > .heading-actions').getBoundingClientRect().width===heading.getBoundingClientRect().width};
|
||||
});
|
||||
expect(layout).toEqual({tracks:1,stacked:true,overflow:'auto',heading:'column',operation:'column',actions:true});
|
||||
});
|
||||
@@ -482,7 +484,8 @@ test('lost response is outcome-unknown',async({page,request})=>{
|
||||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
|
||||
});
|
||||
|
||||
const nav=(page,name)=>page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name,exact:true});
|
||||
// Concept nav links end in their real `.nav-count` once it loads.
|
||||
const nav=(page,name)=>page.locator('aside.sidebar nav').getByRole('link',{name:new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}(?:\\s*[\\d,]+)?$`)});
|
||||
const chips=(page,name)=>main(page).locator('article[data-prompt-id] .library-chip').filter({hasText:new RegExp(`^${name}$`)});
|
||||
/** Visit the Library so its shelf is cached, then open Rename on blue_hour and start a rename whose PATCH is held. */
|
||||
async function heldRename(page) {
|
||||
@@ -666,10 +669,10 @@ async function openConceptTags(page,theme,motion) {
|
||||
async function openProductionTags(page,theme,motion) {
|
||||
await page.emulateMedia({reducedMotion:motion});
|
||||
await page.goto('about:blank');await page.goto(route);
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await expect(rows(page)).toHaveCount(manifest.tags.length);await expect(categoryRow(page,'Landscapes')).toHaveCount(1);
|
||||
}
|
||||
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf,production);
|
||||
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf);
|
||||
/** Breadcrumb, document title (D7: "Tags", only the concept suffix differs) and the keyboard focus ring on a row action. */
|
||||
async function chromeOf(page,production) {
|
||||
const scope=page.locator(production?'#workspace-main':'#main');
|
||||
@@ -791,3 +794,29 @@ test('mutation check: the comparison reports a changed surface padding and a cha
|
||||
expect(dialogPaths).toContain('fields.0.control.color');
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
// Task A2: the workspace loads the verbatim concept stylesheet as its base and one overlay last; the screen and its
|
||||
// dialogs must stay equal to the concept with the retired vocabulary and editor sheets gone.
|
||||
const CONCEPT_BASE=['js/shared/toolkit/controls.css','lib/viewerjs/viewer.min.css','js/workspace/concept.css',
|
||||
'js/workspace/images.css','js/workspace/library.css','js/workspace/inspector.css','js/workspace/workspace.css'];
|
||||
const loadedSheets=page=>page.evaluate(()=>[...document.styleSheets].filter(sheet=>sheet.href).map(sheet=>new URL(sheet.href).pathname.replace(/^\/prompt_manager\//,'')));
|
||||
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`concept base keeps the tags screen and editor dialogs equal to the concept ${width} ${theme}`,async({page})=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const donors=await conceptDonors(page,theme);
|
||||
// Park the pointer: donor clicks leave it over a heading or row button, and a hover background is not compared.
|
||||
await openConceptTags(page,theme,'no-preference');await page.mouse.move(0,0);const reference=await pageOf(page,false);
|
||||
const dialogs={};
|
||||
for(const mode of Object.keys(conceptDialogs)) {
|
||||
await openConceptTags(page,theme,'no-preference');await conceptDialogs[mode](page);
|
||||
await expect(page.locator('#modal')).toBeVisible();dialogs[mode]=await page.locator('#modal').evaluate(inventoryOf);
|
||||
}
|
||||
await openProductionTags(page,theme,'no-preference');await page.mouse.move(0,0);
|
||||
expect(await loadedSheets(page)).toEqual(CONCEPT_BASE);
|
||||
const {diff,extra}=comparePage(reference,await pageOf(page,true));
|
||||
expect(diff).toEqual([]);expect(extra).toEqual(fixtureOnly());
|
||||
for(const mode of Object.keys(productionDialogs)) {
|
||||
const box=await productionDialogs[mode](page);
|
||||
expect(diffInventory(mapVocabularyDialog(mode,dialogs[mode],donors),await box.evaluate(inventoryOf)),`${mode} dialog`).toEqual([]);
|
||||
await page.keyboard.press('Escape');await expect(box).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,21 +4,27 @@ import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/i
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const statistic = (page, label) => page.locator('#workspace-main dt')
|
||||
.filter({ hasText: new RegExp(`^${label}$`) }).locator('..').locator('dd');
|
||||
// Concept sidebar: WORKSPACE links carry a real `.nav-count` once loaded, so their accessible name may end in it.
|
||||
const escapeName = label => label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const navName = label => new RegExp(`^${escapeName(label)}(?:\\s*[\\d,]+)?$`);
|
||||
const sidebarLink = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: navName(label) });
|
||||
const sidebarLinks = page => page.locator('aside.sidebar nav a');
|
||||
const routes = [
|
||||
['overview', 'Overview', null],
|
||||
['images', 'Images', '/prompt_manager/gallery'],
|
||||
// D29: Images no longer links the legacy gallery; Overview's tool cards still do.
|
||||
['images', 'Images', null],
|
||||
['library', 'Prompt library', '/prompt_manager/admin'],
|
||||
['tags', 'Tags & categories', null],
|
||||
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
|
||||
['system', 'System', '/prompt_manager/admin'],
|
||||
];
|
||||
|
||||
async function countAppearanceWrites(page) {
|
||||
async function countThemeWrites(page) {
|
||||
await page.evaluate(() => {
|
||||
window.__appearanceChanges = 0;
|
||||
window.__themeChanges = 0;
|
||||
const original = Storage.prototype.setItem;
|
||||
Storage.prototype.setItem = function (...args) {
|
||||
if (args[0] === 'pm_workspace_theme') window.__appearanceChanges++;
|
||||
if (args[0] === 'pm_workspace_theme') window.__themeChanges++;
|
||||
return original.apply(this, args);
|
||||
};
|
||||
});
|
||||
@@ -66,8 +72,7 @@ test('all shell destinations deep-link, reload and expose working existing appli
|
||||
await page.goto(`${workspace}#/${route}`);
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
.getByRole('link', { name: label, exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
await expect(sidebarLink(page, label)).toHaveAttribute('aria-current', 'page');
|
||||
if (route === 'tags') await expect(main.locator('.handoff')).toHaveCount(0);
|
||||
if (legacy) {
|
||||
await expect(main.locator(`a[href="${legacy}"]`)).toBeVisible();
|
||||
@@ -76,16 +81,23 @@ test('all shell destinations deep-link, reload and expose working existing appli
|
||||
await page.reload();
|
||||
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
|
||||
}
|
||||
// D29: the legacy image gallery is reached from Overview's tool card, not from Images.
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const gallery = page.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
|
||||
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
|
||||
await expect(await request.get('/prompt_manager/gallery')).toBeOK();
|
||||
await gallery.click();
|
||||
await expect(page).toHaveURL(/\/prompt_manager\/gallery$/);
|
||||
});
|
||||
|
||||
test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
const images = navigation.getByRole('link', { name: 'Images', exact: true });
|
||||
const images = navigation.getByRole('link', { name: navName('Images') });
|
||||
await images.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await navigation.getByRole('link', { name: 'Prompt library', exact: true }).click();
|
||||
await navigation.getByRole('link', { name: navName('Prompt library') }).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await page.goBack();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
@@ -93,7 +105,7 @@ test('keyboard navigation, browser history and unknown routes remain usable', as
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await page.goto(`${workspace}#/not-a-page`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(navigation.getByRole('link', { name: 'Images', exact: true }))
|
||||
await expect(navigation.getByRole('link', { name: navName('Images') }))
|
||||
.toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
@@ -101,44 +113,144 @@ test('skip to content focuses the current view without changing its route', asyn
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.getByRole('link', { name: 'Skip to content', exact: true })).toBeFocused();
|
||||
await expect(page.getByRole('link', { name: 'Skip to workspace', exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page).toHaveURL(`${workspace}#/library`);
|
||||
await expect(page.locator('#workspace-main')).toBeFocused();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
});
|
||||
|
||||
// The concept focuses its page title on every hashchange navigation (docs/concepts/workspace/app.js 1168), so a screen
|
||||
// reader announces "<page>, heading level 1"; the first render leaves focus alone.
|
||||
test('a sidebar route change focuses the page title, as the concept does', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/library`);
|
||||
const title = page.locator('#page-title');
|
||||
await expect(title).toHaveText('Prompt library');
|
||||
await expect(title).not.toBeFocused();
|
||||
await sidebarLink(page, 'Images').click();
|
||||
await expect(title).toHaveText('Images');
|
||||
await expect(title).toBeFocused();
|
||||
await expect(page.locator('#workspace-main')).not.toBeFocused();
|
||||
await sidebarLink(page, 'Tags & categories').click();
|
||||
await expect(title).toHaveText('Tags & categories');
|
||||
await expect(title).toBeFocused();
|
||||
await page.goBack();
|
||||
await expect(title).toHaveText('Images');
|
||||
await expect(title).toBeFocused();
|
||||
});
|
||||
|
||||
test('returning to overview refreshes persisted counts without duplicating navigation', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const prompts = statistic(page, 'Prompts');
|
||||
await expect(prompts).toHaveText('2');
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
await navigation.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
await sidebarLink(page, 'Images').click();
|
||||
await expect(await request.post('/prompt_manager/save', { data: { text: 'A new persisted workspace prompt' } })).toBeOK();
|
||||
await navigation.getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await sidebarLink(page, 'Overview').click();
|
||||
await expect(prompts).toHaveText('3');
|
||||
await expect(navigation).toHaveCount(1);
|
||||
await expect(navigation.getByRole('link')).toHaveCount(routes.length);
|
||||
await expect(sidebarLinks(page)).toHaveCount(routes.length);
|
||||
});
|
||||
|
||||
test('appearance follows ComfyUI and persists an explicit light or dark preference', async ({ page }) => {
|
||||
test('theme System follows ComfyUI and persists an explicit light or dark preference', async ({ page }) => {
|
||||
await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ json: 'light' }));
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const theme = page.locator('[data-ca-theme]').first();
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
|
||||
const appearance = page.getByRole('combobox', { name: 'Appearance', exact: true });
|
||||
await appearance.selectOption({ label: 'Dark' });
|
||||
const themeSelect = page.getByRole('combobox', { name: 'Theme', exact: true });
|
||||
await expect(themeSelect).toHaveValue('system');
|
||||
await themeSelect.selectOption({ label: 'Dark' });
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'dark');
|
||||
await page.reload();
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'dark');
|
||||
await expect(appearance).toHaveValue('dark');
|
||||
await appearance.selectOption({ label: 'Light' });
|
||||
await expect(themeSelect).toHaveValue('dark');
|
||||
await themeSelect.selectOption({ label: 'Light' });
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
|
||||
await appearance.selectOption({ label: 'Follow ComfyUI' });
|
||||
await themeSelect.selectOption({ label: 'System' });
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
|
||||
});
|
||||
|
||||
test('unavailable host palette falls back to system appearance and tracks system changes', async ({ page }) => {
|
||||
// Task A4 (D4, D5): the concept topbar (index.html 45-63, style.css 225-277) without the prototype badge or Activity.
|
||||
test('topbar matches the concept actions', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
const topbar = page.locator('.workspace > header.topbar');
|
||||
await expect(topbar).toHaveCount(1);
|
||||
const shape = await topbar.evaluate(node => ({
|
||||
children: [...node.children].map(child => `${child.localName}.${child.className}`),
|
||||
crumbs: [...node.querySelector('.breadcrumbs').childNodes].filter(item => item.nodeType === 1 || item.textContent.trim())
|
||||
.map(item => item.nodeType === 1 ? `${item.localName}${item.id ? `#${item.id}` : ''}:${item.textContent}` : item.textContent.trim()),
|
||||
actions: [...node.querySelector('.top-actions').children].map(child => `${child.localName}.${child.className}`),
|
||||
label: node.querySelector('.theme-label').firstChild.textContent.trim(),
|
||||
labelFor: node.querySelector('.theme-label').htmlFor,
|
||||
options: [...node.querySelectorAll('#theme option')].map(option => [option.value, option.textContent]),
|
||||
arrow: [...node.querySelectorAll('.canvas-link span')].map(span => [span.textContent, span.getAttribute('aria-hidden')]),
|
||||
tokens: {
|
||||
height: getComputedStyle(node).height, padding: getComputedStyle(node).padding,
|
||||
crumbs: getComputedStyle(node.querySelector('.breadcrumbs')).fontSize,
|
||||
current: getComputedStyle(node.querySelector('#workspace-breadcrumb')).color === getComputedStyle(document.body).color,
|
||||
label: getComputedStyle(node.querySelector('.theme-label')).fontSize,
|
||||
select: getComputedStyle(node.querySelector('#theme')).fontSize,
|
||||
link: getComputedStyle(node.querySelector('.canvas-link')).fontSize,
|
||||
gap: getComputedStyle(node.querySelector('.top-actions')).columnGap,
|
||||
},
|
||||
}));
|
||||
expect(shape).toEqual({
|
||||
children: ['div.breadcrumbs', 'div.top-actions'],
|
||||
crumbs: ['ComfyAssets', 'span:/', 'span#workspace-breadcrumb:Images'],
|
||||
actions: ['label.theme-label', 'a.canvas-link'],
|
||||
label: 'Theme', labelFor: 'theme',
|
||||
options: [['dark', 'Dark'], ['light', 'Light'], ['system', 'System']],
|
||||
arrow: [['↗', 'true']],
|
||||
tokens: { height: '68px', padding: '0px 30px', crumbs: '12px', current: true, label: '11px', select: '11px', link: '12px', gap: '12px' },
|
||||
});
|
||||
const theme = page.getByRole('combobox', { name: 'Theme', exact: true });
|
||||
await expect(theme).toHaveValue('system');
|
||||
const link = page.getByRole('link', { name: 'ComfyUI', exact: true });
|
||||
await expect(link).toHaveText('ComfyUI ↗');
|
||||
// D6: the ComfyUI canvas root under the page's mount (resolveUrl), never a hard-coded origin.
|
||||
await expect(link).toHaveAttribute('href', '/');
|
||||
expect(await link.evaluate(node => node.href === `${location.origin}/`)).toBe(true);
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /^Activity/ })).toHaveCount(0);
|
||||
await expect(page.locator('.concept-badge, #activity-open')).toHaveCount(0);
|
||||
await page.locator('nav [data-route="library"]').click();
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Prompt library');
|
||||
await expect(topbar.locator('.top-actions > *')).toHaveCount(2);
|
||||
});
|
||||
|
||||
// Concept style.css 1375-1391: at <=700px the breadcrumbs hide, the actions spread across the bar and the Theme
|
||||
// label text collapses while the select keeps its accessible name.
|
||||
test('mobile topbar hides breadcrumbs and spreads actions', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
const theme = page.getByRole('combobox', { name: 'Theme', exact: true });
|
||||
await expect(theme).toBeVisible();
|
||||
await expect(page.locator('.breadcrumbs')).toBeHidden();
|
||||
const layout = await page.locator('.topbar').evaluate(node => {
|
||||
const bar = node.getBoundingClientRect(), actions = node.querySelector('.top-actions');
|
||||
const box = element => element.getBoundingClientRect();
|
||||
return {
|
||||
padding: getComputedStyle(node).paddingInline,
|
||||
actions: { width: box(actions).width, justify: getComputedStyle(actions).justifyContent, gap: getComputedStyle(actions).columnGap },
|
||||
expected: bar.width - 28,
|
||||
label: getComputedStyle(node.querySelector('.theme-label')).fontSize,
|
||||
select: getComputedStyle(node.querySelector('#theme')).fontSize,
|
||||
labelLeft: box(node.querySelector('.theme-label')).left - bar.left,
|
||||
linkRight: bar.right - box(node.querySelector('.canvas-link')).right,
|
||||
};
|
||||
});
|
||||
expect(layout).toMatchObject({ padding: '14px', actions: { justify: 'space-between', gap: '8px' }, label: '0px', select: '11px' });
|
||||
expect(Math.abs(layout.actions.width - layout.expected)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(layout.labelLeft - 14)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(layout.linkRight - 14)).toBeLessThanOrEqual(1);
|
||||
await expect(page.getByRole('link', { name: 'ComfyUI', exact: true })).toBeVisible();
|
||||
await theme.selectOption('light');
|
||||
await expect(page.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
});
|
||||
|
||||
test('unavailable host palette falls back to the system theme and tracks system changes', async ({ page }) => {
|
||||
await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ status: 503, json: {} }));
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
@@ -170,15 +282,14 @@ test('overview reports loading, offers retry after failure and renders real empt
|
||||
}
|
||||
});
|
||||
|
||||
test('small screens keep navigation and appearance accessible without horizontal page overflow', async ({ page }) => {
|
||||
test('small screens keep navigation and the theme accessible without horizontal page overflow', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
for (const [, label] of routes) {
|
||||
await navigation.getByRole('link', { name: label, exact: true }).click();
|
||||
await sidebarLink(page, label).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText(label);
|
||||
}
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('combobox', { name: 'Theme', exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -196,20 +307,19 @@ test('leaving a loading overview prevents its delayed result from replacing the
|
||||
});
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
await sidebarLink(page, 'Images').click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
release();
|
||||
await finished;
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(page.locator('#workspace-main')).not.toContainText('999');
|
||||
await expect(page.locator('.workspace-nav-primary')).toHaveCount(1);
|
||||
await expect(page.locator('.workspace-nav-tools')).toHaveCount(1);
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toHaveCount(1);
|
||||
await expect(page.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toBeVisible();
|
||||
await countAppearanceWrites(page);
|
||||
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption('light');
|
||||
expect(await page.evaluate(() => window.__appearanceChanges)).toBe(1);
|
||||
await expect(page.locator('#primary-nav')).toHaveCount(1);
|
||||
await expect(page.locator('#system-nav')).toHaveCount(1);
|
||||
await expect(page.getByRole('combobox', { name: 'Theme', exact: true })).toHaveCount(1);
|
||||
await expect(page.locator('#workspace-main .section-caption')).toBeVisible();
|
||||
await countThemeWrites(page);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption('light');
|
||||
expect(await page.evaluate(() => window.__themeChanges)).toBe(1);
|
||||
});
|
||||
|
||||
test('cached-page restoration remounts one working shell and refreshes its data', async ({ page }) => {
|
||||
@@ -227,15 +337,384 @@ test('cached-page restoration remounts one working shell and refreshes its data'
|
||||
await expect(statistic(page, 'Prompts')).toHaveText('2');
|
||||
}
|
||||
expect(reads).toBe(3);
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
await expect(navigation.getByRole('link')).toHaveCount(routes.length);
|
||||
await expect(page.locator('.workspace-nav-primary')).toHaveCount(1);
|
||||
await expect(page.locator('.workspace-nav-tools')).toHaveCount(1);
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toHaveCount(1);
|
||||
await countAppearanceWrites(page);
|
||||
await navigation.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
await expect(sidebarLinks(page)).toHaveCount(routes.length);
|
||||
await expect(page.locator('#primary-nav')).toHaveCount(1);
|
||||
await expect(page.locator('#system-nav')).toHaveCount(1);
|
||||
await expect(page.getByRole('combobox', { name: 'Theme', exact: true })).toHaveCount(1);
|
||||
await countThemeWrites(page);
|
||||
await sidebarLink(page, 'Images').click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await page.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
|
||||
await page.getByRole('combobox', { name: 'Theme' }).selectOption('light');
|
||||
await expect(page.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
expect(await page.evaluate(() => window.__appearanceChanges)).toBe(1);
|
||||
expect(await page.evaluate(() => window.__themeChanges)).toBe(1);
|
||||
});
|
||||
|
||||
// Task A2: the Overview and handoff panels use the concept's `.surface` (style.css 709-722) and `.small` from the
|
||||
// concept base stylesheet, not the retired shell sheet (18px h2, 11px panel radius).
|
||||
const surfaceStyle = node => {
|
||||
const style = getComputedStyle(node);
|
||||
const heading = getComputedStyle(node.querySelector(':scope > h2'));
|
||||
const intro = node.querySelector(':scope > p');
|
||||
return {
|
||||
padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft],
|
||||
radius: style.borderTopLeftRadius, border: `${style.borderTopWidth} ${style.borderTopStyle}`,
|
||||
background: style.backgroundColor,
|
||||
h2: { fontSize: heading.fontSize, fontWeight: heading.fontWeight, marginTop: heading.marginTop, marginBottom: heading.marginBottom },
|
||||
intro: intro && { fontSize: getComputedStyle(intro).fontSize, marginTop: getComputedStyle(intro).marginTop },
|
||||
};
|
||||
};
|
||||
const SURFACE = {
|
||||
padding: ['22px', '22px', '22px', '22px'], radius: '7px', border: '1px solid',
|
||||
h2: { fontSize: '17px', fontWeight: '600', marginTop: '0px', marginBottom: '0px' },
|
||||
intro: { fontSize: '12px', marginTop: '7px' },
|
||||
};
|
||||
|
||||
for (const width of [1600, 390]) test(`overview and handoff render with concept surfaces ${width}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(statistic(page, 'Prompts')).toHaveText('2');
|
||||
const tools = main.locator('section.surface').filter({ has: page.getByRole('heading', { level: 2, name: 'Continue creating', exact: true }) });
|
||||
await expect(tools).toHaveCount(1);
|
||||
const surface = await tools.evaluate(surfaceStyle);
|
||||
expect(surface).toMatchObject(SURFACE);
|
||||
expect(surface.background).not.toBe('rgba(0, 0, 0, 0)');
|
||||
await expect(main.locator('.panel')).toHaveCount(0);
|
||||
const card = tools.locator('.tool-card').first();
|
||||
await expect(card.locator('p.small')).toHaveCount(1);
|
||||
expect(await card.locator('p.small').evaluate(node => getComputedStyle(node).fontSize)).toBe('12px');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
|
||||
await sidebarLink(page, 'System').click();
|
||||
const handoff = main.locator('section.handoff');
|
||||
await expect(handoff).toHaveClass(/\bsurface\b/);
|
||||
expect(await handoff.evaluate(surfaceStyle)).toMatchObject(SURFACE);
|
||||
await expect(handoff.locator('a[href="/prompt_manager/admin"]')).toBeVisible();
|
||||
await expect(main.locator('.panel')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
});
|
||||
|
||||
// Task A3: the concept sidebar (index.html h16-41, app.js nav()) rendered from real data (D27, D28).
|
||||
const NAV_TOTALS = [
|
||||
['images', 'Images', '/prompt_manager/images/output?mode=workspace&limit=1'],
|
||||
['library', 'Prompt library', '/prompt_manager/library?limit=1'],
|
||||
['tags', 'Tags & categories', '/prompt_manager/vocabulary/tags?limit=1'],
|
||||
];
|
||||
async function serverTotals(request) {
|
||||
const totals = {};
|
||||
for (const [id, , url] of NAV_TOTALS) {
|
||||
const response = await request.get(url);
|
||||
await expect(response).toBeOK();
|
||||
totals[id] = (await response.json()).total;
|
||||
expect(Number.isSafeInteger(totals[id]) && totals[id] >= 0).toBe(true);
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
const navCount = (page, route) => page.locator(`#primary-nav a[data-route="${route}"] .nav-count`);
|
||||
const childShape = locator => locator.evaluate(node => [...node.children]
|
||||
.map(child => `${child.localName}${child.id ? `#${child.id}` : ''}${[...child.classList].map(name => `.${name}`).join('')}`));
|
||||
|
||||
test('sidebar matches the concept groups and shows real counts', async ({ page, request }) => {
|
||||
const totals = await serverTotals(request);
|
||||
expect(totals.library).toBe(2);
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(page.locator('body > .app-shell > aside.sidebar')).toHaveCount(1);
|
||||
await expect(page.locator('body > .app-shell > .workspace > #workspace-main')).toHaveCount(1);
|
||||
await expect(page.locator('.workspace-shell, .workspace-body, .sidebar-footer, .connection-dot, .workspace-icon')).toHaveCount(0);
|
||||
const sidebar = page.locator('aside.sidebar');
|
||||
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-bottom']);
|
||||
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
|
||||
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE']);
|
||||
await expect(page.locator('#saved-nav, #collections-nav, .new-label')).toHaveCount(0);
|
||||
const primary = page.locator('nav#primary-nav');
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Workspace');
|
||||
for (const [id] of NAV_TOTALS) await expect(navCount(page, id)).toHaveText(totals[id].toLocaleString('en-US'));
|
||||
const links = await primary.evaluate(node => [...node.children].map(link => ({ tag: link.localName,
|
||||
href: link.getAttribute('href'), icon: link.querySelector('svg')?.getAttribute('class'),
|
||||
nodes: [...link.childNodes].map(child => child.nodeType === Node.TEXT_NODE ? `text:${child.textContent}`
|
||||
: `${child.localName}${[...child.classList].map(name => `.${name}`).join('')}`) })));
|
||||
expect(links).toEqual([
|
||||
{ tag: 'a', href: '#/images', nodes: ['svg.icon', 'text:Images', 'span.nav-count'], icon: 'icon' },
|
||||
{ tag: 'a', href: '#/library', nodes: ['svg.icon', 'text:Prompt library', 'span.nav-count'], icon: 'icon' },
|
||||
{ tag: 'a', href: '#/tags', nodes: ['svg.icon', 'text:Tags & categories', 'span.nav-count'], icon: 'icon' },
|
||||
{ tag: 'a', href: '#/inspect', nodes: ['svg.icon', 'text:Inspect a file'], icon: 'icon' },
|
||||
]);
|
||||
await expect(primary.locator('a[href="#/images"]')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(primary.locator('a[aria-current]')).toHaveCount(1);
|
||||
await expect(page.locator('#primary-nav a[data-route="inspect"] .nav-count')).toHaveCount(0);
|
||||
// Concept tokens: 38px rows, 12px muted labels, 10px muted counts pushed to the end.
|
||||
const style = await primary.locator('a[href="#/library"]').evaluate(link => {
|
||||
const own = getComputedStyle(link), count = link.querySelector('.nav-count');
|
||||
return { minHeight: own.minHeight, fontSize: own.fontSize, gap: own.columnGap, count: getComputedStyle(count).fontSize,
|
||||
end: Math.round(link.getBoundingClientRect().right - count.getBoundingClientRect().right) };
|
||||
});
|
||||
expect(style).toEqual({ minHeight: '38px', fontSize: '12px', gap: '11px', count: '10px', end: 12 });
|
||||
// At phone width the concept wraps the links and hides counts and the connection (concept 950px block).
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(navCount(page, 'library')).toBeHidden();
|
||||
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(2);
|
||||
for (const hidden of await page.locator('.sidebar-label, .connection').all()) await expect(hidden).toBeHidden();
|
||||
await expect(primary.locator('a[href="#/inspect"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('nav counts stay hidden until loaded and when their request fails', async ({ page, request }) => {
|
||||
const totals = await serverTotals(request);
|
||||
let release;
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
await page.route('**/prompt_manager/library?limit=1', async route => {
|
||||
await pending;
|
||||
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
|
||||
});
|
||||
await page.route('**/prompt_manager/vocabulary/tags?limit=1', route => route.fulfill({ json: { success: true, total: -4 } }));
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
// Nothing renders for a read that is outstanding: no "0" placeholder. The settled Images count does not wait for it.
|
||||
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
|
||||
await expect(navCount(page, 'library')).toHaveCount(0);
|
||||
await expect(navCount(page, 'tags')).toHaveCount(0);
|
||||
release();
|
||||
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
|
||||
await expect(navCount(page, 'library')).toHaveCount(0);
|
||||
await expect(navCount(page, 'tags')).toHaveCount(0);
|
||||
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(1);
|
||||
});
|
||||
|
||||
// A3 review: the Images listing scans output folders, so its slow count must not hold back Library and Tags.
|
||||
test('a slow Images count does not delay the Library and Tags badges', async ({ page, request }) => {
|
||||
const totals = await serverTotals(request);
|
||||
let release;
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('limit') === '1', async route => {
|
||||
await pending;
|
||||
await route.continue().catch(() => {});
|
||||
});
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(navCount(page, 'library')).toHaveText(String(totals.library));
|
||||
await expect(navCount(page, 'tags')).toHaveText(String(totals.tags));
|
||||
await expect(navCount(page, 'images')).toHaveCount(0);
|
||||
release();
|
||||
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
|
||||
});
|
||||
|
||||
test('sidebar bottom holds System and Overview with a neutral connection', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/system`);
|
||||
const bottom = page.locator('aside.sidebar > .sidebar-bottom');
|
||||
expect(await childShape(bottom)).toEqual(['nav#system-nav', 'div.connection']);
|
||||
const system = page.locator('nav#system-nav');
|
||||
await expect(system).toHaveAttribute('aria-label', 'Tools and system');
|
||||
expect(await system.locator(':scope > a').evaluateAll(nodes => nodes.map(node => [node.getAttribute('href'), node.textContent]))).toEqual([
|
||||
['#/system', 'System'], ['#/overview', 'Overview'],
|
||||
]);
|
||||
await expect(system.locator('.nav-count')).toHaveCount(0);
|
||||
await expect(system.locator('a[href="#/system"]')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(system.locator('svg.icon')).toHaveCount(2);
|
||||
const connection = bottom.locator('.connection');
|
||||
expect(await childShape(connection)).toEqual(['i', 'div']);
|
||||
await expect(connection.locator(':scope > div')).toHaveText('ComfyUI workspaceYour library, on your server');
|
||||
await expect(connection.locator('small')).toHaveText('Your library, on your server');
|
||||
// D28: no fake connection state. The dot is the muted text colour, not the concept's demo green.
|
||||
const dot = await connection.evaluate(node => ({ dot: getComputedStyle(node.querySelector('i')).backgroundColor,
|
||||
muted: getComputedStyle(node.querySelector('small')).color }));
|
||||
expect(dot.dot).toBe(dot.muted);
|
||||
await page.locator('nav#system-nav').getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(system.locator('a[href="#/overview"]')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('aside.sidebar a[aria-current]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('counts refresh after a confirmed editor save', async ({ page, request }) => {
|
||||
const before = await serverTotals(request);
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(navCount(page, 'library')).toHaveText(String(before.library));
|
||||
await expect(navCount(page, 'tags')).toHaveText(String(before.tags));
|
||||
const reads = [];
|
||||
page.on('request', item => {
|
||||
const url = new URL(item.url());
|
||||
if (url.searchParams.get('limit') === '1') reads.push(url.pathname);
|
||||
});
|
||||
await page.getByRole('button', { name: '+ New prompt', exact: true }).click();
|
||||
const dialog = page.locator('dialog.library-editor');
|
||||
await expect(dialog.getByRole('button', { name: 'Save', exact: true })).toBeEnabled();
|
||||
await dialog.getByLabel('Title', { exact: true }).fill('Harbor at dawn');
|
||||
await dialog.getByLabel('Positive prompt', { exact: true }).fill('A quiet harbor at dawn for the sidebar count');
|
||||
await dialog.getByLabel('Tags, separated by commas', { exact: true }).fill('sidebar_count_tag');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
const after = await serverTotals(request);
|
||||
expect(after.library).toBe(before.library + 1);
|
||||
expect(after.tags).toBeGreaterThan(before.tags);
|
||||
await expect(navCount(page, 'library')).toHaveText(String(after.library));
|
||||
await expect(navCount(page, 'tags')).toHaveText(String(after.tags));
|
||||
expect(reads.filter(path => path === '/prompt_manager/library').length).toBeGreaterThanOrEqual(1);
|
||||
// A Library write cannot change the Images count, so the Images listing is not read again.
|
||||
expect(reads.filter(path => path === '/prompt_manager/images/output')).toEqual([]);
|
||||
});
|
||||
|
||||
test('a superseded count read never lands and a failed refresh removes the stale count', async ({ page, request }) => {
|
||||
const totals = await serverTotals(request);
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(navCount(page, 'library')).toHaveText(String(totals.library));
|
||||
const held = [];
|
||||
let reads = 0;
|
||||
await page.route('**/prompt_manager/library?limit=1', route => {
|
||||
if (reads++ === 0) { held.push(route); return undefined; }
|
||||
return route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
|
||||
});
|
||||
const invalidate = () => page.evaluate(async () => {
|
||||
const { invalidateLibraryAfterWrite } = await import('/prompt_manager/js/workspace/library.mjs');
|
||||
invalidateLibraryAfterWrite({ kind: 'test', receipt: {} });
|
||||
});
|
||||
await invalidate();
|
||||
await expect.poll(() => held.length).toBe(1);
|
||||
await invalidate();
|
||||
await expect(navCount(page, 'library')).toHaveCount(0);
|
||||
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
|
||||
await expect(navCount(page, 'tags')).toHaveText(String(totals.tags));
|
||||
await held[0].fulfill({ json: { success: true, total: 999 } }).catch(() => {});
|
||||
await page.waitForTimeout(100);
|
||||
await expect(navCount(page, 'library')).toHaveCount(0);
|
||||
await page.unroute('**/prompt_manager/library?limit=1');
|
||||
// Leaving the page with a read in flight aborts it; the restored shell reads the counts again.
|
||||
let release;
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
await page.route('**/prompt_manager/library?limit=1', async route => { await pending; await route.continue().catch(() => {}); }, { times: 1 });
|
||||
await invalidate();
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true }));
|
||||
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
|
||||
});
|
||||
release();
|
||||
await expect(navCount(page, 'library')).toHaveText(String(totals.library));
|
||||
await expect(page.locator('#primary-nav a')).toHaveCount(4);
|
||||
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3);
|
||||
});
|
||||
|
||||
// Task A5: the concept heading() (app.js 303-305) with its typography (style.css 73-103, 283-300; <=700 1408-1416) on
|
||||
// every native route, and the production footer copy in the concept `.app-footer` (D29).
|
||||
const headingShape = node => {
|
||||
const style = element => getComputedStyle(element);
|
||||
const shape = element => `${element.localName}${element.id ? `#${element.id}` : ''}${[...element.classList].map(name => `.${name}`).join('')}`;
|
||||
const [copy, actions] = node.children;
|
||||
const [h1, description] = copy.children;
|
||||
const main = node.parentElement.getBoundingClientRect();
|
||||
return {
|
||||
children: [...node.children].map(shape), copy: [...copy.children].map(shape), tabIndex: h1.tabIndex,
|
||||
direction: style(node).flexDirection, marginBottom: style(node).marginBottom,
|
||||
h1: { fontSize: style(h1).fontSize, lineHeight: Math.round(parseFloat(style(h1).lineHeight) / parseFloat(style(h1).fontSize) * 100) / 100, fontWeight: style(h1).fontWeight, letterSpacing: style(h1).letterSpacing, marginTop: style(h1).marginTop },
|
||||
description: { text: description.textContent.length > 0, fontSize: style(description).fontSize, marginTop: style(description).marginTop, muted: style(description).color === style(document.querySelector('.connection small')).color },
|
||||
actions: { gap: style(actions).columnGap, left: Math.round(actions.getBoundingClientRect().left), fullWidth: Math.round(actions.getBoundingClientRect().width) === Math.round(node.getBoundingClientRect().width) },
|
||||
mainLeft: Math.round(main.left + parseFloat(getComputedStyle(node.parentElement).paddingLeft)),
|
||||
pageTitles: document.querySelectorAll('#page-title').length,
|
||||
retired: document.querySelectorAll('.workspace-heading, .workspace-heading-copy, #workspace-main .intro').length,
|
||||
};
|
||||
};
|
||||
|
||||
test('every route renders the concept heading', async ({ page, request }) => {
|
||||
const library = await (await request.get('/prompt_manager/library?limit=1')).json();
|
||||
const positiveId = library.items[0].id;
|
||||
const pages = [['overview', 'Overview'], ['images', 'Images'], ['library', 'Prompt library'], ['tags', 'Tags & categories'], [`library/${positiveId}`, null]];
|
||||
for (const width of [1600, 390]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
for (const [hash, title] of pages) {
|
||||
await page.goto(`${workspace}#/${hash}`);
|
||||
const heading = page.locator('#workspace-main > .heading');
|
||||
await expect(heading).toHaveCount(1);
|
||||
if (title) await expect(heading.locator('h1')).toHaveText(title);
|
||||
else await expect(heading.getByRole('button', { name: '← Prompt library', exact: true })).toBeVisible();
|
||||
await expect(heading.locator('p.muted')).not.toHaveText('');
|
||||
const shape = await heading.evaluate(headingShape);
|
||||
expect(shape, hash).toMatchObject({
|
||||
children: ['div', 'div.heading-actions'], copy: ['h1#page-title', 'p.muted'], tabIndex: -1, marginBottom: '22px',
|
||||
h1: width === 1600 ? { fontSize: '28px', lineHeight: 1.3, fontWeight: '620', letterSpacing: '-0.7px', marginTop: '0px' }
|
||||
: { fontSize: '25px', lineHeight: 1.3, fontWeight: '620', letterSpacing: '-0.7px', marginTop: '0px' },
|
||||
description: { text: true, fontSize: '12px', marginTop: '6px', muted: true },
|
||||
actions: { gap: '8px' }, pageTitles: 1, retired: 0,
|
||||
});
|
||||
if (width === 1600) {
|
||||
expect(shape.direction, hash).toBe('row');
|
||||
} else {
|
||||
// The concept stacks the heading and gives the actions the full row, starting at main's 14px gutter.
|
||||
expect(shape.direction, hash).toBe('column');
|
||||
expect(shape.actions, hash).toMatchObject({ left: 14, fullWidth: true });
|
||||
expect(shape.mainLeft, hash).toBe(14);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the footer is the concept app footer with production copy', async ({ page }) => {
|
||||
for (const [width, padding, direction] of [[1600, '22px 30px', 'row'], [390, '20px 14px', 'column']]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const footer = page.locator('.workspace > footer.app-footer');
|
||||
await expect(footer).toHaveCount(1);
|
||||
await expect(page.locator('.page-footer')).toHaveCount(0);
|
||||
expect(await footer.evaluate(node => ({
|
||||
children: [...node.children].map(child => child.localName), text: node.textContent,
|
||||
padding: getComputedStyle(node).padding, fontSize: getComputedStyle(node).fontSize, direction: getComputedStyle(node).flexDirection,
|
||||
muted: getComputedStyle(node).color === getComputedStyle(document.querySelector('.connection small')).color,
|
||||
afterMain: node.previousElementSibling.id,
|
||||
}))).toEqual({ children: ['span'], text: 'ComfyAssets · Local tools. Your library.', padding, fontSize: '10px', direction, muted: true, afterMain: 'workspace-main' });
|
||||
}
|
||||
});
|
||||
|
||||
// #164: the maintainer serves ComfyUI behind a proxy prefix. Everything, the ComfyUI link included, stays on that mount.
|
||||
const PROXY_MOUNT = '/ComfyBackendDirect';
|
||||
async function serveUnderProxyMount(page) {
|
||||
const escaped = [];
|
||||
page.on('request', item => {
|
||||
const { pathname } = new URL(item.url());
|
||||
if (pathname.startsWith('/prompt_manager/')) escaped.push(pathname);
|
||||
});
|
||||
await page.route(url => url.pathname.startsWith(`${PROXY_MOUNT}/`), async route => {
|
||||
const url = new URL(route.request().url());
|
||||
url.pathname = url.pathname.slice(PROXY_MOUNT.length);
|
||||
const response = await route.fetch({ url: url.href });
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
return escaped;
|
||||
}
|
||||
|
||||
test('the ComfyUI link points to the root of a non-standard proxy mount', async ({ page }) => {
|
||||
const escaped = await serveUnderProxyMount(page);
|
||||
for (const route of ['overview', 'library', 'tags']) {
|
||||
await page.goto(`${PROXY_MOUNT}${workspace}#/${route}`);
|
||||
await expect(page.locator('#workspace-main > .heading h1')).toBeVisible();
|
||||
const link = page.getByRole('link', { name: 'ComfyUI', exact: true });
|
||||
await expect(link).toHaveAttribute('href', `${PROXY_MOUNT}/`);
|
||||
expect(await link.evaluate(node => node.href)).toBe(new URL(`${PROXY_MOUNT}/`, page.url()).href);
|
||||
}
|
||||
await expect(navCount(page, 'library')).toHaveCount(1);
|
||||
expect(escaped).toEqual([]);
|
||||
});
|
||||
|
||||
test('a resolver failure for the ComfyUI link never stops the shell from mounting', async ({ page }) => {
|
||||
await serveUnderProxyMount(page);
|
||||
// Wrap the shared client as it loads so only the topbar's canvas-root lookup throws (the sender reads the same root).
|
||||
// Every engine's stack names the calling module's URL; each injected failure is counted so a renamed module or a
|
||||
// changed call site fails this test on the count instead of silently testing nothing.
|
||||
await page.addInitScript(() => {
|
||||
let client;
|
||||
window.__resolverFailures = { injected: 0 };
|
||||
Object.defineProperty(window, 'ApiClient', { configurable: true, get: () => client, set(value) {
|
||||
client = { ...value, resolveUrl: (url, ...rest) => {
|
||||
if (url === '/prompt_manager/' && /\/workspace\/topbar\.mjs\b/.test(String(new Error().stack))) {
|
||||
window.__resolverFailures.injected += 1;
|
||||
throw new Error('resolver unavailable');
|
||||
}
|
||||
return value.resolveUrl(url, ...rest);
|
||||
} };
|
||||
} });
|
||||
});
|
||||
const warnings = [];
|
||||
page.on('console', message => { if (message.type() === 'warning') warnings.push(message.text()); });
|
||||
await page.goto(`${PROXY_MOUNT}${workspace}#/overview`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(sidebarLinks(page)).toHaveCount(routes.length);
|
||||
expect(await page.evaluate(() => window.__resolverFailures.injected), 'the topbar lookup was the one that failed').toBe(1);
|
||||
expect(warnings.filter(text => text.includes('the ComfyUI link falls back to the page mount'))).toHaveLength(1);
|
||||
await expect(page.getByRole('link', { name: 'ComfyUI', exact: true })).toHaveAttribute('href', `${PROXY_MOUNT}/`);
|
||||
await sidebarLink(page, 'Prompt library').click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
"""Concept-shaped real data for every concept comparison (D30 of #249).
|
||||
|
||||
The approved concept's six prompts (docs/concepts/workspace/app.js:71-178) and its
|
||||
24 sample images (app.js:180-239) become real rows and real PNG files in this
|
||||
server's disposable database and output folder, so production renders the
|
||||
concept's shapes from its own stores: the same titles, texts, categories, tags,
|
||||
ratings and negative names, the same image dimensions, filenames and seeds, and
|
||||
the same sidebar counts (24 images, 6 prompts, 15 tags). Nothing is mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid5
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from utils.execution_contexts import (
|
||||
ContextBundle,
|
||||
PromptSnapshot,
|
||||
RecipeContext,
|
||||
TextEvidence,
|
||||
)
|
||||
from utils.execution_history_events import make_finish, make_start
|
||||
from utils.execution_outputs import OutputBundle, OutputReference, snapshot_output_roots
|
||||
from utils.pair_execution import EXECUTION_KEY
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConceptPrompt:
|
||||
id: str
|
||||
title: str
|
||||
category: str
|
||||
tags: tuple[str, ...]
|
||||
positive: str
|
||||
negatives: tuple[tuple[str, str], ...]
|
||||
color: tuple[str, str, str]
|
||||
rating: int
|
||||
|
||||
|
||||
# app.js:71-178, in concept order. Negatives are (name, text) pairs.
|
||||
CONCEPT_PROMPTS = (
|
||||
ConceptPrompt(
|
||||
"alpine",
|
||||
"Alpine observatory",
|
||||
"Landscapes",
|
||||
("cinematic", "blue_hour", "architecture"),
|
||||
"A remote alpine observatory at blue hour, warm light through glass, dramatic "
|
||||
"mountain peaks, cinematic composition, atmospheric mist, fine detail",
|
||||
(
|
||||
("Quality baseline", "blurry, low quality, low resolution"),
|
||||
(
|
||||
"Clean architecture",
|
||||
"blurry, low quality, low resolution, distorted architecture, "
|
||||
"watermark, text",
|
||||
),
|
||||
("No negative", ""),
|
||||
),
|
||||
("#14233c", "#9aaac3", "#d8d9dd"),
|
||||
5,
|
||||
),
|
||||
ConceptPrompt(
|
||||
"dunes",
|
||||
"Quiet geometry",
|
||||
"Architecture",
|
||||
("minimal", "warm_light", "editorial"),
|
||||
"Monolithic sandstone pavilion in a vast desert, sweeping dunes, warm "
|
||||
"terracotta, late afternoon sun, architectural photography, quiet geometry",
|
||||
(
|
||||
("Natural materials", "plastic, harsh shadows, noise, watermark"),
|
||||
(
|
||||
"Clean lines",
|
||||
"plastic, harsh shadows, noise, watermark, distorted geometry",
|
||||
),
|
||||
),
|
||||
("#6c4948", "#ddad91", "#efe0c5"),
|
||||
4,
|
||||
),
|
||||
ConceptPrompt(
|
||||
"forest",
|
||||
"Somewhere in the pines",
|
||||
"Landscapes",
|
||||
("forest", "mist", "peaceful"),
|
||||
"A quiet cedar cabin above a still forest lake, towering evergreens, soft "
|
||||
"morning mist, muted jade and moss, a small amber window, cinematic still",
|
||||
(("Soft atmosphere", "oversaturated, blurry, low quality, text"),),
|
||||
("#163b3d", "#6f9691", "#c0cfc1"),
|
||||
5,
|
||||
),
|
||||
ConceptPrompt(
|
||||
"city",
|
||||
"After the rain",
|
||||
"Environments",
|
||||
("neon", "night", "cinematic"),
|
||||
"Rain-soaked city street at night, blue neon and rose reflections, deep "
|
||||
"perspective, cinematic science fiction, solitary architecture, detailed "
|
||||
"atmosphere",
|
||||
(
|
||||
("Clean atmosphere", "blurry, low resolution, watermark, oversaturated"),
|
||||
(
|
||||
"Less signage",
|
||||
"blurry, low resolution, watermark, oversaturated, text, signs",
|
||||
),
|
||||
),
|
||||
("#1a2143", "#6869a1", "#cf8fbc"),
|
||||
4,
|
||||
),
|
||||
ConceptPrompt(
|
||||
"lunar",
|
||||
"The long way home",
|
||||
"Illustration",
|
||||
("space", "minimal", "concept_art"),
|
||||
"An isolated lunar research outpost, a luminous planet above the horizon, "
|
||||
"sweeping pale terrain, quiet science fiction, finely detailed concept art",
|
||||
(("Quality baseline", "blurry, low quality, watermark"),),
|
||||
("#19283b", "#7398ac", "#e2d3c4"),
|
||||
5,
|
||||
),
|
||||
ConceptPrompt(
|
||||
"coast",
|
||||
"Between sea and sky",
|
||||
"Landscapes",
|
||||
("coast", "soft_light", "peaceful"),
|
||||
"A solitary lighthouse on a rugged coastal cliff, sea mist, glints of morning "
|
||||
"light on turquoise water, atmospheric perspective, fine art landscape",
|
||||
(("Natural light", "oversaturated, flat lighting, watermark, text"),),
|
||||
("#255565", "#8cbcc2", "#e8d9bf"),
|
||||
4,
|
||||
),
|
||||
)
|
||||
# app.js:220-227: the image at flat index n has the n % 6 dimensions; with six
|
||||
# prompts per round that is the prompt's own index.
|
||||
CONCEPT_DIMENSIONS = (
|
||||
(1536, 1152),
|
||||
(1024, 1536),
|
||||
(1280, 1280),
|
||||
(1600, 900),
|
||||
(1152, 1536),
|
||||
(1536, 1024),
|
||||
)
|
||||
CONCEPT_IMAGE_ROUNDS = 4
|
||||
CONCEPT_RUN_COUNT = 4
|
||||
# The concept's Model fact (app.js:408).
|
||||
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
|
||||
SEEDLESS_SIZE = (1536, 1152)
|
||||
SESSION = "5c1f9a7e-3f0b-4a55-9d0e-2b8c4f6a7d10"
|
||||
_TABLES = (
|
||||
"execution_run_outputs",
|
||||
"execution_output_capture",
|
||||
"execution_run_contexts",
|
||||
"execution_context_capture",
|
||||
"execution_runs",
|
||||
"execution_contexts",
|
||||
"execution_prompt_snapshots",
|
||||
"execution_context_texts",
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
"generated_images",
|
||||
"prompt_tags",
|
||||
"tags",
|
||||
"prompt_library_state",
|
||||
"prompts",
|
||||
)
|
||||
|
||||
|
||||
def concept_starred(prompt_index: int, round_index: int) -> bool:
|
||||
"""The concept's favorites (app.js ``starred``): each other prompt's first image."""
|
||||
return round_index == 0 and prompt_index % 2 == 0
|
||||
|
||||
|
||||
def concept_seed(prompt_index: int, round_index: int) -> int:
|
||||
"""The concept's sample seed (app.js:185)."""
|
||||
return 42001 + prompt_index * 100 + round_index
|
||||
|
||||
|
||||
def concept_filename(prompt: ConceptPrompt, round_index: int) -> str:
|
||||
"""The concept's sample filename (app.js:187)."""
|
||||
return f"{prompt.id}_{round_index + 1:05}.png"
|
||||
|
||||
|
||||
def _graph(positive: str, negative: str, seed: int) -> dict:
|
||||
"""A ComfyUI API prompt with the concept's facts (app.js:406-408).
|
||||
|
||||
The recipe is a Prompt Manager pair node, so with its embedded execution record
|
||||
the inspector reports both roles as execution evidence, which the panel badges
|
||||
"Execution recorded" (D21). One sampler keeps the seed unambiguous.
|
||||
"""
|
||||
return {
|
||||
"1": {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {"text": positive, "negative_text": negative, "clip": ["2", 1]},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": CONCEPT_MODEL},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["2", 0],
|
||||
"positive": ["1", 0],
|
||||
"negative": ["1", 1],
|
||||
"seed": seed,
|
||||
"steps": 28,
|
||||
"cfg": 6.5,
|
||||
"sampler_name": "dpmpp_2m",
|
||||
"scheduler": "karras",
|
||||
},
|
||||
},
|
||||
"4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}},
|
||||
}
|
||||
|
||||
|
||||
def _execution_record(positive: str, negative: str) -> dict:
|
||||
"""The pair node's embedded execution evidence (utils/pair_execution.py)."""
|
||||
record = {
|
||||
"text": positive,
|
||||
"negative_text": negative,
|
||||
"positive_text": positive,
|
||||
"negative_effective_text": negative,
|
||||
"prepend_text": "",
|
||||
"append_text": "",
|
||||
}
|
||||
return {"version": 1, "nodes": {"1": {"records": [record]}}}
|
||||
|
||||
|
||||
def _snapshot(text: str) -> PromptSnapshot:
|
||||
"""A recorded prompt snapshot whose raw and effective texts are ``text``."""
|
||||
|
||||
def known(value: str) -> TextEvidence:
|
||||
return TextEvidence(value, "recorded", "runtime_return", None)
|
||||
|
||||
return PromptSnapshot(known(text), known(text), known(""), known(""))
|
||||
|
||||
|
||||
def write_concept_png(path: Path, prompt_index: int, round_index: int) -> dict:
|
||||
"""Write the concept's sample image ``round_index`` of prompt ``prompt_index``.
|
||||
|
||||
Returns its facts: variant, negative, seed and dimensions.
|
||||
"""
|
||||
prompt = CONCEPT_PROMPTS[prompt_index]
|
||||
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
|
||||
variant = round_index % len(prompt.negatives)
|
||||
negative = prompt.negatives[variant][1]
|
||||
seed = concept_seed(prompt_index, round_index)
|
||||
width, height = size = CONCEPT_DIMENSIONS[index % len(CONCEPT_DIMENSIONS)]
|
||||
sky, ground, light = prompt.color
|
||||
info = PngInfo()
|
||||
info.add_text("prompt", json.dumps(_graph(prompt.positive, negative, seed)))
|
||||
info.add_text(
|
||||
EXECUTION_KEY, json.dumps(_execution_record(prompt.positive, negative))
|
||||
)
|
||||
with Image.new("RGB", size, sky) as image:
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle((0, height * 2 // 3, width, height), fill=ground)
|
||||
radius = min(width, height) // 9
|
||||
left, top = width * 2 // 3, height // 6
|
||||
draw.ellipse((left, top, left + radius, top + radius), fill=light)
|
||||
image.save(path, pnginfo=info)
|
||||
return {
|
||||
"variant": variant,
|
||||
"negative": negative,
|
||||
"seed": seed,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
|
||||
|
||||
def _clear(api: PromptManagerAPI, root: Path) -> None:
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in _TABLES:
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for name in ("output", "input"):
|
||||
shutil.rmtree(root / name)
|
||||
(root / name).mkdir()
|
||||
|
||||
|
||||
def _record_run(
|
||||
api: PromptManagerAPI,
|
||||
*,
|
||||
epoch: str,
|
||||
item: int,
|
||||
context: RecipeContext,
|
||||
output: OutputReference,
|
||||
observed_at: int,
|
||||
) -> str:
|
||||
"""One finished execution with its recipe context and its one output."""
|
||||
job = str(uuid5(UUID(SESSION), f"concept-job-{item}"))
|
||||
start = make_start(SESSION, ((item, job, {}, {}), item), epoch, observed_at)
|
||||
api.db.write_execution_start(start)
|
||||
finish = make_finish(
|
||||
SESSION,
|
||||
item,
|
||||
epoch,
|
||||
SimpleNamespace(messages=[("execution_success", {"prompt_id": job})]),
|
||||
observed_at + 10,
|
||||
)
|
||||
api.db.write_execution_finish(finish)
|
||||
api.db.write_execution_contexts(
|
||||
start.run_id, epoch, ContextBundle((context,), "complete", "captured")
|
||||
)
|
||||
api.db.write_execution_outputs_for_finish(
|
||||
finish, OutputBundle((output,), "complete", "reported")
|
||||
)
|
||||
return start.run_id
|
||||
|
||||
|
||||
def _write_seedless_png(path: Path) -> None:
|
||||
"""A plain PNG with no metadata: no prompt, no seed, no link."""
|
||||
with Image.new("RGB", SEEDLESS_SIZE, "#2b3442") as image:
|
||||
image.save(path)
|
||||
|
||||
|
||||
def _bounded(name: str, value: int, maximum: int) -> None:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise TypeError(f"{name} must be an integer")
|
||||
if not 0 <= value <= maximum:
|
||||
raise ValueError(f"{name} is out of range")
|
||||
|
||||
|
||||
def seed_workspace_concept(
|
||||
api: PromptManagerAPI,
|
||||
root: Path,
|
||||
*,
|
||||
extra_images: int = 0,
|
||||
seedless_images: int = 0,
|
||||
) -> dict:
|
||||
"""Replace this server's disposable rows and files with the concept's data.
|
||||
|
||||
``extra_images`` continues the concept's rounds (same prompts, dimensions and
|
||||
seed rule) after its 24 images, for bounded-rendering and Load more checks.
|
||||
``seedless_images`` adds unlinked PNGs without metadata, newest of all.
|
||||
"""
|
||||
_bounded("extra_images", extra_images, MAX_EXTRA_IMAGES)
|
||||
_bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES)
|
||||
_clear(api, root)
|
||||
db = api.db
|
||||
epoch = db.get_execution_epoch()
|
||||
prompts = []
|
||||
for prompt in CONCEPT_PROMPTS:
|
||||
first_name, first_text = prompt.negatives[0]
|
||||
receipt = db.create_library_recipe(
|
||||
prompt.positive,
|
||||
first_text,
|
||||
expected_epoch=epoch,
|
||||
parent_metadata={
|
||||
"title": prompt.title,
|
||||
"category": prompt.category,
|
||||
"tags": list(prompt.tags),
|
||||
"rating": prompt.rating,
|
||||
},
|
||||
)
|
||||
positive_id = receipt["positive"]["id"]
|
||||
pair_ids = [receipt["pair"]["id"]]
|
||||
db.update_prompt_pair_metadata(pair_ids[0], title=first_name)
|
||||
for name, text in prompt.negatives[1:]:
|
||||
pair_ids.append(db.save_prompt_pair(prompt.positive, text, title=name))
|
||||
# Real queue-hook counting (D23): four runs, the last one now.
|
||||
db.record_prompt_use(positive_id, times=CONCEPT_RUN_COUNT)
|
||||
prompts.append(
|
||||
{"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids}
|
||||
)
|
||||
|
||||
folder = root / "output" / "concepts"
|
||||
folder.mkdir()
|
||||
roots = snapshot_output_roots("posix", str(root / "output"), str(root / "temp"))
|
||||
# The API package needs the host stubs the server installs before importing it.
|
||||
from py.api.images import _favorite_relative_key, _favorite_root_key
|
||||
|
||||
favorite_root = _favorite_root_key(list(api._get_all_output_dirs())[0])
|
||||
total = len(CONCEPT_PROMPTS) * CONCEPT_IMAGE_ROUNDS + extra_images
|
||||
rounds = math.ceil(total / len(CONCEPT_PROMPTS))
|
||||
now = time.time()
|
||||
images, runs = [], []
|
||||
for round_index in range(rounds):
|
||||
for prompt_index, prompt in enumerate(CONCEPT_PROMPTS):
|
||||
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
|
||||
if index >= total:
|
||||
break
|
||||
name = concept_filename(prompt, round_index)
|
||||
path = folder / name
|
||||
facts = write_concept_png(path, prompt_index, round_index)
|
||||
variant, negative, seed = facts["variant"], facts["negative"], facts["seed"]
|
||||
# The concept lists images newest first in this order.
|
||||
stamp = now - 60 - index * 60
|
||||
os.utime(path, (stamp, stamp))
|
||||
owner = prompts[prompt_index]
|
||||
image_id = db.link_image_to_prompt(owner["positive_id"], str(path))
|
||||
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}")
|
||||
)
|
||||
context = RecipeContext(
|
||||
"3", _snapshot(prompt.positive), _snapshot(negative), "inferred"
|
||||
)
|
||||
reference = OutputReference(
|
||||
"4", 0, "output", "concepts", name, "posix", roots.output_key
|
||||
)
|
||||
runs.append(
|
||||
_record_run(
|
||||
api,
|
||||
epoch=epoch,
|
||||
item=index,
|
||||
context=context,
|
||||
output=reference,
|
||||
observed_at=int(stamp * 1000),
|
||||
)
|
||||
)
|
||||
images.append(
|
||||
{
|
||||
"filename": name,
|
||||
"image_id": image_id,
|
||||
"pair_id": pair_id,
|
||||
"prompt": prompt.id,
|
||||
"variant": variant,
|
||||
"seed": seed,
|
||||
"path": f"concepts/{name}",
|
||||
"width": facts["width"],
|
||||
"height": facts["height"],
|
||||
}
|
||||
)
|
||||
seedless = []
|
||||
for number in range(seedless_images):
|
||||
name = f"seedless_{number + 1:05}.png"
|
||||
path = folder / name
|
||||
_write_seedless_png(path)
|
||||
stamp = now - 10 - number * 10
|
||||
os.utime(path, (stamp, stamp))
|
||||
seedless.append({"filename": name, "path": f"concepts/{name}"})
|
||||
with db.model.get_connection() as conn:
|
||||
counts = conn.execute(
|
||||
"SELECT COUNT(*), MIN(run_count), MAX(run_count) FROM prompts "
|
||||
"WHERE id IN (SELECT prompt_id FROM prompt_library_state)"
|
||||
).fetchone()
|
||||
tags = [row[0] for row in conn.execute("SELECT name FROM tags ORDER BY id")]
|
||||
if tuple(counts) != (len(CONCEPT_PROMPTS), CONCEPT_RUN_COUNT, CONCEPT_RUN_COUNT):
|
||||
raise RuntimeError(f"Concept fixture run counts are not real: {tuple(counts)}")
|
||||
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
|
||||
api.invalidate_gallery_cache()
|
||||
return {
|
||||
"epoch": epoch,
|
||||
# The newest file's mtime (epoch seconds), for the D17 caption date.
|
||||
"newest_modified": now - 10 if seedless_images else now - 60,
|
||||
"seedless": seedless,
|
||||
"prompts": prompts,
|
||||
"images": images,
|
||||
"runs": runs,
|
||||
"tags": tags,
|
||||
}
|
||||
@@ -89,7 +89,9 @@ def seed_workspace_inspector(
|
||||
],
|
||||
"links": [],
|
||||
}
|
||||
ambiguous = {**complete, "5": complete["3"]}
|
||||
# Two branches that differ only in seed: facts merge per field (spec section 10).
|
||||
branch = {**complete["3"], "inputs": {**complete["3"]["inputs"], "seed": 7}}
|
||||
ambiguous = {**complete, "5": branch}
|
||||
examples = (
|
||||
("complete.png", {"prompt": complete, "workflow": workflow}),
|
||||
("empty.png", {}),
|
||||
|
||||
@@ -26,6 +26,7 @@ EDITOR_METADATA = {
|
||||
"notes": "Keep the soft light.",
|
||||
}
|
||||
_TABLES = (
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
@@ -112,6 +112,7 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
|
||||
"""Replace only the owned browser harness records and media roots."""
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
@@ -79,6 +79,7 @@ UNUSED_TAG = "unused_vocabulary"
|
||||
MAX_EXTRA_TAGS = 300
|
||||
MAX_EXTRA_CATEGORIES = 200
|
||||
_TABLES = (
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
@@ -237,16 +237,16 @@ test.describe('public chronological history with the actual acknowledged host br
|
||||
const run = workspace.getByRole('dialog', { name: 'Recorded run', exact: true });await expect(run).toBeVisible();
|
||||
await expect(run.locator('[data-receipt]')).toHaveCount(4);
|
||||
await run.getByRole('button', { name: 'View current file', exact: true }).first().click();
|
||||
const viewer = workspace.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toBeVisible();
|
||||
const viewer = workspace.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
|
||||
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
|
||||
await workspace.keyboard.press('Escape');await expect(run).toBeVisible();
|
||||
const frozen = attempts[0].context;
|
||||
for (const mode of ['Raw', 'Effective']) {
|
||||
await run.getByRole('button', { name: mode, exact: true }).click();
|
||||
for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: `${role} prompt`, exact: true }).locator('pre').evaluate(node => node.textContent)).toBe(value);
|
||||
for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('region', { name: `${role} prompt`, exact: true }).locator('pre').evaluate(node => node.textContent)).toBe(value);
|
||||
await page.evaluate(async id => {const { app } = await import('/scripts/app.js');const node = app.graph.getNodeById(id);node.widgets.find(w => w.name === 'text').value = 'Before send';window.chronologyEffects = { queue: 0, load: 0 };window.chronologyOriginals = {};for (const [method, key] of [['queuePrompt', 'queue'], ['loadGraphData', 'load']]) {const original = app[method];window.chronologyOriginals[method] = original;app[method] = function(...args) {window.chronologyEffects[key]++;return original.apply(this, args);};}}, destination);
|
||||
try {
|
||||
await run.getByRole('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
|
||||
await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
|
||||
const hosts = sender.getByRole('combobox', { name: 'ComfyUI window', exact: true });await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({ index: 1 });
|
||||
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ index: 1 });await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
|
||||
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
|
||||
@@ -28,7 +28,7 @@ test('native library sends the selected saved pair into a real ComfyUI destinati
|
||||
await history.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
|
||||
await expect(history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('region',{name:'Negative prompt',exact:true})).toContainText(negative);
|
||||
// The pair send lives in the history heading, as in the approved concept.
|
||||
await workspace.locator('.workspace-heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,exact:true}).click();
|
||||
await workspace.locator('#workspace-main > .heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,exact:true}).click();
|
||||
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({index:1});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
|
||||
@@ -60,7 +60,7 @@ test('native editor preview sends exact saved roles across renderer transitions'
|
||||
try {
|
||||
// Created through the native editor entry action, with an explicitly empty negative.
|
||||
await workspace.goto('/prompt_manager/workspace#/library');
|
||||
await workspace.locator('.workspace-heading').getByRole('button',{name:'+ New prompt',exact:true}).click();
|
||||
await workspace.locator('#workspace-main > .heading').getByRole('button',{name:'+ New prompt',exact:true}).click();
|
||||
const dialog=workspace.locator('dialog.library-editor');await expect(dialog.getByRole('button',{name:'Save',exact:true})).toBeEnabled();
|
||||
await dialog.getByLabel('Title',{exact:true}).fill(title);await dialog.getByLabel('Positive prompt',{exact:true}).fill(positive);
|
||||
await dialog.getByRole('button',{name:'Save',exact:true}).click();await expect(dialog).toHaveCount(0);
|
||||
@@ -75,7 +75,7 @@ test('native editor preview sends exact saved roles across renderer transitions'
|
||||
pairMetadata:detail.variants.map(({id,title,category,tags,archived,negative_text})=>({id,title,category,tags,archived,negative_text}))};
|
||||
};
|
||||
const before=await evidence();expect(before.pairMetadata).toHaveLength(1);
|
||||
const send=workspace.locator('.workspace-heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${title}`,exact:true});
|
||||
const send=workspace.locator('#workspace-main > .heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${title}`,exact:true});
|
||||
for(const renderer of [testInfo.project.metadata.initialRenderer,!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]) {
|
||||
await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);await app.loadGraphData(app.graph.serialize());app.canvas.centerOnNode(app.graph._nodes[0]);},renderer);
|
||||
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
|
||||
@@ -41,13 +41,15 @@ test('native image inspector previews and sends a pair into real visible widgets
|
||||
const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message));
|
||||
try {
|
||||
await workspace.goto('/prompt_manager/workspace#/images');
|
||||
await workspace.getByRole('button',{name:'Refresh',exact:true}).click();
|
||||
// D15: Refresh is Import → Rescan.
|
||||
await workspace.getByRole('button',{name:'Import',exact:true}).click();
|
||||
await workspace.getByRole('dialog',{name:'Import & inspect',exact:true}).getByRole('button',{name:'Rescan',exact:true}).click();
|
||||
const card=workspace.locator('article[data-image-key]').filter({has:workspace.getByRole('button',{name:`Open ${filename}`,exact:true})});
|
||||
await card.getByRole('button',{name:`Open ${filename}`,exact:true}).click();
|
||||
await card.getByRole('button',{name:`Inspect ${filename}`,exact:true}).click();
|
||||
const inspector=workspace.getByRole('region',{name:'Image details',exact:true});
|
||||
await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText(positive);
|
||||
await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
const viewer=workspace.getByRole('dialog',{name:'Image preview',exact:true});
|
||||
const viewer=workspace.locator('dialog#viewer-dialog');
|
||||
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
|
||||
await expect(viewer.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','host-fixture.safetensors']);
|
||||
await viewer.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
|
||||
@@ -17,14 +17,14 @@ test('workspace serves real library data beside either ComfyUI renderer', async
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(workspace.locator('dt').filter({ hasText: /^Prompts$/ }).locator('+ dd'))
|
||||
.toHaveText(stats.total_prompts.toLocaleString());
|
||||
await workspace.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
|
||||
await workspace.getByRole('combobox', { name: 'Theme' }).selectOption('light');
|
||||
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
const imageResponse = workspace.waitForResponse(response => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/prompt_manager/images/output' && url.searchParams.get('mode') === 'workspace';
|
||||
});
|
||||
await workspace.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
.getByRole('link', { name: /^Images(?:\s*[\d,]+)?$/ }).click();
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
|
||||
const images = await imageResponse;
|
||||
expect(images.ok()).toBe(true);
|
||||
@@ -34,9 +34,16 @@ test('workspace serves real library data beside either ComfyUI renderer', async
|
||||
if (collection.images.length) {
|
||||
await expect(workspace.locator('article[data-image-key]').first()).toBeVisible();
|
||||
} else {
|
||||
await expect(workspace.getByText('No matching images or media. Try changing the filters.', { exact: true })).toBeVisible();
|
||||
await expect(workspace.getByText('No matching images · 0 images', { exact: true })).toBeVisible();
|
||||
await expect(workspace.locator('.gallery-body > .empty')).toHaveText('No matching images. Try another search or category.');
|
||||
}
|
||||
await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveAttribute('href', '/prompt_manager/gallery');
|
||||
// D29: Images no longer links the legacy gallery; Overview's "Open existing image gallery" card does.
|
||||
await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveCount(0);
|
||||
await workspace.evaluate(() => { location.hash = '/overview'; });
|
||||
const gallery = workspace.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
|
||||
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
|
||||
expect((await workspace.request.get('/prompt_manager/gallery')).ok()).toBe(true);
|
||||
await workspace.evaluate(() => { location.hash = '/images'; });
|
||||
await workspace.reload();
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
|
||||
Vendored
+1
-1
@@ -15,7 +15,7 @@ Browser tests use the committed file; FFmpeg is not required to run them.
|
||||
|
||||
# Inspection parity fixtures
|
||||
|
||||
`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f17.png`, each
|
||||
`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f24.png`, each
|
||||
under 32 KiB) and `vectors.json`, used by the #240 inspection parity tests. Every
|
||||
graph, prompt text, model name and filename prefix in them is original synthetic
|
||||
content written for this repository. Nothing comes from user output. They are
|
||||
|
||||
+826
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 988 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 989 B |
+427
-73
@@ -6,9 +6,9 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "No supported sampler pair found (saw FaceDetailer, SamplerCustomAdvanced)",
|
||||
"reason_code": "no-sampler",
|
||||
"state": "ambiguous",
|
||||
"reason": "Multiple sampler branches",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
@@ -19,24 +19,24 @@
|
||||
"value": null
|
||||
},
|
||||
"steps": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "20"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "ambiguous",
|
||||
"status": "unresolved",
|
||||
"value": null
|
||||
},
|
||||
"sampler": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"model": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "flux-2-klein-9b-fp8.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,12 +48,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "No supported sampler pair found (saw FaceDetailer, SamplerCustomAdvanced)",
|
||||
"reason_code": "no-sampler",
|
||||
"state": "absent",
|
||||
"reason": "Guider has no negative input",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur, Scene 0: unique shot description soft winter light,",
|
||||
"negative": "<absent>",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -61,8 +61,8 @@
|
||||
"value": "1000"
|
||||
},
|
||||
"steps": {
|
||||
"status": "unresolved",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "20"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "not-recorded",
|
||||
@@ -73,8 +73,8 @@
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "unresolved",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "Flux2Scheduler"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
@@ -258,12 +258,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": "unsupported-node",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -300,12 +300,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": "unsupported-node",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -342,12 +342,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": "unsupported-node",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -385,8 +385,8 @@
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": "unsupported-node",
|
||||
"reason": "Combined conditioning has different texts",
|
||||
"reason_code": "composite",
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
@@ -426,12 +426,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": "unsupported-node",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -510,12 +510,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "No supported sampler pair found (saw SamplerCustomAdvanced, CFGGuider)",
|
||||
"reason_code": "no-sampler",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -603,28 +603,28 @@
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -636,12 +636,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported latent ancestry",
|
||||
"reason_code": "unsupported-node",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -678,12 +678,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported latent ancestry",
|
||||
"reason_code": "unsupported-node",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -888,12 +888,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "No supported sampler pair found (saw SamplerCustomAdvanced)",
|
||||
"reason_code": "no-sampler",
|
||||
"state": "absent",
|
||||
"reason": "Guider has no negative input",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "<absent>",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -923,6 +923,258 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f19-inspection",
|
||||
"fixture": "f19",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "ambiguous",
|
||||
"reason": "Sampler stages have different prompts",
|
||||
"reason_code": "multistage-differs",
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"steps": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"cfg": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f20-inspection",
|
||||
"fixture": "f20",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f21-inspection",
|
||||
"fixture": "f21",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Text is decided at run time",
|
||||
"reason_code": "runtime-value",
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f22-inspection",
|
||||
"fixture": "f22",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Text is decided at run time",
|
||||
"reason_code": "runtime-value",
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f23-inspection",
|
||||
"fixture": "f23",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f24-inspection",
|
||||
"fixture": "f24",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f01-default",
|
||||
"fixture": "f01",
|
||||
@@ -1296,5 +1548,107 @@
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f19-default",
|
||||
"fixture": "f19",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f20-default",
|
||||
"fixture": "f20",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f21-default",
|
||||
"fixture": "f21",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f22-default",
|
||||
"fixture": "f22",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f23-default",
|
||||
"fixture": "f23",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f24-default",
|
||||
"fixture": "f24",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
"""Named workflow cases shared by the generation-parameter parser tests.
|
||||
|
||||
Each case is the raw metadata records a file would carry, as text, so the cases
|
||||
exercise ``parse_js_json`` as well as the tracing. The JS parity test runs every
|
||||
case through the legacy browser parser too. Prompt texts and seeds are invented.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def _text(value):
|
||||
return None if value is None else json.dumps(value)
|
||||
|
||||
|
||||
def case(prompt=None, workflow=None):
|
||||
return {"prompt": _text(prompt), "workflow": _text(workflow)}
|
||||
|
||||
|
||||
def encoder(text, clip=("4", 1)):
|
||||
return {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": text, "clip": list(clip)},
|
||||
}
|
||||
|
||||
|
||||
def ksampler(positive, negative, model=("4", 0), **settings):
|
||||
inputs = {
|
||||
"seed": 5,
|
||||
"steps": 20,
|
||||
"cfg": 7,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1.0,
|
||||
"model": list(model),
|
||||
"positive": list(positive),
|
||||
"negative": list(negative),
|
||||
"latent_image": ["2", 0],
|
||||
}
|
||||
inputs.update(settings)
|
||||
return {"class_type": "KSampler", "inputs": inputs}
|
||||
|
||||
|
||||
CHECKPOINT = {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "example.safetensors"},
|
||||
}
|
||||
LATENT = {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 512, "height": 512, "batch_size": 1},
|
||||
}
|
||||
|
||||
|
||||
def _basic():
|
||||
return {
|
||||
"4": CHECKPOINT,
|
||||
"2": LATENT,
|
||||
"6": encoder("a cat on a windowsill"),
|
||||
"7": encoder("blurry, watermark"),
|
||||
"3": ksampler(("6", 0), ("7", 0)),
|
||||
"9": {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}},
|
||||
}
|
||||
|
||||
|
||||
def _prompt_manager(text, prepend="", append=""):
|
||||
return {
|
||||
"class_type": "PromptManager",
|
||||
"inputs": {
|
||||
"text": text,
|
||||
"category": "",
|
||||
"tags": "",
|
||||
"search_text": "",
|
||||
"prepend_text": prepend,
|
||||
"append_text": append,
|
||||
"clip": ["4", 1],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _shared_prompt_pipeline():
|
||||
"""One PromptManager pair feeds a base pass, an upscale pass and a detailer."""
|
||||
return {
|
||||
"1": CHECKPOINT,
|
||||
"5": ksampler(
|
||||
("25", 0),
|
||||
("26", 0),
|
||||
("1", 0),
|
||||
seed=111,
|
||||
steps=4,
|
||||
cfg=1.0,
|
||||
sampler_name="lcm",
|
||||
latent_image=["20", 0],
|
||||
),
|
||||
"6": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["5", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"11": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {"pixels": ["6", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"12": ksampler(
|
||||
("25", 0),
|
||||
("26", 0),
|
||||
("1", 0),
|
||||
seed=222,
|
||||
steps=6,
|
||||
cfg=1.0,
|
||||
sampler_name="lcm",
|
||||
denoise=0.45,
|
||||
latent_image=["11", 0],
|
||||
),
|
||||
"13": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["12", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"17": {
|
||||
"class_type": "FaceDetailer",
|
||||
"inputs": {
|
||||
"seed": 333,
|
||||
"steps": 4,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "lcm",
|
||||
"image": ["13", 0],
|
||||
"model": ["1", 0],
|
||||
"clip": ["1", 1],
|
||||
"vae": ["1", 2],
|
||||
"positive": ["25", 0],
|
||||
"negative": ["26", 0],
|
||||
},
|
||||
},
|
||||
"20": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 640, "height": 1536, "batch_size": 1},
|
||||
},
|
||||
"25": _prompt_manager("a lighthouse at dusk, oil painting"),
|
||||
"26": _prompt_manager("blurry, watermark"),
|
||||
"48": {
|
||||
"class_type": "KikoSaveImage",
|
||||
"inputs": {"filename_prefix": ["49", 0], "images": ["17", 0]},
|
||||
},
|
||||
"49": {
|
||||
"class_type": "KikoProjectFilePath",
|
||||
"inputs": {"project_name": "example"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _custom_sampler(guider):
|
||||
return {
|
||||
"13": CHECKPOINT,
|
||||
"20": encoder("a red fox in snow", clip=("13", 1)),
|
||||
"21": encoder("lowres", clip=("13", 1)),
|
||||
"22": guider,
|
||||
"25": {"class_type": "RandomNoise", "inputs": {"noise_seed": 42}},
|
||||
"16": {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "dpmpp_2m"}},
|
||||
"17": {
|
||||
"class_type": "BasicScheduler",
|
||||
"inputs": {
|
||||
"scheduler": "karras",
|
||||
"steps": 8,
|
||||
"denoise": 1.0,
|
||||
"model": ["13", 0],
|
||||
},
|
||||
},
|
||||
"1300:8": {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {
|
||||
"noise": ["25", 0],
|
||||
"guider": ["22", 0],
|
||||
"sampler": ["16", 0],
|
||||
"sigmas": ["17", 0],
|
||||
"latent_image": ["5", 0],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _workflow_only():
|
||||
return {
|
||||
"nodes": [
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"widgets_values": ["example.safetensors"],
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "clip", "link": 3}],
|
||||
"widgets_values": ["a tidepool at noon"],
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "clip", "link": 5}],
|
||||
"widgets_values": ["noisy"],
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "Reroute",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "", "link": 7}],
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "link": 1},
|
||||
{"name": "positive", "link": 8},
|
||||
{"name": "negative", "link": 4},
|
||||
{"name": "latent_image", "link": 2},
|
||||
],
|
||||
"widgets_values": [314, "randomize", 25, 6.5, "dpmpp_2m", "karras", 1],
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "CLIPTextEncode",
|
||||
"mode": 2,
|
||||
"inputs": [],
|
||||
"widgets_values": ["muted node text"],
|
||||
},
|
||||
],
|
||||
"links": [
|
||||
[1, 4, 0, 3, 0, "MODEL"],
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[7, 6, 0, 10, 0, "CONDITIONING"],
|
||||
[8, 10, 0, 3, 1, "CONDITIONING"],
|
||||
[4, 7, 0, 3, 2, "CONDITIONING"],
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 99,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _nested(depth):
|
||||
"""A value nested ``depth`` arrays deep, as a crafted file can hold."""
|
||||
value = ["leaf", 7]
|
||||
for _ in range(depth):
|
||||
value = [value, None]
|
||||
return value
|
||||
|
||||
|
||||
def _chain(length):
|
||||
graph = {"4": CHECKPOINT, "1": encoder("deep chain text")}
|
||||
previous = "1"
|
||||
for index in range(length):
|
||||
node_id = str(100 + index)
|
||||
graph[node_id] = {
|
||||
"class_type": "ConditioningPass",
|
||||
"inputs": {"conditioning": [previous, 0]},
|
||||
}
|
||||
previous = node_id
|
||||
graph["3"] = ksampler((previous, 0), ("1", 0))
|
||||
return graph
|
||||
|
||||
|
||||
CASES = {
|
||||
"basic": case(_basic()),
|
||||
"prompt-manager-prepend-append": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": _prompt_manager("a cat", prepend="masterpiece", append="4k"),
|
||||
"7": _prompt_manager(
|
||||
"masterpiece, a dog", prepend="masterpiece", append="4k"
|
||||
),
|
||||
"3": ksampler(("6", 0), ("7", 0)),
|
||||
}
|
||||
),
|
||||
"linked-text-concat": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"10": {"class_type": "PrimitiveString", "inputs": {"value": "first part"}},
|
||||
"11": {
|
||||
"class_type": "StringConcatenate",
|
||||
"inputs": {
|
||||
"string_a": ["10", 0],
|
||||
"string_b": "second part",
|
||||
"delimiter": ", ",
|
||||
"seed": 3,
|
||||
"string_c": " ",
|
||||
},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": ["11", 0], "clip": ["4", 1]},
|
||||
},
|
||||
"7": encoder(""),
|
||||
"3": ksampler(("6", 0), ("7", 0)),
|
||||
}
|
||||
),
|
||||
"prompt-manager-pair": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"8": {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {
|
||||
"text": "pair positive",
|
||||
"negative_text": "pair negative",
|
||||
"prepend_text": "",
|
||||
"append_text": "",
|
||||
"clip": ["4", 1],
|
||||
},
|
||||
},
|
||||
"3": ksampler(("8", 0), ("8", 1)),
|
||||
}
|
||||
),
|
||||
"conditioning-passthrough": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": encoder("through a controlnet"),
|
||||
"7": encoder("negative through it"),
|
||||
"12": {
|
||||
"class_type": "ControlNetApplyAdvanced",
|
||||
"inputs": {
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"control_net": ["13", 0],
|
||||
},
|
||||
},
|
||||
"3": ksampler(("12", 0), ("12", 1)),
|
||||
}
|
||||
),
|
||||
"first-sampler-in-js-key-order": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": encoder("from sampler twelve"),
|
||||
"7": encoder("from sampler five"),
|
||||
"x": ksampler(("6", 0), ("6", 0)),
|
||||
"12": ksampler(("6", 0), ("6", 0), seed=12),
|
||||
"5": ksampler(("7", 0), ("7", 0), seed=55),
|
||||
}
|
||||
),
|
||||
"custom-sampler-cfg-guider": case(
|
||||
_custom_sampler(
|
||||
{
|
||||
"class_type": "CFGGuider",
|
||||
"inputs": {
|
||||
"model": ["13", 0],
|
||||
"positive": ["20", 0],
|
||||
"negative": ["21", 0],
|
||||
"cfg": 3.5,
|
||||
},
|
||||
}
|
||||
)
|
||||
),
|
||||
"custom-sampler-basic-guider": case(
|
||||
_custom_sampler(
|
||||
{
|
||||
"class_type": "BasicGuider",
|
||||
"inputs": {"model": ["13", 0], "conditioning": ["20", 0]},
|
||||
}
|
||||
)
|
||||
),
|
||||
"no-sampler-loose-settings": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"3": {
|
||||
"class_type": "MyKSamplerVariant",
|
||||
"inputs": {
|
||||
"noise_seed": 9,
|
||||
"steps": 12,
|
||||
"cfg": 2,
|
||||
"sampler_name": "ddim",
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
"primitive-seed": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": encoder("seeded"),
|
||||
"30": {
|
||||
"class_type": "Seed (rgthree)",
|
||||
"inputs": {"other": "x", "seed": 77},
|
||||
},
|
||||
"3": ksampler(("6", 0), ("6", 0), seed=["30", 0]),
|
||||
}
|
||||
),
|
||||
"workflow-only": case(workflow=_workflow_only()),
|
||||
"overwritten-prompt-manager-text": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"25": _prompt_manager("same saved text"),
|
||||
"26": _prompt_manager("same saved text"),
|
||||
"3": ksampler(("25", 0), ("26", 0)),
|
||||
},
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": 25,
|
||||
"type": "PromptManager",
|
||||
"widgets_values": ["the real positive"],
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"type": "PromptManager",
|
||||
"widgets_values": ["the real negative"],
|
||||
},
|
||||
],
|
||||
"links": [],
|
||||
},
|
||||
),
|
||||
"conditioning-cycle": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"20": {
|
||||
"class_type": "ConditioningCombine",
|
||||
"inputs": {"conditioning_1": ["21", 0]},
|
||||
},
|
||||
"21": {
|
||||
"class_type": "ConditioningCombine",
|
||||
"inputs": {"conditioning_1": ["20", 0]},
|
||||
},
|
||||
"3": ksampler(("20", 0), ("21", 0)),
|
||||
}
|
||||
),
|
||||
"chain-within-visit-limit": case(_chain(450)),
|
||||
"chain-beyond-visit-limit": case(_chain(600)),
|
||||
"chain-at-last-visit": case(_chain(499)),
|
||||
"chain-one-past-last-visit": case(_chain(500)),
|
||||
"blank-text-and-zero-seed": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": encoder(" "),
|
||||
"7": encoder("kept negative"),
|
||||
"3": ksampler(("6", 0), ("7", 0), seed=0),
|
||||
}
|
||||
),
|
||||
"shared-prompt-pipeline": case(_shared_prompt_pipeline()),
|
||||
"null-workflow-node": case(_basic(), {"nodes": [None], "links": []}),
|
||||
"unparsable-records": {"prompt": '{"3": NaN}', "workflow": "not json"},
|
||||
"seed-beyond-double-precision": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": encoder("x"),
|
||||
"3": ksampler(("6", 0), ("6", 0), cfg=1.0, seed=4974052037430000001),
|
||||
}
|
||||
),
|
||||
"deeply-nested-values": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": encoder("nested neighbours"),
|
||||
"3": ksampler(("6", 0), ("6", 0)),
|
||||
"8": {"class_type": _nested(900), "inputs": {"text": "x"}},
|
||||
},
|
||||
{"nodes": [{"id": _nested(900), "type": _nested(900)}], "links": []},
|
||||
),
|
||||
"nothing": case(),
|
||||
}
|
||||
+116
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
Every graph here is original synthetic content (CC0): neutral texts, generic model
|
||||
file names and invented filename prefixes. Nothing is copied from user outputs.
|
||||
The shapes reproduce the parity audit's fixture numbering (``f01``..``f17``).
|
||||
The shapes reproduce the parity audit's fixture numbering (``f01``..``f24``).
|
||||
|
||||
``build(name)`` returns ``{"prompt", "workflow", "extra_chunks"}``. ``write_png``
|
||||
writes the two records the way core ``SaveImage`` does (Pillow ``PngInfo.add_text``,
|
||||
@@ -66,6 +66,12 @@ COMMITTED = (
|
||||
"f15",
|
||||
"f16",
|
||||
"f17",
|
||||
"f19",
|
||||
"f20",
|
||||
"f21",
|
||||
"f22",
|
||||
"f23",
|
||||
"f24",
|
||||
)
|
||||
# Built in a temporary directory at test time (too large to commit).
|
||||
TEST_TIME = ("f01", "f04", "f05")
|
||||
@@ -100,6 +106,10 @@ def _enc(text):
|
||||
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["4", 1]}}
|
||||
|
||||
|
||||
def _linked_enc(link):
|
||||
return {"class_type": "CLIPTextEncode", "inputs": {"text": link, "clip": ["4", 1]}}
|
||||
|
||||
|
||||
def _ksampler_graph(positive, negative, sampler="KSampler", latent=None, extra=None):
|
||||
graph = {
|
||||
"4": {
|
||||
@@ -691,6 +701,105 @@ def _f17():
|
||||
return _basic(graph, _ui(graph))
|
||||
|
||||
|
||||
DETAILER_POS = "a red fox in snow, sharp detailed face"
|
||||
|
||||
|
||||
def _f19():
|
||||
"""KSampler -> VAEDecode -> FaceDetailer -> SaveImage; the detailer re-prompts."""
|
||||
graph = _ksampler_graph(_enc(POS), _enc(NEG))
|
||||
graph["10"] = _enc(DETAILER_POS)
|
||||
graph["50"] = {
|
||||
"class_type": "FaceDetailer",
|
||||
"inputs": {
|
||||
"image": ["8", 0],
|
||||
"model": ["4", 0],
|
||||
"clip": ["4", 1],
|
||||
"vae": ["4", 2],
|
||||
"positive": ["10", 0],
|
||||
"negative": ["7", 0],
|
||||
"seed": 7,
|
||||
"steps": 12,
|
||||
"cfg": 4.5,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 0.4,
|
||||
},
|
||||
}
|
||||
graph["9"]["inputs"]["images"] = ["50", 0]
|
||||
return _basic(graph)
|
||||
|
||||
|
||||
def _f20():
|
||||
"""``easy ifElse`` with a literal boolean picks the positive branch."""
|
||||
select = {
|
||||
"class_type": "easy ifElse",
|
||||
"inputs": {"boolean": True, "on_true": ["10", 0], "on_false": ["11", 0]},
|
||||
}
|
||||
extra = {"10": _enc(POS), "11": _enc("a grey wolf in fog")}
|
||||
return _basic(_ksampler_graph(select, _enc(NEG), extra=extra))
|
||||
|
||||
|
||||
def _f21():
|
||||
"""``JoinStringMulti`` with ``return_list: true`` feeds the positive encoder."""
|
||||
join = {
|
||||
"class_type": "JoinStringMulti",
|
||||
"inputs": {
|
||||
"inputcount": 2,
|
||||
"delimiter": ", ",
|
||||
"return_list": True,
|
||||
"string_1": ["11", 0],
|
||||
"string_2": ["12", 0],
|
||||
},
|
||||
}
|
||||
extra = {
|
||||
"10": join,
|
||||
"11": {"class_type": "PrimitiveString", "inputs": {"value": "a red fox"}},
|
||||
"12": {"class_type": "PrimitiveString", "inputs": {"value": "in snow"}},
|
||||
}
|
||||
positive = _linked_enc(["10", 0])
|
||||
return _basic(_ksampler_graph(positive, _enc(NEG), extra=extra))
|
||||
|
||||
|
||||
def _f22():
|
||||
"""``PromptSearchList`` (database search at run time) feeds the positive."""
|
||||
search = {
|
||||
"class_type": "PromptSearchList",
|
||||
"inputs": {
|
||||
"text": "fox",
|
||||
"category": "",
|
||||
"tags": "",
|
||||
"min_rating": 0,
|
||||
"limit": 50,
|
||||
},
|
||||
}
|
||||
positive = _linked_enc(["10", 0])
|
||||
return _basic(_ksampler_graph(positive, _enc(NEG), extra={"10": search}))
|
||||
|
||||
|
||||
def _f24():
|
||||
"""``TextEncodeQwenImage21``: slot 0 is the positive, slot 1 the negative."""
|
||||
qwen = {
|
||||
"class_type": "TextEncodeQwenImage21",
|
||||
"inputs": {
|
||||
"clip": ["4", 1],
|
||||
"prompt": POS,
|
||||
"negative_prompt": NEG,
|
||||
"vae": ["4", 2],
|
||||
"resolution": 1024,
|
||||
},
|
||||
}
|
||||
graph = _ksampler_graph(qwen, None)
|
||||
del graph["7"]
|
||||
graph["3"]["inputs"]["negative"] = ["6", 1]
|
||||
return _basic(graph)
|
||||
|
||||
|
||||
def _f23():
|
||||
"""The negative is ``ConditioningZeroOut`` of the positive encoder."""
|
||||
zero = {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["6", 0]}}
|
||||
return _basic(_ksampler_graph(_enc(POS), zero))
|
||||
|
||||
|
||||
_BUILDERS = {
|
||||
"f01": _f01,
|
||||
"f01b": _f01b,
|
||||
@@ -714,6 +823,12 @@ _BUILDERS = {
|
||||
"f15": _f15,
|
||||
"f16": _f16,
|
||||
"f17": _f17,
|
||||
"f19": _f19,
|
||||
"f20": _f20,
|
||||
"f21": _f21,
|
||||
"f22": _f22,
|
||||
"f23": _f23,
|
||||
"f24": _f24,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -163,6 +163,19 @@ test("post, put and del send JSON bodies with the right method", async () => {
|
||||
await Promise.all([p1, p2, p3]);
|
||||
});
|
||||
|
||||
test("del sends an optional JSON body", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
const controller = new AbortController();
|
||||
const pending = api.del("/prompt_manager/images/favorite", { body: { root_index: 1, path: "a/b.png" }, signal: controller.signal });
|
||||
assert.equal(fetch.calls[0].init.method, "DELETE");
|
||||
assert.equal(fetch.calls[0].init.body, JSON.stringify({ root_index: 1, path: "a/b.png" }));
|
||||
assert.equal(fetch.calls[0].init.headers["Content-Type"], "application/json");
|
||||
assert.equal(fetch.calls[0].init.signal, controller.signal);
|
||||
fetch.calls[0].resolve(jsonResponse(200, { success: true }));
|
||||
await pending;
|
||||
});
|
||||
|
||||
test("network failures propagate as ordinary errors", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
@@ -164,6 +164,232 @@ test("facts require model output ancestry with supported loaders and passthrough
|
||||
assert.equal(factsFor(numericModel).model.status, "unresolved", "model names require string evidence");
|
||||
});
|
||||
|
||||
// D19: a FaceDetailer re-samples the KSampler's decoded image with its own settings.
|
||||
function detailerFactGraph(detailer = {}) {
|
||||
const graph = factGraph({ seed: "42", steps: 25, cfg: "6.5", sampler_name: "euler", scheduler: "normal" });
|
||||
graph.decode = { class_type: "VAEDecode", inputs: { samples: ["s", 0], vae: ["model", 2] } };
|
||||
graph.d = { class_type: "FaceDetailer", inputs: { image: ["decode", 0], model: ["model", 0], seed: "7", steps: 12, cfg: "6.5", sampler_name: "euler", scheduler: "normal", denoise: "0.4", ...detailer } };
|
||||
return graph;
|
||||
}
|
||||
const statusValues = facts => Object.fromEntries(factFields.map(field => [field, [facts[field].status, facts[field].value]]));
|
||||
|
||||
test("facts use resolution.sampler_id for detailer-stage nodes", () => {
|
||||
// Resolved (the stages' prompts agree): the final stage the server named supplies every fact.
|
||||
const resolved = factsFor(detailerFactGraph(), { resolution: { state: "resolved", sampler_id: "d" } });
|
||||
assert.deepEqual(statusValues(resolved), {
|
||||
seed: ["known", "7"], steps: ["known", "12"], cfg: ["known", "6.5"],
|
||||
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
||||
});
|
||||
assert.ok(factFields.every(field => resolved[field].sampler_id === "d" && resolved[field].evidence === "configuration"));
|
||||
// The upstream KSampler is also accepted when it is the one resolved.
|
||||
assert.equal(factsFor(detailerFactGraph(), { resolution: { state: "resolved", sampler_id: "s" } }).seed.value, "42");
|
||||
// Ambiguous stages: merged per field, never the detailer's or the upstream value alone.
|
||||
const ambiguous = factsFor(detailerFactGraph(), { resolution: { state: "ambiguous", sampler_id: "d", reason_code: "multistage-differs" } });
|
||||
assert.deepEqual(statusValues(ambiguous), {
|
||||
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
|
||||
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
||||
});
|
||||
assert.ok(factFields.every(field => ambiguous[field].sampler_id === null), "a merged fact names no single sampler");
|
||||
// A field one stage cannot read and the others agree on stays unresolved; not-recorded is not agreement.
|
||||
const unreadable = factsFor(detailerFactGraph({ cfg: ["missing", 0] }), { resolution: { state: "ambiguous", sampler_id: "d" } });
|
||||
assert.deepEqual([unreadable.cfg.status, unreadable.cfg.value], ["unresolved", null]);
|
||||
const unrecorded = detailerFactGraph(); delete unrecorded.d.inputs.scheduler;
|
||||
assert.equal(factsFor(unrecorded, { resolution: { state: "ambiguous", sampler_id: "d" } }).scheduler.status, "unresolved");
|
||||
// Two detailer stages over one KSampler: three stages merge.
|
||||
const chained = detailerFactGraph({ seed: "42" });
|
||||
chained.d2 = { class_type: "FaceDetailer", inputs: { image: ["d", 0], model: ["model", 0], seed: "42", steps: 25, cfg: "3", sampler_name: "euler", scheduler: "normal" } };
|
||||
assert.deepEqual(statusValues(factsFor(chained, { resolution: { state: "ambiguous", sampler_id: "d2" } })), {
|
||||
seed: ["known", "42"], steps: ["ambiguous", null], cfg: ["ambiguous", null],
|
||||
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
||||
});
|
||||
// Without a resolution, two samplers stay ambiguous (today's rules, now over the samplers table).
|
||||
assert.ok(Object.values(factsFor(detailerFactGraph())).every(fact => fact.status === "ambiguous"));
|
||||
// A sampler_id the graph or the samplers table does not hold never falls back to a guess.
|
||||
for (const sampler_id of ["missing", "decode", "model", "__proto__"]) {
|
||||
for (const state of ["resolved", "ambiguous"]) {
|
||||
const facts = factsFor(detailerFactGraph(), { resolution: { state, sampler_id } });
|
||||
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null), `${state} ${sampler_id}`);
|
||||
}
|
||||
}
|
||||
// Stage ancestry that cannot be walked is unresolved, not a partial merge.
|
||||
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
|
||||
const unlinked = detailerFactGraph({ image: "not a link" });
|
||||
for (const graph of [cyclic, unlinked]) {
|
||||
assert.ok(Object.values(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"));
|
||||
}
|
||||
const wide = detailerFactGraph();
|
||||
let previous = ["s", 0];
|
||||
for (let index = 0; index < 600; index++) { wide[`hop${index}`] = { class_type: "ImageScale", inputs: { image: previous } }; previous = [`hop${index}`, 0]; }
|
||||
wide.decode.inputs.samples = ["s", 0]; wide.d.inputs.image = previous;
|
||||
assert.ok(Object.values(factsFor(wide, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"), "the stage walk is bounded by MAX_VISITS");
|
||||
});
|
||||
|
||||
test("ambiguous results without a sampler_id merge facts across every listed stage", () => {
|
||||
const ambiguous = { resolution: { state: "ambiguous", sampler_id: null, reason: "Multiple sampler branches" } };
|
||||
// f01b's shape: an upstream sampler and a detailer whose settings partly differ.
|
||||
assert.deepEqual(statusValues(factsFor(detailerFactGraph(), ambiguous)), {
|
||||
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
|
||||
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
||||
});
|
||||
assert.ok(Object.values(factsFor(detailerFactGraph(), ambiguous)).every(fact => fact.sampler_id === null));
|
||||
// Equal independent branches agree on every field once the server says ambiguous;
|
||||
// without a resolution they stay ambiguous (today's rules, reversed in PR3).
|
||||
const equal = factGraph(); equal.other = structuredClone(equal.s);
|
||||
assert.ok(Object.values(factsFor(equal, ambiguous)).every(fact => fact.status === "known"));
|
||||
assert.ok(Object.values(factsFor(equal)).every(fact => fact.status === "ambiguous"));
|
||||
// A walk that fails keeps every field unresolved; so does a graph with no listed sampler.
|
||||
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
|
||||
const broken = detailerFactGraph(); broken.d.inputs.image = ["gone", 0];
|
||||
for (const graph of [cyclic, broken, { model: factGraph().model }]) {
|
||||
assert.ok(Object.values(factsFor(graph, ambiguous)).every(fact => fact.status === "unresolved" && fact.value === null));
|
||||
}
|
||||
const many = factGraph();
|
||||
for (let index = 0; index < 40; index++) many[`s${index}`] = structuredClone(many.s);
|
||||
assert.ok(Object.values(factsFor(many, ambiguous)).every(fact => fact.status === "unresolved"), "more stages than the candidate cap");
|
||||
});
|
||||
|
||||
test("stage walks fail on unlisted sampler-shaped nodes like the resolver", () => {
|
||||
const shaped = [
|
||||
{ positive: ["model", 0] }, { latent_image: ["model", 0] }, { guider: ["model", 0] }, { negative: ["model", 0] },
|
||||
{ pipe: ["model", 0] }, { basic_pipe: ["model", 0] }, { detailer_pipe: ["model", 0] }, { sampler: ["model", 0] },
|
||||
{ sigmas: ["model", 0] }, { noise: ["model", 0] }, { text_embeds: ["model", 0] }, { hyvid_embeds: ["model", 0] },
|
||||
{ model: ["model", 0], conditioning: ["model", 0] }, { model: ["model", 0], neg_conditioning: ["model", 0] },
|
||||
{ model: ["model", 0], samples: ["model", 0] }, { model: ["model", 0], latent: ["model", 0] },
|
||||
];
|
||||
for (const inputs of shaped) {
|
||||
const graph = detailerFactGraph();
|
||||
graph.efficient = { class_type: "MysterySampler", inputs: { ...inputs, image: ["decode", 0] } };
|
||||
graph.d.inputs.image = ["efficient", 0];
|
||||
for (const sampler_id of ["d", null]) {
|
||||
const facts = factsFor(graph, { resolution: { state: "ambiguous", sampler_id } });
|
||||
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved"), `${JSON.stringify(inputs)} ${sampler_id}`);
|
||||
}
|
||||
}
|
||||
// Model patches (model alone, or with clip) and listed table classes are walked through.
|
||||
for (const [class_type, inputs] of [["ModelPatch", { model: ["model", 0] }], ["LoraPatch", { model: ["model", 0], clip: ["model", 1] }], ["ImageScale", { samples: ["model", 0] }], ["CFGGuider", { model: ["model", 0], positive: ["model", 0] }]]) {
|
||||
const graph = detailerFactGraph();
|
||||
graph.hop = { class_type, inputs: { ...inputs, image: ["decode", 0] } };
|
||||
graph.d.inputs.image = ["hop", 0];
|
||||
assert.equal(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", class_type);
|
||||
}
|
||||
});
|
||||
|
||||
test("unresolved and absent results over several stages merge facts per field", () => {
|
||||
// Review I-1: the run's settings are facts even when its prompt is not, e.g. an
|
||||
// upstream prompt behind an unsupported node (LoRAfromText) leaves the server unresolved.
|
||||
const merged = {
|
||||
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
|
||||
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
||||
};
|
||||
for (const resolution of [
|
||||
{ state: "unresolved", sampler_id: "d", reason_code: "unsupported-node" },
|
||||
{ state: "unresolved", sampler_id: null, reason_code: "malformed" },
|
||||
{ state: "absent", sampler_id: "d" },
|
||||
]) {
|
||||
const facts = factsFor(detailerFactGraph(), { resolution });
|
||||
assert.deepEqual(statusValues(facts), merged, JSON.stringify(resolution));
|
||||
assert.ok(factFields.every(field => facts[field].sampler_id === null));
|
||||
}
|
||||
// A stage missing a field leaves it unresolved; a failed walk leaves everything unresolved.
|
||||
const unrecorded = detailerFactGraph(); delete unrecorded.d.inputs.scheduler;
|
||||
assert.equal(factsFor(unrecorded, { resolution: { state: "unresolved", sampler_id: "d" } }).scheduler.status, "unresolved");
|
||||
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
|
||||
assert.ok(Object.values(factsFor(cyclic, { resolution: { state: "unresolved", sampler_id: "d" } })).every(fact => fact.status === "unresolved"));
|
||||
// One listed sampler keeps today's rule: its own facts, attributed to it.
|
||||
const single = factsFor(factGraph(), { resolution: { state: "unresolved", sampler_id: "s" } });
|
||||
assert.ok(factFields.every(field => single[field].status === "known" && single[field].sampler_id === "s"));
|
||||
});
|
||||
|
||||
test("a named sampler limits absent and unresolved facts to its own stages", () => {
|
||||
// Codex P2: the server dropped a preview-only detailer and named the saved sampler, so
|
||||
// the detailer's settings never leak into the saved file's facts.
|
||||
const graph = detailerFactGraph();
|
||||
graph.save = { class_type: "SaveImage", inputs: { images: ["decode", 0] } };
|
||||
graph.preview = { class_type: "PreviewImage", inputs: { images: ["d", 0] } };
|
||||
const own = {
|
||||
seed: ["known", "42"], steps: ["known", "25"], cfg: ["known", "6.5"],
|
||||
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
||||
};
|
||||
for (const state of ["absent", "unresolved"]) assert.deepEqual(statusValues(factsFor(graph, { resolution: { state, sampler_id: "s" } })), own, state);
|
||||
// Without a named sampler every listed sampler still merges.
|
||||
assert.equal(factsFor(graph, { resolution: { state: "absent", sampler_id: null } }).seed.status, "ambiguous");
|
||||
});
|
||||
|
||||
test("stage walks follow only the resolver's links", () => {
|
||||
// Review M-2: `["eff", -1]` is not a link to the server, so it is not walked.
|
||||
for (const slot of [-1, 0.5, "0"]) {
|
||||
const graph = detailerFactGraph();
|
||||
graph.eff = { class_type: "MysterySampler", inputs: { positive: ["model", 0], model: ["model", 0] } };
|
||||
graph.decode.inputs.extra = ["eff", slot];
|
||||
assert.equal(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", String(slot));
|
||||
const shaped = detailerFactGraph();
|
||||
shaped.eff = { class_type: "Unlisted", inputs: { positive: ["model", slot], image: ["decode", 0] } };
|
||||
shaped.d.inputs.image = ["eff", 0];
|
||||
assert.equal(factsFor(shaped, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", `shaped ${slot}`);
|
||||
}
|
||||
const linked = detailerFactGraph();
|
||||
linked.eff = { class_type: "MysterySampler", inputs: { positive: ["model", 0], model: ["model", 0] } };
|
||||
linked.decode.inputs.extra = ["eff", 0];
|
||||
assert.equal(factsFor(linked, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "unresolved");
|
||||
});
|
||||
|
||||
test("Flux2Scheduler provides steps and scheduler is its node type", () => {
|
||||
const graph = customFactGraph();
|
||||
graph.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 4, width: 1024, height: 1024 } };
|
||||
const facts = factsFor(graph);
|
||||
assert.deepEqual([facts.steps.status, facts.steps.value], ["known", "4"]);
|
||||
assert.deepEqual([facts.scheduler.status, facts.scheduler.value, facts.scheduler.evidence], ["known", "Flux2Scheduler", "configuration"]);
|
||||
for (const slot of [1, -1]) {
|
||||
const linked = customFactGraph(); linked.sigmas = graph.sigmas; linked.s.inputs.sigmas = ["sigmas", slot];
|
||||
assert.equal(factsFor(linked).scheduler.status, "unresolved", `output${slot}`);
|
||||
}
|
||||
// Its own inputs cannot replace the node type, and steps keep exact-integer rules.
|
||||
const named = customFactGraph(); named.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 0, scheduler: "karras" } };
|
||||
assert.equal(factsFor(named).scheduler.value, "Flux2Scheduler");
|
||||
assert.equal(factsFor(named).steps.status, "unresolved");
|
||||
});
|
||||
|
||||
test("ModelSampling passthroughs reach UNETLoader", () => {
|
||||
const graph = customFactGraph();
|
||||
graph.g.inputs.model = ["flux", 0];
|
||||
graph.flux = { class_type: "ModelSamplingFlux", inputs: { model: ["aura", 0], max_shift: "1.15", base_shift: "0.5" } };
|
||||
graph.aura = { class_type: "ModelSamplingAuraFlow", inputs: { model: ["sd3", 0], shift: "1.73" } };
|
||||
graph.sd3 = { class_type: "ModelSamplingSD3", inputs: { model: ["lora", 0], shift: "3" } };
|
||||
graph.lora = { class_type: "LoraLoaderModelOnly", inputs: { model: ["m", 0], lora_name: "style", strength_model: "1" } };
|
||||
assert.deepEqual([factsFor(graph).model.status, factsFor(graph).model.value], ["known", "flux.safetensors"]);
|
||||
for (const node of ["flux", "aura", "sd3"]) {
|
||||
const wrongSlot = structuredClone(graph);
|
||||
const parent = { flux: "g", aura: "flux", sd3: "aura" }[node];
|
||||
wrongSlot[parent].inputs.model = [node, 1];
|
||||
assert.equal(factsFor(wrongSlot).model.status, "unresolved", `${node} output1`);
|
||||
const unlisted = structuredClone(graph); unlisted[node].class_type = "ModelSamplingUnknown";
|
||||
assert.equal(factsFor(unlisted).model.status, "unresolved", `${node} unlisted`);
|
||||
}
|
||||
});
|
||||
|
||||
test("vectors.json pins full facts for every inspection fixture", () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vectors = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "fixtures", "inspection_parity", "vectors.json"), "utf8"));
|
||||
const inspection = vectors.filter(vector => vector.profile === "inspection");
|
||||
assert.ok(inspection.length > 0);
|
||||
const statuses = new Set(["known", "ambiguous", "unresolved", "not-recorded"]);
|
||||
for (const vector of inspection) {
|
||||
const facts = vector.expect.facts;
|
||||
assert.deepEqual(Object.keys(facts), factFields, vector.name);
|
||||
for (const field of factFields) {
|
||||
assert.ok(statuses.has(facts[field].status), `${vector.name} ${field}`);
|
||||
assert.equal(facts[field].value === null, facts[field].status !== "known", `${vector.name} ${field} value`);
|
||||
}
|
||||
}
|
||||
// f19: KSampler seed 42 / steps 25 / cfg 6.5 then FaceDetailer seed 7 / steps 12 / cfg 4.5, different prompts.
|
||||
const f19 = inspection.find(vector => vector.fixture === "f19");
|
||||
assert.equal(f19.expect.state, "ambiguous");
|
||||
assert.deepEqual(Object.fromEntries(factFields.map(field => [field, f19.expect.facts[field].status])), {
|
||||
seed: "ambiguous", steps: "ambiguous", cfg: "ambiguous", sampler: "known", scheduler: "known", model: "known",
|
||||
});
|
||||
});
|
||||
|
||||
test("wide workflow lookups retain selected core sampler facts", () => {
|
||||
const nodes = [{ id: "text", type: "PrimitiveString", widgets_values_named: { value: "unrelated text" } }], links = [];
|
||||
let linkId = 1;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Run with: node --test tests/js/
|
||||
// Legacy parser rules ported to the inspection facts (#240, Task 2.7b). Synthetic texts.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { extractGenerationFacts } = require("../../web/js/comfy-metadata.js");
|
||||
|
||||
const fields = ["seed", "steps", "cfg", "sampler", "scheduler", "model"];
|
||||
const FOOTER = "Steps: 20, Sampler: Euler, CFG scale: 7, Seed: 5";
|
||||
|
||||
function parameterFacts(parameters) {
|
||||
const facts = extractGenerationFacts({
|
||||
prompt_graph: { status: "absent", value: null },
|
||||
workflow_graph: { status: "absent", value: null },
|
||||
parameters,
|
||||
recordFormats: { parameters: "original-text" },
|
||||
metadataStatus: "available",
|
||||
});
|
||||
return Object.fromEntries(fields.map(field => [field, [facts[field].status, facts[field].value]]));
|
||||
}
|
||||
|
||||
const FOUR_KNOWN = {
|
||||
seed: ["known", "5"], steps: ["known", "20"], cfg: ["known", "7"], sampler: ["known", "Euler"],
|
||||
scheduler: ["not-recorded", null], model: ["not-recorded", null],
|
||||
};
|
||||
const ALL_UNRESOLVED = Object.fromEntries(fields.map(field => [field, ["unresolved", null]]));
|
||||
|
||||
test("R4: text starting with the negative delimiter has no positive, as with a leading newline", () => {
|
||||
for (const text of [`Negative prompt: bad\n${FOOTER}`, `\nNegative prompt: bad\n${FOOTER}`, `Negative prompt: a\nNegative prompt: b\n${FOOTER}`]) {
|
||||
assert.deepEqual(parameterFacts(text), ALL_UNRESOLVED, text);
|
||||
}
|
||||
assert.deepEqual(parameterFacts(`a cat\nNegative prompt: bad\n${FOOTER}`), FOUR_KNOWN);
|
||||
});
|
||||
|
||||
test("R5: template records after the footer and a bare Steps footer", () => {
|
||||
const base = `a cat\nNegative prompt: bad\n${FOOTER}`;
|
||||
for (const trailer of ["\nTemplate: a {cat|dog}", "\nTemplate: a {cat|dog}\nNegative Template: bad", "\nNegative Template: bad\n"]) {
|
||||
assert.deepEqual(parameterFacts(base + trailer), FOUR_KNOWN, trailer);
|
||||
}
|
||||
for (const trailer of ["\nTemplate: a\nTemplate: b", "\nNegative Template: bad\nTemplate: a", "\nTemplate: a\nmore", "\ntrailing"]) {
|
||||
assert.deepEqual(parameterFacts(base + trailer), ALL_UNRESOLVED, trailer);
|
||||
}
|
||||
const bare = parameterFacts("a cat\nNegative prompt: bad\nSteps: 20\nTemplate: a {cat|dog}");
|
||||
assert.deepEqual(bare.steps, ["known", "20"]);
|
||||
assert.ok(["seed", "cfg", "sampler", "scheduler", "model"].every(field => bare[field][0] === "not-recorded"));
|
||||
// A full footer wins over a bare `Steps: N` line in the prompt; two bare ones are not unique.
|
||||
assert.deepEqual(parameterFacts(`a cat\nSteps: 3\nNegative prompt: bad\n${FOOTER}`), FOUR_KNOWN);
|
||||
assert.deepEqual(parameterFacts("a cat\nSteps: 3\nNegative prompt: bad\nSteps: 20"), ALL_UNRESOLVED);
|
||||
});
|
||||
|
||||
test("R10: commas inside quoted settings values do not split them", () => {
|
||||
assert.deepEqual(parameterFacts(`a cat\nNegative prompt: bad\n${FOOTER}, ADetailer prompt: "face, smile"`), FOUR_KNOWN);
|
||||
assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Note: "say \\"hi, there\\"", Model: base`), { ...FOUR_KNOWN, model: ["known", "base"] });
|
||||
// A quoted value of a fact is its JSON string.
|
||||
assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Model: "base, v2"`).model, ["known", "base, v2"]);
|
||||
// Unquoted JSON (comfy-image-saver hashes) keeps the historical split, field by field.
|
||||
assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Hashes: {"model": "abc", "lora:x": "def"}, Model: base`).model, ["known", "base"]);
|
||||
// An unbalanced quote is not a quoted value: the historical split, as A1111 reads it.
|
||||
assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Note: "open, Model: base`).model, ["known", "base"]);
|
||||
});
|
||||
|
||||
function graphFacts(graph, resolution) {
|
||||
const facts = extractGenerationFacts({
|
||||
prompt_graph: { status: "parsed", value: graph },
|
||||
workflow_graph: { status: "absent", value: null },
|
||||
metadataStatus: "available",
|
||||
resolution,
|
||||
});
|
||||
return Object.fromEntries(fields.map(field => [field, [facts[field].status, facts[field].value]]));
|
||||
}
|
||||
|
||||
function modelGraph(between) {
|
||||
return {
|
||||
ckpt: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "base.safetensors" } },
|
||||
hop: { class_type: between, inputs: { model: ["ckpt", 0], clip: ["ckpt", 1] } },
|
||||
s: { class_type: "KSampler", inputs: { seed: 5, steps: 20, cfg: 7, sampler_name: "euler", scheduler: "normal", model: ["hop", 0] } },
|
||||
};
|
||||
}
|
||||
|
||||
test("R9: model pass-throughs verified in their sources lead to the loader", () => {
|
||||
for (const name of ["Lora Loader (LoraManager)", "Power Lora Loader (rgthree)", "ModelPatchTorchSettings", "CFGNorm", "PerturbedAttentionGuidance"]) {
|
||||
assert.deepEqual(graphFacts(modelGraph(name)).model, ["known", "base.safetensors"], name);
|
||||
// Only output 0 is the model.
|
||||
const other = modelGraph(name); other.s.inputs.model = ["hop", 1];
|
||||
assert.deepEqual(graphFacts(other).model, ["unresolved", null], name);
|
||||
}
|
||||
assert.deepEqual(graphFacts(modelGraph("ModelMergeSDXL")).model, ["unresolved", null]);
|
||||
});
|
||||
|
||||
test("R6: upscale and SEGS detailers supply stage facts like FaceDetailer", () => {
|
||||
for (const [class_type, image] of [["UltimateSDUpscale", "image"], ["UltimateSDUpscaleNoUpscale", "upscaled_image"], ["DetailerForEach", "image"]]) {
|
||||
const graph = modelGraph("CFGNorm");
|
||||
graph.decode = { class_type: "VAEDecode", inputs: { samples: ["s", 0], vae: ["ckpt", 2] } };
|
||||
graph.d = { class_type, inputs: { [image]: ["decode", 0], model: ["hop", 0], seed: 7, steps: 12, cfg: 7, sampler_name: "euler", scheduler: "normal" } };
|
||||
assert.deepEqual(graphFacts(graph, { state: "resolved", sampler_id: "d" }).seed, ["known", "7"], class_type);
|
||||
const merged = graphFacts(graph, { state: "ambiguous", sampler_id: "d" });
|
||||
assert.deepEqual([merged.seed[0], merged.steps[0], merged.cfg], ["ambiguous", "ambiguous", ["known", "7"]], class_type);
|
||||
}
|
||||
});
|
||||
|
||||
test("R6: upstream-verified samplers supply facts unless an override input is set", () => {
|
||||
for (const [class_type, overrides] of [["ClownsharKSampler_Beta", ["options", "sigmas"]], ["KSampler (Efficient)", ["script"]], ["ttN KSampler_v2", ["adv_xyPlot"]]]) {
|
||||
const graph = modelGraph("CR Apply LoRA Stack");
|
||||
graph.s.class_type = class_type;
|
||||
assert.deepEqual(graphFacts(graph), {
|
||||
seed: ["known", "5"], steps: ["known", "20"], cfg: ["known", "7"], sampler: ["known", "euler"],
|
||||
scheduler: ["known", "normal"], model: ["known", "base.safetensors"],
|
||||
}, class_type);
|
||||
for (const name of overrides) {
|
||||
const overridden = structuredClone(graph);
|
||||
overridden.s.inputs[name] = ["script", 0];
|
||||
overridden.script = { class_type: "SomeScript", inputs: {} };
|
||||
assert.deepEqual(graphFacts(overridden), ALL_UNRESOLVED, `${class_type} ${name}`);
|
||||
for (const unset of [null, ""]) {
|
||||
overridden.s.inputs[name] = unset;
|
||||
assert.equal(graphFacts(overridden).seed[0], "known", `${class_type} ${name} ${unset}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// Run with: node --test tests/js/
|
||||
// comfy-metadata.js stays a classic script for the legacy pages, so its facts
|
||||
// tables are inline literals. This test keeps them equal to the one semantics
|
||||
// table (web/js/inspection-semantics.json) that Python also loads.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const metadata = require("../../web/js/comfy-metadata.js");
|
||||
|
||||
const tablePath = path.join(__dirname, "..", "..", "web", "js", "inspection-semantics.json");
|
||||
const semantics = JSON.parse(fs.readFileSync(tablePath, "utf8"));
|
||||
|
||||
function plain(value) {
|
||||
if (value instanceof Set) return [...value];
|
||||
if (Array.isArray(value)) return value.map(plain);
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, plain(item)]));
|
||||
return value;
|
||||
}
|
||||
|
||||
test("facts tables equal semantics.facts", () => {
|
||||
assert.equal(typeof metadata._factsTables, "object", "comfy-metadata exposes its facts tables for this test");
|
||||
assert.deepStrictEqual(plain(metadata._factsTables), semantics.facts);
|
||||
});
|
||||
|
||||
test("facts guider providers are guiders in the semantics table", () => {
|
||||
const providers = Object.entries(semantics.facts.configuration_outputs).filter(([, adapter]) => adapter.kind === "guider");
|
||||
assert.ok(providers.length > 0);
|
||||
for (const [name] of providers) assert.ok(Object.hasOwn(semantics.guiders, name), name);
|
||||
});
|
||||
|
||||
test("the facts tables are not part of the legacy page contract", () => {
|
||||
assert.deepStrictEqual(Object.keys(metadata).filter(key => !key.startsWith("_")).sort(), ["PLACEHOLDERS", "extractGenerationFacts", "extractGenerationParams"]);
|
||||
assert.ok(Object.isFrozen(metadata._factsTables));
|
||||
});
|
||||
|
||||
// Task 2.1 checked only `facts`; the facts code also reads the samplers table
|
||||
// (which classes are samplers, their facts shape and a detailer's image input).
|
||||
test("sampler facts table equals semantics.samplers", () => {
|
||||
assert.equal(typeof metadata._samplerTable, "object", "comfy-metadata exposes its sampler table for this test");
|
||||
// Task 2.7b: plus `fact_overrides`; `prompt_overrides` is read only by the resolver.
|
||||
const expected = Object.fromEntries(Object.entries(semantics.samplers).map(([name, entry]) => [name, {
|
||||
facts: entry.facts,
|
||||
...(entry.image_stage === undefined ? {} : { image_stage: entry.image_stage }),
|
||||
...(entry.fact_overrides === undefined ? {} : { fact_overrides: entry.fact_overrides }),
|
||||
}]));
|
||||
assert.deepStrictEqual(plain(metadata._samplerTable), expected);
|
||||
assert.ok(Object.isFrozen(metadata._samplerTable));
|
||||
});
|
||||
|
||||
test("detailer-stage samplers, and only they, name an image stage", () => {
|
||||
for (const [name, entry] of Object.entries(semantics.samplers)) {
|
||||
assert.equal(Object.hasOwn(metadata._samplerTable[name], "image_stage"), entry.strategy === "detailer-stage", name);
|
||||
}
|
||||
});
|
||||
|
||||
// The stage walk treats every table class as known (the resolver's `all_classes`).
|
||||
test("stage walk table classes equal every class in the semantics table", () => {
|
||||
const classes = new Set();
|
||||
for (const category of ["samplers", "guiders", "selectors", "composites", "savers"]) Object.keys(semantics[category]).forEach(name => classes.add(name));
|
||||
for (const category of ["conditioning", "encoders", "strings"]) semantics[category].forEach(entry => classes.add(entry.class));
|
||||
for (const category of ["runtime", "non_file_outputs"]) semantics[category].forEach(name => classes.add(name));
|
||||
assert.deepStrictEqual([...metadata._tableClasses].sort(), [...classes].sort());
|
||||
});
|
||||
|
||||
// Review I-2: one source for the stage-walk input lists (Python reads them there too).
|
||||
test("stage walk inputs equal semantics.stage_inputs", () => {
|
||||
assert.equal(typeof semantics.stage_inputs, "object");
|
||||
assert.equal(typeof metadata._stageInputs, "object", "comfy-metadata exposes its stage inputs for this test");
|
||||
assert.deepStrictEqual(plain(metadata._stageInputs), semantics.stage_inputs);
|
||||
assert.ok(Object.isFrozen(metadata._stageInputs));
|
||||
});
|
||||
|
||||
// Review M-3: JS treats every samplers entry as a candidate; the server only these strategies.
|
||||
test("every samplers entry is a candidate strategy", () => {
|
||||
for (const [name, entry] of Object.entries(semantics.samplers)) {
|
||||
assert.ok(["ksampler", "custom-advanced", "detailer-stage"].includes(entry.strategy), name);
|
||||
}
|
||||
});
|
||||
@@ -3,7 +3,10 @@
|
||||
// (web/metadata.html, web/js/gallery.js) show today on the synthetic inspection
|
||||
// parity fixtures, so the #240 workspace work cannot change them by accident.
|
||||
// Some answers are known to be wrong (f01b shows the preview-only detailer text
|
||||
// as both roles; f16 shows the last of two conflicting prompt chunks). They are
|
||||
// as both roles; f16 shows the last of two conflicting prompt chunks; f19 shows the
|
||||
// upstream sampler's prompt and settings, not the detailer's; f23 shows the positive
|
||||
// text as the zeroed negative; f21 joins a list output; f22 shows PromptSearchList's
|
||||
// search text as the prompt). They are
|
||||
// recorded as-is and intentionally unchanged (spec section 11).
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
@@ -56,6 +59,12 @@ const EXPECTED = {
|
||||
f15: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f16: { positivePrompt: "a grey wolf in fog", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f17: { positivePrompt: "No prompt found", negativePrompt: "No negative prompt found", checkpoint: "flux1-dev.safetensors", steps: "Unknown", cfgScale: "Unknown", sampler: "euler", seed: "Unknown" },
|
||||
f19: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f20: { positivePrompt: "No prompt found", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f21: { positivePrompt: "a red fox, in snow", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f22: { positivePrompt: "fox", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f23: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "a red fox in snow, detailed fur", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
f24: { positivePrompt: "No prompt found", negativePrompt: "No negative prompt found", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
|
||||
};
|
||||
|
||||
test("legacy extractGenerationParams characterization covers every committed fixture", () => {
|
||||
|
||||
@@ -70,3 +70,76 @@ test('frozen fields disclose their exact source and keep null, empty and whitesp
|
||||
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);assert.equal(sent.length,2);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('a zeroed conditioning negative shows as an explicit empty negative with its own source',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:{status:'resolved',text:'positive',source:'graph-inferred'},negative:{status:'resolved',text:'',source:'zeroed-conditioning'}}});
|
||||
assert.equal(f.walk(container,node=>node.textContent==='Zeroed conditioning (no negative text)').length,1);
|
||||
assert.equal(f.walk(container,node=>node.textContent==='Empty negative prompt').length,1);
|
||||
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);
|
||||
assert.deepEqual(sent,[{positive:'positive',negative:''}]);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
const unresolved=message=>({status:'unresolved',text:null,source:null,...(message===undefined?{}:{message})});
|
||||
const statusLines=(f,container)=>f.walk(container,node=>node.tagName==='p'&&node.className==='muted').map(node=>node.textContent);
|
||||
test('unsupported node status names the class',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
const line='Prompt uses a node the inspector does not support (DualCFGGuider).';
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(line),negative:unresolved(line)}});
|
||||
assert.deepEqual(statusLines(f,container),[line,line]);
|
||||
assert.equal(f.walk(container,node=>node.dataset.sendRole!==undefined||node.dataset.copyRole!==undefined).length,0,'no action implies a prompt exists');
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('runtime and composite statuses',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
const runtime='Prompt is chosen at run time (PromptSearchList).',composite='Prompt combines several texts (ConditioningCombine).';
|
||||
try{
|
||||
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(runtime),negative:unresolved(runtime)}});
|
||||
assert.deepEqual(statusLines(f,container),[runtime,runtime]);
|
||||
view.update({positive:unresolved(composite),negative:{status:'resolved',text:'kept',source:'execution'}});
|
||||
assert.deepEqual(statusLines(f,container),[composite]);
|
||||
assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent==='kept').length,1);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('limit status',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
const line='Prompt exceeds inspection limits.';
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(line),negative:unresolved(line)}});
|
||||
assert.deepEqual(statusLines(f,container),[line,line]);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('roles without a usable line keep the status copy, and a candidate is never shown as a prompt',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(''),negative:{status:'ambiguous',text:'candidate text',source:null}}});
|
||||
assert.deepEqual(statusLines(f,container),['Prompt could not be resolved.','Multiple possible prompts. Source could not be determined.']);
|
||||
assert.equal(f.walk(container,node=>node.textContent==='candidate text').length,0);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('class names in status lines render as text, never markup',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
const line='Prompt uses a node the inspector does not support (<img src=x onerror=alert(1)>).';
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(line),negative:unresolved(line)}});
|
||||
assert.deepEqual(statusLines(f,container),[line,line]);
|
||||
assert.equal(f.walk(container,node=>node.tagName==='img').length,0);
|
||||
assert.equal(f.walk(container,node=>Object.hasOwn(node,'innerHTML')).length,0);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('a context label names nested prompt regions and actions apart from the frozen blocks while the visible heading stays',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},contextLabel:'Current file',prompts:{positive:record('p'),negative:record('n')}});
|
||||
const labels=f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']);
|
||||
for(const name of ['Current file positive prompt','Current file negative prompt','Copy current file positive prompt','Send current file positive prompt','Copy current file negative prompt','Send current file negative prompt']) assert.ok(labels.includes(name),name);
|
||||
assert.equal(labels.filter(name=>/^(Positive|Negative) prompt$/.test(name)).length,0);
|
||||
assert.deepEqual(f.walk(container,node=>node.tagName==='h3').map(node=>node.textContent),['Positive prompt','Negative prompt']);
|
||||
const plain=new f.Element('div');module.mountPromptBlocks(plain,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('p')}});
|
||||
assert.ok(f.walk(plain,node=>node.attributes['aria-label']==='Positive prompt').length);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Runs the legacy browser parser (comfy-metadata.js extractGenerationParams) on
|
||||
// one JSON case per input line and prints one JSON result per line. The Python
|
||||
// port (utils/generation_params.py) must produce the same results.
|
||||
//
|
||||
// Input line: {"prompt": <text or null>, "workflow": <text or null>}
|
||||
// Each present record is parsed with JSON.parse; a record that fails
|
||||
// to parse is treated as absent, as the browser pages do.
|
||||
// Output line: {"ok": true, "result": {...}} or {"ok": false} when the parser throws.
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const { extractGenerationParams } = require(path.join(here, '..', '..', '..', 'web', 'js', 'comfy-metadata.js'));
|
||||
|
||||
function parsed(text) {
|
||||
if (typeof text !== 'string') return undefined;
|
||||
try { return JSON.parse(text); } catch { return undefined; }
|
||||
}
|
||||
|
||||
for (const line of fs.readFileSync(0, 'utf8').split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
const item = JSON.parse(line), data = {};
|
||||
const prompt = parsed(item.prompt), workflow = parsed(item.workflow);
|
||||
if (prompt !== undefined) data.prompt = prompt;
|
||||
if (workflow !== undefined) data.workflow = workflow;
|
||||
let out;
|
||||
try { out = { ok: true, result: extractGenerationParams(data) }; } catch { out = { ok: false }; }
|
||||
process.stdout.write(JSON.stringify(out) + '\n');
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// The seed the workspace inspector shows for encoded inspection responses, through
|
||||
// its own path: projectInspectionPayload with the server facts and the legacy
|
||||
// parser fill (comfy-metadata.js, passed explicitly; Node has no window global).
|
||||
// Used by tests/test_api_workspace_images.py to compare card and inspector seeds.
|
||||
// stdin: {listing, inspections: [{name, body}]}; each body is the exact response
|
||||
// JSON text, and listing (optional) is the exact workspace listing response text.
|
||||
// stdout: {inspector: {name: {status, value}}, card: {path: String(seed) | null}}.
|
||||
// Inspector status is 'error' where the page cannot project the record. The card
|
||||
// values come from JSON.parse of the listing, as the browser reads them, so a
|
||||
// seed sent as a JSON number past 2**53 shows up rounded here.
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const web = path.join(here, '..', '..', '..', 'web', 'js');
|
||||
const require = createRequire(import.meta.url);
|
||||
const parser = require(path.join(web, 'comfy-metadata.js'));
|
||||
const { projectInspectionPayload } = await import(pathToFileURL(path.join(web, 'workspace', 'inspection-state.mjs')).href);
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const { listing = null, inspections } = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
const inspector = {};
|
||||
for (const { name, body } of inspections) {
|
||||
const identity = { kind: 'rooted-file', key: `0:${name}`, root_index: 0, path: name, root_scope: null };
|
||||
try {
|
||||
const record = projectInspectionPayload(JSON.parse(body), identity, null, parser.extractGenerationFacts, false, parser);
|
||||
inspector[name] = { status: record.generation.seed.status, value: record.generation.seed.value };
|
||||
} catch (error) {
|
||||
// The page shows no record here, so no seed either.
|
||||
inspector[name] = { status: 'error', value: String(error && error.name) };
|
||||
}
|
||||
}
|
||||
const card = {};
|
||||
for (const item of listing === null ? [] : JSON.parse(listing).images) {
|
||||
card[item.path] = item.seed === null || item.seed === undefined ? null : String(item.seed);
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ inspector, card }));
|
||||
@@ -19,7 +19,7 @@ test('layouts preserve data order, remain within width and never overlap', async
|
||||
|
||||
test('empty, unknown dimensions and narrow containers keep valid geometry',async()=>{
|
||||
const {layoutImages}=await source;
|
||||
assert.deepEqual(layoutImages([],0,'grid'),{positions:[],height:0,maxHeight:0});
|
||||
assert.deepEqual(layoutImages([],0,'grid'),{positions:[],columns:1,height:0,maxHeight:0});
|
||||
for(const mode of ['grid','list','masonry']) {
|
||||
const result=layoutImages([{}, {width:0,height:null}],1,mode);
|
||||
assert.ok(Number.isFinite(result.height));
|
||||
@@ -74,64 +74,78 @@ test('binary-searched windows handle boundaries, empty collections and focus wit
|
||||
|
||||
test('reported maximum height keeps unusually tall custom layouts inside the virtual window',async()=>{
|
||||
const {layoutImages,visibleImages}=await source;
|
||||
const geometry=layoutImages([{}, {}, {}],10000,'grid',20000);
|
||||
assert.equal(geometry.maxHeight,7554.5);
|
||||
const geometry=layoutImages([{}, {}, {}],10000,'grid',{columns:1});
|
||||
assert.equal(geometry.maxHeight,9998/1.33+56);
|
||||
assert.deepEqual(visibleImages(geometry.positions,6000,200,-1,geometry.maxHeight),[0]);
|
||||
});
|
||||
|
||||
// The concept's mountMasonry reads the same two values (app.js 530-534); its breakpoints live in concept.css only.
|
||||
function computedGrid(columns, gap) {
|
||||
globalThis.getComputedStyle = node => {
|
||||
assert.equal(node, grid);
|
||||
return {gridTemplateColumns:columns, columnGap:gap};
|
||||
};
|
||||
const grid = {};
|
||||
return grid;
|
||||
}
|
||||
|
||||
test('approved workspace densities share viewport metrics and reject unknown values', async () => {
|
||||
const {workspaceImageMetrics, layoutImages} = await source;
|
||||
assert.equal(typeof workspaceImageMetrics, 'function', 'workspace density metrics must be exported');
|
||||
for (const [width, expected] of [[1600,[3,2,4]], [1250,[2,2,3]], [950,[2,2,3]], [700,[2,2,2]], [390,[2,2,2]], [1700,[4,2,5]]]) {
|
||||
assert.deepEqual(['comfortable','large','compact'].map(density => workspaceImageMetrics(width,density).columns), expected);
|
||||
for (const density of ['comfortable','large','compact']) {
|
||||
const metrics = workspaceImageMetrics(width,density);
|
||||
assert.deepEqual(metrics, {columns: expected[['comfortable','large','compact'].indexOf(density)], gap:13, footerHeight:54, listHeight:97});
|
||||
assert.equal(layoutImages([{},{}],990,'list',240,metrics).positions[1].x,0);
|
||||
}
|
||||
}
|
||||
assert.throws(() => workspaceImageMetrics(1600,'unknown'), TypeError);
|
||||
assert.deepEqual(workspaceImageMetrics(1600),workspaceImageMetrics(1600,'comfortable'));
|
||||
});
|
||||
|
||||
test('approved grid uses inner media at four to three and explicit columns with thirteen pixel gaps', async () => {
|
||||
const {workspaceImageMetrics,layoutImages} = await source;
|
||||
assert.equal(typeof workspaceImageMetrics,'function');
|
||||
const metrics = workspaceImageMetrics(1600);
|
||||
const {positions} = layoutImages(Array(4).fill({}),990,'grid',240,metrics);
|
||||
assert.equal(positions[1].x-positions[0].width,13);
|
||||
assert.equal(positions[3].x,0);
|
||||
assert.equal(positions[3].y-positions[0].height,13);
|
||||
assert.equal(positions[0].height-54-2,(positions[0].width-2)*.75);
|
||||
assert.equal(layoutImages([{}],990,'list',240,metrics).height,97);
|
||||
const custom=layoutImages([{}],990,'grid',240,{columns:2,gap:20,footerHeight:60,listHeight:100});
|
||||
assert.equal(custom.positions[0].width,485);
|
||||
assert.equal(custom.height,(485-2)*.75+62);
|
||||
assert.equal(layoutImages([{}],990,'list',240,{listHeight:100}).height,100);
|
||||
test('layout uses columns and gap from computed style', async () => {
|
||||
const {gridMetrics, layoutImages} = await source;
|
||||
const original = globalThis.getComputedStyle;
|
||||
try {
|
||||
for (const [columns, gap, expected] of [
|
||||
['329.333px 329.333px 329.333px', '13px', {columns:3, gap:13}],
|
||||
['200px 200px 200px 200px 200px', '20px', {columns:5, gap:20}],
|
||||
['990px', '13px', {columns:1, gap:13}],
|
||||
['none', 'normal', {columns:1, gap:0}],
|
||||
['', '', {columns:1, gap:0}],
|
||||
]) assert.deepEqual(gridMetrics(computedGrid(columns, gap)), expected, `${columns} / ${gap}`);
|
||||
} finally { globalThis.getComputedStyle = original; }
|
||||
const {positions, columns} = layoutImages(Array(6).fill({}), 990, 'grid', {columns:3, gap:13});
|
||||
assert.equal(columns, 3);
|
||||
assert.equal(positions[1].x, positions[0].width + 13);
|
||||
assert.equal(positions[3].x, 0);
|
||||
assert.equal(positions[3].y, positions[0].height + 13);
|
||||
// The concept media is `aspect-ratio: 1.33` inside a 1px card border, under the measured `.media-info`.
|
||||
assert.equal(positions[0].height, (positions[0].width - 2) / 1.33 + 54 + 2);
|
||||
const wide = layoutImages(Array(6).fill({}), 990, 'grid', {columns:5, gap:20, footerHeight:60});
|
||||
assert.equal(wide.positions[0].width, (990 - 80) / 5);
|
||||
assert.equal(wide.positions[0].height, (wide.positions[0].width - 2) / 1.33 + 62);
|
||||
assert.equal(layoutImages([{}, {}], 990, 'list', {columns:1, gap:13}).positions[1].x, 0);
|
||||
assert.equal(layoutImages([{}], 990, 'list', {columns:1, gap:13}).height, 97);
|
||||
assert.equal(layoutImages([{}], 990, 'list', {columns:1, listHeight:100}).height, 100);
|
||||
});
|
||||
|
||||
test('masonry uses real finite proportions and falls back for invalid dimensions', async () => {
|
||||
const {layoutImages}=await source;
|
||||
const images=[{width:100,height:1000},{width:1000,height:100},{width:Infinity,height:10},{width:10,height:NaN}];
|
||||
const {positions}=layoutImages(images,990,'masonry',240,{columns:3});
|
||||
const {positions}=layoutImages(images,990,'masonry',{columns:3});
|
||||
assert.equal(positions[0].height,(positions[0].width-2)*10+56);
|
||||
assert.equal(positions[1].height,(positions[1].width-2)/10+56);
|
||||
assert.equal(positions[2].height,(positions[2].width-2)*.75+56);
|
||||
for (const position of positions) assert.ok(Number.isFinite(position.height));
|
||||
});
|
||||
|
||||
test('gallery width limits density columns without changing ordinary workspace geometry', async () => {
|
||||
const {workspaceImageMetrics, layoutImages} = await source;
|
||||
for (const [viewport, width, density, columns] of [
|
||||
[1600,990,'comfortable',3], [1600,990,'compact',4],
|
||||
[951,428,'compact',3], [390,362,'comfortable',2],
|
||||
[1600,190,'comfortable',1], [1600,190,'compact',1],
|
||||
[390,167,'large',1], [390,167,'compact',1], [390,69.5,'comfortable',1], [390,0,'comfortable',1],
|
||||
test('masonry falls back to the concept ratio when a finite size underflows the proportion', async () => {
|
||||
const {layoutImages}=await source;
|
||||
// Both sides are finite and positive, but their ratio underflows to 0, so the proportional height is infinite.
|
||||
const {positions,height}=layoutImages([{width:Number.MIN_VALUE,height:1e300}],300,'masonry',{columns:1});
|
||||
assert.equal(positions[0].height,(300-2)/1.33+56);
|
||||
assert.ok(Number.isFinite(height));
|
||||
});
|
||||
|
||||
test('a narrow gallery caps the concept columns at the smallest readable card', async () => {
|
||||
const {layoutImages, MINIMUM_CARD_WIDTH} = await source;
|
||||
// One shared minimum: the page marks `.narrow-cards` below the same width the layout caps columns at.
|
||||
assert.equal(MINIMUM_CARD_WIDTH, 120);
|
||||
// At 200 percent zoom the concept's 390px breakpoint still asks for two columns of a half-width grid (D3, D31).
|
||||
for (const [width, asked, columns] of [
|
||||
[990,3,3], [990,4,4], [428,3,3], [362,2,2], [190,3,1], [190,4,1], [167,2,1], [69.5,2,1], [0,2,1],
|
||||
]) {
|
||||
const metrics = workspaceImageMetrics(viewport,density,width);
|
||||
assert.equal(metrics.columns,columns,`${viewport}/${width}/${density}`);
|
||||
const {positions} = layoutImages(Array(8).fill({}),width,'grid',240,metrics);
|
||||
assert.equal(positions[columns].x,0);
|
||||
assert.ok(positions[columns].y > 0);
|
||||
const geometry = layoutImages(Array(8).fill({}), width, 'grid', {columns:asked, gap:13});
|
||||
assert.equal(geometry.columns, columns, `${width}/${asked}`);
|
||||
assert.equal(geometry.positions[columns].x, 0);
|
||||
assert.ok(geometry.positions[columns].y > 0);
|
||||
}
|
||||
assert.equal(layoutImages([{}], 990, 'list', {columns:3}).columns, 1);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/images-category.mjs');
|
||||
|
||||
const linked = (id, extra = {}) => ({path:`p${id}.png`, linked:{status:'linked', positive_id:id, ...extra}});
|
||||
|
||||
test('targets are the distinct linked positives, in selection order, and the rest count as skipped', async () => {
|
||||
const {categoryTargets} = await source;
|
||||
const images = [
|
||||
linked(7), linked(3), linked(7),
|
||||
{path:'u.png', linked:{status:'unlinked', positive_id:null}},
|
||||
{path:'a.png', linked:{status:'ambiguous', positive_id:4}},
|
||||
{path:'v.png', linked:{status:'unavailable', positive_id:null}},
|
||||
{path:'none.png'},
|
||||
{path:'bad.png', linked:{status:'linked', positive_id:'9'}},
|
||||
{path:'zero.png', linked:{status:'linked', positive_id:0}},
|
||||
{path:'frac.png', linked:{status:'linked', positive_id:1.5}},
|
||||
null,
|
||||
];
|
||||
assert.deepEqual(categoryTargets(images), {promptIds:[7, 3], skipped:8});
|
||||
assert.deepEqual(categoryTargets([]), {promptIds:[], skipped:0});
|
||||
assert.deepEqual(categoryTargets([linked(2)]), {promptIds:[2], skipped:0});
|
||||
});
|
||||
|
||||
test('the outcome toast counts the prompts and the skipped images', async () => {
|
||||
const {categoryOutcome} = await source;
|
||||
assert.equal(categoryOutcome(3, 0), 'Category set on 3 prompts.');
|
||||
assert.equal(categoryOutcome(1, 0), 'Category set on 1 prompt.');
|
||||
assert.equal(categoryOutcome(2, 4), 'Category set on 2 prompts. 4 selected images had no linked prompt and were skipped.');
|
||||
assert.equal(categoryOutcome(1, 1), 'Category set on 1 prompt. 1 selected image had no linked prompt and was skipped.');
|
||||
});
|
||||
|
||||
test('the dialog and failure copy is the plan\'s', async () => {
|
||||
const {CATEGORY_DIALOG, CATEGORY_NONE_LINKED, CATEGORY_FAILED} = await source;
|
||||
assert.deepEqual(CATEGORY_DIALOG, {title:'Categorize selected images', label:'Category', action:'Apply'});
|
||||
assert.equal(CATEGORY_NONE_LINKED, 'None of the selected images has a linked prompt.');
|
||||
assert.equal(CATEGORY_FAILED, 'Category could not be set. Try again.');
|
||||
assert.ok(Object.isFrozen(CATEGORY_DIALOG));
|
||||
});
|
||||
|
||||
test('the updated count is the server\'s when it is a count, else the number of prompts posted', async () => {
|
||||
const {updatedCount} = await source;
|
||||
assert.equal(updatedCount({updated_count:0}, 3), 0);
|
||||
assert.equal(updatedCount({updated_count:2}, 3), 2);
|
||||
for (const result of [{}, {updated_count:-1}, {updated_count:1.5}, {updated_count:'2'}, null, undefined]) {
|
||||
assert.equal(updatedCount(result, 3), 3, JSON.stringify(result));
|
||||
}
|
||||
});
|
||||
|
||||
test('the category field is limited to the server\'s 100 characters', async () => {
|
||||
const {CATEGORY_MAX_LENGTH} = await source;
|
||||
assert.equal(CATEGORY_MAX_LENGTH, 100);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/images-export.mjs');
|
||||
const inspector = import('../../web/js/workspace/file-inspector.mjs');
|
||||
|
||||
function record(path, status = 'available') {
|
||||
return {identity:{kind:'rooted-file', key:`0:${path}`, root_index:0, path, root_scope:null}, file:{format:'PNG'},
|
||||
prompts:{positive:{status:'resolved', text:path}}, generation:{}, library:{status:'unlinked'},
|
||||
download:{filename:path.split('/').at(-1), url:`/prompt_manager/images/serve/${path}?root=0`},
|
||||
metadata:{status, omitted_reason:null, raw:{prompt:'{}'}, record_formats:{prompt:'original-text'}}};
|
||||
}
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => {resolve = a; reject = b;}); return {promise, resolve, reject}; };
|
||||
|
||||
test('the single-image download and the bundle share one envelope builder', async () => {
|
||||
const {metadataEnvelope, inspectionDownloads} = await inspector;
|
||||
const value = record('a/one.png');
|
||||
assert.deepEqual(JSON.parse(inspectionDownloads(value).metadata.bodyText), metadataEnvelope(value));
|
||||
assert.equal(metadataEnvelope(record('b.png', 'limited')), null);
|
||||
assert.equal(metadataEnvelope({}), null);
|
||||
});
|
||||
|
||||
test('the bundle holds the format, version, export time and envelopes in the given order', async () => {
|
||||
const {metadataBundle, BUNDLE_FILENAME} = await source;
|
||||
const envelopes = [{inspection_version:1, n:1}, {inspection_version:1, n:2}];
|
||||
const bundle = metadataBundle(envelopes, '2026-10-20T00:00:00.000Z');
|
||||
assert.deepEqual(bundle, {format:'prompt-manager-images-metadata', version:1, exported_at:'2026-10-20T00:00:00.000Z', images:envelopes});
|
||||
assert.notEqual(bundle.images, envelopes);
|
||||
assert.equal(BUNDLE_FILENAME, 'prompt-manager-images-metadata.json');
|
||||
});
|
||||
|
||||
test('collection keeps listing order, runs at most four reads at once and counts failures', async () => {
|
||||
const {collectImagesMetadata} = await source;
|
||||
const {metadataEnvelope} = await inspector;
|
||||
const images = Array.from({length:9}, (_, index) => ({path:`img-${index}.png`, root_index:0}));
|
||||
const waits = new Map(images.map(image => [image.path, deferred()]));
|
||||
let running = 0, peak = 0;
|
||||
const done = collectImagesMetadata(images, async image => {
|
||||
running += 1; peak = Math.max(peak, running);
|
||||
try { return await waits.get(image.path).promise; } finally { running -= 1; }
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(running, 4);
|
||||
// Settle out of order: later images finish first, one read fails and one file's metadata is limited.
|
||||
for (const image of [...images].reverse()) {
|
||||
const wait = waits.get(image.path);
|
||||
if (image.path === 'img-3.png') wait.reject(new Error('unreadable'));
|
||||
else wait.resolve(record(image.path, image.path === 'img-5.png' ? 'limited' : 'available'));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
const {envelopes, failed} = await done;
|
||||
assert.equal(peak, 4);
|
||||
assert.equal(failed, 2);
|
||||
assert.deepEqual(envelopes.map(envelope => envelope.identity.path),
|
||||
['img-0.png', 'img-1.png', 'img-2.png', 'img-4.png', 'img-6.png', 'img-7.png', 'img-8.png']);
|
||||
assert.deepEqual(envelopes[0], metadataEnvelope(record('img-0.png')));
|
||||
});
|
||||
|
||||
test('a read that throws synchronously counts as a failure', async () => {
|
||||
const {collectImagesMetadata} = await source;
|
||||
const result = await collectImagesMetadata([{path:'a.png'}, {path:'b.png'}], image => {
|
||||
if (image.path === 'a.png') throw new TypeError('Invalid inspection identity');
|
||||
return record(image.path);
|
||||
});
|
||||
assert.equal(result.failed, 1);
|
||||
assert.deepEqual(result.envelopes.map(envelope => envelope.identity.path), ['b.png']);
|
||||
assert.deepEqual(await collectImagesMetadata([], () => assert.fail('no reads')), {envelopes:[], failed:0});
|
||||
});
|
||||
|
||||
test('the outcome toasts name the exported and unread counts', async () => {
|
||||
const {exportOutcome} = await source;
|
||||
assert.equal(exportOutcome(1, 1), 'Exported metadata for 1 image.');
|
||||
assert.equal(exportOutcome(2, 2), 'Exported metadata for 2 images.');
|
||||
assert.equal(exportOutcome(1, 3), 'Exported metadata for 1 of 3 images. 2 could not be read.');
|
||||
assert.equal(exportOutcome(2, 3), 'Exported metadata for 2 of 3 images. 1 could not be read.');
|
||||
assert.equal(exportOutcome(0, 2), 'Metadata could not be read. Try again.');
|
||||
});
|
||||
@@ -183,3 +183,152 @@ test('custom collection pages reuse image-stream cancellation, cursors and ident
|
||||
assert.equal(await stream.loadMore(),1);assert.equal(calls[1].parameters.get('cursor'),'next');assert.equal(stream.state.loaded,2);stream.dispose();
|
||||
assert.throws(()=>createImageStream({api:{get:async()=>{}},fetchPage:'invalid'}),/page/i);
|
||||
});
|
||||
|
||||
test('stream sends category, tags and the rating sort and validates them', async () => {
|
||||
const {createImageStream}=await source;const calls=[];
|
||||
const stream=createImageStream({api:{get:async url=>{calls.push(new URL(url,'http://local').searchParams);return page([]);}}});
|
||||
await stream.reset({q:'cat',category:'Animals',tags:['cats','studio'],sort:'rating'});
|
||||
assert.equal(calls[0].get('category'),'Animals');assert.equal(calls[0].get('tags'),'cats,studio');assert.equal(calls[0].get('sort'),'rating');
|
||||
await stream.reset({category:'',tags:[]});
|
||||
assert.equal(calls[1].has('category'),false);assert.equal(calls[1].has('tags'),false);
|
||||
for(const filters of [{category:3},{tags:'cats'},{tags:[1]},{tags:['a,b']},{tags:['']}]) await assert.rejects(stream.reset(filters),/filter/i);
|
||||
});
|
||||
|
||||
test('positive count comes from the first page, survives plain cursor pages and hides when unknown', async () => {
|
||||
const {createImageStream}=await source;
|
||||
const replies=[{...page([image('a')],3,'one'),positive_count:2},page([image('b')],3,'two'),{...page([image('c')],3),positive_count:null}];
|
||||
const stream=createImageStream({api:{get:async()=>replies.shift()}});
|
||||
assert.equal(stream.state.positiveCount,null);
|
||||
await stream.reset();assert.equal(stream.state.positiveCount,2);
|
||||
await stream.loadMore();assert.equal(stream.state.positiveCount,2);
|
||||
await stream.loadMore();assert.equal(stream.state.positiveCount,2);
|
||||
const failed=createImageStream({api:{get:async()=>({...page([image('a')]),positive_count:null})}});
|
||||
await failed.reset();assert.equal(failed.state.positiveCount,null);
|
||||
const absent=createImageStream({api:{get:async()=>page([image('a')])}});
|
||||
await absent.reset();assert.equal(absent.state.positiveCount,null);
|
||||
for(const positive_count of [-1,'2',1.5]) {
|
||||
const invalid=createImageStream({api:{get:async()=>({...page([image('a')]),positive_count})}});
|
||||
await invalid.reset();assert.match(invalid.state.error.message,/page/i);
|
||||
}
|
||||
const restored=createImageStream({api:{get(){assert.fail('unexpected request');}},restore:{filters:{},state:{...stream.state}}});
|
||||
assert.equal(restored.state.positiveCount,2);
|
||||
assert.throws(()=>createImageStream({api:{get(){}},restore:{filters:{},state:{...stream.state,positiveCount:-3}}}),TypeError);
|
||||
});
|
||||
|
||||
test('the count line reads N of M images and K positive prompts only when known', async () => {
|
||||
const {countLine}=await source;
|
||||
assert.equal(countLine({loaded:40,total:102,positiveCount:2}),'40 of 102 images · 2 positive prompts');
|
||||
assert.equal(countLine({loaded:1,total:1,positiveCount:1}),'1 of 1 image · 1 positive prompt');
|
||||
assert.equal(countLine({loaded:2,total:2,positiveCount:null}),'2 of 2 images');
|
||||
assert.equal(countLine({loaded:1200,total:3400,positiveCount:0}),'1,200 of 3,400 images · 0 positive prompts');
|
||||
});
|
||||
|
||||
test('the caption date names the newest result day under Newest first only', async () => {
|
||||
const {captionDate}=await source;
|
||||
const now=new Date(2026,9,17,15,0);
|
||||
const at=(...parts)=>({modified_time:new Date(...parts).getTime()/1000});
|
||||
assert.equal(captionDate([at(2026,9,17,9),at(2026,9,10)],'newest',now),'Today · October 17');
|
||||
assert.equal(captionDate([at(2026,9,16,23),at(2026,9,10)],'newest',now),'Yesterday · October 16');
|
||||
// D17 ruling: only under Newest first; Oldest first hides the date too.
|
||||
assert.equal(captionDate([at(2026,9,10),at(2026,9,16,23)],'oldest',now),'');
|
||||
assert.equal(captionDate([at(2026,9,13)],'newest',now),'Tuesday · October 13');
|
||||
assert.equal(captionDate([at(2025,2,3)],'newest',now),'Monday · March 3, 2025');
|
||||
for(const sort of ['name','name-desc','rating']) assert.equal(captionDate([at(2026,9,17)],sort,now),'');
|
||||
assert.equal(captionDate([],'newest',now),'');
|
||||
assert.equal(captionDate([{modified_time:'bad'}],'newest',now),'');
|
||||
});
|
||||
|
||||
test('the scope chip summarizes source and folder', async () => {
|
||||
const {scopeLabel}=await source;
|
||||
const roots=[{index:0,label:'output'},{index:1,label:'input'}];
|
||||
assert.equal(scopeLabel({root:'all',subfolder:''},roots),'All output folders');
|
||||
assert.equal(scopeLabel({root:'1',subfolder:'shared'},roots),'input · shared');
|
||||
assert.equal(scopeLabel({root:'0',subfolder:''},roots),'output · All output folders');
|
||||
assert.equal(scopeLabel({root:'all',subfolder:'bulk'},roots),'All sources · bulk');
|
||||
assert.equal(scopeLabel({root:'7',subfolder:''},roots),'Source 7 · All output folders');
|
||||
});
|
||||
|
||||
test('the card title is the confirmed linked title, else the filename (D10)', async () => {
|
||||
const {cardTitle}=await source;
|
||||
const linked=(status,title)=>({filename:'alpine_00001.png',linked:{status,positive_id:1,title,title_source:'title',category:null,rating:null}});
|
||||
assert.equal(cardTitle(linked('linked','Alpine observatory')),'Alpine observatory');
|
||||
for (const status of ['ambiguous','unlinked','unavailable','none']) assert.equal(cardTitle(linked(status,'Alpine observatory')),'alpine_00001.png');
|
||||
assert.equal(cardTitle(linked('linked',null)),'alpine_00001.png');
|
||||
assert.equal(cardTitle(linked('linked','')),'alpine_00001.png');
|
||||
assert.equal(cardTitle({filename:'plain.png'}),'plain.png');
|
||||
assert.equal(cardTitle({filename:'plain.png',linked:null}),'plain.png');
|
||||
});
|
||||
|
||||
test('the card subtitle is W × H · EXT · Seed N with unknown parts omitted (D11)', async () => {
|
||||
const {cardSubtitle, mediaRatio}=await source;
|
||||
assert.equal(cardSubtitle({filename:'a.png',width:1536,height:1152,seed:'42001'}),'1536 × 1152 · PNG · Seed 42001');
|
||||
// The seed is the listing's decimal string: never a Number, so 2^53 + 1 survives.
|
||||
assert.equal(cardSubtitle({filename:'a.png',width:8,height:8,seed:'9007199254740993'}),'8 × 8 · PNG · Seed 9007199254740993');
|
||||
assert.equal(cardSubtitle({filename:'clip.webm',width:null,height:null,seed:null}),'WEBM');
|
||||
assert.equal(cardSubtitle({filename:'clip.mp4',media_type:'video',width:640,height:360}),'640 × 360 · MP4');
|
||||
assert.equal(cardSubtitle({filename:'noext',width:0,height:10,seed:''}),'');
|
||||
assert.equal(cardSubtitle({filename:'.hidden',width:10,height:Infinity,seed:42}),'');
|
||||
assert.equal(cardSubtitle({filename:'trailing.',seed:'7'}),'Seed 7');
|
||||
assert.equal(mediaRatio({width:1536,height:1152}),'1536/1152');
|
||||
for (const image of [{}, {width:0,height:5}, {width:5,height:NaN}, {width:Infinity,height:5}]) assert.equal(mediaRatio(image),'4/3');
|
||||
});
|
||||
|
||||
test('updating one item replaces it immutably and later pages append after it', async () => {
|
||||
const { createImageStream, imageKey } = await source;
|
||||
const pages = [page([{...image('a'), favorite:false}, {...image('b'), favorite:true}], 3, 'next'), page([image('c')], 3)];
|
||||
const states = [];
|
||||
const hints = [];
|
||||
const stream = createImageStream({api:{get:async () => pages.shift()}, onChange:(s, hint) => { states.push(s); hints.push(hint); }, batchSize:2});
|
||||
await stream.reset({});
|
||||
const before = stream.state, notified = states.length;
|
||||
// Page loads carry no hint; a patch says so, so the page can repaint one card instead of laying out the collection.
|
||||
assert.ok(hints.every(hint => hint === undefined));
|
||||
assert.equal(stream.updateItem('0:a', {favorite:true}), true);
|
||||
const after = stream.state;
|
||||
assert.equal(states.length, notified + 1); assert.equal(states.at(-1), after);
|
||||
assert.deepEqual(hints.at(-1), {patch:'0:a'});
|
||||
assert.notEqual(after.items, before.items); assert.ok(Object.isFrozen(after.items)); assert.ok(Object.isFrozen(after.items[0]));
|
||||
assert.equal(after.items[0].favorite, true); assert.equal(before.items[0].favorite, false);
|
||||
assert.equal(after.items[1], before.items[1]);
|
||||
assert.equal(after.loaded, before.loaded); assert.equal(after.cursor, before.cursor); assert.equal(after.total, before.total);
|
||||
// Identity fields cannot be patched, and an unknown key changes nothing.
|
||||
assert.throws(() => stream.updateItem('0:a', {path:'z'}), TypeError);
|
||||
assert.throws(() => stream.updateItem('0:a', {root_index:1}), TypeError);
|
||||
assert.equal(stream.updateItem('0:missing', {favorite:true}), false);
|
||||
assert.equal(stream.state, after); assert.equal(states.length, notified + 1);
|
||||
await stream.loadMore();
|
||||
assert.deepEqual(stream.state.items.map(imageKey), ['0:a', '0:b', '0:c']);
|
||||
assert.equal(stream.state.items[0].favorite, true);
|
||||
stream.dispose();
|
||||
assert.equal(stream.updateItem('0:b', {favorite:false}), false);
|
||||
assert.equal(stream.state.items[1].favorite, true);
|
||||
});
|
||||
|
||||
test('withItem patches a saved browsing state without touching the other items', async () => {
|
||||
const { withItem } = await source;
|
||||
const items = Object.freeze([Object.freeze({...image('a'), favorite:false}), Object.freeze({...image('b'), favorite:false})]);
|
||||
const state = Object.freeze({items, total:2, cursor:null, hasMore:false, loading:false, error:null, loaded:2, positiveCount:null});
|
||||
const next = withItem(state, '0:b', {favorite:true});
|
||||
assert.ok(Object.isFrozen(next)); assert.equal(next.items[1].favorite, true); assert.equal(next.items[0], items[0]);
|
||||
assert.equal(state.items[1].favorite, false);
|
||||
assert.equal(withItem(state, '1:b', {favorite:true}), state);
|
||||
});
|
||||
|
||||
test('toggleSelection returns a new frozen set and leaves the given one untouched', async () => {
|
||||
const { toggleSelection } = await source;
|
||||
const empty = Object.freeze(new Set());
|
||||
const one = toggleSelection(empty, '0:a', true);
|
||||
assert.ok(Object.isFrozen(one)); assert.deepEqual([...one], ['0:a']); assert.equal(empty.size, 0);
|
||||
const two = toggleSelection(one, '1:a', true);
|
||||
assert.deepEqual([...two], ['0:a', '1:a']); assert.deepEqual([...one], ['0:a']);
|
||||
assert.equal(toggleSelection(two, '0:a', true), two, 'an already selected key keeps the same set');
|
||||
assert.equal(toggleSelection(one, '9:z', false), one, 'an unselected key keeps the same set');
|
||||
assert.deepEqual([...toggleSelection(two, '0:a', false)], ['1:a']);
|
||||
});
|
||||
|
||||
test('selectionLabel counts the selected images as the concept bar does', async () => {
|
||||
const { selectionLabel } = await source;
|
||||
assert.equal(selectionLabel(1), '1 selected');
|
||||
assert.equal(selectionLabel(2), '2 selected');
|
||||
assert.equal(selectionLabel(1234), '1,234 selected');
|
||||
});
|
||||
|
||||
@@ -225,6 +225,48 @@ test('record accepts closed reason codes and rejects contradictory ones', () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('resolution validator accepts closed reason codes and a bounded node record, rejects others', () => {
|
||||
const toRecord = contract('toInspectionRecord');
|
||||
for (const reason_code of ['unsupported-node', 'runtime-value', 'composite']) {
|
||||
for (const node of [{ id: '7', class_type: 'N'.repeat(200) }, { id: '7'.repeat(200), class_type: null }, null]) {
|
||||
const data = envelope(); Object.assign(data.resolution, { state: 'unresolved', reason_code, node });
|
||||
assert.doesNotThrow(() => toRecord(data, image(), extractGenerationFacts), JSON.stringify({ reason_code, node }));
|
||||
}
|
||||
}
|
||||
for (const change of [
|
||||
{ reason_code: 'unsupported-node', node: { id: '7', class_type: 'N'.repeat(201) } },
|
||||
{ reason_code: 'unsupported-node', node: { id: '7'.repeat(201), class_type: 'X' } },
|
||||
{ reason_code: 'unsupported-node', node: { id: '', class_type: 'X' } },
|
||||
{ reason_code: 'unsupported-node', node: { id: '7', class_type: 'X', extra: true } },
|
||||
{ reason_code: 'unsupported-node', node: [] },
|
||||
{ reason_code: 'malformed', node: { id: '7', class_type: 'X' } },
|
||||
{ reason_code: 'work-limit', node: { id: '7', class_type: 'X' } },
|
||||
{ reason_code: 'no-sampler', node: { id: '7', class_type: 'X' } },
|
||||
{ reason_code: 'Unsupported-Node', node: null },
|
||||
{ reason_code: '', node: null },
|
||||
]) {
|
||||
const data = envelope(); Object.assign(data.resolution, { state: 'unresolved', ...change });
|
||||
assert.throws(() => toRecord(data, image(), extractGenerationFacts), TypeError, JSON.stringify(change));
|
||||
}
|
||||
});
|
||||
|
||||
test('role outcome lines are bounded text on non-resolved roles only', () => {
|
||||
const toRecord = contract('toInspectionRecord');
|
||||
const line = 'Prompt uses a node the inspector does not support (<img src=x onerror=alert(1)>).';
|
||||
const data = envelope(); data.prompts.negative = { status: 'unresolved', text: null, source: null, message: line };
|
||||
assert.equal(toRecord(data, image(), extractGenerationFacts).prompts.negative.message, line);
|
||||
for (const [role, record] of [
|
||||
['positive', { status: 'resolved', text: 'exact', source: 'execution', message: 'Hidden reason.' }],
|
||||
['negative', { status: 'unresolved', text: null, source: null, message: '' }],
|
||||
['negative', { status: 'unresolved', text: null, source: null, message: 7 }],
|
||||
['negative', { status: 'unresolved', text: null, source: null, message: null }],
|
||||
['negative', { status: 'unresolved', text: null, source: null, message: 'x'.repeat(513) }],
|
||||
]) {
|
||||
const bad = envelope(); bad.prompts[role] = record;
|
||||
assert.throws(() => toRecord(bad, image(), extractGenerationFacts), TypeError, JSON.stringify(record));
|
||||
}
|
||||
});
|
||||
|
||||
test('record rejects root path media and invalid versioned fields with retryable TypeError', () => {
|
||||
const toRecord = contract('toInspectionRecord');
|
||||
for (const mutate of [
|
||||
@@ -422,3 +464,107 @@ test('rooted controller keeps legacy descriptors with ignored noncloneable field
|
||||
let pending;assert.doesNotThrow(()=>{pending=controller.select(selected);});
|
||||
requests[0].resolve(envelope());await pending;assert.equal(controller.state.record.identity.key,'0:folder/a #%.png');controller.dispose();
|
||||
});
|
||||
|
||||
// The maintainer's standard pipeline: one PromptManager pair feeds a base pass, an
|
||||
// upscale pass and a FaceDetailer, saved by a custom saver. Text and seeds are invented.
|
||||
function detailerPipelineGraph() {
|
||||
return {
|
||||
1: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'Base/example_model.safetensors' } },
|
||||
5: { class_type: 'KSampler', inputs: { seed: 111, steps: 4, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 1.0, model: ['47', 0], positive: ['25', 0], negative: ['26', 0], latent_image: ['20', 0] } },
|
||||
6: { class_type: 'VAEDecode', inputs: { samples: ['5', 0], vae: ['1', 2] } },
|
||||
7: { class_type: 'PreviewImage', inputs: { images: ['6', 0] } },
|
||||
8: { class_type: 'UpscaleModelLoader', inputs: { model_name: 'upscaler.pth' } },
|
||||
9: { class_type: 'ImageUpscaleWithModel', inputs: { upscale_model: ['8', 0], image: ['6', 0] } },
|
||||
10: { class_type: 'ImageScaleBy', inputs: { upscale_method: 'lanczos', scale_by: 0.75, image: ['9', 0] } },
|
||||
11: { class_type: 'VAEEncode', inputs: { pixels: ['10', 0], vae: ['1', 2] } },
|
||||
12: { class_type: 'KSampler', inputs: { seed: 222, steps: 6, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 0.45, model: ['47', 0], positive: ['25', 0], negative: ['26', 0], latent_image: ['11', 0] } },
|
||||
13: { class_type: 'VAEDecode', inputs: { samples: ['12', 0], vae: ['1', 2] } },
|
||||
17: { class_type: 'FaceDetailer', inputs: { seed: 333, steps: 4, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 0.4, image: ['13', 0], model: ['47', 0], clip: ['47', 1], vae: ['1', 2], positive: ['25', 0], negative: ['26', 0] } },
|
||||
20: { class_type: 'EmptyLatentBatch', inputs: { width: 640, height: 1536, batch_size: 1 } },
|
||||
25: { class_type: 'PromptManager', inputs: { text: 'a lighthouse at dusk, oil painting', category: '', tags: '', search_text: '', prepend_text: '', append_text: '', clip: ['47', 1] } },
|
||||
26: { class_type: 'PromptManager', inputs: { text: 'blurry, watermark', category: '', tags: '', search_text: '', prepend_text: '', append_text: '', clip: ['47', 1] } },
|
||||
47: { class_type: 'Lora Loader (LoraManager)', inputs: { text: '', model: ['1', 0], clip: ['1', 1] } },
|
||||
48: { class_type: 'KikoSaveImage', inputs: { filename_prefix: ['49', 0], format: 'PNG', images: ['17', 0] } },
|
||||
49: { class_type: 'KikoProjectFilePath', inputs: { project_name: 'example', filename: 'image' } },
|
||||
};
|
||||
}
|
||||
|
||||
test('workflow prompts and settings match the legacy parser when the server cannot decide', t => {
|
||||
const parser = require('../../web/js/comfy-metadata.js'), { extractGenerationParams } = parser;
|
||||
globalThis.ComfyMetadata = parser;
|
||||
t.after(() => { delete globalThis.ComfyMetadata; });
|
||||
const entry = image(), data = envelope(entry);
|
||||
data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'ambiguous', text: null, source: null } };
|
||||
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
|
||||
data.resolution = { state: 'ambiguous', sampler_id: null, reason: 'Multiple sampler branches' };
|
||||
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
|
||||
const legacy = extractGenerationParams({ prompt: detailerPipelineGraph() });
|
||||
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: legacy.positivePrompt, source: 'graph-inferred' });
|
||||
assert.deepEqual(record.prompts.negative, { status: 'resolved', text: legacy.negativePrompt, source: 'graph-inferred' });
|
||||
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
|
||||
assert.equal(record.prompts.negative.text, 'blurry, watermark');
|
||||
for (const [field, key] of [['seed', 'seed'], ['steps', 'steps'], ['cfg', 'cfgScale'], ['sampler', 'sampler'], ['model', 'checkpoint']]) {
|
||||
assert.equal(record.generation[field].status, 'known', field);
|
||||
assert.equal(record.generation[field].value, String(legacy[key]), field);
|
||||
}
|
||||
});
|
||||
|
||||
test('recorded and saved prompts are never replaced by the workflow parse', t => {
|
||||
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
|
||||
t.after(() => { delete globalThis.ComfyMetadata; });
|
||||
const entry = image(), data = envelope(entry);
|
||||
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
|
||||
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
|
||||
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' });
|
||||
assert.equal(record.prompts.negative.text, 'blurry, watermark');
|
||||
});
|
||||
|
||||
test('a definite server prompt is kept even when the legacy parser reads it differently', t => {
|
||||
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
|
||||
t.after(() => { delete globalThis.ComfyMetadata; });
|
||||
const entry = image(), data = envelope(entry);
|
||||
// The legacy parser follows ConditioningZeroOut back to the positive text node.
|
||||
const graph = detailerPipelineGraph();
|
||||
graph[30] = { class_type: 'ConditioningZeroOut', inputs: { conditioning: ['25', 0] } };
|
||||
graph[5].inputs.negative = ['30', 0];
|
||||
data.metadata.prompt_graph = { status: 'parsed', value: graph };
|
||||
data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'resolved', text: '', source: 'zeroed-conditioning' } };
|
||||
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
|
||||
assert.deepEqual(record.prompts.negative, { status: 'resolved', text: '', source: 'zeroed-conditioning' });
|
||||
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
|
||||
});
|
||||
|
||||
test('callers without the browser global pass the legacy parser explicitly', () => {
|
||||
const parser = require('../../web/js/comfy-metadata.js');
|
||||
const entry = image(), data = envelope(entry);
|
||||
data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'ambiguous', text: null, source: null } };
|
||||
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
|
||||
const identity = { kind: 'rooted-file', key: '0:x', root_index: 0, path: entry.path, root_scope: scopeA };
|
||||
const record = contract('projectInspectionPayload')(data, identity, null, extractGenerationFacts, false, parser);
|
||||
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
|
||||
assert.equal(record.prompts.negative.text, 'blurry, watermark');
|
||||
});
|
||||
|
||||
test('a setting the file does not record is never filled from the legacy parse', t => {
|
||||
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
|
||||
t.after(() => { delete globalThis.ComfyMetadata; });
|
||||
const entry = image(), data = envelope(entry);
|
||||
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
|
||||
const extract = input => ({ ...extractGenerationFacts(input), cfg: { status: 'not-recorded', value: null, source: null, evidence: null, reason: null, sampler_id: null } });
|
||||
const record = contract('toInspectionRecord')(data, entry, extract);
|
||||
assert.equal(record.generation.cfg.status, 'not-recorded');
|
||||
assert.equal(record.generation.cfg.value, null);
|
||||
});
|
||||
|
||||
test('a workflow the legacy parser throws on keeps the server result instead of failing the record', t => {
|
||||
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
|
||||
t.after(() => { delete globalThis.ComfyMetadata; });
|
||||
const entry = image(), data = envelope(entry);
|
||||
// The browser parser throws on a null workflow node (it reads properties of it).
|
||||
data.metadata.workflow_graph = { status: 'parsed', value: { nodes: [null], links: [] } };
|
||||
data.metadata.raw.workflow = '{"nodes":[null],"links":[]}';
|
||||
data.metadata.record_formats.workflow = 'reserialized-json';
|
||||
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
|
||||
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' });
|
||||
assert.equal(record.prompts.negative.status, 'ambiguous');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { libraryTitle } = await import('../../web/js/workspace/library.mjs').catch(() => ({}));
|
||||
|
||||
test('library title counts UTF-16 units and never splits a surrogate pair', () => {
|
||||
assert.equal(typeof libraryTitle, 'function', 'libraryTitle is exported');
|
||||
assert.equal(libraryTitle(' short line \nsecond'), 'short line');
|
||||
assert.equal(libraryTitle('a'.repeat(80)), 'a'.repeat(80));
|
||||
assert.equal(libraryTitle('a'.repeat(81)), `${'a'.repeat(80)}…`);
|
||||
// 80 emoji are 160 UTF-16 units: 40 fit.
|
||||
assert.equal(libraryTitle('😀'.repeat(80)), `${'😀'.repeat(40)}…`);
|
||||
// 79 units then an emoji: the emoji would straddle the limit, so it is dropped whole.
|
||||
assert.equal(libraryTitle(`${'a'.repeat(79)}😀b`), `${'a'.repeat(79)}…`);
|
||||
});
|
||||
@@ -89,7 +89,7 @@ test('occurrence Viewer retains exact caller selection and synchronously closes
|
||||
const media=f.walk(f.container,n=>n.tagName==='img')[0];assert.match(media.src,/\/history\/runs\/.*\/output\/current\/media\?/);assert.match(media.src,/_pm_viewer=/);assert.doesNotMatch(media.src,/root=/);
|
||||
const download=f.walk(f.container,n=>n.attributes['aria-label']==='Download original')[0];assert.equal(download.href,items[0].url);
|
||||
f.walk(f.container,n=>n.attributes['aria-label']==='Next media')[0].fire('click');assert.equal(selections.at(-1).output_node_id,'2');
|
||||
f.walk(f.container,n=>n.attributes['aria-label']==='Close preview')[0].fire('click');assert.deepEqual(closes,[[]]);assert.equal(controller.disposed,0);
|
||||
f.walk(f.container,n=>n.attributes['aria-label']==='Close image viewer')[0].fire('click');assert.deepEqual(closes,[[]]);assert.equal(controller.disposed,0);
|
||||
const count=selections.length;media.fire('load');controller.publish({loading:true});assert.equal(selections.length,count);
|
||||
view.dispose();view.open({items,selectedKey:'c'.repeat(64)});assert.equal(f.container.children.length,0);assert.equal(controller.disposed,0);
|
||||
} finally {view.dispose();parentScope.dispose();f.restore();}
|
||||
@@ -106,7 +106,7 @@ test('slideshow stops for detail reading nested send close and parent disposal w
|
||||
const record={identity:{kind:'reported-location',key:'c'.repeat(64),path:'a.png',occurrence:items[0].occurrence,reference:items[0].reference,history_epoch:items[0].history_epoch,current_scope:items[0].current_scope},file:null,library:null,metadata:null,generation:{},prompts:{positive:{status:'resolved',text:'current'},negative:{status:'resolved',text:''}},download:{url:items[0].url,filename:'a.png'}};
|
||||
controller.publish({loading:false,error:false,record});assert.ok(f.walk(f.container,n=>n.textContent==='Current file at the reported location · identity unverified').length);
|
||||
find('Play slideshow').fire('click');const pair=f.walk(f.container,n=>n.dataset.sendRole==='pair')[0];pair.fire('click');assert.equal(sends,1);assert.ok(find('Play slideshow'));
|
||||
find('Play slideshow').fire('click');find('Close preview').fire('click');assert.equal(closes,1);assert.equal(controller.listeners,0);media.fire('load');
|
||||
find('Play slideshow').fire('click');find('Close image viewer').fire('click');assert.equal(closes,1);assert.equal(controller.listeners,0);media.fire('load');
|
||||
view.open({items,selectedKey:'c'.repeat(64)});media=f.walk(f.container,n=>n.tagName==='img')[0];parentScope.dispose();assert.equal(closes,1,'parent disposal suppresses onClose');assert.equal(f.container.children.length,0);media.fire('load');assert.equal(controller.disposed,0);
|
||||
} finally {view.dispose();parentScope.dispose();f.restore();}
|
||||
});
|
||||
@@ -152,9 +152,96 @@ test('real inspection navigation preserves slideshow across two advances and def
|
||||
find('Play slideshow').fire('click');f.walk(f.container,n=>n.tagName==='aside')[0].fire('click',{target:retryButton});retryButton.fire('click');
|
||||
assert.ok(find('Play slideshow'),'explicit retry interaction stops intent');assert.equal(controller.state.loading,true);assert.equal(timers.size,0);
|
||||
calls.at(-1).resolve(currentInspectionEnvelope(items[2]));await new Promise(resolve=>setImmediate(resolve));
|
||||
find('Play slideshow').fire('click');const lateTimer=[...timers.values()][0];find('Close preview').fire('click');assert.equal(closed,1);assert.equal(timers.size,0);
|
||||
find('Play slideshow').fire('click');const lateTimer=[...timers.values()][0];find('Close image viewer').fire('click');assert.equal(closed,1);assert.equal(timers.size,0);
|
||||
lateTimer();assert.equal(selected.length,3);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3','closing keeps the borrowed controller live');
|
||||
view.open({items,selectedKey:items[2].location_key});const lateMedia=image();lateMedia.fire('load');find('Play slideshow').fire('click');parentScope.dispose();assert.equal(timers.size,0);assert.equal(closed,1);lateMedia.fire('load');
|
||||
assert.equal(f.container.children.length,0);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3');
|
||||
} finally {view.dispose();controller.dispose();parentScope.dispose();globalThis.setTimeout=oldSetTimeout;globalThis.clearTimeout=oldClearTimeout;f.restore();}
|
||||
});
|
||||
|
||||
test('an inline Viewer still loading its image is destroyed only after it has viewed it', async () => {
|
||||
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
|
||||
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c')];
|
||||
const instances = [];
|
||||
// Viewer.js's inline destroy() leaves an in-flight view running; its callback then needs a live instance.
|
||||
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.viewed = false; instances.push(this); }
|
||||
destroy() { this.destroyed++; } resize() {} };
|
||||
const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame};
|
||||
Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}});
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
|
||||
try {
|
||||
view.open({items,selectedKey:'c'.repeat(64)});
|
||||
const [loading] = instances, image = loading.node, removed = [];
|
||||
image.removeAttribute = key => removed.push(key);
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click');
|
||||
assert.equal(loading.destroyed, 0, 'not destroyed while its image view is pending');
|
||||
// Viewer.js lists only images that still have a src; the pending view needs it.
|
||||
assert.deepEqual(removed, [], 'the image keeps its src while the view is pending');
|
||||
loading.viewed = true; loading.options.viewed.call(loading);
|
||||
assert.equal(loading.destroyed, 1, 'destroyed once its pending view settles');
|
||||
assert.deepEqual(removed, ['src'], 'the src is released after the viewer');
|
||||
loading.options.viewed.call(loading);
|
||||
assert.equal(loading.destroyed, 1, 'never destroyed twice');
|
||||
|
||||
view.open({items,selectedKey:'c'.repeat(64)});
|
||||
const shown = instances[1]; shown.viewed = true; shown.options.viewed.call(shown);
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click');
|
||||
assert.equal(shown.destroyed, 1, 'a viewed image is destroyed at once');
|
||||
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
|
||||
});
|
||||
|
||||
test('viewer renders the concept shell and toolbar order', async () => {
|
||||
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
|
||||
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
|
||||
try {
|
||||
view.open({items,selectedKey:'c'.repeat(64)});
|
||||
const [dialog] = f.container.children;
|
||||
assert.equal(dialog.tagName,'dialog');assert.equal(dialog.id,'viewer-dialog');assert.equal(dialog.attributes['aria-labelledby'],'viewer-title');
|
||||
assert.equal(dialog.attributes['aria-label'],undefined,'the title names the dialog');
|
||||
const [header,layout] = dialog.children;
|
||||
assert.equal(header.className,'dialog-header');
|
||||
const [heading,close] = header.children, [title,subtitle] = heading.children;
|
||||
assert.deepEqual([heading.tagName,title.tagName,title.id,title.textContent],['div','h2','viewer-title','a.png']);
|
||||
assert.deepEqual([subtitle.tagName,subtitle.className,subtitle.textContent],['p','muted small','One viewer · image, prompts, generation data, workflow']);
|
||||
assert.deepEqual([close.tagName,close.id,close.textContent,close.attributes['aria-label']],['button','viewer-close','×','Close image viewer']);
|
||||
assert.equal(layout.className,'viewer-layout');
|
||||
const [left,details] = layout.children;
|
||||
assert.equal(left.className,'viewer-left');
|
||||
assert.deepEqual([details.tagName,details.id,details.attributes['aria-label']],['aside','viewer-details','Image metadata']);
|
||||
const [stage,toolbar] = left.children;
|
||||
assert.deepEqual([stage.tagName,stage.id,toolbar.tagName,toolbar.id,toolbar.className],['div','viewer-stage','div','viewer-toolbar','viewer-toolbar']);
|
||||
// #249 D34 #8: the toolbar keeps production's named group (the concept's has no role; adding one is invisible).
|
||||
assert.deepEqual([toolbar.attributes.role,toolbar.attributes['aria-label']],['group','Media controls']);
|
||||
// With more than one loaded item, Previous/Next are enabled and not described by the position line.
|
||||
for (const label of ['Previous media','Next media']) {
|
||||
const [node] = f.walk(f.container,n => n.attributes['aria-label'] === label);
|
||||
assert.deepEqual([node.attributes['aria-disabled'],node.attributes['aria-describedby']],['false',undefined],label);
|
||||
}
|
||||
// The concept's toolbar order, then production's position status and its two reasons.
|
||||
assert.deepEqual(toolbar.children.map(node => node.textContent.split(':')[0]),
|
||||
['← Previous','−','+','Fit','Rotate','Flip','Play','Download','Next →','1 of 2 loaded','Image transforms are unavailable because Viewer.js could not load.','']);
|
||||
assert.deepEqual(toolbar.children.slice(9).map(node => [node.tagName,node.attributes.role ?? null]),[['p','status'],['p',null],['p',null]]);
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Next media')[0].fire('click');
|
||||
assert.equal(toolbar.children[9].textContent,'2 of 2 loaded: a.png');
|
||||
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
|
||||
});
|
||||
|
||||
test('viewer titles a linked card "<title> · <filename>" before any inspection arrives', async () => {
|
||||
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
|
||||
const f = mountedDom(), parentScope = createScope(), controller = borrowedController();
|
||||
const linked = {...occurrenceMedia('1',1,'c'),linked:{status:'linked',title:'Quiet geometry'}};
|
||||
const unlinked = {...occurrenceMedia('2',0,'d'),linked:{status:'unlinked',title:'Ignored'}};
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
|
||||
try {
|
||||
view.open({items:[linked,unlinked],selectedKey:'c'.repeat(64)});
|
||||
const title = f.walk(f.container,n=>n.id==='viewer-title')[0];
|
||||
assert.equal(title.textContent,`Quiet geometry · ${linked.filename}`);
|
||||
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
|
||||
const g = mountedDom(), scope = createScope();
|
||||
const other = module.createMediaViewer({container:g.container,parentScope:scope,inspectionController:borrowedController(),adapter,sender:{open(){}}});
|
||||
try {
|
||||
other.open({items:[linked,unlinked],selectedKey:'d'.repeat(64)});
|
||||
assert.equal(g.walk(g.container,n=>n.id==='viewer-title')[0].textContent,unlinked.filename);
|
||||
} finally { other.dispose(); scope.dispose(); g.restore(); }
|
||||
});
|
||||
|
||||
@@ -76,3 +76,74 @@ test('positive library deep links preserve the selected identity without duplica
|
||||
win.location.hash='#/library';win.dispatchEvent(new Event('hashchange'));
|
||||
assert.deepEqual(seen,[['library',12],['library',13],['library',undefined]]);dispose();
|
||||
});
|
||||
|
||||
test('nav counts reject negative, non-integer and missing totals', async () => {
|
||||
const { summarizeNavCounts } = await source;
|
||||
const counts = summarizeNavCounts({images: {total: 24}, library: {total: 0}, tags: {total: 7}});
|
||||
assert.ok(counts instanceof Map);
|
||||
assert.deepEqual([...counts], [['images', 24], ['library', 0], ['tags', 7]]);
|
||||
assert.equal(counts.has('inspect'), false, 'Inspect a file has no count');
|
||||
for (const invalid of [undefined, null, {}, {total: -1}, {total: 1.5}, {total: '3'}, {total: NaN},
|
||||
{total: Infinity}, {total: Number.MAX_SAFE_INTEGER + 1}, {total: null}, [], 'x']) {
|
||||
const result = summarizeNavCounts({images: invalid, library: {total: 2}, tags: invalid});
|
||||
assert.equal(result.get('images'), null, JSON.stringify(invalid));
|
||||
assert.equal(result.get('library'), 2);
|
||||
assert.equal(result.get('tags'), null);
|
||||
}
|
||||
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['library', null], ['tags', null]]);
|
||||
});
|
||||
|
||||
test('nav counts settle independently so a slow images read never delays the others', async () => {
|
||||
const { loadNavCounts } = await source;
|
||||
let releaseImages;
|
||||
const images = new Promise(resolve => { releaseImages = resolve; });
|
||||
const api = {get: async url => {
|
||||
if (url.startsWith('/prompt_manager/images')) return images;
|
||||
if (url.startsWith('/prompt_manager/library')) return {success: true, total: 3};
|
||||
throw new Error('Unavailable');
|
||||
}};
|
||||
const settled = [];
|
||||
const done = loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.deepEqual(settled.sort(), [['library', 3], ['tags', null]]);
|
||||
releaseImages({success: true, total: 40});
|
||||
const counts = await done;
|
||||
assert.deepEqual(settled.at(-1), ['images', 40]);
|
||||
assert.deepEqual([...counts], [['images', 40], ['library', 3], ['tags', null]]);
|
||||
});
|
||||
|
||||
test('nav counts issue three bounded requests and drop only the failed count', async () => {
|
||||
const { loadNavCounts } = await source;
|
||||
const calls = [];
|
||||
const signal = new AbortController().signal;
|
||||
const api = {get: async (url, options) => {
|
||||
calls.push([url, options.signal]);
|
||||
if (url.startsWith('/prompt_manager/library')) throw new Error('Unavailable');
|
||||
if (url.startsWith('/prompt_manager/vocabulary/tags')) return {success: true, total: 5};
|
||||
return {success: true, total: 12};
|
||||
}};
|
||||
const counts = await loadNavCounts(api, signal);
|
||||
assert.deepEqual([...counts], [['images', 12], ['library', null], ['tags', 5]]);
|
||||
const urls = calls.map(([url]) => new URL(url, 'http://host'));
|
||||
assert.deepEqual(urls.map(url => url.pathname),
|
||||
['/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
|
||||
for (const url of urls) assert.equal(url.searchParams.get('limit'), '1');
|
||||
assert.equal(urls[0].searchParams.get('mode'), 'workspace');
|
||||
assert.ok(calls.every(([, used]) => used === signal));
|
||||
});
|
||||
|
||||
test('a Library or vocabulary write re-reads only the Library and Tags counts', async () => {
|
||||
const { loadNavCounts, LIBRARY_WRITE_COUNTS } = await source;
|
||||
assert.deepEqual([...LIBRARY_WRITE_COUNTS], ['library', 'tags']);
|
||||
assert.ok(Object.isFrozen(LIBRARY_WRITE_COUNTS));
|
||||
const calls = [];
|
||||
const api = {get: async url => {
|
||||
calls.push(new URL(url, 'http://host').pathname);
|
||||
return {success: true, total: url.startsWith('/prompt_manager/library') ? 4 : 9};
|
||||
}};
|
||||
const settled = [];
|
||||
const counts = await loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]), LIBRARY_WRITE_COUNTS);
|
||||
assert.deepEqual(calls, ['/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
|
||||
assert.deepEqual(settled.sort(), [['library', 4], ['tags', 9]]);
|
||||
assert.deepEqual([...counts], [['library', 4], ['tags', 9]]);
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ function fixture({ dark = false, preference, fetchImpl, storageDenied = false, p
|
||||
const response = value => ({ ok: true, json: async () => value });
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
test('follow uses host palette over OS and preserves reverse proxy prefix', async () => {
|
||||
test('system uses host palette over OS and preserves reverse proxy prefix', async () => {
|
||||
const f = fixture({ pathname: '/proxy/comfy/prompt_manager/workspace' });
|
||||
await f.theme.ready;
|
||||
assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
@@ -41,13 +41,14 @@ test('supported light palettes accept string and wrapped values', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit overrides persist and follow restores host choice', async () => {
|
||||
test('explicit overrides persist and system restores host choice', async () => {
|
||||
const f = fixture({ preference: 'light' }); await f.theme.ready;
|
||||
assert.equal(f.root.dataset.caTheme, 'light');
|
||||
f.theme.setPreference('dark'); assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
assert.equal(f.saved.get('pm_workspace_theme'), 'dark');
|
||||
f.theme.setPreference('follow'); assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
assert.equal(f.saved.get('pm_workspace_theme'), 'follow');
|
||||
f.theme.setPreference('system'); assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
assert.equal(f.saved.get('pm_workspace_theme'), 'system');
|
||||
assert.throws(() => f.theme.setPreference('follow'), /preference/i);
|
||||
assert.throws(() => f.theme.setPreference('orange'), /preference/i);
|
||||
f.scope.dispose();
|
||||
});
|
||||
@@ -132,7 +133,7 @@ test('blocked localStorage getter and missing proxy marker stay usable', async (
|
||||
let url;
|
||||
const theme = mountWorkspaceTheme({ root: f.root, win: f.win, scope,
|
||||
fetchImpl: async value => { url = value; return response(' Obsidian '); } });
|
||||
assert.equal(theme.preference, 'follow');
|
||||
assert.equal(theme.preference, 'system');
|
||||
await theme.ready;
|
||||
assert.equal(url, '/api/settings/Comfy.ColorPalette');
|
||||
assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
@@ -140,3 +141,18 @@ test('blocked localStorage getter and missing proxy marker stay usable', async (
|
||||
assert.equal(theme.preference, 'light');
|
||||
scope.dispose();
|
||||
});
|
||||
|
||||
test('stored follow migrates to system and system follows the host palette', async () => {
|
||||
const f = fixture({ preference: 'follow', fetchImpl: async () => response('light') });
|
||||
assert.equal(f.theme.preference, 'system');
|
||||
await f.theme.ready;
|
||||
assert.equal(f.root.dataset.caTheme, 'light');
|
||||
f.media.matches = true; f.media.dispatchEvent(new Event('change'));
|
||||
assert.equal(f.root.dataset.caTheme, 'light', 'a known host palette wins over the OS');
|
||||
f.scope.dispose();
|
||||
const unknown = fixture({ preference: 'follow', dark: true, fetchImpl: async () => response('custom') });
|
||||
await unknown.theme.ready;
|
||||
assert.equal(unknown.theme.preference, 'system');
|
||||
assert.equal(unknown.root.dataset.caTheme, 'dark', 'an unknown host palette falls back to the OS');
|
||||
unknown.scope.dispose();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
// The shared concept toolbar and dialogs (#249 C1): concept app.js toolbar() a347–360, "Refine this view"
|
||||
// a1348–1364, "Import & inspect" a1483–1491 (D15) and the "Copy prompt" fallback a1223–1241.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const toolbarModule = import('../../web/js/workspace/toolbar.mjs');
|
||||
const dialogsModule = import('../../web/js/workspace/workspace-dialogs.mjs');
|
||||
|
||||
function optionsCollection(list) {
|
||||
const collection = { length: list.length, item: index => list[index] ?? null, [Symbol.iterator]: () => list[Symbol.iterator]() };
|
||||
list.forEach((option, index) => { collection[index] = option; });
|
||||
return Object.freeze(collection);
|
||||
}
|
||||
|
||||
/** A minimal DOM: enough element behavior for markup, events, focus and dialogs. */
|
||||
function fakeDOM() {
|
||||
const previous = { document: global.document };
|
||||
let active = null;
|
||||
class Element extends EventTarget {
|
||||
constructor(tag) {
|
||||
super();
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.attributes = {};
|
||||
this.parentNode = null;
|
||||
this.className = '';
|
||||
this.id = '';
|
||||
this.value = '';
|
||||
this.disabled = false;
|
||||
this.open = false;
|
||||
this.selected = false;
|
||||
this.selectionStart = 0;
|
||||
this.selectionEnd = 0;
|
||||
this._text = '';
|
||||
}
|
||||
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
|
||||
set textContent(text) { this.children = []; this._text = String(text); }
|
||||
get isConnected() { return this === body || Boolean(this.parentNode?.isConnected); }
|
||||
// Like a browser's HTMLOptionsCollection: indexed and iterable, but not an Array (no .some/.map).
|
||||
get options() { return optionsCollection(this.children.filter(child => child.tagName === 'OPTION')); }
|
||||
append(...nodes) {
|
||||
for (const node of nodes) {
|
||||
node.parentNode?.children.splice(node.parentNode.children.indexOf(node), 1);
|
||||
node.parentNode = this;
|
||||
this.children.push(node);
|
||||
}
|
||||
}
|
||||
replaceChildren(...nodes) {
|
||||
for (const child of this.children) child.parentNode = null;
|
||||
this.children = [];
|
||||
this._text = '';
|
||||
this.append(...nodes);
|
||||
}
|
||||
remove() {
|
||||
if (!this.parentNode) return;
|
||||
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
|
||||
this.parentNode = null;
|
||||
}
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||||
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
|
||||
removeAttribute(name) { delete this.attributes[name]; }
|
||||
focus() { active = this; }
|
||||
select() { this.selectionStart = 0; this.selectionEnd = this.value.length; }
|
||||
showModal() { this.open = true; }
|
||||
close() {
|
||||
if (!this.open) return;
|
||||
this.open = false;
|
||||
// Browsers queue the close event as a later task.
|
||||
setTimeout(() => this.dispatchEvent(new Event('close')), 0);
|
||||
}
|
||||
click() { this.dispatchEvent(new Event('click', { cancelable: true })); }
|
||||
}
|
||||
const body = new Element('body');
|
||||
global.document = {
|
||||
body,
|
||||
get activeElement() { return active ?? body; },
|
||||
createElement: tag => new Element(tag),
|
||||
createElementNS: (namespace, tag) => new Element(tag),
|
||||
};
|
||||
const all = (root, predicate, found = []) => {
|
||||
if (predicate(root)) found.push(root);
|
||||
for (const child of root.children) all(child, predicate, found);
|
||||
return found;
|
||||
};
|
||||
return {
|
||||
body, Element,
|
||||
all: predicate => all(body, predicate),
|
||||
find: (root, predicate) => all(root, predicate)[0],
|
||||
restore() { Object.assign(global, previous); },
|
||||
};
|
||||
}
|
||||
|
||||
const byId = (dom, id) => dom.all(node => node.id === id)[0];
|
||||
const tags = node => node.children.map(child => child.tagName.toLowerCase());
|
||||
const optionPairs = select => [...select.options].map(option => [option.value, option.textContent]);
|
||||
const modal = dom => byId(dom, 'modal');
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 5));
|
||||
const coreModule = import('../../web/js/shared/toolkit/core.js');
|
||||
const button = (root, text) => {
|
||||
const found = [];
|
||||
const walk = node => {
|
||||
if (node.tagName === 'BUTTON' && node.textContent === text) found.push(node);
|
||||
node.children.forEach(walk);
|
||||
};
|
||||
walk(root);
|
||||
return found[0];
|
||||
};
|
||||
|
||||
test('toolbar emits concept markup and order', async () => {
|
||||
const { mountToolbar } = await toolbarModule;
|
||||
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
const container = new dom.Element('div');
|
||||
dom.body.append(container);
|
||||
const changes = [];
|
||||
const filters = [];
|
||||
const toolbar = mountToolbar(container, {
|
||||
scope, categories: ['Portraits', 'Landscapes'],
|
||||
sorts: [['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated']],
|
||||
value: { q: 'castle', category: 'Landscapes', sort: 'name' },
|
||||
onChange: value => changes.push(value), onFilters: trigger => filters.push(trigger),
|
||||
});
|
||||
const root = container.children[0];
|
||||
assert.equal(toolbar.element, root);
|
||||
assert.equal(root.tagName, 'SEARCH');
|
||||
assert.equal(root.className, 'toolbar');
|
||||
assert.deepEqual(tags(root), ['label', 'select', 'select', 'button']);
|
||||
|
||||
const [label, category, sort, filtersButton] = root.children;
|
||||
assert.equal(label.className, 'search-field');
|
||||
assert.equal(label.htmlFor, 'search');
|
||||
assert.deepEqual(tags(label), ['svg', 'input']);
|
||||
assert.equal(label.children[0].getAttribute('class'), 'icon');
|
||||
assert.equal(label.children[0].getAttribute('aria-hidden'), 'true');
|
||||
const input = label.children[1];
|
||||
assert.equal(input.id, 'search');
|
||||
assert.equal(input.type, 'search');
|
||||
assert.equal(input.placeholder, 'Search prompts, tags, filenames…');
|
||||
// The listing API rejects search text over 500 characters, as the legacy search capped it.
|
||||
assert.equal(input.maxLength, 500);
|
||||
assert.equal(input.getAttribute('aria-label'), 'Search workspace');
|
||||
assert.equal(input.value, 'castle');
|
||||
|
||||
assert.equal(category.id, 'category-filter');
|
||||
assert.equal(category.getAttribute('aria-label'), 'Filter by category');
|
||||
assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Portraits', 'Portraits'], ['Landscapes', 'Landscapes']]);
|
||||
assert.equal(category.value, 'Landscapes');
|
||||
assert.equal(sort.id, 'sort');
|
||||
assert.equal(sort.getAttribute('aria-label'), 'Sort results');
|
||||
assert.equal(sort.value, 'name');
|
||||
|
||||
// D16: no Save view until saved views are persisted.
|
||||
assert.equal(button(root, 'Save view'), undefined);
|
||||
assert.equal(filtersButton.textContent, 'Filters');
|
||||
assert.equal(filtersButton.type, 'button');
|
||||
filtersButton.click();
|
||||
assert.deepEqual(filters, [filtersButton]);
|
||||
|
||||
category.value = '';
|
||||
category.dispatchEvent(new Event('change'));
|
||||
sort.value = 'rating';
|
||||
sort.dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(changes, [
|
||||
{ q: 'castle', category: '', sort: 'name' },
|
||||
{ q: 'castle', category: '', sort: 'rating' },
|
||||
]);
|
||||
|
||||
// Categories refresh without dropping a still-listed selection.
|
||||
toolbar.setCategories(['Archive']);
|
||||
assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Archive', 'Archive']]);
|
||||
toolbar.setValue({ q: 'moon', category: 'Archive', sort: 'newest' });
|
||||
assert.deepEqual([input.value, category.value, sort.value], ['moon', 'Archive', 'newest']);
|
||||
assert.deepEqual(toolbar.value(), { q: 'moon', category: 'Archive', sort: 'newest' });
|
||||
// A still-listed selection survives a category refresh.
|
||||
toolbar.setCategories(['Portraits', 'Archive']);
|
||||
assert.equal(category.value, 'Archive');
|
||||
assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Portraits', 'Portraits'], ['Archive', 'Archive']]);
|
||||
// A selection that is no longer listed drops to All categories.
|
||||
toolbar.setCategories(['Portraits']);
|
||||
assert.equal(category.value, '');
|
||||
// An unknown sort falls back to the first option instead of an empty select.
|
||||
toolbar.setValue({ sort: 'bogus' });
|
||||
assert.equal(sort.value, 'newest');
|
||||
assert.throws(() => mountToolbar(container, { categories: [], sorts: [['newest', 'Recently added']],
|
||||
onChange() {}, onFilters() {} }), TypeError);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('toolbar search uses a debounced onChange that stops with its scope', async () => {
|
||||
const { mountToolbar, SEARCH_DELAY } = await toolbarModule;
|
||||
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
const container = new dom.Element('div');
|
||||
const changes = [];
|
||||
mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||||
value: { q: '', category: '', sort: 'newest' }, onChange: value => changes.push(value), onFilters() {} });
|
||||
const input = container.children[0].children[0].children[1];
|
||||
assert.equal(input.id, 'search');
|
||||
for (const text of ['c', 'ca', 'cat']) {
|
||||
input.value = text;
|
||||
input.dispatchEvent(new Event('input'));
|
||||
}
|
||||
assert.deepEqual(changes, []);
|
||||
await new Promise(resolve => setTimeout(resolve, SEARCH_DELAY + 30));
|
||||
assert.deepEqual(changes, [{ q: 'cat', category: '', sort: 'newest' }]);
|
||||
input.value = 'dog';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
scope.dispose();
|
||||
await new Promise(resolve => setTimeout(resolve, SEARCH_DELAY + 30));
|
||||
assert.equal(changes.length, 1);
|
||||
assert.equal(container.children.length, 0);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('sort lists concept options first', async () => {
|
||||
const { mountToolbar, orderSorts } = await toolbarModule;
|
||||
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
|
||||
// D14: the concept's three, in the concept's order, then the page's appended options in their own order.
|
||||
const given = [['newest', 'Recently added'], ['oldest', 'Oldest first'], ['name-desc', 'Filename Z–A'],
|
||||
['rating', 'Highest rated'], ['name', 'Name A–Z']];
|
||||
assert.deepEqual(orderSorts(given), [['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated'],
|
||||
['oldest', 'Oldest first'], ['name-desc', 'Filename Z–A']]);
|
||||
// A page without a concept option keeps the ones it has, concept first.
|
||||
assert.deepEqual(orderSorts([['oldest', 'Oldest first'], ['newest', 'Recently added']]),
|
||||
[['newest', 'Recently added'], ['oldest', 'Oldest first']]);
|
||||
// Matching is by the concept's exact labels (en dash): a variant label is an appended option.
|
||||
assert.deepEqual(orderSorts([['name', 'Name A-Z'], ['newest', 'Recently added']]),
|
||||
[['newest', 'Recently added'], ['name', 'Name A-Z']]);
|
||||
assert.throws(() => orderSorts([]), TypeError);
|
||||
assert.throws(() => orderSorts([['newest']]), TypeError);
|
||||
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
const container = new dom.Element('div');
|
||||
mountToolbar(container, { scope, categories: [], sorts: given, value: { q: '', category: '', sort: 'oldest' },
|
||||
onChange() {}, onFilters() {} });
|
||||
const sort = container.children[0].children[2];
|
||||
assert.deepEqual(optionPairs(sort).map(([, label]) => label),
|
||||
['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Filename Z–A']);
|
||||
assert.equal(sort.value, 'oldest');
|
||||
} finally {
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
const REFINE_FIELDS = [
|
||||
{ key: 'source', options: [['all', 'All sources'], ['0', 'output'], ['1', 'archive']] },
|
||||
{ key: 'folder', options: [['', 'All output folders'], ['portraits', 'portraits']] },
|
||||
{ key: 'media', options: [['all', 'All media'], ['image', 'Images'], ['video', 'Videos']] },
|
||||
{ key: 'tags' },
|
||||
];
|
||||
|
||||
test('refine dialog round-trips values and clear resets them', async () => {
|
||||
const { openRefineDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const trigger = new dom.Element('button');
|
||||
dom.body.append(trigger);
|
||||
const applied = [];
|
||||
const cleared = [];
|
||||
const values = { source: '1', folder: 'portraits', media: 'video', tags: 'cinematic' };
|
||||
openRefineDialog({ scope, fields: REFINE_FIELDS, values, trigger,
|
||||
onApply: value => applied.push(value), onClear: value => cleared.push(value) });
|
||||
const dialog = modal(dom);
|
||||
assert.equal(dialog.open, true);
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Refine this view');
|
||||
const body = byId(dom, 'modal-body');
|
||||
const fields = body.children.filter(child => child.className === 'field');
|
||||
assert.deepEqual(fields.map(field => field.children[0].textContent), ['Source', 'Folder', 'Media', 'Tags']);
|
||||
assert.deepEqual(fields.map(field => field.children[0].htmlFor), ['filter-source', 'filter-folder', 'filter-media', 'filter-tags']);
|
||||
assert.deepEqual(fields.map(field => field.children[1].id), ['filter-source', 'filter-folder', 'filter-media', 'filter-tags']);
|
||||
const [source, folder, media, tagsInput] = fields.map(field => field.children[1]);
|
||||
assert.deepEqual(optionPairs(source), REFINE_FIELDS[0].options);
|
||||
assert.deepEqual([source.value, folder.value, media.value, tagsInput.value], ['1', 'portraits', 'video', 'cinematic']);
|
||||
assert.equal(tagsInput.placeholder, 'e.g. cinematic');
|
||||
// D14: no mock-status note, because every field is a real filter.
|
||||
assert.deepEqual(tags(body), ['div', 'div', 'div', 'div', 'div']);
|
||||
assert.equal(body.children.some(child => child.tagName === 'P'), false);
|
||||
const actions = body.children[4];
|
||||
assert.equal(actions.className, 'dialog-actions');
|
||||
assert.deepEqual(actions.children.map(child => [child.textContent, child.className]),
|
||||
[['Clear filters', ''], ['Apply filters', 'ca-primary']]);
|
||||
|
||||
// Apply returns the values as edited and leaves the caller's object alone.
|
||||
media.value = 'image';
|
||||
tagsInput.value = ' moody ';
|
||||
button(body, 'Apply filters').click();
|
||||
assert.deepEqual(applied, [{ source: '1', folder: 'portraits', media: 'image', tags: 'moody' }]);
|
||||
assert.equal(values.media, 'video');
|
||||
assert.equal(dialog.open, false);
|
||||
assert.equal(trigger.isConnected && document.activeElement, trigger);
|
||||
|
||||
// Reopening with the applied values shows them again (a round trip).
|
||||
openRefineDialog({ scope, fields: REFINE_FIELDS, values: applied[0], trigger, onApply: value => applied.push(value), onClear() {} });
|
||||
button(byId(dom, 'modal-body'), 'Apply filters').click();
|
||||
assert.deepEqual(applied[1], applied[0]);
|
||||
|
||||
// Clear resets every field to its first option (Tags to empty) and reports that.
|
||||
openRefineDialog({ scope, fields: REFINE_FIELDS, values: applied[0], trigger, onApply: value => applied.push(value),
|
||||
onClear: value => cleared.push(value) });
|
||||
const reopened = byId(dom, 'modal-body');
|
||||
button(reopened, 'Clear filters').click();
|
||||
// The concept's Clear also clears the toolbar category; the page applies `category`.
|
||||
assert.deepEqual(cleared, [{ source: 'all', folder: '', media: 'all', tags: '', category: '' }]);
|
||||
assert.equal(applied.length, 2);
|
||||
assert.equal(modal(dom).open, false);
|
||||
|
||||
// A page offers only its own fields; the modal is the one shared shell.
|
||||
openRefineDialog({ scope, fields: [{ key: 'tags' }], values: {}, onApply: value => applied.push(value), onClear() {} });
|
||||
const only = byId(dom, 'modal-body').children.filter(child => child.className === 'field');
|
||||
assert.deepEqual(only.map(field => field.children[0].textContent), ['Tags']);
|
||||
button(byId(dom, 'modal-body'), 'Apply filters').click();
|
||||
assert.deepEqual(applied[2], { tags: '' });
|
||||
assert.equal(dom.all(node => node.id === 'modal').length, 1);
|
||||
assert.throws(() => openRefineDialog({ scope, fields: [{ key: 'evidence' }], values: {}, onApply() {}, onClear() {} }), TypeError);
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the Folder list follows the chosen Source', async () => {
|
||||
const { openRefineDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const bySource = { all: ['portraits', 'temp-only'], 0: ['portraits'], 1: ['temp-only'] };
|
||||
const optionsFor = source => [['', 'All output folders'], ...(bySource[source] ?? []).map(path => [path, path])];
|
||||
const fields = [REFINE_FIELDS[0], { key: 'folder', options: optionsFor('all'), optionsFor }, REFINE_FIELDS[2]];
|
||||
const applied = [];
|
||||
openRefineDialog({ scope, fields, values: { source: '1', folder: 'temp-only', media: 'all' },
|
||||
onApply: value => applied.push(value), onClear() {} });
|
||||
const [source, folder] = byId(dom, 'modal-body').children.filter(child => child.className === 'field')
|
||||
.map(field => field.children[1]);
|
||||
// Opened on a chosen Source: only its folders.
|
||||
assert.deepEqual(optionPairs(folder), [['', 'All output folders'], ['temp-only', 'temp-only']]);
|
||||
assert.equal(folder.value, 'temp-only');
|
||||
// Another Source drops a folder it does not have.
|
||||
source.value = '0';
|
||||
source.dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(optionPairs(folder), [['', 'All output folders'], ['portraits', 'portraits']]);
|
||||
assert.equal(folder.value, '');
|
||||
folder.value = 'portraits';
|
||||
// A folder the next Source also has stays chosen.
|
||||
source.value = 'all';
|
||||
source.dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(optionPairs(folder).map(([value]) => value), ['', 'portraits', 'temp-only']);
|
||||
assert.equal(folder.value, 'portraits');
|
||||
button(byId(dom, 'modal-body'), 'Apply filters').click();
|
||||
assert.deepEqual(applied, [{ source: 'all', folder: 'portraits', media: 'all' }]);
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a running rescan marks Rescan busy and ignores a second press', async () => {
|
||||
const { openImportDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const calls = [];
|
||||
const handle = openImportDialog({ scope, rescanBusy: true, onInspect: () => calls.push('inspect'),
|
||||
onRescan: () => calls.push('rescan') });
|
||||
const body = byId(dom, 'modal-body');
|
||||
const busy = button(body, 'Rescanning…');
|
||||
assert.ok(busy, 'the busy Rescan says it is running');
|
||||
assert.equal(busy.getAttribute('aria-disabled'), 'true');
|
||||
busy.click();
|
||||
assert.deepEqual(calls, []);
|
||||
assert.equal(modal(dom).open, true, 'an ignored press keeps the dialog open');
|
||||
handle.setRescanBusy(false);
|
||||
assert.equal(busy.textContent, 'Rescan');
|
||||
assert.equal(busy.getAttribute('aria-disabled'), null);
|
||||
busy.click();
|
||||
assert.deepEqual(calls, ['rescan']);
|
||||
assert.equal(modal(dom).open, false);
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('import dialog lists only real operations', async () => {
|
||||
const { openImportDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const calls = [];
|
||||
openImportDialog({ scope, onInspect: () => calls.push('inspect'), onRescan: () => calls.push('rescan') });
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Import & inspect');
|
||||
const body = byId(dom, 'modal-body');
|
||||
assert.deepEqual(tags(body), ['p', 'div']);
|
||||
assert.equal(body.children[0].className, 'small');
|
||||
const list = body.children[1];
|
||||
assert.equal(list.className, 'operation-list');
|
||||
const rows = list.children.map(row => {
|
||||
assert.equal(row.className, 'operation');
|
||||
const [text, actions] = row.children;
|
||||
assert.deepEqual(tags(text), ['h3', 'p']);
|
||||
assert.equal(actions.className, 'row-actions');
|
||||
return [text.children[0].textContent, text.children[1].textContent, actions.children[0].textContent];
|
||||
});
|
||||
// D15: no "Import portable recipes" until import exists; Rescan finds added or removed files.
|
||||
assert.deepEqual(rows, [
|
||||
['Inspect a local image', 'Read embedded metadata before deciding to add it.', 'Open inspector'],
|
||||
['Scan an output folder', 'Find files added or removed in the output folders.', 'Rescan'],
|
||||
]);
|
||||
assert.doesNotMatch(body.textContent, /recipe|portable|sample/i);
|
||||
|
||||
button(body, 'Rescan').click();
|
||||
assert.deepEqual(calls, ['rescan']);
|
||||
assert.equal(modal(dom).open, false);
|
||||
openImportDialog({ scope, onInspect: () => calls.push('inspect'), onRescan: () => calls.push('rescan') });
|
||||
button(byId(dom, 'modal-body'), 'Open inspector').click();
|
||||
assert.deepEqual(calls, ['rescan', 'inspect']);
|
||||
assert.equal(modal(dom).open, false);
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('copy fallback selects the text', async () => {
|
||||
const { openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const text = '<b>a castle</b>, "moonlight" & fog';
|
||||
openCopyFallback(text, { scope });
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
|
||||
const body = byId(dom, 'modal-body');
|
||||
assert.deepEqual(tags(body), ['div', 'p']);
|
||||
const [field, note] = body.children;
|
||||
assert.equal(field.className, 'field');
|
||||
const [label, area] = field.children;
|
||||
assert.equal(label.textContent, 'Select and copy this text');
|
||||
assert.equal(label.htmlFor, 'copy-text');
|
||||
assert.equal(area.tagName, 'TEXTAREA');
|
||||
assert.equal(area.id, 'copy-text');
|
||||
assert.equal(area.readOnly, true);
|
||||
assert.equal(area.value, text);
|
||||
assert.deepEqual([area.selectionStart, area.selectionEnd], [0, text.length]);
|
||||
assert.equal(document.activeElement, area);
|
||||
assert.equal(note.className, 'small');
|
||||
assert.equal(note.textContent, 'Automatic clipboard access requires a secure browser context.');
|
||||
assert.throws(() => openCopyFallback(42, { scope }), TypeError);
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('dialogs restore focus to the trigger on Escape', async () => {
|
||||
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const opens = [
|
||||
trigger => openRefineDialog({ scope, fields: [{ key: 'tags' }], values: {}, trigger, onApply() {}, onClear() {} }),
|
||||
trigger => openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} }),
|
||||
trigger => openCopyFallback('text', { trigger, scope }),
|
||||
];
|
||||
for (const open of opens) {
|
||||
const trigger = new dom.Element('button');
|
||||
dom.body.append(trigger);
|
||||
open(trigger);
|
||||
const dialog = modal(dom);
|
||||
assert.equal(dialog.open, true);
|
||||
assert.equal(dialog.getAttribute('aria-labelledby'), 'modal-title');
|
||||
const header = dialog.children[0];
|
||||
assert.equal(header.className, 'dialog-header');
|
||||
assert.deepEqual(tags(header), ['h2', 'button']);
|
||||
const close = header.children[1];
|
||||
assert.equal(close.id, 'modal-close');
|
||||
assert.equal(close.textContent, '×');
|
||||
assert.equal(close.getAttribute('aria-label'), 'Close dialog');
|
||||
assert.notEqual(document.activeElement, trigger);
|
||||
// Escape fires `cancel` on a modal dialog.
|
||||
const escape = new Event('cancel', { cancelable: true });
|
||||
dialog.dispatchEvent(escape);
|
||||
assert.equal(dialog.open, false);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
}
|
||||
|
||||
// × closes and restores focus too; a removed trigger leaves focus alone.
|
||||
const trigger = new dom.Element('button');
|
||||
dom.body.append(trigger);
|
||||
openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} });
|
||||
byId(dom, 'modal-close').click();
|
||||
assert.equal(modal(dom).open, false);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} });
|
||||
trigger.remove();
|
||||
const close = byId(dom, 'modal-close');
|
||||
close.click();
|
||||
assert.notEqual(document.activeElement, trigger);
|
||||
|
||||
// Opening over an open dialog swaps its content and keeps the first trigger; dispose removes the shell.
|
||||
const first = new dom.Element('button');
|
||||
dom.body.append(first);
|
||||
openImportDialog({ scope, trigger: first, onInspect() {}, onRescan() {} });
|
||||
openCopyFallback('swap', { scope });
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
|
||||
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
|
||||
assert.equal(document.activeElement, first);
|
||||
openImportDialog({ scope, trigger: first, onInspect() {}, onRescan() {} });
|
||||
closeWorkspaceDialog();
|
||||
assert.equal(modal(dom), undefined);
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('an action may open the next dialog without the late close event closing it', async () => {
|
||||
const { openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const first = new dom.Element('button');
|
||||
const second = new dom.Element('button');
|
||||
dom.body.append(first, second);
|
||||
openImportDialog({ scope, trigger: first, onInspect() {},
|
||||
onRescan: () => openCopyFallback('next', { scope, trigger: second }) });
|
||||
button(byId(dom, 'modal-body'), 'Rescan').click();
|
||||
await tick();
|
||||
assert.equal(modal(dom).open, true);
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
|
||||
assert.equal(document.activeElement, byId(dom, 'copy-text'));
|
||||
// Its own Escape still restores focus to its own trigger.
|
||||
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
|
||||
await tick();
|
||||
assert.equal(document.activeElement, second);
|
||||
// Replacing the shell ignores the old shell's late close event too.
|
||||
openImportDialog({ scope, trigger: first, onInspect() {}, onRescan() {} });
|
||||
closeWorkspaceDialog();
|
||||
openImportDialog({ scope, trigger: second, onInspect() {}, onRescan() {} });
|
||||
await tick();
|
||||
assert.equal(modal(dom).open, true);
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Import & inspect');
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('dialogs need the page scope and close with it', async () => {
|
||||
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
try {
|
||||
assert.throws(() => openImportDialog({ onInspect() {}, onRescan() {} }), /page scope/);
|
||||
assert.throws(() => openRefineDialog({ fields: [], onApply() {}, onClear() {} }), /page scope/);
|
||||
assert.throws(() => openCopyFallback('text'), /page scope/);
|
||||
const scope = createScope();
|
||||
assert.throws(() => openImportDialog({ scope, onInspect() {} }), TypeError);
|
||||
assert.throws(() => openRefineDialog({ scope, fields: [], onApply() {} }), TypeError);
|
||||
assert.equal(modal(dom), undefined);
|
||||
|
||||
// Disposing the page scope closes and removes the shell, however often the page opened it.
|
||||
const trigger = new dom.Element('button');
|
||||
dom.body.append(trigger);
|
||||
openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} });
|
||||
openCopyFallback('again', { scope });
|
||||
scope.dispose();
|
||||
assert.equal(modal(dom), undefined);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
|
||||
// An old page's scope never closes the next page's dialog.
|
||||
const older = createScope();
|
||||
const newer = createScope();
|
||||
openImportDialog({ scope: older, onInspect() {}, onRescan() {} });
|
||||
openCopyFallback('next page', { scope: newer });
|
||||
older.dispose();
|
||||
assert.equal(modal(dom).open, true);
|
||||
newer.dispose();
|
||||
assert.equal(modal(dom), undefined);
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('every route change closes the shared workspace dialog', async () => {
|
||||
const { readFile } = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const source = await readFile(path.join(__dirname, '../../web/js/workspace/index.mjs'), 'utf8');
|
||||
assert.match(source, /import \{[^}]*\bcloseWorkspaceDialog\b[^}]*\} from '\.\/workspace-dialogs\.mjs';/);
|
||||
const render = source.slice(source.indexOf('function render(route) {'));
|
||||
const body = render.slice(0, render.indexOf('viewScope = createScope();'));
|
||||
assert.match(body, /closeWorkspaceDialog\(\);/, 'render closes the dialog before the next page mounts');
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { canvasRoot, canvasHref, bindCanvasLink } from '../../web/js/workspace/topbar.mjs';
|
||||
|
||||
// The shared mount-aware resolver (#164), as api-client.js implements it.
|
||||
const resolverAt = pathname => url => {
|
||||
if (!url.startsWith('/prompt_manager/')) return url;
|
||||
const match = pathname.match(/^(.*)\/prompt_manager(?:\/|$)/);
|
||||
return match && match[1] ? match[1] + url : url;
|
||||
};
|
||||
|
||||
function link() {
|
||||
const node = new EventTarget();
|
||||
node.href = '';
|
||||
return node;
|
||||
}
|
||||
function click(node, init = {}) {
|
||||
const event = new Event('click', { cancelable: true });
|
||||
Object.assign(event, { button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...init });
|
||||
node.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
test('the canvas root is the ComfyUI root under the page mount', () => {
|
||||
assert.equal(canvasRoot(resolverAt('/prompt_manager/workspace')), '/');
|
||||
assert.equal(canvasRoot(resolverAt('/proxy/comfy/prompt_manager/workspace')), '/proxy/comfy/');
|
||||
assert.throws(() => canvasRoot(() => 'https://elsewhere.example/'), /canvas/i);
|
||||
});
|
||||
|
||||
test('the canvas root follows a non-standard proxy mount', () => {
|
||||
assert.equal(canvasRoot(resolverAt('/ComfyBackendDirect/prompt_manager/workspace')), '/ComfyBackendDirect/');
|
||||
assert.equal(canvasHref(resolverAt('/ComfyBackendDirect/prompt_manager/workspace'), '/ComfyBackendDirect/prompt_manager/workspace'),
|
||||
'/ComfyBackendDirect/');
|
||||
});
|
||||
|
||||
// A4 review: a resolver failure must never stop the shell from mounting, and the fallback stays on the page's mount.
|
||||
test('a resolver failure falls back to the page mount and never to another origin', () => {
|
||||
const warn = console.warn;
|
||||
const warnings = [];
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
try {
|
||||
const broken = () => { throw new Error('resolver unavailable'); };
|
||||
assert.equal(canvasHref(broken, '/ComfyBackendDirect/prompt_manager/workspace'), '/ComfyBackendDirect/');
|
||||
assert.equal(canvasHref(broken, '/ComfyBackendDirect/prompt_manager/workspace/'), '/ComfyBackendDirect/');
|
||||
assert.equal(canvasHref(broken, '/prompt_manager/workspace'), '/');
|
||||
assert.equal(canvasHref(() => 'https://elsewhere.example/prompt_manager/', '/a/prompt_manager/workspace'), '/a/');
|
||||
assert.equal(canvasHref(broken, '//elsewhere.example/prompt_manager/workspace'), '/');
|
||||
assert.equal(canvasHref(broken, '/workspace'), '/');
|
||||
assert.equal(canvasHref(broken, undefined), '/');
|
||||
assert.equal(canvasHref(undefined, '/a/prompt_manager/workspace'), '/a/');
|
||||
assert.equal(warnings.length, 8);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
}
|
||||
});
|
||||
|
||||
test('ComfyUI link follows its href unless a resolved pair is selected', () => {
|
||||
const node = link();
|
||||
const opened = [];
|
||||
let pair = null;
|
||||
const sender = { open: (roles, trigger) => opened.push([roles, trigger]) };
|
||||
const unbind = bindCanvasLink(node, { getSelectedPair: () => pair, sender, href: '/proxy/' });
|
||||
assert.equal(node.href, '/proxy/');
|
||||
assert.equal(click(node).defaultPrevented, false);
|
||||
pair = { positive: 'a castle', negative: '' };
|
||||
const event = click(node);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.deepEqual(opened, [[{ positive: 'a castle', negative: '' }, node]]);
|
||||
// A modified or secondary click keeps the browser's link behavior.
|
||||
for (const init of [{ metaKey: true }, { ctrlKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }]) {
|
||||
assert.equal(click(node, init).defaultPrevented, false);
|
||||
}
|
||||
// One role alone is not a pair.
|
||||
pair = { positive: 'a castle' };
|
||||
assert.equal(click(node).defaultPrevented, false);
|
||||
assert.equal(opened.length, 1);
|
||||
unbind();
|
||||
pair = { positive: 'a castle', negative: '' };
|
||||
assert.equal(click(node).defaultPrevented, false);
|
||||
assert.equal(opened.length, 1);
|
||||
});
|
||||
|
||||
test('ComfyUI link without a pair source is a plain link', () => {
|
||||
const node = link();
|
||||
const unbind = bindCanvasLink(node, { href: '/' });
|
||||
assert.equal(click(node).defaultPrevented, false);
|
||||
unbind();
|
||||
});
|
||||
@@ -188,6 +188,10 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
async def test_maximum_parent_page_uses_bounded_sql_queries_for_previews(self):
|
||||
for index in range(200):
|
||||
positive = self._save_prompt(f"Parent {index}", tags=[f"tag {index}"])
|
||||
if index % 3 == 0:
|
||||
self.api.db.save_prompt_pair(f"Parent {index}", f"Negative {index}")
|
||||
if index % 4 == 0:
|
||||
self.api.db.record_prompt_use(positive, times=index + 1)
|
||||
make_png(self.output_dir / f"{index}.png")
|
||||
self.api.db.link_image_to_prompt(
|
||||
positive, str(self.output_dir / f"{index}.png")
|
||||
@@ -232,6 +236,32 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
for item in result["items"]
|
||||
)
|
||||
)
|
||||
# Usage fields and the variant total ride on the same five SELECTs.
|
||||
self.assertEqual(result["variant_total"], 67)
|
||||
self.assertEqual(
|
||||
result["variant_total"],
|
||||
sum(item["variant_count"] for item in result["items"]),
|
||||
)
|
||||
used = {
|
||||
int(item["text"].split()[-1]): item
|
||||
for item in result["items"]
|
||||
if item["last_used_at"] is not None
|
||||
}
|
||||
self.assertEqual(sorted(used), list(range(0, 200, 4)))
|
||||
self.assertTrue(
|
||||
all(
|
||||
item["run_count"] == index + 1
|
||||
and item["last_used_at"].endswith("+00:00")
|
||||
for index, item in used.items()
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
type(item["run_count"]) is int
|
||||
for item in result["items"]
|
||||
if item["last_used_at"] is None
|
||||
)
|
||||
)
|
||||
|
||||
async def test_long_name_cursor_stays_small_and_preserves_full_sorting(self):
|
||||
prefix = "猫" * 10_000
|
||||
@@ -309,6 +339,11 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
relative = "nested/猫 % #.png"
|
||||
pair = self.api.db.save_prompt_pair("Cat", "Noise", title="Pair title")
|
||||
positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
with self.api.db.model.get_connection() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
|
||||
(positive, "Positive title"),
|
||||
)
|
||||
path = make_png(self.output_dir / relative)
|
||||
make_png(self.output_dir / "thumbnails/nested/猫 % #_thumb.png")
|
||||
image = self.api.db.link_image_to_prompt(positive, str(path))
|
||||
@@ -343,7 +378,8 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
self.assertEqual(result["library"]["metadata_scope"], "pair")
|
||||
self.assertEqual(result["library"]["pair_id"], pair)
|
||||
self.assertEqual(result["library"]["positive_id"], positive)
|
||||
self.assertEqual(result["library"]["title"], "Pair title")
|
||||
# D10/D20: the linked positive's title; "Pair title" names the variant.
|
||||
self.assertEqual(result["library"]["title"], "Positive title")
|
||||
|
||||
async def test_epoch_scoped_all_library_cursors(self):
|
||||
from database.execution_history_schema import rotate_history_epoch
|
||||
@@ -438,6 +474,38 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
)
|
||||
return response, await response.json()
|
||||
|
||||
async def test_unconvertible_last_use_does_not_fail_list_detail_or_receipt(self):
|
||||
positive = self._save_prompt("Cat")
|
||||
other = self._save_prompt("Dog")
|
||||
with self.api.db.model.get_connection() as connection:
|
||||
connection.execute(
|
||||
"UPDATE prompts SET last_used_at=? WHERE id=?",
|
||||
("9999-12-31T23:59:59-01:00", positive),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE prompts SET last_used_at=? WHERE id=?",
|
||||
("0001-01-01T00:00:00+01:00", other),
|
||||
)
|
||||
response, listed = await self.get()
|
||||
self.assertEqual(response.status, 200, listed)
|
||||
self.assertEqual(
|
||||
[item["last_used_at"] for item in listed["items"]], [None, None]
|
||||
)
|
||||
response, detail = await self.get(f"/{positive}")
|
||||
self.assertEqual(response.status, 200, detail)
|
||||
self.assertIsNone(detail["positive"]["last_used_at"])
|
||||
response, receipt = await self.patch_parent(
|
||||
positive,
|
||||
{
|
||||
"expected_epoch": detail["library_epoch"],
|
||||
"if_match": detail["positive"]["edit_token"],
|
||||
"metadata": {"title": "Portrait"},
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status, 200, receipt)
|
||||
self.assertEqual(receipt["positive"]["title"], "Portrait")
|
||||
self.assertIsNone(receipt["positive"]["last_used_at"])
|
||||
|
||||
async def test_native_parent_patch_strict_validation(self):
|
||||
from database.execution_history_schema import rotate_history_epoch
|
||||
|
||||
|
||||
@@ -244,10 +244,10 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
|
||||
async def test_corrupt_malformed_and_no_metadata_do_not_invent_text(self):
|
||||
path = self.output_dir / "image.png"
|
||||
for content, expected in (
|
||||
(b"broken", "unresolved"),
|
||||
(None, "absent"),
|
||||
("bad-json", "unresolved"),
|
||||
for content, expected, message in (
|
||||
(b"broken", "unresolved", "Metadata in this file could not be read."),
|
||||
(None, "absent", None),
|
||||
("bad-json", "unresolved", "Prompt metadata in this file is malformed."),
|
||||
):
|
||||
if isinstance(content, bytes):
|
||||
path.write_bytes(content)
|
||||
@@ -256,6 +256,7 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
_, result = await self.inspect()
|
||||
self.assertEqual(result["prompts"]["positive"]["status"], expected)
|
||||
self.assertIsNone(result["prompts"]["positive"]["text"])
|
||||
self.assertEqual(result["prompts"]["negative"].get("message"), message)
|
||||
|
||||
async def test_compressed_international_and_post_pixel_metadata(self):
|
||||
import struct
|
||||
@@ -773,8 +774,11 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
self.assertEqual(result["metadata"]["prompt_graph"]["status"], "parsed")
|
||||
|
||||
async def test_inspect_route_reports_no_sampler_instead_of_absent(self):
|
||||
# f17: a BasicGuider-only prompt graph is a record, never "not recorded".
|
||||
self.parity_fixture("f17")
|
||||
# f17 with a sampler class the inspector does not know: a prompt graph
|
||||
# is a record, never "not recorded".
|
||||
built = parity.build("f17")
|
||||
built["prompt"]["13"]["class_type"] = "FutureSampler"
|
||||
parity.write_png(self.output_dir / "f17.png", built)
|
||||
response, result = await self.inspect("f17.png")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(
|
||||
@@ -785,17 +789,32 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
self.assertEqual(result["resolution"]["reason_code"], "no-sampler")
|
||||
self.assertEqual(
|
||||
result["resolution"]["reason"],
|
||||
"No supported sampler pair found (saw SamplerCustomAdvanced)",
|
||||
"No supported sampler pair found (saw FutureSampler)",
|
||||
)
|
||||
self.assertEqual(
|
||||
result["prompts"]["positive"]["message"], "No supported sampler was found."
|
||||
)
|
||||
|
||||
async def test_inspect_route_exposes_reason_code_and_blocking_node(self):
|
||||
self.parity_fixture("f06a")
|
||||
# f06a's encoder is supported since Task 2.4; an unknown encoder is not.
|
||||
built = parity.build("f06a")
|
||||
built["prompt"]["6"]["class_type"] = "FutureTextEncode"
|
||||
parity.write_png(self.output_dir / "f06a.png", built)
|
||||
_, result = await self.inspect("f06a.png")
|
||||
resolution = result["resolution"]
|
||||
self.assertEqual(resolution["state"], "unresolved")
|
||||
self.assertEqual(resolution["reason_code"], "unsupported-node")
|
||||
self.assertEqual(set(resolution["node"]), {"id", "class_type"})
|
||||
self.assertIsInstance(resolution["node"]["class_type"], str)
|
||||
self.assertEqual(
|
||||
resolution["node"], {"id": "6", "class_type": "FutureTextEncode"}
|
||||
)
|
||||
self.assertEqual(
|
||||
{role: side.get("message") for role, side in result["prompts"].items()},
|
||||
dict.fromkeys(
|
||||
("positive", "negative"),
|
||||
"Prompt uses a node the inspector does not support "
|
||||
"(FutureTextEncode).",
|
||||
),
|
||||
)
|
||||
png(self.output_dir / "image.png", prompt="{broken")
|
||||
_, result = await self.inspect()
|
||||
self.assertEqual(result["resolution"]["reason_code"], "malformed")
|
||||
|
||||
@@ -26,6 +26,7 @@ from database.operations import PromptDatabase # noqa: E402
|
||||
from py.api import PromptManagerAPI # noqa: E402
|
||||
import py.api.prompts as prompts_module # noqa: E402
|
||||
from py.api.prompts import ( # noqa: E402
|
||||
BULK_CATEGORY_MAX_PROMPTS,
|
||||
MAX_PAGE_LIMIT,
|
||||
MAX_PAGE_OFFSET,
|
||||
safe_error_message,
|
||||
@@ -909,6 +910,98 @@ class TestBulkRoutes(RouteCoverageCase):
|
||||
self.assertEqual(ok_data["updated_count"], 2)
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(ids[0])["category"], "c")
|
||||
|
||||
async def _set_category(self, body):
|
||||
return await self._json("POST", "/prompt_manager/bulk/category", json=body)
|
||||
|
||||
async def test_bulk_set_category_rejects_malformed_prompt_ids(self):
|
||||
pid = self._save_prompt("bulk cat strict")
|
||||
cases = (
|
||||
None,
|
||||
"1",
|
||||
{"id": pid},
|
||||
[],
|
||||
[pid, "2"],
|
||||
[pid, 0],
|
||||
[pid, -3],
|
||||
[pid, 1.5],
|
||||
[True],
|
||||
[pid, None],
|
||||
# Beyond SQLite's INTEGER range: binding it would raise OverflowError.
|
||||
[pid, 2**63],
|
||||
[2**64],
|
||||
list(range(1, BULK_CATEGORY_MAX_PROMPTS + 2)),
|
||||
)
|
||||
for prompt_ids in cases:
|
||||
with self.subTest(prompt_ids=str(prompt_ids)[:40]):
|
||||
status, data = await self._set_category(
|
||||
{"prompt_ids": prompt_ids, "category": "c"}
|
||||
)
|
||||
self.assertEqual(status, 400, data)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIsNone(self.api.db.get_prompt_by_id(pid)["category"])
|
||||
|
||||
async def test_bulk_set_category_validates_the_category(self):
|
||||
pid = self._save_prompt("bulk cat validate")
|
||||
for category in ("x" * 101, "bad\x00name", 7, ["c"]):
|
||||
with self.subTest(category=str(category)[:20]):
|
||||
status, data = await self._set_category(
|
||||
{"prompt_ids": [pid], "category": category}
|
||||
)
|
||||
self.assertEqual(status, 400, data)
|
||||
self.assertFalse(data["success"])
|
||||
for body in ([pid], "text"):
|
||||
with self.subTest(body=body):
|
||||
status, data = await self._set_category(body)
|
||||
self.assertEqual(status, 400, data)
|
||||
resp = await self.client.post(
|
||||
"/prompt_manager/bulk/category",
|
||||
data="{not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(resp.status, 400)
|
||||
self.assertIsNone(self.api.db.get_prompt_by_id(pid)["category"])
|
||||
|
||||
async def test_bulk_set_category_dedupes_and_accepts_the_cap(self):
|
||||
ids = [self._save_prompt(f"bulk cat dedupe {i}") for i in range(2)]
|
||||
seen = []
|
||||
original = self.api.db.bulk_set_category
|
||||
|
||||
def spy(prompt_ids, category):
|
||||
seen.append((list(prompt_ids), category))
|
||||
return original(prompt_ids, category)
|
||||
|
||||
self.api.db.bulk_set_category = spy
|
||||
status, data = await self._set_category(
|
||||
{"prompt_ids": [ids[1], ids[0], ids[1], ids[0]], "category": " Land "}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(data["updated_count"], 2)
|
||||
self.assertEqual(seen, [([ids[1], ids[0]], "Land")])
|
||||
# The cap itself is accepted; unknown ids simply do not count.
|
||||
capped = ids + list(range(10**6, 10**6 + BULK_CATEGORY_MAX_PROMPTS - 2))
|
||||
status, data = await self._set_category(
|
||||
{"prompt_ids": capped, "category": "Sea"}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(data["updated_count"], 2)
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(ids[0])["category"], "Sea")
|
||||
|
||||
async def test_bulk_set_category_keeps_the_legacy_admin_request(self):
|
||||
# web/js/admin.js posts parseInt ids and a trimmed, non-empty category.
|
||||
ids = [self._save_prompt(f"bulk cat legacy {i}") for i in range(3)]
|
||||
status, data = await self._set_category(
|
||||
{"prompt_ids": ids, "category": "Portraits"}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(
|
||||
set(data), {"success", "message", "updated_count"}, "response shape"
|
||||
)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["updated_count"], 3)
|
||||
self.assertEqual(data["message"], "Set category for 3 prompts")
|
||||
for pid in ids:
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(pid)["category"], "Portraits")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,236 @@
|
||||
"""Card facts from header records: the shared legacy parser, bounded and exact."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.card_seed import ( # noqa: E402
|
||||
MAX_RECORD_BYTES,
|
||||
card_seed,
|
||||
header_params,
|
||||
parsed_positive,
|
||||
)
|
||||
from utils.generation_params import ( # noqa: E402
|
||||
GenerationParamsError,
|
||||
extract_from_records,
|
||||
extract_generation_params,
|
||||
)
|
||||
from utils.inspection_response import ( # noqa: E402
|
||||
MAX_PROJECTION_DEPTH,
|
||||
project_graph,
|
||||
)
|
||||
|
||||
try:
|
||||
from tests.generation_params_cases import CASES, encoder, ksampler # noqa: E402
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from generation_params_cases import CASES, encoder, ksampler # noqa: E402
|
||||
|
||||
|
||||
def graph(*samplers, extra=None):
|
||||
nodes = {"6": encoder("a lantern at dusk"), "7": encoder("blurry")}
|
||||
for index, sampler in enumerate(samplers):
|
||||
nodes[str(10 + index)] = sampler
|
||||
nodes.update(extra or {})
|
||||
return {"prompt": json.dumps(nodes)}
|
||||
|
||||
|
||||
def seeded(seed, **settings):
|
||||
return ksampler(("6", 0), ("7", 0), seed=seed, **settings)
|
||||
|
||||
|
||||
def nested_workflow(depth=900):
|
||||
"""A small workflow whose node and link ids nest ``depth`` arrays deep."""
|
||||
nested = "[" * depth + "1" + "]" * depth
|
||||
return (
|
||||
'{"nodes":[{"id":%s,"type":"KSampler","widgets_values":[1]}],'
|
||||
'"links":[[%s,1,0,2,0]]}' % (nested, nested)
|
||||
)
|
||||
|
||||
|
||||
def info_seed(info):
|
||||
return card_seed(header_params(info))
|
||||
|
||||
|
||||
class CardSeed(unittest.TestCase):
|
||||
def test_card_seed_single_sampler(self):
|
||||
info = graph(seeded(42001))
|
||||
self.assertEqual(info_seed(info), "42001")
|
||||
self.assertEqual(parsed_positive(header_params(info)), "a lantern at dusk")
|
||||
|
||||
def test_card_seed_two_different_seeds_follows_shared_parser(self):
|
||||
# Ruling: no separate heuristic; the card shows what the legacy parser
|
||||
# (and so the inspector) shows for several samplers.
|
||||
info = graph(seeded(111), seeded(222))
|
||||
expected = extract_from_records(info["prompt"], None)["seed"]
|
||||
self.assertIn(expected, (111, 222))
|
||||
self.assertEqual(info_seed(info), str(expected))
|
||||
|
||||
def test_card_seed_linked_input_follows_shared_parser(self):
|
||||
primitive = {"class_type": "PrimitiveNode", "inputs": {"value": 555}}
|
||||
info = graph(seeded(["20", 0]), extra={"20": primitive})
|
||||
self.assertEqual(info_seed(info), "555")
|
||||
dangling = graph(seeded(["99", 0]))
|
||||
self.assertEqual(
|
||||
extract_from_records(dangling["prompt"], None)["seed"], "Unknown"
|
||||
)
|
||||
self.assertIsNone(info_seed(dangling))
|
||||
|
||||
def test_card_seed_advanced_sampler_noise_seed(self):
|
||||
advanced = seeded(None)
|
||||
advanced["class_type"] = "KSamplerAdvanced"
|
||||
del advanced["inputs"]["seed"]
|
||||
advanced["inputs"]["noise_seed"] = 77
|
||||
self.assertEqual(info_seed(graph(advanced)), "77")
|
||||
|
||||
def test_card_seed_oversized_record_is_none(self):
|
||||
padding = "x" * (MAX_RECORD_BYTES + 1)
|
||||
big_prompt = graph(seeded(1), extra={"30": encoder(padding)})
|
||||
self.assertGreater(len(big_prompt["prompt"]), MAX_RECORD_BYTES)
|
||||
self.assertIsNone(header_params(big_prompt))
|
||||
big_workflow = {**graph(seeded(1)), "workflow": json.dumps({"x": padding})}
|
||||
self.assertIsNone(header_params(big_workflow))
|
||||
# Bytes, not characters: two-byte text over the budget is oversized.
|
||||
wide = "é" * (MAX_RECORD_BYTES // 2 + 1)
|
||||
self.assertLessEqual(len(wide), MAX_RECORD_BYTES)
|
||||
self.assertIsNone(header_params({"prompt": json.dumps({"a": wide})}))
|
||||
self.assertEqual(info_seed(graph(seeded(1))), "1")
|
||||
|
||||
def test_records_past_the_inspection_projection_bound_give_nothing(self):
|
||||
# The inspector hands the browser parser only graphs its projection
|
||||
# parsed; a deeper graph is "limited" there, so the card skips it too.
|
||||
self.assertEqual(
|
||||
project_graph(nested_workflow(), "workflow")["status"], "limited"
|
||||
)
|
||||
self.assertIsNone(header_params({"workflow": nested_workflow()}))
|
||||
# A limited workflow beside a parsed prompt leaves the prompt's facts.
|
||||
both = header_params({**graph(seeded(1)), "workflow": nested_workflow()})
|
||||
self.assertEqual(card_seed(both), "1")
|
||||
|
||||
def test_depth_bound_is_the_inspectors(self):
|
||||
def deep(levels):
|
||||
nested = "[" * levels + "1" + "]" * levels
|
||||
extra = '{"class_type":"Note","inputs":{"v":%s}}' % nested
|
||||
text = graph(seeded(3))["prompt"]
|
||||
return {"prompt": text[:-1] + ',"30":' + extra + "}"}
|
||||
|
||||
# root object, node object, inputs object: three levels before "v".
|
||||
inside = MAX_PROJECTION_DEPTH - 3
|
||||
self.assertEqual(info_seed(deep(inside)), "3")
|
||||
self.assertIsNone(header_params(deep(inside + 1)))
|
||||
|
||||
def test_strict_projection_rules_apply(self):
|
||||
duplicate = '{"10": {"class_type": "KSampler", "inputs": {}}, "10": {}}'
|
||||
self.assertEqual(project_graph(duplicate, "prompt")["status"], "malformed")
|
||||
self.assertIsNone(header_params({"prompt": duplicate}))
|
||||
|
||||
def test_record_size_is_utf8_bytes_at_both_bounds(self):
|
||||
frame = '{"a":"%s"}'
|
||||
ascii_at = frame % ("x" * (MAX_RECORD_BYTES - 8))
|
||||
self.assertEqual(len(ascii_at), MAX_RECORD_BYTES)
|
||||
self.assertIsNotNone(header_params({"prompt": ascii_at}))
|
||||
four_byte = frame % ("\U0001f600" * ((MAX_RECORD_BYTES - 8) // 4))
|
||||
self.assertLessEqual(len(four_byte.encode()), MAX_RECORD_BYTES)
|
||||
self.assertIsNotNone(header_params({"prompt": four_byte}))
|
||||
padded = four_byte + " " * (MAX_RECORD_BYTES - len(four_byte.encode()) + 1)
|
||||
self.assertIsNone(header_params({"prompt": padded}))
|
||||
|
||||
def test_placeholder_and_non_integer_seeds_are_none(self):
|
||||
for value in ("randomize", " 12", True, 1.5, 7.0, 1e21, None):
|
||||
with self.subTest(seed=value):
|
||||
self.assertIsNone(info_seed(graph(seeded(value))))
|
||||
self.assertIsNone(card_seed({"seed": "Unknown"}))
|
||||
self.assertIsNone(card_seed({"seed": 7.0}))
|
||||
self.assertEqual(info_seed(graph(seeded(-3))), "-3")
|
||||
|
||||
def test_digit_string_seed_is_what_the_inspector_reads(self):
|
||||
# The projection carries big integers as text and keeps text as text;
|
||||
# the inspector shows a digit string as the seed, and so does the card.
|
||||
self.assertEqual(info_seed(graph(seeded("123"))), "123")
|
||||
|
||||
def test_seed_beyond_double_precision_is_the_recorded_integer(self):
|
||||
# JSON.parse would round it; the projection carries the record's digits.
|
||||
case = CASES["seed-beyond-double-precision"]
|
||||
rounded = extract_from_records(case["prompt"], None)["seed"]
|
||||
self.assertEqual(rounded, float(4974052037430000001))
|
||||
self.assertEqual(info_seed({"prompt": case["prompt"]}), "4974052037430000001")
|
||||
self.assertEqual(info_seed(graph(seeded(-(2**60) - 1))), str(-(2**60) - 1))
|
||||
|
||||
def test_seed_is_always_a_decimal_string(self):
|
||||
# JSON.parse rounds numbers past 2**53, so every seed travels as text.
|
||||
for value in (0, 7, 2**53, 2**53 + 1, 2**64, -(2**63)):
|
||||
with self.subTest(seed=value):
|
||||
self.assertEqual(card_seed({"seed": value}), str(value))
|
||||
self.assertEqual(card_seed({"seed": "18446744073709551616"}), str(2**64))
|
||||
|
||||
def test_digit_string_seed_is_bounded(self):
|
||||
# 2**64 has 20 digits; a longer string is no seed, and an unbounded one
|
||||
# must not reach int() (Python's int max-str-digits raises ValueError).
|
||||
self.assertEqual(card_seed({"seed": "9" * 20}), "9" * 20)
|
||||
self.assertEqual(card_seed({"seed": "-" + "9" * 20}), "-" + "9" * 20)
|
||||
self.assertIsNone(card_seed({"seed": "1" * 21}))
|
||||
self.assertIsNone(card_seed({"seed": "9" * 5000}))
|
||||
self.assertIsNone(info_seed(graph(seeded("9" * 5000))))
|
||||
|
||||
def test_seed_conversion_error_gives_none(self):
|
||||
class Huge(int):
|
||||
def __str__(self):
|
||||
raise ValueError("Exceeds the limit for integer string conversion")
|
||||
|
||||
self.assertIsNone(card_seed({"seed": Huge(5)}))
|
||||
|
||||
def test_workflow_only_record(self):
|
||||
case = next(
|
||||
case
|
||||
for case in CASES.values()
|
||||
if case["prompt"] is None
|
||||
and case["workflow"] is not None
|
||||
and isinstance(extract_from_records(None, case["workflow"])["seed"], int)
|
||||
)
|
||||
expected = extract_from_records(None, case["workflow"])["seed"]
|
||||
self.assertEqual(info_seed({"workflow": case["workflow"]}), str(expected))
|
||||
|
||||
def test_records_the_parser_cannot_use_give_nothing(self):
|
||||
self.assertIsNone(header_params({}))
|
||||
self.assertIsNone(header_params(None))
|
||||
self.assertIsNone(header_params({"parameters": "a cat\nSteps: 20, Seed: 9"}))
|
||||
self.assertIsNone(header_params({"prompt": b"{}"}))
|
||||
self.assertIsNone(card_seed(None))
|
||||
self.assertIsNone(parsed_positive(None))
|
||||
# The browser parser throws here; the card shows nothing.
|
||||
thrown = CASES["null-workflow-node"]
|
||||
self.assertIsNone(header_params(thrown))
|
||||
unparsable = header_params(CASES["unparsable-records"])
|
||||
self.assertIsNone(card_seed(unparsable))
|
||||
self.assertIsNone(parsed_positive(unparsable))
|
||||
|
||||
def test_every_shared_case_reads_the_projected_graphs(self):
|
||||
for name, case in CASES.items():
|
||||
with self.subTest(case=name):
|
||||
info = {k: v for k, v in case.items() if v is not None}
|
||||
params = header_params(info)
|
||||
graphs = {key: project_graph(text, key) for key, text in info.items()}
|
||||
values = {
|
||||
key: graph["value"]
|
||||
for key, graph in graphs.items()
|
||||
if graph["status"] == "parsed"
|
||||
}
|
||||
if not values:
|
||||
self.assertIsNone(params)
|
||||
continue
|
||||
try:
|
||||
expected = extract_generation_params(values)
|
||||
except GenerationParamsError:
|
||||
self.assertIsNone(params)
|
||||
continue
|
||||
self.assertEqual(params, expected)
|
||||
# Prompt text is the same as from a plain parse of the records.
|
||||
if len(values) == len(info):
|
||||
raw = extract_from_records(case["prompt"], case["workflow"])
|
||||
self.assertEqual(params["positivePrompt"], raw["positivePrompt"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1432,3 +1432,201 @@ class TestImagePaging(DatabaseTestCase):
|
||||
self.assertEqual(len(second), 2)
|
||||
self.assertNotEqual({img["id"] for img in first}, {img["id"] for img in second})
|
||||
self.assertEqual(len(self.db.search_images_by_prompt("paged")), 5)
|
||||
|
||||
|
||||
class TestImageLinkSummaries(DatabaseTestCase):
|
||||
"""Batched card summaries follow get_image_prompt_info's matching rules."""
|
||||
|
||||
def _statements(self, call):
|
||||
statements = []
|
||||
original = self.db.model.get_connection
|
||||
|
||||
def traced():
|
||||
connection = original()
|
||||
connection.set_trace_callback(statements.append)
|
||||
return connection
|
||||
|
||||
try:
|
||||
with patch.object(self.db.model, "get_connection", side_effect=traced):
|
||||
result = call()
|
||||
finally:
|
||||
original().set_trace_callback(None)
|
||||
selects = [s for s in statements if s.lstrip().upper().startswith("SELECT")]
|
||||
return result, selects
|
||||
|
||||
def test_summary_status_matches_image_prompt_info_for_every_rule(self):
|
||||
owner = self._save("Owner prompt\nsecond line", category="Cat", rating=4)
|
||||
rival = self._save("Rival prompt")
|
||||
exact = os.path.join("out", "one", "same.png")
|
||||
other = os.path.join("out", "two", "same.png")
|
||||
unique = os.path.join("out", "three", "unique.png")
|
||||
shared = os.path.join("out", "shared.png")
|
||||
self.db.link_image_to_prompt(owner, exact)
|
||||
self.db.link_image_to_prompt(rival, other)
|
||||
self.db.link_image_to_prompt(owner, unique)
|
||||
self.db.link_image_to_prompt(owner, shared)
|
||||
self.db.link_image_to_prompt(rival, shared)
|
||||
probes = [
|
||||
exact,
|
||||
os.path.join("out", "x", "..", "one", "same.png"),
|
||||
os.path.join("elsewhere", "same.png"),
|
||||
os.path.join("elsewhere", "unique.png"),
|
||||
shared,
|
||||
"missing.png",
|
||||
]
|
||||
for fallback in (True, False):
|
||||
summaries = self.db.get_image_link_summaries(probes, fallback)
|
||||
self.assertEqual(set(summaries), set(probes))
|
||||
for probe in probes:
|
||||
with self.subTest(probe=probe, fallback=fallback):
|
||||
info = self.db.get_image_prompt_info(
|
||||
probe, fallback, report_ambiguity=True
|
||||
)
|
||||
summary = summaries[probe]
|
||||
if info is None:
|
||||
expected = "none"
|
||||
elif info.get("association_status") == "ambiguous":
|
||||
expected = "ambiguous"
|
||||
else:
|
||||
expected = "linked"
|
||||
self.assertEqual(summary["status"], expected)
|
||||
if expected == "linked":
|
||||
self.assertEqual(summary["positive_id"], info["prompt_id"])
|
||||
else:
|
||||
self.assertEqual(
|
||||
summary,
|
||||
{
|
||||
"status": expected,
|
||||
"positive_id": None,
|
||||
"title": None,
|
||||
"title_source": None,
|
||||
"category": None,
|
||||
"rating": None,
|
||||
},
|
||||
)
|
||||
linked = self.db.get_image_link_summaries([unique])[unique]
|
||||
self.assertEqual(
|
||||
linked,
|
||||
{
|
||||
"status": "linked",
|
||||
"positive_id": owner,
|
||||
"title": "Owner prompt",
|
||||
"title_source": "text",
|
||||
"category": "Cat",
|
||||
"rating": 4,
|
||||
},
|
||||
)
|
||||
|
||||
def test_summary_title_prefers_stored_title_then_first_line(self):
|
||||
long_line = "word " * 30
|
||||
titled = self._save("Untitled text")
|
||||
plain = self._save(f" {long_line}\nsecond")
|
||||
blank = self._save("Blank title text")
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.executemany(
|
||||
"INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
|
||||
[(titled, "Stored title"), (blank, " \u3000 ")],
|
||||
)
|
||||
paths = {}
|
||||
for name, prompt in (("titled", titled), ("plain", plain), ("blank", blank)):
|
||||
paths[name] = os.path.join("out", f"{name}.png")
|
||||
self.db.link_image_to_prompt(prompt, paths[name])
|
||||
summaries = self.db.get_image_link_summaries(list(paths.values()))
|
||||
sources = {
|
||||
name: (summaries[path]["title"], summaries[path]["title_source"])
|
||||
for name, path in paths.items()
|
||||
}
|
||||
self.assertEqual(sources["titled"], ("Stored title", "stored"))
|
||||
# Cut like the Library's title(): 80 characters, then an ellipsis.
|
||||
self.assertEqual(sources["plain"], (long_line.strip()[:80] + "\u2026", "text"))
|
||||
self.assertEqual(sources["blank"], ("Blank title text", "text"))
|
||||
|
||||
def test_pair_summary_title_is_the_linked_positives_never_the_variant(self):
|
||||
# D10/D20: a pair's own title names its negative variant.
|
||||
stored = self.db.save_prompt_pair("Stored positive", "Noise", title="Variant")
|
||||
plain = self.db.save_prompt_pair("Plain positive\nmore", "Blur", title="Other")
|
||||
positives = {
|
||||
name: self.db.get_prompt_pair(pair_id)["positive_prompt_id"]
|
||||
for name, pair_id in (("stored", stored), ("plain", plain))
|
||||
}
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
|
||||
(positives["stored"], "Positive title"),
|
||||
)
|
||||
paths = {}
|
||||
for name, pair_id in (("stored", stored), ("plain", plain)):
|
||||
paths[name] = os.path.join("out", f"pair-{name}.png")
|
||||
image = self.db.link_image_to_prompt(positives[name], paths[name])
|
||||
self.db.link_image_to_pair(pair_id, image, {"source": "test"})
|
||||
summaries = self.db.get_image_link_summaries(list(paths.values()))
|
||||
titles = {
|
||||
name: (
|
||||
summaries[path]["positive_id"],
|
||||
summaries[path]["title"],
|
||||
summaries[path]["title_source"],
|
||||
)
|
||||
for name, path in paths.items()
|
||||
}
|
||||
self.assertEqual(
|
||||
titles["stored"], (positives["stored"], "Positive title", "stored")
|
||||
)
|
||||
self.assertEqual(
|
||||
titles["plain"], (positives["plain"], "Plain positive", "text")
|
||||
)
|
||||
|
||||
def test_display_title_matches_library_title_cut(self):
|
||||
from database.library import display_title
|
||||
|
||||
exact = "x" * 80
|
||||
self.assertEqual(display_title((), exact), exact)
|
||||
self.assertEqual(display_title((), exact + "y"), exact + "\u2026")
|
||||
self.assertEqual(display_title((), " short \nnext"), "short")
|
||||
self.assertIsNone(display_title((" ",), " \n"))
|
||||
self.assertEqual(display_title(("Kept ",), "text"), "Kept ")
|
||||
|
||||
def test_summary_uses_one_select_per_200_paths(self):
|
||||
prompt = self._save("Batch")
|
||||
paths = [os.path.join("out", f"{index}.png") for index in range(201)]
|
||||
for path in paths[:5]:
|
||||
self.db.link_image_to_prompt(prompt, path)
|
||||
result, selects = self._statements(
|
||||
lambda: self.db.get_image_link_summaries(paths[:200])
|
||||
)
|
||||
self.assertEqual(len(selects), 1)
|
||||
self.assertEqual(len(result), 200)
|
||||
self.assertEqual(
|
||||
[result[path]["status"] for path in paths[:6]], ["linked"] * 5 + ["none"]
|
||||
)
|
||||
_, selects = self._statements(lambda: self.db.get_image_link_summaries(paths))
|
||||
self.assertEqual(len(selects), 2)
|
||||
_, selects = self._statements(lambda: self.db.get_image_link_summaries([]))
|
||||
self.assertEqual(selects, [])
|
||||
|
||||
def test_windows_style_paths_normalize(self):
|
||||
import ntpath
|
||||
|
||||
def windows_normalize(path):
|
||||
return ntpath.normcase(ntpath.normpath(path))
|
||||
|
||||
prompt = self._save("Windows prompt")
|
||||
stored = r"C:\ComfyUI\Output\Sub\Image.PNG"
|
||||
with patch("database.operations.normalize_image_path", windows_normalize):
|
||||
self.db.link_image_to_prompt(prompt, stored)
|
||||
probes = [
|
||||
stored,
|
||||
r"c:\comfyui\output\SUB\image.png",
|
||||
r"C:\ComfyUI\Output\x\..\Sub\Image.PNG",
|
||||
"C:/ComfyUI/Output/Sub/Image.PNG",
|
||||
r"D:\ComfyUI\Output\Sub\Other.png",
|
||||
]
|
||||
summaries = self.db.get_image_link_summaries(probes, False)
|
||||
for probe in probes:
|
||||
with self.subTest(probe=probe):
|
||||
info = self.db.get_image_prompt_info(probe, False)
|
||||
expected = "linked" if info else "none"
|
||||
self.assertEqual(summaries[probe]["status"], expected)
|
||||
self.assertEqual(
|
||||
[summaries[probe]["status"] for probe in probes],
|
||||
["linked"] * 4 + ["none"],
|
||||
)
|
||||
|
||||
@@ -428,6 +428,72 @@ class TestRestoreFromFile(BackupTestCase):
|
||||
PromptModel.verify_database_file(dst, require_library_state=True)[0]
|
||||
)
|
||||
|
||||
def test_restore_old_backup_without_favorites(self):
|
||||
self.db.set_image_favorite("output", "live.png")
|
||||
src = self._make_source_db(["predates favorites"])
|
||||
with closing(sqlite3.connect(src)) as conn:
|
||||
conn.execute("DROP TABLE image_favorites")
|
||||
conn.commit()
|
||||
self.assertTrue(PromptModel.verify_database_file(src)[0])
|
||||
|
||||
self.db.model.restore_from_file(src)
|
||||
|
||||
self.assertEqual(_texts(self.path), ["predates favorites"])
|
||||
self.assertEqual(self.db.get_image_favorites("output", ["live.png"]), set())
|
||||
self.assertTrue(self.db.set_image_favorite("output", "new.png"))
|
||||
ok, reason = PromptModel.verify_database_file(
|
||||
self.path, require_library_state=True
|
||||
)
|
||||
self.assertTrue(ok, reason)
|
||||
with closing(sqlite3.connect(src)) as conn: # the source is untouched
|
||||
self.assertIsNone(
|
||||
conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE name='image_favorites'"
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
def test_restore_preserves_favorites_and_closes_handles(self):
|
||||
self.db.set_image_favorite("output", "live.png")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
src_path = os.path.join(directory, "source.db")
|
||||
src = PromptDatabase(src_path)
|
||||
try:
|
||||
self._save(src, "with favorites")
|
||||
src.set_image_favorite("output", "sub/kept.png")
|
||||
src.set_image_favorite("extra/root", "kept.png")
|
||||
finally:
|
||||
src.close_all()
|
||||
# The favorites still sit in the WAL; a backup must carry them.
|
||||
backup = os.path.join(directory, "backup.db")
|
||||
reopened = PromptDatabase(src_path)
|
||||
try:
|
||||
self.assertTrue(reopened.model.backup_database(backup))
|
||||
finally:
|
||||
reopened.close_all()
|
||||
|
||||
self.db.model.restore_from_file(backup)
|
||||
|
||||
self.assertEqual(
|
||||
self.db.get_image_favorites("output", ["sub/kept.png", "live.png"]),
|
||||
{"sub/kept.png"},
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.get_image_favorites("extra/root", ["kept.png"]), {"kept.png"}
|
||||
)
|
||||
PromptModel.close_all_instances()
|
||||
# Windows refuses to delete a file a restore left open.
|
||||
assert_closed(self, directory)
|
||||
backups = list(Path(self.tmpdir).glob("prompts.db.backup_*"))
|
||||
self.assertEqual(len(backups), 1)
|
||||
with closing(sqlite3.connect(str(backups[0]))) as conn:
|
||||
self.assertEqual(
|
||||
conn.execute(
|
||||
"SELECT root_key, relative_path FROM image_favorites"
|
||||
).fetchall(),
|
||||
[("output", "live.png")],
|
||||
)
|
||||
assert_closed(self, str(backups[0]))
|
||||
|
||||
def test_restore_without_a_live_database_returns_empty_backup_path(self):
|
||||
src = self._make_source_db(["fresh"])
|
||||
self.db.close_all()
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Tests for utils.generation_params, the Python port of the browser parser.
|
||||
|
||||
Expected values are what web/js/comfy-metadata.js extractGenerationParams
|
||||
returns for the same input (tests/test_generation_params_js_parity.py checks
|
||||
that directly when Node is available).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.generation_params import ( # noqa: E402
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
extract_from_records,
|
||||
parse_js_json,
|
||||
)
|
||||
|
||||
try:
|
||||
from tests.generation_params_cases import CASES # noqa: E402
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from generation_params_cases import CASES # noqa: E402
|
||||
|
||||
|
||||
def run(name):
|
||||
return extract_from_records(CASES[name]["prompt"], CASES[name]["workflow"])
|
||||
|
||||
|
||||
class TracesPrompts(unittest.TestCase):
|
||||
def test_sampler_prompts_and_settings(self):
|
||||
self.assertEqual(
|
||||
run("basic"),
|
||||
{
|
||||
"positivePrompt": "a cat on a windowsill",
|
||||
"negativePrompt": "blurry, watermark",
|
||||
"checkpoint": "example.safetensors",
|
||||
"steps": 20,
|
||||
"cfgScale": 7,
|
||||
"sampler": "euler",
|
||||
"seed": 5,
|
||||
},
|
||||
)
|
||||
|
||||
def test_prompt_manager_joins_prepend_and_append_once(self):
|
||||
result = run("prompt-manager-prepend-append")
|
||||
self.assertEqual(result["positivePrompt"], "masterpiece a cat 4k")
|
||||
# The text already starts with the prepend text, so it is not repeated.
|
||||
self.assertEqual(result["negativePrompt"], "masterpiece, a dog 4k")
|
||||
|
||||
def test_linked_text_joins_text_inputs_with_the_delimiter(self):
|
||||
result = run("linked-text-concat")
|
||||
self.assertEqual(result["positivePrompt"], "first part, second part")
|
||||
self.assertEqual(result["negativePrompt"], PLACEHOLDERS["negativePrompt"])
|
||||
|
||||
def test_prompt_manager_pair_outputs(self):
|
||||
result = run("prompt-manager-pair")
|
||||
self.assertEqual(result["positivePrompt"], "pair positive")
|
||||
self.assertEqual(result["negativePrompt"], "pair negative")
|
||||
|
||||
def test_conditioning_passthrough_follows_the_role(self):
|
||||
result = run("conditioning-passthrough")
|
||||
self.assertEqual(result["positivePrompt"], "through a controlnet")
|
||||
self.assertEqual(result["negativePrompt"], "negative through it")
|
||||
|
||||
def test_several_samplers_sharing_one_prompt_pair(self):
|
||||
result = run("shared-prompt-pipeline")
|
||||
self.assertEqual(result["positivePrompt"], "a lighthouse at dusk, oil painting")
|
||||
self.assertEqual(result["negativePrompt"], "blurry, watermark")
|
||||
self.assertEqual(
|
||||
(result["seed"], result["steps"], result["sampler"]), (111, 4, "lcm")
|
||||
)
|
||||
|
||||
def test_overwritten_prompt_manager_text_is_recovered_from_widgets(self):
|
||||
result = run("overwritten-prompt-manager-text")
|
||||
self.assertEqual(result["positivePrompt"], "the real positive")
|
||||
self.assertEqual(result["negativePrompt"], "the real negative")
|
||||
|
||||
|
||||
class ChoosesTheSampler(unittest.TestCase):
|
||||
def test_first_sampler_follows_javascript_key_order(self):
|
||||
# Integer-like IDs come first in ascending order, then the others.
|
||||
result = run("first-sampler-in-js-key-order")
|
||||
self.assertEqual(result["positivePrompt"], "from sampler five")
|
||||
self.assertEqual(result["seed"], 55)
|
||||
|
||||
def test_custom_sampler_reads_its_guider_and_split_settings(self):
|
||||
result = run("custom-sampler-cfg-guider")
|
||||
self.assertEqual(result["positivePrompt"], "a red fox in snow")
|
||||
self.assertEqual(result["negativePrompt"], "lowres")
|
||||
self.assertEqual(
|
||||
(result["seed"], result["steps"], result["cfgScale"], result["sampler"]),
|
||||
(42, 8, 3.5, "dpmpp_2m"),
|
||||
)
|
||||
|
||||
def test_a_guider_without_prompt_inputs_leaves_prompts_unknown(self):
|
||||
result = run("custom-sampler-basic-guider")
|
||||
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
|
||||
# KSamplerSelect still matches the loose /ksampler/ settings lookup.
|
||||
self.assertEqual(result["sampler"], "dpmpp_2m")
|
||||
self.assertEqual(result["seed"], PLACEHOLDERS["seed"])
|
||||
|
||||
def test_no_sampler_reads_loose_settings(self):
|
||||
result = run("no-sampler-loose-settings")
|
||||
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
|
||||
self.assertEqual(
|
||||
(result["seed"], result["steps"], result["cfgScale"], result["sampler"]),
|
||||
(9, 12, 2, "ddim"),
|
||||
)
|
||||
self.assertEqual(result["checkpoint"], "example.safetensors")
|
||||
|
||||
def test_linked_seed_prefers_the_input_of_the_same_name(self):
|
||||
self.assertEqual(run("primitive-seed")["seed"], 77)
|
||||
|
||||
|
||||
class ReadsTheWorkflow(unittest.TestCase):
|
||||
def test_workflow_only_file(self):
|
||||
self.assertEqual(
|
||||
run("workflow-only"),
|
||||
{
|
||||
"positivePrompt": "a tidepool at noon",
|
||||
"negativePrompt": "noisy",
|
||||
"checkpoint": "example.safetensors",
|
||||
"steps": 25,
|
||||
"cfgScale": 6.5,
|
||||
"sampler": "dpmpp_2m",
|
||||
"seed": 314,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class StaysBounded(unittest.TestCase):
|
||||
def test_cycles_terminate(self):
|
||||
result = run("conditioning-cycle")
|
||||
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
|
||||
|
||||
def test_long_chains_resolve_within_the_visit_limit(self):
|
||||
self.assertEqual(
|
||||
run("chain-within-visit-limit")["positivePrompt"], "deep chain text"
|
||||
)
|
||||
|
||||
def test_chains_beyond_the_visit_limit_stop_without_recursion_errors(self):
|
||||
result = run("chain-beyond-visit-limit")
|
||||
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
|
||||
self.assertEqual(result["negativePrompt"], "deep chain text")
|
||||
|
||||
def test_the_visit_limit_boundary_matches_the_browser_parser(self):
|
||||
# 499 pass-through nodes and the encoder are exactly 500 visits.
|
||||
self.assertEqual(
|
||||
run("chain-at-last-visit")["positivePrompt"], "deep chain text"
|
||||
)
|
||||
self.assertEqual(
|
||||
run("chain-one-past-last-visit")["positivePrompt"],
|
||||
PLACEHOLDERS["positivePrompt"],
|
||||
)
|
||||
|
||||
def test_very_long_chains_do_not_exhaust_the_python_stack(self):
|
||||
graph = {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "t"}}}
|
||||
for index in range(5000):
|
||||
graph[str(10 + index)] = {
|
||||
"class_type": "P",
|
||||
"inputs": {"conditioning": [str(9 + index if index else 1), 0]},
|
||||
}
|
||||
graph["2"] = {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["5009", 0],
|
||||
"negative": ["1", 0],
|
||||
"model": ["1", 0],
|
||||
},
|
||||
}
|
||||
result = extract_generation_params({"prompt": graph})
|
||||
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
|
||||
self.assertEqual(result["negativePrompt"], "t")
|
||||
|
||||
|
||||
class DeeplyNestedValues(unittest.TestCase):
|
||||
def test_deeply_nested_values_do_not_exhaust_the_python_stack(self):
|
||||
result = run("deeply-nested-values")
|
||||
self.assertEqual(result["positivePrompt"], "nested neighbours")
|
||||
|
||||
|
||||
class Placeholders(unittest.TestCase):
|
||||
def test_blank_text_is_a_placeholder_and_zero_seed_is_kept(self):
|
||||
result = run("blank-text-and-zero-seed")
|
||||
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
|
||||
self.assertEqual(result["negativePrompt"], "kept negative")
|
||||
self.assertEqual(result["seed"], 0)
|
||||
|
||||
def test_nothing_recorded(self):
|
||||
self.assertEqual(run("nothing"), dict(PLACEHOLDERS))
|
||||
self.assertEqual(run("unparsable-records"), dict(PLACEHOLDERS))
|
||||
|
||||
|
||||
class MalformedInput(unittest.TestCase):
|
||||
def test_a_null_workflow_node_fails_like_the_browser_parser(self):
|
||||
with self.assertRaises(GenerationParamsError):
|
||||
run("null-workflow-node")
|
||||
|
||||
|
||||
class ParsesLikeJavaScript(unittest.TestCase):
|
||||
def test_rejects_non_standard_numbers(self):
|
||||
for text in ('{"a": NaN}', '{"a": Infinity}', "[-Infinity]", "not json", ""):
|
||||
with self.subTest(text=text):
|
||||
self.assertIsNone(parse_js_json(text))
|
||||
|
||||
def test_large_integers_round_like_doubles(self):
|
||||
self.assertEqual(
|
||||
parse_js_json('{"seed": 9007199254740993}'), {"seed": 9007199254740992.0}
|
||||
)
|
||||
self.assertEqual(
|
||||
parse_js_json('{"seed": 9007199254740991}'), {"seed": 9007199254740991}
|
||||
)
|
||||
|
||||
def test_seeds_beyond_double_precision_round_like_the_browser(self):
|
||||
self.assertEqual(
|
||||
run("seed-beyond-double-precision")["seed"], 4974052037430000001.0
|
||||
)
|
||||
|
||||
def test_duplicate_keys_keep_the_last_value(self):
|
||||
self.assertEqual(parse_js_json('{"a": 1, "a": 2}'), {"a": 2})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,637 @@
|
||||
"""Randomized differential test: the Python parser equals the browser parser.
|
||||
|
||||
Generates seeded random API graphs and UI workflows, from realistic to
|
||||
malformed, and requires utils.generation_params to return exactly what
|
||||
web/js/comfy-metadata.js extractGenerationParams returns, or to fail where it
|
||||
throws. Runs only where Node is available. Set PM_PARAMS_FUZZ_CASES to run more.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
from tests.test_generation_params_js_parity import (
|
||||
NODE,
|
||||
js_equal,
|
||||
run_oracle,
|
||||
run_python,
|
||||
) # noqa: E402
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from test_generation_params_js_parity import (
|
||||
NODE,
|
||||
js_equal,
|
||||
run_oracle,
|
||||
run_python,
|
||||
) # noqa: E402
|
||||
|
||||
CASE_COUNT = int(os.environ.get("PM_PARAMS_FUZZ_CASES", "3000"))
|
||||
SEEDS = (20261020, 7, 424242)
|
||||
|
||||
NODE_IDS = [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"9",
|
||||
"10",
|
||||
"12",
|
||||
"25",
|
||||
"26",
|
||||
"100",
|
||||
"1300:5",
|
||||
"1300:8",
|
||||
"x",
|
||||
"05",
|
||||
"4294967295",
|
||||
"-1",
|
||||
"constructor",
|
||||
]
|
||||
CLASS_TYPES = [
|
||||
"KSampler",
|
||||
"KSamplerAdvanced",
|
||||
"SamplerCustomAdvanced",
|
||||
"CFGGuider",
|
||||
"BasicGuider",
|
||||
"CLIPTextEncode",
|
||||
"PromptManager",
|
||||
"PromptManagerPair",
|
||||
"PromptManagerText",
|
||||
"StringConcatenate",
|
||||
"PrimitiveString",
|
||||
"ControlNetApplyAdvanced",
|
||||
"ConditioningCombine",
|
||||
"CheckpointLoaderSimple",
|
||||
"UNETLoader",
|
||||
"LoraLoader",
|
||||
"RandomNoise",
|
||||
"KSamplerSelect",
|
||||
"BasicScheduler",
|
||||
"Reroute",
|
||||
"MyKSamplerThing",
|
||||
"SaveImage",
|
||||
"FaceDetailer",
|
||||
"toString",
|
||||
"",
|
||||
]
|
||||
INPUT_KEYS = [
|
||||
"text",
|
||||
"positive",
|
||||
"negative",
|
||||
"model",
|
||||
"conditioning",
|
||||
"conditioning_1",
|
||||
"conditioning_to",
|
||||
"guider",
|
||||
"sigmas",
|
||||
"noise",
|
||||
"sampler",
|
||||
"seed",
|
||||
"noise_seed",
|
||||
"steps",
|
||||
"cfg",
|
||||
"sampler_name",
|
||||
"ckpt_name",
|
||||
"unet_name",
|
||||
"model_name",
|
||||
"prepend_text",
|
||||
"append_text",
|
||||
"negative_text",
|
||||
"delimiter",
|
||||
"string_a",
|
||||
"string_b",
|
||||
"value",
|
||||
"text_g",
|
||||
"prompt",
|
||||
"0",
|
||||
"10",
|
||||
"clip",
|
||||
]
|
||||
TEXTS = [
|
||||
"a cat",
|
||||
" padded text ",
|
||||
"",
|
||||
" ",
|
||||
"bom text ",
|
||||
"line one\nline two",
|
||||
"masterpiece",
|
||||
"masterpiece, a dog",
|
||||
"4k",
|
||||
"a dog 4k",
|
||||
"ünïcödé ✓",
|
||||
", ",
|
||||
]
|
||||
WORKFLOW_TYPES = [
|
||||
"KSampler",
|
||||
"KSamplerAdvanced",
|
||||
"CLIPTextEncode",
|
||||
"PromptManager",
|
||||
"PromptManagerPair",
|
||||
"CheckpointLoaderSimple",
|
||||
"CheckpointLoader",
|
||||
"UNETLoader",
|
||||
"MyUnetLoader",
|
||||
"Reroute",
|
||||
"GetNode",
|
||||
"SetNode",
|
||||
"Note",
|
||||
"toString",
|
||||
"valueOf",
|
||||
]
|
||||
|
||||
|
||||
class Generator:
|
||||
def __init__(self, seed):
|
||||
self.random = random.Random(seed)
|
||||
|
||||
def pick(self, items):
|
||||
return self.random.choice(items)
|
||||
|
||||
def chance(self, probability):
|
||||
return self.random.random() < probability
|
||||
|
||||
def number(self):
|
||||
return self.pick(
|
||||
[
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
5,
|
||||
20,
|
||||
7,
|
||||
1.0,
|
||||
1.5,
|
||||
6.5,
|
||||
0.45,
|
||||
1.5e-7,
|
||||
2.5e21,
|
||||
-3,
|
||||
9007199254740991,
|
||||
]
|
||||
)
|
||||
|
||||
def link(self):
|
||||
return [
|
||||
self.pick(NODE_IDS + [3, 12, 5.0]),
|
||||
self.pick([0, 1, 2, 3, 1.0, 0, 0, 0, -1]),
|
||||
]
|
||||
|
||||
def value(self, key):
|
||||
roll = self.random.random()
|
||||
if (
|
||||
key
|
||||
in (
|
||||
"positive",
|
||||
"negative",
|
||||
"model",
|
||||
"conditioning",
|
||||
"conditioning_1",
|
||||
"conditioning_to",
|
||||
"guider",
|
||||
"sigmas",
|
||||
"noise",
|
||||
"sampler",
|
||||
"clip",
|
||||
)
|
||||
and roll < 0.8
|
||||
):
|
||||
return self.link()
|
||||
if roll < 0.3:
|
||||
return self.link()
|
||||
if roll < 0.6:
|
||||
return self.pick(TEXTS)
|
||||
if roll < 0.85:
|
||||
return self.number()
|
||||
return self.pick(
|
||||
[True, False, None, [1, 2, 3], {"nested": "x"}, [], "randomize"]
|
||||
)
|
||||
|
||||
def inputs(self):
|
||||
if self.chance(0.04):
|
||||
return self.pick([[], ["text"], "text", 3, None, True])
|
||||
keys = self.random.sample(INPUT_KEYS, self.random.randint(0, 9))
|
||||
return {key: self.value(key) for key in keys}
|
||||
|
||||
def node(self):
|
||||
if self.chance(0.03):
|
||||
return self.pick([None, "node", 5, [1, 2]])
|
||||
node = {"class_type": self.pick(CLASS_TYPES), "inputs": self.inputs()}
|
||||
if self.chance(0.05):
|
||||
del node["class_type"]
|
||||
return node
|
||||
|
||||
def graph(self):
|
||||
ids = self.random.sample(NODE_IDS, self.random.randint(1, 12))
|
||||
if self.chance(0.03):
|
||||
return [self.node() for _ in ids]
|
||||
return {node_id: self.node() for node_id in ids}
|
||||
|
||||
def workflow_node(self, node_id):
|
||||
if self.chance(0.02):
|
||||
return self.pick([None, 3, "node"])
|
||||
node = {
|
||||
"id": node_id,
|
||||
"type": self.pick(WORKFLOW_TYPES),
|
||||
"mode": self.pick([0, 0, 0, 2, 4]),
|
||||
}
|
||||
inputs = []
|
||||
for _ in range(self.random.randint(0, 4)):
|
||||
item = {
|
||||
"name": self.pick(INPUT_KEYS),
|
||||
"link": self.pick([None, 1, 2, 3, 4, 5, 6, 7, 8, "2"]),
|
||||
"type": self.pick(["CONDITIONING", "MODEL", "CLIP", "*", "STRING"]),
|
||||
}
|
||||
if self.chance(0.2):
|
||||
item["widget"] = {"name": self.pick(INPUT_KEYS)}
|
||||
inputs.append(item)
|
||||
node["inputs"] = (
|
||||
inputs if not self.chance(0.03) else self.pick([{"a": 1}, "abc", None])
|
||||
)
|
||||
node["widgets_values"] = [
|
||||
self.value(self.pick(INPUT_KEYS)) for _ in range(self.random.randint(0, 6))
|
||||
]
|
||||
if self.chance(0.1):
|
||||
node["widgets_values"].insert(1, "randomize")
|
||||
if self.chance(0.05):
|
||||
node["widgets_values_named"] = {
|
||||
"text": self.pick(TEXTS),
|
||||
"seed": self.number(),
|
||||
}
|
||||
return node
|
||||
|
||||
def workflow(self):
|
||||
if self.chance(0.03):
|
||||
return self.pick([[], "workflow", {"nodes": "x"}, {"links": []}])
|
||||
ids = self.random.sample(
|
||||
[1, 2, 3, 4, 5, 6, 7, 8, "9", 10.0], self.random.randint(1, 8)
|
||||
)
|
||||
links = []
|
||||
for link_id in range(1, self.random.randint(1, 9)):
|
||||
origin, slot = self.pick(ids), self.pick([0, 1, 2])
|
||||
if self.chance(0.5):
|
||||
links.append(
|
||||
[
|
||||
link_id,
|
||||
origin,
|
||||
slot,
|
||||
self.pick(ids),
|
||||
0,
|
||||
self.pick(["CONDITIONING", "MODEL", "*", "STRING"]),
|
||||
]
|
||||
)
|
||||
else:
|
||||
links.append(
|
||||
{
|
||||
"id": link_id,
|
||||
"origin_id": origin,
|
||||
"origin_slot": slot,
|
||||
"type": self.pick(["CONDITIONING", "MODEL", "*"]),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"nodes": [self.workflow_node(node_id) for node_id in ids],
|
||||
"links": links,
|
||||
}
|
||||
|
||||
# --- coherent pipelines, then corrupted ------------------------------------
|
||||
|
||||
def text_source(self, graph, ids, depth=0):
|
||||
"""A node id producing text: an encoder, PromptManager, pair or a text chain."""
|
||||
node_id = ids.pop()
|
||||
kind = self.pick(
|
||||
["encoder", "manager", "pair", "concat", "primitive"]
|
||||
if depth < 2
|
||||
else ["encoder", "manager"]
|
||||
)
|
||||
if kind == "encoder":
|
||||
text = (
|
||||
self.pick(TEXTS)
|
||||
if self.chance(0.7)
|
||||
else [self.text_source(graph, ids, depth + 1), 0]
|
||||
)
|
||||
graph[node_id] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": text, "clip": ["4", 1]},
|
||||
}
|
||||
elif kind == "manager":
|
||||
graph[node_id] = {
|
||||
"class_type": self.pick(["PromptManager", "PromptManagerText"]),
|
||||
"inputs": {
|
||||
"text": self.pick(TEXTS),
|
||||
"prepend_text": self.pick(TEXTS + ["masterpiece"]),
|
||||
"append_text": self.pick(TEXTS + ["4k"]),
|
||||
"clip": ["4", 1],
|
||||
},
|
||||
}
|
||||
elif kind == "pair":
|
||||
graph[node_id] = {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {
|
||||
"text": self.pick(TEXTS),
|
||||
"negative_text": self.pick(TEXTS),
|
||||
"prepend_text": "",
|
||||
"append_text": "",
|
||||
},
|
||||
}
|
||||
elif kind == "concat":
|
||||
graph[node_id] = {
|
||||
"class_type": "StringConcatenate",
|
||||
"inputs": {
|
||||
"string_a": (
|
||||
[self.text_source(graph, ids, depth + 1), 0] if ids else "x"
|
||||
),
|
||||
"string_b": self.pick(TEXTS),
|
||||
"delimiter": self.pick([", ", " ", 3, "|"]),
|
||||
},
|
||||
}
|
||||
else:
|
||||
graph[node_id] = {
|
||||
"class_type": "PrimitiveString",
|
||||
"inputs": {"value": self.pick(TEXTS)},
|
||||
}
|
||||
return node_id
|
||||
|
||||
def conditioning(self, graph, ids, role):
|
||||
source = self.text_source(graph, ids)
|
||||
slot = (
|
||||
self.pick([0, 1, 2, 3])
|
||||
if graph[source]["class_type"] == "PromptManagerPair"
|
||||
else 0
|
||||
)
|
||||
link = [source, slot]
|
||||
for _ in range(self.pick([0, 0, 1, 2, 3])):
|
||||
if not ids:
|
||||
break
|
||||
node_id = ids.pop()
|
||||
key = self.pick([role, "conditioning", "conditioning_to", "conditioning_1"])
|
||||
graph[node_id] = {
|
||||
"class_type": self.pick(
|
||||
[
|
||||
"ControlNetApplyAdvanced",
|
||||
"ConditioningCombine",
|
||||
"ConditioningSetArea",
|
||||
]
|
||||
),
|
||||
"inputs": {key: link, "strength": 1.0},
|
||||
}
|
||||
link = [node_id, 0]
|
||||
return link
|
||||
|
||||
def pipeline(self):
|
||||
ids = [str(i) for i in self.random.sample(range(1, 400), 120)] + [
|
||||
"1300:7",
|
||||
"x",
|
||||
"constructor",
|
||||
]
|
||||
self.random.shuffle(ids)
|
||||
graph = {
|
||||
"4": {
|
||||
"class_type": self.pick(["CheckpointLoaderSimple", "UNETLoader"]),
|
||||
"inputs": {
|
||||
self.pick(
|
||||
["ckpt_name", "unet_name", "gguf_name"]
|
||||
): "model.safetensors"
|
||||
},
|
||||
}
|
||||
}
|
||||
for _ in range(self.pick([1, 1, 2, 3])):
|
||||
positive, negative = self.conditioning(
|
||||
graph, ids, "positive"
|
||||
), self.conditioning(graph, ids, "negative")
|
||||
settings = {
|
||||
"seed": self.number() if self.chance(0.8) else [ids.pop(), 0],
|
||||
"steps": self.number(),
|
||||
"cfg": self.number(),
|
||||
"sampler_name": self.pick(["euler", "lcm", "dpmpp_2m"]),
|
||||
}
|
||||
if settings["seed"] and isinstance(settings["seed"], list):
|
||||
graph[settings["seed"][0]] = {
|
||||
"class_type": "Seed",
|
||||
"inputs": {"seed": self.number(), "other": "x"},
|
||||
}
|
||||
if self.chance(0.25):
|
||||
guider = ids.pop()
|
||||
graph[guider] = {
|
||||
"class_type": "CFGGuider",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": positive,
|
||||
"negative": negative,
|
||||
"cfg": self.number(),
|
||||
},
|
||||
}
|
||||
noise, chooser, sigmas = ids.pop(), ids.pop(), ids.pop()
|
||||
graph[noise] = {
|
||||
"class_type": "RandomNoise",
|
||||
"inputs": {"noise_seed": self.number()},
|
||||
}
|
||||
graph[chooser] = {
|
||||
"class_type": "KSamplerSelect",
|
||||
"inputs": {"sampler_name": "euler"},
|
||||
}
|
||||
graph[sigmas] = {
|
||||
"class_type": "BasicScheduler",
|
||||
"inputs": {"steps": self.number()},
|
||||
}
|
||||
graph[ids.pop()] = {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {
|
||||
"noise": [noise, 0],
|
||||
"guider": [guider, 0],
|
||||
"sampler": [chooser, 0],
|
||||
"sigmas": [sigmas, 0],
|
||||
},
|
||||
}
|
||||
else:
|
||||
graph[ids.pop()] = {
|
||||
"class_type": self.pick(
|
||||
["KSampler", "KSamplerAdvanced", "FaceDetailer"]
|
||||
),
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": positive,
|
||||
"negative": negative,
|
||||
**settings,
|
||||
},
|
||||
}
|
||||
for _ in range(self.pick([0, 0, 1, 3])): # corruption
|
||||
node_id = self.pick(list(graph))
|
||||
node = graph[node_id]
|
||||
if (
|
||||
self.chance(0.5)
|
||||
and isinstance(node, dict)
|
||||
and isinstance(node.get("inputs"), dict)
|
||||
and node["inputs"]
|
||||
):
|
||||
key = self.pick(list(graph[node_id]["inputs"]))
|
||||
graph[node_id]["inputs"][key] = self.value(key)
|
||||
else:
|
||||
graph[node_id] = self.node()
|
||||
return graph
|
||||
|
||||
def pipeline_workflow(self):
|
||||
"""A connected workflow: loader, encoders, Reroute or Set/Get hops, sampler."""
|
||||
nodes, links = [], []
|
||||
|
||||
def link(origin, slot, kind):
|
||||
links.append(
|
||||
[len(links) + 1, origin, slot, 0, 0, kind]
|
||||
if self.chance(0.6)
|
||||
else {
|
||||
"id": len(links) + 1,
|
||||
"origin_id": origin,
|
||||
"origin_slot": slot,
|
||||
"type": kind,
|
||||
}
|
||||
)
|
||||
return len(links)
|
||||
|
||||
nodes.append(
|
||||
{
|
||||
"id": 4,
|
||||
"type": self.pick(
|
||||
["CheckpointLoaderSimple", "UNETLoader", "MyUnetLoader"]
|
||||
),
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"widgets_values": ["model.safetensors"],
|
||||
}
|
||||
)
|
||||
refs = []
|
||||
for index, role in enumerate(("positive", "negative")):
|
||||
encoder = 10 + index
|
||||
nodes.append(
|
||||
{
|
||||
"id": encoder,
|
||||
"type": self.pick(
|
||||
["CLIPTextEncode", "PromptManager", "PromptManagerText"]
|
||||
),
|
||||
"mode": self.pick([0, 0, 0, 4]),
|
||||
"inputs": [
|
||||
{"name": "clip", "link": link(4, 1, "CLIP"), "type": "CLIP"}
|
||||
],
|
||||
"widgets_values": [
|
||||
self.pick(TEXTS),
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
self.pick(TEXTS),
|
||||
self.pick(TEXTS),
|
||||
],
|
||||
}
|
||||
)
|
||||
ref = link(encoder, 0, "CONDITIONING")
|
||||
hop = self.pick(["none", "none", "reroute", "setget"])
|
||||
if hop == "reroute":
|
||||
nodes.append(
|
||||
{
|
||||
"id": 20 + index,
|
||||
"type": "Reroute",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "", "link": ref}],
|
||||
}
|
||||
)
|
||||
ref = link(20 + index, 0, "*")
|
||||
elif hop == "setget":
|
||||
name = self.pick(["cond_" + role, "shared"])
|
||||
nodes.append(
|
||||
{
|
||||
"id": 30 + index,
|
||||
"type": "SetNode",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "c", "link": ref}],
|
||||
"widgets_values": [name],
|
||||
}
|
||||
)
|
||||
nodes.append(
|
||||
{
|
||||
"id": 40 + index,
|
||||
"type": "GetNode",
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"widgets_values": [name],
|
||||
}
|
||||
)
|
||||
ref = link(40 + index, 0, "CONDITIONING")
|
||||
refs.append(ref)
|
||||
widgets = [
|
||||
self.number(),
|
||||
self.pick(["randomize", "fixed", "x"]),
|
||||
self.number(),
|
||||
self.number(),
|
||||
"euler",
|
||||
"normal",
|
||||
1,
|
||||
]
|
||||
nodes.append(
|
||||
{
|
||||
"id": 3,
|
||||
"type": self.pick(["KSampler", "KSamplerAdvanced"]),
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "link": link(4, 0, "MODEL"), "type": "MODEL"},
|
||||
{"name": "positive", "link": refs[0], "type": "CONDITIONING"},
|
||||
{"name": "negative", "link": refs[1], "type": "CONDITIONING"},
|
||||
],
|
||||
"widgets_values": widgets,
|
||||
}
|
||||
)
|
||||
if self.chance(0.3):
|
||||
nodes[self.random.randrange(len(nodes))] = self.workflow_node(
|
||||
self.pick([1, 2, 99])
|
||||
)
|
||||
return {"nodes": nodes, "links": links}
|
||||
|
||||
def case(self):
|
||||
roll = self.random.random()
|
||||
structured = self.chance(0.7)
|
||||
prompt = (
|
||||
json.dumps(self.pipeline() if structured else self.graph())
|
||||
if roll < 0.75
|
||||
else None
|
||||
)
|
||||
workflow = (
|
||||
json.dumps(self.pipeline_workflow() if structured else self.workflow())
|
||||
if roll > 0.45
|
||||
else None
|
||||
)
|
||||
if self.chance(0.02):
|
||||
prompt = self.pick(['{"1": NaN}', "{", "null", "[]", '"text"', "12"])
|
||||
return {"prompt": prompt, "workflow": workflow}
|
||||
|
||||
|
||||
@unittest.skipUnless(NODE, "Node is required to run the browser parser")
|
||||
class RandomGraphs(unittest.TestCase):
|
||||
def test_random_graphs_match_the_browser_parser(self):
|
||||
for seed in SEEDS:
|
||||
generator = Generator(seed)
|
||||
cases = [generator.case() for _ in range(CASE_COUNT)]
|
||||
expected = run_oracle(cases)
|
||||
self.assertEqual(len(expected), len(cases))
|
||||
mismatches = []
|
||||
for index, (case, result) in enumerate(zip(cases, expected)):
|
||||
actual = run_python(case)
|
||||
if not js_equal(actual, result):
|
||||
mismatches.append((index, case, actual, result))
|
||||
with self.subTest(seed=seed):
|
||||
outcomes = {
|
||||
"ok": sum(r["ok"] for r in expected),
|
||||
"throws": sum(not r["ok"] for r in expected),
|
||||
}
|
||||
self.assertEqual(
|
||||
mismatches[:3],
|
||||
[],
|
||||
f"{len(mismatches)} of {len(cases)} differ; outcomes {outcomes}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,155 @@
|
||||
"""The Python generation-parameter parser equals the browser parser.
|
||||
|
||||
Golden vectors (tests/fixtures/generation_params/vectors.json) hold the browser
|
||||
parser's results for every named case and every PNG fixture, so this runs on
|
||||
machines without Node. With Node, the live browser parser is compared too and
|
||||
the vectors are checked for staleness. Regenerate them with:
|
||||
|
||||
python tests/test_generation_params_js_parity.py --write-vectors
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
import subprocess # nosec B404 - runs the repository's own Node oracle
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from utils.generation_params import ( # noqa: E402
|
||||
GenerationParamsError,
|
||||
extract_from_records,
|
||||
)
|
||||
|
||||
try:
|
||||
from tests.generation_params_cases import CASES # noqa: E402
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from generation_params_cases import CASES # noqa: E402
|
||||
|
||||
VECTORS = ROOT / "tests" / "fixtures" / "generation_params" / "vectors.json"
|
||||
ORACLE = ROOT / "tests" / "js" / "support" / "generation-params-oracle.mjs"
|
||||
FIXTURE_DIRS = [ROOT / "tests" / "fixtures"]
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def fixture_cases():
|
||||
"""Text records of every PNG fixture, keyed by its repository path."""
|
||||
from PIL import Image
|
||||
|
||||
cases = {}
|
||||
for directory in FIXTURE_DIRS:
|
||||
for path in sorted(directory.rglob("*.png")):
|
||||
with Image.open(path) as image:
|
||||
info = {k: v for k, v in image.info.items() if isinstance(v, str)}
|
||||
if "prompt" in info or "workflow" in info:
|
||||
name = path.relative_to(ROOT).as_posix()
|
||||
cases[name] = {
|
||||
"prompt": info.get("prompt"),
|
||||
"workflow": info.get("workflow"),
|
||||
}
|
||||
return cases
|
||||
|
||||
|
||||
def all_cases():
|
||||
return {**{f"case:{k}": v for k, v in CASES.items()}, **fixture_cases()}
|
||||
|
||||
|
||||
def run_oracle(cases):
|
||||
"""Browser parser results for case records, in order."""
|
||||
lines = "".join(json.dumps(case) + "\n" for case in cases)
|
||||
completed = subprocess.run( # nosec B603 - fixed argv, no shell
|
||||
[NODE, str(ORACLE)],
|
||||
input=lines,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
timeout=600,
|
||||
)
|
||||
return [json.loads(line) for line in completed.stdout.splitlines()]
|
||||
|
||||
|
||||
def run_python(case):
|
||||
try:
|
||||
return {
|
||||
"ok": True,
|
||||
"result": extract_from_records(case["prompt"], case["workflow"]),
|
||||
}
|
||||
except GenerationParamsError:
|
||||
return {"ok": False}
|
||||
|
||||
|
||||
def js_equal(left, right):
|
||||
"""Equality as JavaScript values: 1 == 1.0, but true is not 1."""
|
||||
if isinstance(left, bool) or isinstance(right, bool):
|
||||
return isinstance(left, bool) and isinstance(right, bool) and left == right
|
||||
if isinstance(left, (int, float)) and isinstance(right, (int, float)):
|
||||
# Every JavaScript number is a double; compare as doubles.
|
||||
left, right = float(left), float(right)
|
||||
return left == right or (math.isnan(left) and math.isnan(right))
|
||||
if isinstance(left, list) and isinstance(right, list):
|
||||
return len(left) == len(right) and all(
|
||||
js_equal(a, b) for a, b in zip(left, right)
|
||||
)
|
||||
if isinstance(left, dict) and isinstance(right, dict):
|
||||
return left.keys() == right.keys() and all(
|
||||
js_equal(left[k], right[k]) for k in left
|
||||
)
|
||||
return type(left) is type(right) and left == right
|
||||
|
||||
|
||||
def write_vectors():
|
||||
cases = all_cases()
|
||||
results = run_oracle(cases.values())
|
||||
vectors = [
|
||||
{"name": name, **case, "expected": result}
|
||||
for (name, case), result in zip(cases.items(), results)
|
||||
]
|
||||
VECTORS.parent.mkdir(parents=True, exist_ok=True)
|
||||
VECTORS.write_text(json.dumps(vectors, indent=1, sort_keys=True) + "\n", "utf-8")
|
||||
print(f"wrote {len(vectors)} vectors to {VECTORS.relative_to(ROOT)}")
|
||||
|
||||
|
||||
class GoldenVectors(unittest.TestCase):
|
||||
def test_every_vector_matches(self):
|
||||
vectors = json.loads(VECTORS.read_text("utf-8"))
|
||||
self.assertGreater(len(vectors), len(CASES))
|
||||
for vector in vectors:
|
||||
with self.subTest(vector=vector["name"]):
|
||||
actual = run_python(vector)
|
||||
self.assertTrue(
|
||||
js_equal(actual, vector["expected"]), (actual, vector["expected"])
|
||||
)
|
||||
|
||||
def test_vectors_cover_every_case_and_fixture(self):
|
||||
names = {vector["name"] for vector in json.loads(VECTORS.read_text("utf-8"))}
|
||||
self.assertEqual(names, set(all_cases()))
|
||||
|
||||
|
||||
@unittest.skipUnless(NODE, "Node is required to run the browser parser")
|
||||
class LiveBrowserParser(unittest.TestCase):
|
||||
def test_live_results_match_python_and_the_vectors(self):
|
||||
cases = all_cases()
|
||||
stored = {
|
||||
vector["name"]: vector["expected"]
|
||||
for vector in json.loads(VECTORS.read_text("utf-8"))
|
||||
}
|
||||
for (name, case), expected in zip(cases.items(), run_oracle(cases.values())):
|
||||
with self.subTest(case=name):
|
||||
self.assertTrue(
|
||||
js_equal(run_python(case), expected), (run_python(case), expected)
|
||||
)
|
||||
self.assertTrue(
|
||||
js_equal(stored[name], expected),
|
||||
"vectors are stale: regenerate them",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--write-vectors" in sys.argv:
|
||||
write_vectors()
|
||||
else:
|
||||
unittest.main()
|
||||
@@ -449,6 +449,46 @@ class CurrentLocationTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(reads, [], "Unsupported media must not reach the parser")
|
||||
|
||||
def test_preview_files_in_the_host_temp_folder_keep_preview_only_stages(self):
|
||||
# Saved raw image plus a FaceDetailer that only previews (Codex P2).
|
||||
import json
|
||||
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
from tests import inspection_parity as parity
|
||||
|
||||
service = self.module("py.api.inspection_service")
|
||||
graph = parity.build("f19")["prompt"]
|
||||
graph["9"]["class_type"] = "PreviewImage"
|
||||
graph["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}}
|
||||
info = PngInfo()
|
||||
info.add_text("prompt", json.dumps(graph))
|
||||
observed = {}
|
||||
for name, folder in (("output", self.output), ("temp", self.temp)):
|
||||
path = folder / "nested" / "same.png"
|
||||
path.parent.mkdir()
|
||||
with Image.new("RGB", (4, 4)) as image:
|
||||
image.save(path, pnginfo=info)
|
||||
observed[name] = service.inspect_current_file(
|
||||
path, source_label=name, saved=None, library={"status": "none"}
|
||||
)
|
||||
saved = observed["output"]["prompts"]
|
||||
self.assertEqual(saved["positive"]["status"], "resolved")
|
||||
self.assertEqual(saved["positive"]["text"], parity.POS)
|
||||
self.assertEqual(observed["output"]["resolution"]["sampler_id"], "3")
|
||||
preview = observed["temp"]["prompts"]
|
||||
self.assertEqual(preview["positive"]["status"], "ambiguous")
|
||||
self.assertIsNone(preview["positive"]["text"])
|
||||
# An unknown host temp folder treats the file as saved output.
|
||||
self.host_temp = None
|
||||
again = service.inspect_current_file(
|
||||
self.output / "nested" / "same.png",
|
||||
source_label="output",
|
||||
saved=None,
|
||||
library={"status": "none"},
|
||||
)
|
||||
self.assertEqual(again["prompts"]["positive"]["status"], "resolved")
|
||||
|
||||
|
||||
class HistoryImportTests(unittest.TestCase):
|
||||
def test_api_import_does_not_require_a_running_host_server(self):
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
"""Persisted image favorites (spec D7): store, schema and rooted API boundary."""
|
||||
|
||||
import json
|
||||
import ntpath
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from test_api_images import ImageAPITestCase, make_png
|
||||
from open_handles import assert_closed
|
||||
except ImportError:
|
||||
from tests.test_api_images import ImageAPITestCase, make_png
|
||||
from tests.open_handles import assert_closed
|
||||
|
||||
from database.image_favorites import (
|
||||
MAX_KEY_LENGTH,
|
||||
TABLE,
|
||||
image_favorites_schema_error,
|
||||
migrate_image_favorites,
|
||||
)
|
||||
from database.models import PromptModel
|
||||
from database.operations import PromptDatabase
|
||||
|
||||
FAVORITE = "/prompt_manager/images/favorite"
|
||||
|
||||
|
||||
class TestImageFavoriteApi(ImageAPITestCase):
|
||||
async def tearDownAsync(self):
|
||||
self.api.db.close_all()
|
||||
assert_closed(self, self._temp_db.name, self._output_tmp.name)
|
||||
await super().tearDownAsync()
|
||||
|
||||
async def send(self, method, body):
|
||||
response = await self.client.request(method, FAVORITE, json=body)
|
||||
return response, await response.json()
|
||||
|
||||
async def workspace(self, **params):
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/images/output", params={"mode": "workspace", **params}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
data = await response.json()
|
||||
return {image["path"]: image for image in data["images"]}
|
||||
|
||||
async def test_favorite_round_trip_and_idempotent(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
make_png(self.output_dir / "b.png")
|
||||
items = await self.workspace()
|
||||
self.assertIs(items["sub/a.png"]["favorite"], False)
|
||||
self.assertIs(items["b.png"]["favorite"], False)
|
||||
|
||||
body = {"root_index": 0, "path": "sub/a.png"}
|
||||
for _ in range(2): # a repeated PUT is a no-op, not an error
|
||||
response, data = await self.send("PUT", body)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(
|
||||
data,
|
||||
{
|
||||
"success": True,
|
||||
"favorite": True,
|
||||
"root_index": 0,
|
||||
"path": "sub/a.png",
|
||||
},
|
||||
)
|
||||
items = await self.workspace()
|
||||
self.assertIs(items["sub/a.png"]["favorite"], True)
|
||||
self.assertIs(items["b.png"]["favorite"], False)
|
||||
count = self.api.db.model.get_connection().execute(
|
||||
f"SELECT COUNT(*) FROM {TABLE}"
|
||||
)
|
||||
self.assertEqual(count.fetchone()[0], 1)
|
||||
|
||||
for _ in range(2):
|
||||
response, data = await self.send("DELETE", body)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertIs(data["favorite"], False)
|
||||
items = await self.workspace()
|
||||
self.assertIs(items["sub/a.png"]["favorite"], False)
|
||||
|
||||
def rows(self):
|
||||
return [
|
||||
tuple(row)
|
||||
for row in self.api.db.model.get_connection().execute(
|
||||
f"SELECT root_key, relative_path FROM {TABLE} ORDER BY 1, 2"
|
||||
)
|
||||
]
|
||||
|
||||
async def test_favorite_is_keyed_by_root_not_index(self):
|
||||
second = self.comfy_dir / "second"
|
||||
make_png(second / "a.png")
|
||||
make_png(self.output_dir / "a.png")
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
||||
response, data = await self.send("PUT", {"root_index": 1, "path": "a.png"})
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertNotIn(str(self.comfy_dir), json.dumps(data))
|
||||
root_key = Path(os.path.normcase(os.path.realpath(second))).as_posix()
|
||||
self.assertEqual(self.rows(), [(root_key, "a.png")])
|
||||
# The same root at another index keeps its favorite; the other root
|
||||
# with the same relative path does not inherit it.
|
||||
self.api._get_all_output_dirs = lambda: [second, self.output_dir]
|
||||
self.api.invalidate_gallery_cache()
|
||||
self.assertIs((await self.workspace(root=0))["a.png"]["favorite"], True)
|
||||
self.assertIs((await self.workspace(root=1))["a.png"]["favorite"], False)
|
||||
|
||||
async def test_roots_sharing_a_folder_name_keep_separate_favorites(self):
|
||||
# Two roots under different parents, both shown as "output".
|
||||
with tempfile.TemporaryDirectory() as other_parent:
|
||||
other = Path(os.path.realpath(other_parent)) / "output"
|
||||
make_png(other / "a.png")
|
||||
make_png(self.output_dir / "a.png")
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, other]
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/images/output", params={"mode": "workspace"}
|
||||
)
|
||||
labels = {
|
||||
item["root_index"]: item["root_dir"]
|
||||
for item in (await response.json())["images"]
|
||||
}
|
||||
self.assertEqual(labels, {0: "output", 1: "output"})
|
||||
|
||||
await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
||||
self.assertIs((await self.workspace(root=0))["a.png"]["favorite"], True)
|
||||
self.assertIs((await self.workspace(root=1))["a.png"]["favorite"], False)
|
||||
|
||||
await self.send("PUT", {"root_index": 1, "path": "a.png"})
|
||||
response, data = await self.send(
|
||||
"DELETE", {"root_index": 0, "path": "a.png"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertNotIn(other_parent, json.dumps(data))
|
||||
self.assertIs((await self.workspace(root=0))["a.png"]["favorite"], False)
|
||||
self.assertIs((await self.workspace(root=1))["a.png"]["favorite"], True)
|
||||
listing = await self.client.get(
|
||||
"/prompt_manager/images/output", params={"mode": "workspace"}
|
||||
)
|
||||
self.assertNotIn(other_parent, await listing.text())
|
||||
|
||||
async def test_windows_case_only_rename_keeps_the_star(self):
|
||||
make_png(self.output_dir / "Sub" / "A.png")
|
||||
with patch("py.api.images._path_flavor", ntpath):
|
||||
response, data = await self.send(
|
||||
"PUT", {"root_index": 0, "path": "Sub/A.png"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(data["path"], "Sub/A.png") # the listing's spelling
|
||||
(self.output_dir / "Sub" / "A.png").rename(
|
||||
self.output_dir / "Sub" / "a.png"
|
||||
)
|
||||
(self.output_dir / "Sub").rename(self.output_dir / "sub")
|
||||
self.api.invalidate_gallery_cache()
|
||||
self.assertIs((await self.workspace())["sub/a.png"]["favorite"], True)
|
||||
response, data = await self.send(
|
||||
"DELETE", {"root_index": 0, "path": "SUB/A.PNG"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(data["path"], "sub/a.png")
|
||||
self.assertIs((await self.workspace())["sub/a.png"]["favorite"], False)
|
||||
self.assertEqual(self.rows(), [])
|
||||
|
||||
async def test_posix_case_distinct_files_keep_independent_stars(self):
|
||||
make_png(self.output_dir / "A.png")
|
||||
make_png(self.output_dir / "a.png")
|
||||
if len(list(self.output_dir.iterdir())) != 2:
|
||||
self.skipTest("case-insensitive filesystem")
|
||||
await self.send("PUT", {"root_index": 0, "path": "A.png"})
|
||||
items = await self.workspace()
|
||||
self.assertIs(items["A.png"]["favorite"], True)
|
||||
self.assertIs(items["a.png"]["favorite"], False)
|
||||
|
||||
async def test_favorite_rejects_traversal_and_unknown_root(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
make_png(self.comfy_dir / "outside.png")
|
||||
(self.output_dir / "notes.txt").write_text("not media")
|
||||
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
|
||||
cases = [
|
||||
({"root_index": 0, "path": "../outside.png"}, 403),
|
||||
({"root_index": 0, "path": "sub/../../outside.png"}, 403),
|
||||
({"root_index": 0, "path": str(self.comfy_dir / "outside.png")}, 403),
|
||||
({"root_index": 0, "path": "/a.png"}, 403),
|
||||
({"root_index": 0, "path": "C:/a.png"}, 403),
|
||||
({"root_index": 0, "path": "a.png\0.png"}, 403),
|
||||
({"root_index": 0, "path": "notes.txt"}, 403),
|
||||
({"root_index": 1, "path": "a.png"}, 400),
|
||||
({"root_index": -1, "path": "a.png"}, 400),
|
||||
({"root_index": "0", "path": "a.png"}, 400),
|
||||
({"root_index": True, "path": "a.png"}, 400),
|
||||
({"root_index": 0}, 400),
|
||||
({"root_index": 0, "path": ""}, 400),
|
||||
({"root_index": 0, "path": 7}, 400),
|
||||
({"root_index": 0, "path": "x" * 5000 + ".png"}, 400),
|
||||
({"root_index": 0, "path": "missing.png"}, 404),
|
||||
({"root_index": 0, "path": "thumbnails/a_thumb.png"}, 404),
|
||||
]
|
||||
for method in ("PUT", "DELETE"):
|
||||
for body, status in cases:
|
||||
if method == "DELETE" and status == 404:
|
||||
continue # unlisting a stale favorite is allowed (below)
|
||||
with self.subTest(method=method, body=str(body)[:60]):
|
||||
response, data = await self.send(method, body)
|
||||
self.assertEqual(response.status, status)
|
||||
self.assertIs(data["success"], False)
|
||||
self.assertNotIn(str(self.comfy_dir), data["error"])
|
||||
response = await self.client.put(FAVORITE, data=b"[1]")
|
||||
self.assertEqual(response.status, 400)
|
||||
response = await self.client.put(FAVORITE, data=b"{not json")
|
||||
self.assertEqual(response.status, 400)
|
||||
count = self.api.db.model.get_connection().execute(
|
||||
f"SELECT COUNT(*) FROM {TABLE}"
|
||||
)
|
||||
self.assertEqual(count.fetchone()[0], 0)
|
||||
|
||||
async def test_delete_clears_a_favorite_whose_file_is_gone(self):
|
||||
target = make_png(self.output_dir / "gone.png")
|
||||
await self.send("PUT", {"root_index": 0, "path": "gone.png"})
|
||||
target.unlink()
|
||||
self.api.invalidate_gallery_cache()
|
||||
response, data = await self.send(
|
||||
"DELETE", {"root_index": 0, "path": "gone.png"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIs(data["favorite"], False)
|
||||
count = self.api.db.model.get_connection().execute(
|
||||
f"SELECT COUNT(*) FROM {TABLE}"
|
||||
)
|
||||
self.assertEqual(count.fetchone()[0], 0)
|
||||
|
||||
async def test_legacy_listing_is_unchanged_by_favorites(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
||||
response = await self.client.get("/prompt_manager/images/output")
|
||||
data = await response.json()
|
||||
self.assertEqual(len(data["images"]), 1)
|
||||
self.assertNotIn("favorite", data["images"][0])
|
||||
|
||||
async def test_toggle_lookup_is_built_once_per_scan(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
files = await self.api._get_gallery_files(self.output_dir)
|
||||
first = self.api._favorite_listing(self.output_dir, files)
|
||||
self.assertEqual(first, {"a.png": "a.png"})
|
||||
self.assertIs(self.api._favorite_listing(self.output_dir, files), first)
|
||||
make_png(self.output_dir / "b.png")
|
||||
self.api.invalidate_gallery_cache()
|
||||
rescanned = await self.api._get_gallery_files(self.output_dir)
|
||||
self.assertEqual(
|
||||
self.api._favorite_listing(self.output_dir, rescanned),
|
||||
{"a.png": "a.png", "b.png": "b.png"},
|
||||
)
|
||||
self.api.reset_gallery_roots_cache()
|
||||
self.assertEqual(self.api._favorite_listing_cache, {})
|
||||
|
||||
async def test_monitor_invalidation_drops_the_favorites_lookup(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
||||
self.assertNotEqual(self.api._favorite_listing_cache, {})
|
||||
# The image monitor's invalidation must not keep the old scan alive.
|
||||
self.api.invalidate_gallery_cache()
|
||||
self.assertEqual(self.api._favorite_listing_cache, {})
|
||||
self.assertIs((await self.workspace())["a.png"]["favorite"], True)
|
||||
|
||||
async def test_put_rejects_a_key_beyond_the_stored_bound(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
too_long = "/" + "r" * MAX_KEY_LENGTH
|
||||
with patch("py.api.images._favorite_root_key", return_value=too_long):
|
||||
response, data = await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
||||
self.assertEqual(response.status, 403)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertNotIn(too_long, json.dumps(data))
|
||||
self.assertEqual(self.rows(), [])
|
||||
# The live database still validates (a stored over-long key would
|
||||
# fail startup and restore).
|
||||
conn = self.api.db.model.get_connection()
|
||||
self.assertIsNone(image_favorites_schema_error(conn))
|
||||
|
||||
async def test_favorite_store_failure_keeps_the_listing(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
|
||||
def broken(*_args, **_kwargs):
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
self.api.db.get_image_favorites = broken
|
||||
items = await self.workspace()
|
||||
self.assertIs(items["a.png"]["favorite"], False)
|
||||
self.api.db.set_image_favorite = broken
|
||||
response, data = await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
||||
self.assertEqual(response.status, 500)
|
||||
self.assertEqual(data, {"success": False, "error": "Favorite update failed"})
|
||||
|
||||
|
||||
class TestImageFavoriteSchema(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.path = os.path.join(self.tmpdir, "favorites.db")
|
||||
|
||||
def tearDown(self):
|
||||
PromptModel.close_all_instances()
|
||||
assert_closed(self, self.tmpdir)
|
||||
for name in os.listdir(self.tmpdir):
|
||||
os.unlink(os.path.join(self.tmpdir, name))
|
||||
os.rmdir(self.tmpdir)
|
||||
|
||||
def test_store_round_trip_and_bounded_lookup(self):
|
||||
db = PromptDatabase(self.path)
|
||||
try:
|
||||
self.assertTrue(db.set_image_favorite("output", "a.png"))
|
||||
self.assertTrue(db.set_image_favorite("output", "a.png"))
|
||||
db.set_image_favorite("other", "a.png")
|
||||
self.assertEqual(
|
||||
db.get_image_favorites("output", ["a.png", "b.png"]), {"a.png"}
|
||||
)
|
||||
self.assertEqual(db.get_image_favorites("output", []), set())
|
||||
many = [f"{n}.png" for n in range(2000)] + ["a.png"]
|
||||
self.assertEqual(db.get_image_favorites("output", many), {"a.png"})
|
||||
self.assertTrue(db.clear_image_favorite("output", "a.png"))
|
||||
self.assertFalse(db.clear_image_favorite("output", "a.png"))
|
||||
self.assertEqual(db.get_image_favorites("output", ["a.png"]), set())
|
||||
created = db.model.get_connection().execute(
|
||||
f"SELECT created_at FROM {TABLE}"
|
||||
)
|
||||
self.assertRegex(created.fetchone()[0], r"^\d{4}-\d\d-\d\dT")
|
||||
finally:
|
||||
db.close_all()
|
||||
|
||||
def test_schema_rejects_unsupported_definitions_and_values(self):
|
||||
variants = {
|
||||
"missing": None,
|
||||
"wrong table": f"CREATE TABLE {TABLE} (x TEXT)",
|
||||
"view": f"CREATE VIEW {TABLE} AS SELECT 1",
|
||||
"nullable key": (
|
||||
f"CREATE TABLE {TABLE} (root_key TEXT, relative_path TEXT, "
|
||||
"created_at TEXT, PRIMARY KEY(root_key, relative_path))"
|
||||
),
|
||||
}
|
||||
for label, ddl in variants.items():
|
||||
with self.subTest(label):
|
||||
with closing(sqlite3.connect(":memory:")) as conn:
|
||||
if ddl:
|
||||
conn.execute(ddl)
|
||||
error = image_favorites_schema_error(conn)
|
||||
if ddl is None:
|
||||
self.assertIsNone(error)
|
||||
self.assertIsNotNone(
|
||||
image_favorites_schema_error(conn, allow_missing=False)
|
||||
)
|
||||
else:
|
||||
self.assertIsNotNone(error)
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
migrate_image_favorites(conn)
|
||||
with closing(sqlite3.connect(":memory:")) as conn:
|
||||
migrate_image_favorites(conn)
|
||||
migrate_image_favorites(conn) # idempotent
|
||||
self.assertIsNone(image_favorites_schema_error(conn, allow_missing=False))
|
||||
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', 'a.png', 'now')")
|
||||
self.assertIsNone(image_favorites_schema_error(conn))
|
||||
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', x'00ff', 'now')")
|
||||
self.assertIsNotNone(image_favorites_schema_error(conn))
|
||||
conn.execute(f"DELETE FROM {TABLE} WHERE typeof(relative_path)='blob'")
|
||||
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', ?, 'now')", ("x" * 5000,))
|
||||
self.assertIsNotNone(image_favorites_schema_error(conn))
|
||||
conn.execute(f"DELETE FROM {TABLE}")
|
||||
conn.execute(f"CREATE INDEX extra ON {TABLE}(created_at)")
|
||||
self.assertIsNotNone(image_favorites_schema_error(conn))
|
||||
|
||||
def test_verify_rejects_a_backup_with_an_invalid_favorites_table(self):
|
||||
db = PromptDatabase(self.path)
|
||||
db.close_all()
|
||||
PromptModel.close_all_instances()
|
||||
with closing(sqlite3.connect(self.path)) as conn:
|
||||
conn.execute(f"DROP TABLE {TABLE}")
|
||||
conn.execute(f"CREATE TABLE {TABLE} (anything TEXT)")
|
||||
conn.commit()
|
||||
ok, reason = PromptModel.verify_database_file(self.path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("favorites", reason.lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,6 +8,7 @@ leaves it unresolved, while the inspection profile resolves it.
|
||||
|
||||
import logging
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from utils import prompt_pair_resolver as resolver
|
||||
@@ -72,12 +73,19 @@ class TestIngestionKeepsDefaultProfile(unittest.TestCase):
|
||||
|
||||
mixin = admin.AdminRoutesMixin.__new__(admin.AdminRoutesMixin)
|
||||
mixin.db = Mock()
|
||||
mixin.db.get_prompt_by_hash.return_value = None
|
||||
mixin.db.save_prompt.return_value = 7
|
||||
spy = _Spy()
|
||||
with patch.object(admin, "resolve_prompt_pair", spy):
|
||||
status = mixin._ingest_scanned_file("/out/f03.png", _f03_metadata())
|
||||
self.assertEqual(status, "unresolved")
|
||||
status = mixin._ingest_scanned_file(Path("/out/f03.png"), _f03_metadata())
|
||||
spy.assert_default_and_gated(self)
|
||||
mixin.db.assert_not_called()
|
||||
# The resolver leaves f03 unresolved; the scan links the prompt the
|
||||
# browser parser traces, as the inspector shows it.
|
||||
self.assertEqual(status, "added")
|
||||
self.assertEqual(
|
||||
mixin.db.save_prompt.call_args.args[0], "a red fox in snow, detailed fur"
|
||||
)
|
||||
mixin.db.link_image_to_prompt.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,943 @@
|
||||
"""Legacy parser rules ported into the inspection resolver (#240, Task 2.7b).
|
||||
|
||||
Every graph here is synthetic (neutral texts, generic names). Each class names
|
||||
the rule from the parser reconciliation (R1, R3, ...) it ports. The default
|
||||
(ingestion) profile must stay exactly as it was, so each rule also checks that
|
||||
``DEFAULT_PROFILE`` ignores it.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
import unittest.mock
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from utils import prompt_pair_resolver as resolver
|
||||
from utils.prompt_inspector import (
|
||||
DUPLICATE_ROLE_BYTES,
|
||||
MetadataReadError,
|
||||
inspect_prompt_evidence,
|
||||
read_inspection_file,
|
||||
)
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
|
||||
try:
|
||||
from tests import inspection_parity as parity
|
||||
except ImportError: # discovered with ``-s tests``
|
||||
import inspection_parity as parity
|
||||
|
||||
|
||||
def _inspect(metadata: dict, **options) -> dict:
|
||||
return resolve_prompt_pair(metadata, profile=resolver.INSPECTION_PROFILE, **options)
|
||||
|
||||
|
||||
def _texts(result: dict) -> tuple:
|
||||
return tuple(
|
||||
result[role]["text"] if isinstance(result[role], dict) else None
|
||||
for role in ("positive", "negative")
|
||||
)
|
||||
|
||||
|
||||
def _pm_encoder(text: str) -> dict:
|
||||
"""A PromptManager node whose API class was rewritten to CLIPTextEncode."""
|
||||
return {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": text,
|
||||
"category": "",
|
||||
"tags": "",
|
||||
"search_text": "",
|
||||
"clip": ["4", 1],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _overwritten(widgets=("a cat", "a dog", "a bird"), api_text="a bird") -> dict:
|
||||
"""Pre-3.2.3 shape: three PromptManager nodes, every API ``text`` overwritten.
|
||||
|
||||
KSampler 3 reads node 17 (widget A) for its positive; the saved API text of
|
||||
17, 113 and 119 is the last-run node's prompt (C) for all three.
|
||||
"""
|
||||
prompt = parity.build("f02")["prompt"]
|
||||
prompt["3"]["inputs"]["positive"] = ["17", 0]
|
||||
del prompt["6"]
|
||||
for node_id in ("17", "113", "119"):
|
||||
prompt[node_id] = _pm_encoder(api_text)
|
||||
workflow = {
|
||||
"nodes": [
|
||||
{"id": int(node_id), "type": "PromptManager", "widgets_values": [widget]}
|
||||
for node_id, widget in zip(("17", "113", "119"), widgets)
|
||||
]
|
||||
+ [{"id": 7, "type": "CLIPTextEncode", "widgets_values": [parity.NEG]}],
|
||||
"links": [],
|
||||
}
|
||||
return {"prompt": prompt, "workflow": workflow}
|
||||
|
||||
|
||||
class TestOverwrittenPromptManagerText(unittest.TestCase):
|
||||
"""R1 (comfy-metadata.js:52-86): never resolve to the overwritten text."""
|
||||
|
||||
def test_overwritten_text_is_refused_not_resolved(self):
|
||||
result = _inspect(_overwritten())
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "malformed")
|
||||
self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON)
|
||||
self.assertIsNone(result["positive"])
|
||||
|
||||
def test_the_inspector_passes_the_workflow_to_the_resolver(self):
|
||||
metadata = _overwritten()
|
||||
evidence = inspect_prompt_evidence(
|
||||
{
|
||||
"prompt": json.dumps(metadata["prompt"]),
|
||||
"workflow": json.dumps(metadata["workflow"]),
|
||||
}
|
||||
)
|
||||
self.assertEqual(evidence["resolution"]["reason_code"], "malformed")
|
||||
self.assertEqual(evidence["prompts"]["positive"]["status"], "unresolved")
|
||||
self.assertNotEqual(evidence["prompts"]["positive"]["text"], "a bird")
|
||||
self.assertEqual(
|
||||
evidence["prompts"]["positive"]["message"],
|
||||
"Prompt metadata in this file is malformed.",
|
||||
)
|
||||
|
||||
def test_untouched_shapes_still_resolve(self):
|
||||
cases = {
|
||||
# One PromptManager node, or several whose widgets agree.
|
||||
"single": ("a bird",),
|
||||
"agreeing widgets": ("a bird", "a bird", "a bird"),
|
||||
}
|
||||
for label, widgets in cases.items():
|
||||
with self.subTest(label):
|
||||
metadata = _overwritten()
|
||||
metadata["workflow"]["nodes"] = [
|
||||
node
|
||||
for node in metadata["workflow"]["nodes"]
|
||||
if node["type"] != "PromptManager"
|
||||
] + [
|
||||
{"id": node_id, "type": "PromptManager", "widgets_values": [text]}
|
||||
for node_id, text in zip((17, 113, 119), widgets)
|
||||
]
|
||||
self.assertEqual(_inspect(metadata)["state"], "resolved")
|
||||
# Distinct API texts are what ran, whatever the widgets say.
|
||||
metadata = _overwritten()
|
||||
metadata["prompt"]["113"]["inputs"]["text"] = "a dog"
|
||||
metadata["prompt"]["119"]["inputs"]["text"] = "a cat"
|
||||
metadata["prompt"]["17"]["inputs"]["text"] = "a fish"
|
||||
self.assertEqual(_texts(_inspect(metadata)), ("a fish", parity.NEG))
|
||||
# A PromptManagerPair widget is its positive and negative: not the signature.
|
||||
metadata = _overwritten()
|
||||
for node in metadata["workflow"]["nodes"]:
|
||||
node["type"] = node["type"].replace("PromptManager", "PromptManagerPair")
|
||||
self.assertEqual(_inspect(metadata)["state"], "resolved")
|
||||
# No workflow, or a malformed one, cannot show the signature.
|
||||
orphans = {
|
||||
"nodes": [{"id": 5, "type": "PromptManager", "widgets_values": ["a"]}]
|
||||
}
|
||||
for workflow in (None, "nodes", {"nodes": "x"}, {"nodes": [1, None]}, orphans):
|
||||
with self.subTest(workflow=workflow):
|
||||
metadata = _overwritten()
|
||||
metadata["workflow"] = workflow
|
||||
self.assertEqual(_inspect(metadata)["state"], "resolved")
|
||||
|
||||
def test_an_untouched_branch_beside_overwritten_nodes_resolves(self):
|
||||
# Only the overwritten nodes are refused: the negative is not one.
|
||||
metadata = _overwritten()
|
||||
metadata["prompt"]["3"]["inputs"]["positive"] = ["7", 0]
|
||||
self.assertEqual(_texts(_inspect(metadata)), (parity.NEG, parity.NEG))
|
||||
|
||||
def test_overwritten_nodes_inside_subgraphs_are_refused(self):
|
||||
# Review M-1: subgraph nodes run as "<instance>:<inner>" API IDs.
|
||||
def nested(depth: int = 1) -> dict:
|
||||
metadata = _overwritten()
|
||||
prompt, workflow = metadata["prompt"], metadata["workflow"]
|
||||
prefix = ":".join(["50", "60", "70"][:depth]) + ":"
|
||||
for node_id in ("17", "113", "119"):
|
||||
prompt[prefix + node_id] = prompt.pop(node_id)
|
||||
prompt["3"]["inputs"]["positive"] = [prefix + "17", 0]
|
||||
inner = [n for n in workflow["nodes"] if n["type"] == "PromptManager"]
|
||||
outer = [n for n in workflow["nodes"] if n["type"] != "PromptManager"]
|
||||
subgraphs = []
|
||||
for level in range(depth, 0, -1):
|
||||
nodes = (
|
||||
inner
|
||||
if level == depth
|
||||
else [{"id": [50, 60, 70][level], "type": f"sub-{level + 1}"}]
|
||||
)
|
||||
subgraphs.append({"id": f"sub-{level}", "nodes": nodes})
|
||||
metadata["workflow"] = {
|
||||
"nodes": outer + [{"id": 50, "type": "sub-1", "widgets_values": []}],
|
||||
"links": [],
|
||||
"definitions": {"subgraphs": subgraphs},
|
||||
}
|
||||
return metadata
|
||||
|
||||
for depth in (1, 2, 3):
|
||||
with self.subTest(depth=depth):
|
||||
for anchor in (None, "9"):
|
||||
result = _inspect(nested(depth), output_node_id=anchor)
|
||||
self.assertEqual(
|
||||
result["reason"], resolver.OVERWRITTEN_PROMPT_REASON, anchor
|
||||
)
|
||||
# A subgraph definition with no instance runs nothing.
|
||||
metadata = nested()
|
||||
metadata["workflow"]["nodes"] = metadata["workflow"]["nodes"][:-1]
|
||||
self.assertEqual(_inspect(metadata)["state"], "resolved")
|
||||
# Malformed definitions never raise.
|
||||
for definitions in ("x", {"subgraphs": "x"}, {"subgraphs": [1, {"id": 3}]}):
|
||||
metadata = nested()
|
||||
metadata["workflow"]["definitions"] = definitions
|
||||
self.assertEqual(_inspect(metadata)["state"], "resolved", definitions)
|
||||
# A self-referencing subgraph is bounded.
|
||||
metadata = nested()
|
||||
metadata["workflow"]["definitions"]["subgraphs"].append(
|
||||
{"id": "loop", "nodes": [{"id": 1, "type": "loop"}]}
|
||||
)
|
||||
metadata["workflow"]["nodes"].append({"id": 99, "type": "loop"})
|
||||
self.assertEqual(
|
||||
_inspect(metadata)["reason"], resolver.OVERWRITTEN_PROMPT_REASON
|
||||
)
|
||||
|
||||
def test_object_widget_values_name_the_text(self):
|
||||
metadata = _overwritten()
|
||||
for node in metadata["workflow"]["nodes"]:
|
||||
node["widgets_values"] = {"text": node["widgets_values"][0]}
|
||||
self.assertEqual(
|
||||
_inspect(metadata)["reason"], resolver.OVERWRITTEN_PROMPT_REASON
|
||||
)
|
||||
|
||||
def test_the_workflow_is_parsed_only_for_prompt_manager_graphs(self):
|
||||
# Review M-4: no PromptManager-like node, no workflow parse.
|
||||
|
||||
from utils import prompt_inspector
|
||||
|
||||
prompt = parity.build("f02")["prompt"]
|
||||
with unittest.mock.patch.object(
|
||||
prompt_inspector,
|
||||
"_parsed_workflow",
|
||||
wraps=prompt_inspector._parsed_workflow,
|
||||
) as parsed:
|
||||
inspect_prompt_evidence(
|
||||
{"prompt": json.dumps(prompt), "workflow": json.dumps({"nodes": []})}
|
||||
)
|
||||
self.assertEqual(parsed.call_count, 0)
|
||||
metadata = _overwritten()
|
||||
inspect_prompt_evidence(
|
||||
{
|
||||
"prompt": json.dumps(metadata["prompt"]),
|
||||
"workflow": json.dumps(metadata["workflow"]),
|
||||
}
|
||||
)
|
||||
self.assertEqual(parsed.call_count, 1)
|
||||
|
||||
def test_default_profile_ignores_the_workflow(self):
|
||||
result = resolve_prompt_pair(_overwritten())
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result), ("a bird", parity.NEG))
|
||||
|
||||
|
||||
def _historical(class_type: str, slot: int, inputs: dict, kind: str) -> dict:
|
||||
"""``f02`` whose positive reads ``class_type`` output ``slot`` (node 10).
|
||||
|
||||
A conditioning output feeds the sampler directly; a string output feeds a
|
||||
CLIPTextEncode (node 6).
|
||||
"""
|
||||
prompt = parity.build("f02")["prompt"]
|
||||
prompt["10"] = {"class_type": class_type, "inputs": {"clip": ["4", 1], **inputs}}
|
||||
if kind == "conditioning":
|
||||
prompt["3"]["inputs"]["positive"] = ["10", slot]
|
||||
else:
|
||||
prompt["6"]["inputs"]["text"] = ["10", slot]
|
||||
return {"prompt": prompt}
|
||||
|
||||
|
||||
class TestHistoricalPromptManagerNodes(unittest.TestCase):
|
||||
"""R7: this repository's node classes up to 07a5af3, with their own join."""
|
||||
|
||||
COMPOSED = {"text": " a cat ", "prepend_text": " trig ", "append_text": "sky "}
|
||||
|
||||
def test_v2_outputs_and_comma_join(self):
|
||||
inputs = {**self.COMPOSED, "negative_text": " bad hands "}
|
||||
for slot, kind, expected in (
|
||||
(0, "conditioning", "trig, a cat, sky"),
|
||||
(1, "conditioning", "bad hands"),
|
||||
(2, "string", "trig, a cat, sky"),
|
||||
(3, "string", "bad hands"),
|
||||
):
|
||||
with self.subTest(slot=slot):
|
||||
result = _inspect(_historical("PromptManagerV2", slot, inputs, kind))
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["positive"]["text"], expected)
|
||||
|
||||
def test_comma_rule_matches_the_historical_code(self):
|
||||
cases = (
|
||||
({"text": "a cat", "prepend_text": "trig,"}, "trig,a cat"),
|
||||
({"text": "a cat,", "append_text": "sky"}, "a cat,sky"),
|
||||
({"text": "", "prepend_text": "trig", "append_text": "sky"}, "trig, sky"),
|
||||
({"text": " ", "prepend_text": " ", "append_text": "sky"}, "sky"),
|
||||
({"text": "a cat"}, "a cat"),
|
||||
# Review M-5: an explicit null part is skipped, as ``if prepend_text:``.
|
||||
({"text": "a cat", "prepend_text": None, "append_text": None}, "a cat"),
|
||||
)
|
||||
for inputs, expected in cases:
|
||||
with self.subTest(inputs=inputs):
|
||||
result = _inspect(_historical("PromptManagerV2", 2, inputs, "string"))
|
||||
self.assertEqual(result["positive"]["text"], expected)
|
||||
|
||||
def test_single_role_classes(self):
|
||||
cases = (
|
||||
("PromptManagerPositive", 0, "conditioning", "trig, a cat, sky"),
|
||||
("PromptManagerPositive", 1, "string", "trig, a cat, sky"),
|
||||
("PromptManagerDetailer", 0, "conditioning", "trig, a cat, sky"),
|
||||
("PromptManagerDetailer", 1, "string", "trig, a cat, sky"),
|
||||
("PromptManagerV2Text", 0, "string", "trig, a cat, sky"),
|
||||
("PromptManagerPositiveText", 0, "string", "trig, a cat, sky"),
|
||||
("PromptManagerNegativeText", 0, "string", "trig, a cat, sky"),
|
||||
# The negative-only encoder never composes.
|
||||
("PromptManagerNegative", 0, "conditioning", "a cat"),
|
||||
("PromptManagerNegative", 1, "string", "a cat"),
|
||||
)
|
||||
for class_type, slot, kind, expected in cases:
|
||||
with self.subTest(node=class_type, slot=slot):
|
||||
result = _inspect(_historical(class_type, slot, self.COMPOSED, kind))
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["positive"]["text"], expected)
|
||||
result = _inspect(
|
||||
_historical(
|
||||
"PromptManagerV2Text",
|
||||
1,
|
||||
{"text": "a cat", "negative_text": " bad "},
|
||||
"string",
|
||||
)
|
||||
)
|
||||
self.assertEqual(result["positive"]["text"], "bad")
|
||||
|
||||
def test_optional_negative_defaults_to_empty(self):
|
||||
result = _inspect(
|
||||
_historical("PromptManagerV2", 0, {"text": "a cat"}, "conditioning")
|
||||
)
|
||||
self.assertEqual(result["positive"]["text"], "a cat")
|
||||
prompt = _historical("PromptManagerV2", 0, {"text": "a cat"}, "conditioning")
|
||||
prompt["prompt"]["3"]["inputs"]["negative"] = ["10", 1]
|
||||
self.assertEqual(_texts(_inspect(prompt)), ("a cat", ""))
|
||||
|
||||
def test_unlisted_outputs_and_kinds_are_not_guessed(self):
|
||||
# PromptManagerDetailer output 2 is an INT prompt id.
|
||||
result = _inspect(
|
||||
_historical("PromptManagerDetailer", 2, self.COMPOSED, "string")
|
||||
)
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
# A STRING output read as conditioning is a malformed link.
|
||||
result = _inspect(
|
||||
_historical("PromptManagerV2", 2, self.COMPOSED, "conditioning")
|
||||
)
|
||||
self.assertEqual(result["reason_code"], "malformed")
|
||||
|
||||
def test_linked_parts_are_traced(self):
|
||||
source = _historical(
|
||||
"PromptManagerV2",
|
||||
0,
|
||||
{"text": ["11", 0], "prepend_text": ["12", 0]},
|
||||
"conditioning",
|
||||
)
|
||||
source["prompt"]["11"] = {
|
||||
"class_type": "PrimitiveString",
|
||||
"inputs": {"value": "a cat"},
|
||||
}
|
||||
source["prompt"]["12"] = {
|
||||
"class_type": "PrimitiveString",
|
||||
"inputs": {"value": "trig"},
|
||||
}
|
||||
result = _inspect(source)
|
||||
self.assertEqual(result["positive"]["text"], "trig, a cat")
|
||||
self.assertEqual(
|
||||
[step["node_id"] for step in result["positive"]["provenance"]["path"]],
|
||||
["10", "12", "11"],
|
||||
)
|
||||
|
||||
def test_overwritten_historical_nodes_are_still_refused(self):
|
||||
metadata = _overwritten()
|
||||
for node_id in ("17", "113", "119"):
|
||||
metadata["prompt"][node_id]["class_type"] = "PromptManagerV2"
|
||||
for node in metadata["workflow"]["nodes"]:
|
||||
if node["type"] == "PromptManager":
|
||||
node["type"] = "PromptManagerV2"
|
||||
self.assertEqual(_inspect(metadata)["reason_code"], "malformed")
|
||||
|
||||
def test_default_profile_keeps_them_unsupported(self):
|
||||
result = resolve_prompt_pair(
|
||||
_historical("PromptManagerV2", 0, self.COMPOSED, "conditioning")
|
||||
)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason"], "Unsupported node semantics or output slot")
|
||||
|
||||
|
||||
FOOTER = "Steps: 20, Sampler: Euler, CFG scale: 7, Seed: 5"
|
||||
|
||||
|
||||
def _parameters(text: str, *, legacy: bool = False) -> dict:
|
||||
profile = resolver.DEFAULT_PROFILE if legacy else resolver.INSPECTION_PROFILE
|
||||
return resolve_prompt_pair({"parameters": text}, profile=profile)
|
||||
|
||||
|
||||
class TestA1111Text(unittest.TestCase):
|
||||
"""R4 and R5 (#165, py/api/__init__.py:738-750) for inspection."""
|
||||
|
||||
def test_text_starting_with_the_negative_delimiter_has_no_positive(self):
|
||||
for text in (
|
||||
f"Negative prompt: bad\n{FOOTER}",
|
||||
f"\nNegative prompt: bad\n{FOOTER}",
|
||||
):
|
||||
with self.subTest(text=text):
|
||||
result = _parameters(text)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason"], resolver.EMPTY_POSITIVE_REASON)
|
||||
self.assertIsNone(result["positive"])
|
||||
# Two delimiters, one at the start, are still two.
|
||||
result = _parameters(f"Negative prompt: a\nNegative prompt: b\n{FOOTER}")
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
|
||||
def test_dynamic_prompts_template_lines_follow_the_footer(self):
|
||||
base = f"a cat\nNegative prompt: bad\n{FOOTER}"
|
||||
for trailer in (
|
||||
"\nTemplate: a {cat|dog}",
|
||||
"\nTemplate: a {cat|dog}\nNegative Template: bad",
|
||||
"\nNegative Template: bad",
|
||||
"\nTemplate: a {cat|dog}\nNegative Template: bad\n",
|
||||
):
|
||||
with self.subTest(trailer=trailer):
|
||||
result = _parameters(base + trailer)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result), ("a cat", "bad"))
|
||||
for trailer in (
|
||||
"\nTemplate: a\nTemplate: b",
|
||||
"\nNegative Template: bad\nTemplate: a",
|
||||
"\nTemplate: a\nmore template text",
|
||||
"\ntrailing",
|
||||
):
|
||||
with self.subTest(trailer=trailer):
|
||||
result = _parameters(base + trailer)
|
||||
self.assertEqual(result["reason_code"], "malformed")
|
||||
|
||||
def test_footer_with_only_steps(self):
|
||||
result = _parameters("a cat\nNegative prompt: bad\nSteps: 20")
|
||||
self.assertEqual(_texts(result), ("a cat", "bad"))
|
||||
result = _parameters("a cat\nSteps: 20\nTemplate: a {cat|dog}")
|
||||
self.assertEqual((result["state"], _texts(result)[0]), ("absent", "a cat"))
|
||||
# A full footer wins over a bare ``Steps: N`` line in the prompt text.
|
||||
result = _parameters(f"a cat\nSteps: 3\nNegative prompt: bad\n{FOOTER}")
|
||||
self.assertEqual(_texts(result), ("a cat\nSteps: 3", "bad"))
|
||||
result = _parameters("a cat\nSteps: 3\nNegative prompt: bad\nSteps: 20")
|
||||
self.assertEqual(result["reason_code"], "malformed")
|
||||
|
||||
def test_default_profile_keeps_the_historical_split(self):
|
||||
result = _parameters(f"Negative prompt: bad\n{FOOTER}", legacy=True)
|
||||
self.assertEqual(_texts(result), ("Negative prompt: bad", None))
|
||||
for text in (
|
||||
f"a cat\nNegative prompt: bad\n{FOOTER}\nTemplate: a {{cat|dog}}",
|
||||
"a cat\nNegative prompt: bad\nSteps: 20",
|
||||
):
|
||||
with self.subTest(text=text):
|
||||
result = _parameters(text, legacy=True)
|
||||
self.assertEqual(
|
||||
result["reason"], "A1111 settings footer is not unique"
|
||||
)
|
||||
|
||||
|
||||
def _chunk(keyword: str, text: str, kind: bytes = b"tEXt") -> bytes:
|
||||
body = text.encode("latin-1")
|
||||
if kind == b"zTXt":
|
||||
body = b"\0" + zlib.compress(body)
|
||||
elif kind == b"iTXt":
|
||||
body = b"\0\0\0\0" + text.encode("utf-8")
|
||||
return parity._raw_chunk(kind, keyword.encode("latin-1") + b"\0" + body)
|
||||
|
||||
|
||||
def _batch(index: int, positive: str = "a cat", seed: int = 5) -> str:
|
||||
return (
|
||||
f"{positive}\nNegative prompt: bad\nSteps: 20, Sampler: Euler, "
|
||||
f"CFG scale: 7, Seed: {seed}, Batch index: {index}, Batch size: 12"
|
||||
)
|
||||
|
||||
|
||||
class TestBatchIndexDuplicates(unittest.TestCase):
|
||||
"""R3: an accumulating batch saver repeats ``parameters`` per batch item."""
|
||||
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.directory = Path(temporary.name)
|
||||
|
||||
def png(self, chunks, name="batch.png"):
|
||||
buffer = io.BytesIO()
|
||||
with Image.new("RGB", (4, 4), (40, 80, 120)) as image:
|
||||
image.save(buffer, format="PNG")
|
||||
data = buffer.getvalue()
|
||||
iend = data.rindex(b"IEND") - 4
|
||||
path = self.directory / name
|
||||
path.write_bytes(data[:iend] + b"".join(chunks) + data[iend:])
|
||||
return path
|
||||
|
||||
def test_copies_differing_only_in_batch_index_read_as_the_last(self):
|
||||
cases = {
|
||||
"same length": [0, 1, 2],
|
||||
"growing length": [8, 9, 10, 11],
|
||||
"any order": [3, 0],
|
||||
"repeat of an earlier copy": [0, 1, 0],
|
||||
}
|
||||
for label, indexes in cases.items():
|
||||
with self.subTest(label):
|
||||
path = self.png([_chunk("parameters", _batch(i)) for i in indexes])
|
||||
read = read_inspection_file(path)
|
||||
self.assertEqual(read.metadata["parameters"], _batch(indexes[-1]))
|
||||
self.assertEqual(read.record_formats["parameters"], "original-text")
|
||||
evidence = inspect_prompt_evidence(read.metadata)
|
||||
self.assertEqual(evidence["resolution"]["state"], "resolved")
|
||||
self.assertEqual(evidence["prompts"]["positive"]["text"], "a cat")
|
||||
# Encodings may differ; the decoded copies are compared.
|
||||
path = self.png(
|
||||
[
|
||||
_chunk("parameters", _batch(0)),
|
||||
_chunk("parameters", _batch(1), b"zTXt"),
|
||||
_chunk("parameters", _batch(2), b"iTXt"),
|
||||
]
|
||||
)
|
||||
self.assertEqual(read_inspection_file(path).metadata["parameters"], _batch(2))
|
||||
|
||||
def test_any_other_difference_is_still_a_conflict(self):
|
||||
cases = {
|
||||
"positive": [_batch(0), _batch(1, positive="a dog")],
|
||||
"seed": [_batch(0), _batch(1, seed=6)],
|
||||
"no batch index": [_batch(0), _batch(0).replace(", Batch index: 0", "")],
|
||||
"two batch indexes": [_batch(0), _batch(1) + ", Batch index: 2"],
|
||||
"prose": [_batch(0), _batch(1).replace("a cat", "a cat Batch index: 1")],
|
||||
# Review I-1: the field is matched only in the settings footer.
|
||||
"prompt field": [
|
||||
_batch(0).replace("a cat", "a cat, Batch index: 0"),
|
||||
_batch(0).replace("a cat", "a cat, Batch index: 1"),
|
||||
],
|
||||
"prompt only": [
|
||||
"a cat, Batch index: 0\nNegative prompt: bad\nSteps: 20, Seed: 5",
|
||||
"a cat, Batch index: 1\nNegative prompt: bad\nSteps: 20, Seed: 5",
|
||||
],
|
||||
"negative field": [
|
||||
_batch(0).replace("bad", "bad, Batch index: 0"),
|
||||
_batch(1).replace("bad", "bad, Batch index: 1"),
|
||||
],
|
||||
"no footer": [
|
||||
"a cat\nBatch index: 0, Batch size: 2",
|
||||
"a cat\nBatch index: 1, Batch size: 2",
|
||||
],
|
||||
}
|
||||
for label, texts in cases.items():
|
||||
with self.subTest(label):
|
||||
path = self.png([_chunk("parameters", text) for text in texts])
|
||||
with self.assertRaisesRegex(
|
||||
MetadataReadError, "Duplicate prompt metadata"
|
||||
):
|
||||
read_inspection_file(path)
|
||||
# The rule is only for ``parameters``: prompt graphs stay byte-identical.
|
||||
prompt = json.dumps(parity.build("f02")["prompt"])
|
||||
path = self.png([_chunk("prompt", prompt), _chunk("prompt", prompt + " ")])
|
||||
with self.assertRaisesRegex(MetadataReadError, "Duplicate prompt metadata"):
|
||||
read_inspection_file(path)
|
||||
|
||||
def test_decoded_copies_are_bounded(self):
|
||||
# Small compressed copies must not decode unbounded text.
|
||||
text = _batch(0) + "," + " " * (DUPLICATE_ROLE_BYTES // 8)
|
||||
chunks = [
|
||||
_chunk("parameters", text.replace("index: 0", f"index: {i}"), b"zTXt")
|
||||
for i in range(10)
|
||||
]
|
||||
path = self.png(chunks)
|
||||
with self.assertRaisesRegex(MetadataReadError, "exceeds inspection limits"):
|
||||
read_inspection_file(path)
|
||||
# The kept copy still fits the role budget when a later one is longer.
|
||||
from utils.prompt_inspector import ROLE_METADATA_BYTES
|
||||
|
||||
first = _batch(9)
|
||||
first += "," + " " * (ROLE_METADATA_BYTES - len(first) - 1)
|
||||
later = first.replace("index: 9", "index: 10")
|
||||
for copy in (later, later + " " * ROLE_METADATA_BYTES):
|
||||
with self.subTest(size=len(copy)):
|
||||
path = self.png(
|
||||
[
|
||||
_chunk("parameters", first, b"zTXt"),
|
||||
_chunk("parameters", copy, b"zTXt"),
|
||||
]
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
MetadataReadError, "exceeds inspection limits"
|
||||
):
|
||||
read_inspection_file(path)
|
||||
|
||||
|
||||
def _f02(**changes) -> dict:
|
||||
"""``f02`` (KSampler 3, encoders 6 and 7) with nodes added or replaced."""
|
||||
prompt = parity.build("f02")["prompt"]
|
||||
prompt.update(changes)
|
||||
return prompt
|
||||
|
||||
|
||||
def _stage(class_type: str, image_input: str, positive=("6", 0), **inputs) -> dict:
|
||||
"""``f19``'s shape with ``class_type`` as the detailer stage (node 50)."""
|
||||
prompt = parity.build("f19")["prompt"]
|
||||
if "upscale_model" in inputs:
|
||||
prompt["12"] = {"class_type": "UpscaleModelLoader", "inputs": {}}
|
||||
if "segs" in inputs:
|
||||
prompt["13"] = {"class_type": "SegsDetector", "inputs": {"image": ["8", 0]}}
|
||||
prompt["50"] = {
|
||||
"class_type": class_type,
|
||||
"inputs": {
|
||||
image_input: ["8", 0],
|
||||
"model": ["4", 0],
|
||||
"vae": ["4", 2],
|
||||
"positive": list(positive),
|
||||
"negative": ["7", 0],
|
||||
"seed": 7,
|
||||
"steps": 12,
|
||||
"cfg": 4.5,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 0.4,
|
||||
**inputs,
|
||||
},
|
||||
}
|
||||
return prompt
|
||||
|
||||
|
||||
DETAILERS = (
|
||||
("UltimateSDUpscale", "image", {"upscale_model": ["12", 0], "upscale_by": 2}),
|
||||
("UltimateSDUpscaleNoUpscale", "upscaled_image", {}),
|
||||
("DetailerForEach", "image", {"segs": ["13", 0], "wildcard": ""}),
|
||||
)
|
||||
|
||||
|
||||
class TestInstalledNodeSemantics(unittest.TestCase):
|
||||
"""R6 entries verified against the installed node sources (see the report)."""
|
||||
|
||||
def test_upscale_and_segs_detailers_are_stages(self):
|
||||
for class_type, image, extra in DETAILERS:
|
||||
with self.subTest(node=class_type):
|
||||
same = _inspect({"prompt": _stage(class_type, image, **extra)})
|
||||
self.assertEqual(same["state"], "resolved")
|
||||
self.assertEqual(same["sampler_id"], "50")
|
||||
self.assertEqual(_texts(same), (parity.POS, parity.NEG))
|
||||
other = _inspect(
|
||||
{"prompt": _stage(class_type, image, ("10", 0), **extra)}
|
||||
)
|
||||
self.assertEqual(other["state"], "ambiguous")
|
||||
self.assertEqual(other["reason_code"], "multistage-differs")
|
||||
self.assertEqual(
|
||||
sorted(item["sampler_id"] for item in other["candidates"]),
|
||||
["3", "50"],
|
||||
)
|
||||
|
||||
def test_a_detailer_wildcard_replaces_its_prompt(self):
|
||||
# Impact Pack core.py: a non-empty wildcard is encoded instead of positive.
|
||||
for class_type in ("DetailerForEach", "FaceDetailer"):
|
||||
with self.subTest(node=class_type):
|
||||
result = _inspect(
|
||||
{"prompt": _stage(class_type, "image", wildcard="a smiling face")}
|
||||
)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason_code"], "runtime-value")
|
||||
self.assertEqual(result["node"], {"id": "50", "class_type": class_type})
|
||||
linked = _stage(class_type, "image", wildcard=["14", 0])
|
||||
linked["14"] = {"class_type": "WildcardSource", "inputs": {}}
|
||||
result = _inspect({"prompt": linked})
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(
|
||||
result["node"], {"id": "14", "class_type": "WildcardSource"}
|
||||
)
|
||||
for empty in ("", None):
|
||||
result = _inspect(
|
||||
{"prompt": _stage(class_type, "image", wildcard=empty)}
|
||||
)
|
||||
self.assertEqual(result["state"], "resolved", empty)
|
||||
|
||||
def test_conditioning_passthroughs_keep_each_role(self):
|
||||
pair = {"positive": ["6", 0], "negative": ["7", 0]}
|
||||
cases = {
|
||||
"ControlNetInpaintingAliMamaApply": (
|
||||
{
|
||||
**pair,
|
||||
"control_net": ["11", 0],
|
||||
"vae": ["4", 2],
|
||||
"image": ["12", 0],
|
||||
"mask": ["12", 1],
|
||||
"strength": 1.0,
|
||||
},
|
||||
0,
|
||||
1,
|
||||
),
|
||||
"LTXVConditioning": ({**pair, "frame_rate": 25.0}, 0, 1),
|
||||
"FluxKontextMultiReferenceLatentMethod": (
|
||||
{"conditioning": ["6", 0], "reference_latents_method": "offset"},
|
||||
0,
|
||||
None,
|
||||
),
|
||||
}
|
||||
for class_type, (inputs, positive, negative) in cases.items():
|
||||
with self.subTest(node=class_type):
|
||||
prompt = _f02(**{"20": {"class_type": class_type, "inputs": inputs}})
|
||||
prompt["3"]["inputs"]["positive"] = ["20", positive]
|
||||
if negative is not None:
|
||||
prompt["3"]["inputs"]["negative"] = ["20", negative]
|
||||
result = _inspect({"prompt": prompt})
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(_texts(result), (parity.POS, parity.NEG))
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["path"][0]["node_id"], "20"
|
||||
)
|
||||
if negative is None:
|
||||
prompt["3"]["inputs"]["negative"] = ["20", 1]
|
||||
self.assertEqual(
|
||||
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
|
||||
)
|
||||
|
||||
def test_text_nodes(self):
|
||||
prompt = _f02(
|
||||
**{
|
||||
"6": {
|
||||
"class_type": "Text to Conditioning",
|
||||
"inputs": {"clip": ["4", 1], "text": ["21", 0]},
|
||||
},
|
||||
"21": {"class_type": "DisplayText", "inputs": {"text": ["22", 0]}},
|
||||
"22": {"class_type": "TextInput", "inputs": {"text": parity.POS}},
|
||||
}
|
||||
)
|
||||
result = _inspect({"prompt": prompt})
|
||||
self.assertEqual(_texts(result), (parity.POS, parity.NEG))
|
||||
self.assertEqual(
|
||||
[step["node_id"] for step in result["positive"]["provenance"]["path"]],
|
||||
["6", "21", "22"],
|
||||
)
|
||||
# Text to Conditioning has one output; TextInput reads only ``text``.
|
||||
prompt["3"]["inputs"]["positive"] = ["6", 1]
|
||||
self.assertEqual(
|
||||
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
|
||||
)
|
||||
|
||||
def test_overwritten_text_behind_a_new_passthrough_is_still_refused(self):
|
||||
# R1 before R6: the pass-through must not let overwritten text resolve.
|
||||
metadata = _overwritten()
|
||||
metadata["prompt"]["20"] = {
|
||||
"class_type": "FluxKontextMultiReferenceLatentMethod",
|
||||
"inputs": {"conditioning": ["17", 0], "reference_latents_method": "offset"},
|
||||
}
|
||||
metadata["prompt"]["3"]["inputs"]["positive"] = ["20", 0]
|
||||
result = _inspect(metadata)
|
||||
self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON)
|
||||
|
||||
def test_default_profile_keeps_them_unsupported(self):
|
||||
for class_type, image, extra in DETAILERS:
|
||||
with self.subTest(node=class_type):
|
||||
result = resolve_prompt_pair(
|
||||
{"prompt": _stage(class_type, image, **extra)}
|
||||
)
|
||||
# The historical rule: the one KSampler, whatever reads its image.
|
||||
self.assertEqual(result["sampler_id"], "3")
|
||||
prompt = _f02(
|
||||
**{"6": {"class_type": "Text to Conditioning", "inputs": {"text": "a"}}}
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair({"prompt": prompt})["reason"],
|
||||
"Unsupported node semantics or output slot",
|
||||
)
|
||||
|
||||
|
||||
def _sampled(class_type: str, latent: str = "latent_image", **inputs) -> dict:
|
||||
"""``f02`` with its KSampler (node 3) replaced by ``class_type``."""
|
||||
prompt = _f02()
|
||||
sampler = dict(prompt["3"]["inputs"], **inputs)
|
||||
sampler[latent] = sampler.pop("latent_image")
|
||||
prompt["3"] = {"class_type": class_type, "inputs": sampler}
|
||||
return prompt
|
||||
|
||||
|
||||
SAMPLERS = (
|
||||
("ClownsharKSampler_Beta", "latent_image", "options"),
|
||||
("KSampler (Efficient)", "latent_image", "script"),
|
||||
("ttN KSampler_v2", "latent", "adv_xyPlot"),
|
||||
)
|
||||
|
||||
|
||||
class TestUpstreamVerifiedNodeSemantics(unittest.TestCase):
|
||||
"""R6 entries for nodes not installed here, read from their pinned sources.
|
||||
|
||||
Each source is the commit the maintainer's saved workflows name in the
|
||||
node's ``cnr_id``/``ver`` properties (the report lists repository, commit,
|
||||
file and line).
|
||||
"""
|
||||
|
||||
def test_ksampler_shaped_samplers_resolve(self):
|
||||
for class_type, latent, _ in SAMPLERS:
|
||||
with self.subTest(node=class_type):
|
||||
result = _inspect({"prompt": _sampled(class_type, latent)})
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["sampler_id"], "3")
|
||||
self.assertEqual(_texts(result), (parity.POS, parity.NEG))
|
||||
|
||||
def test_inputs_that_replace_the_prompt_are_not_followed(self):
|
||||
# RES4LYF options may carry a guider; Efficiency scripts and the ttN
|
||||
# XY plot re-run the sampler with other settings.
|
||||
for class_type, latent, override in SAMPLERS:
|
||||
with self.subTest(node=class_type):
|
||||
prompt = _sampled(class_type, latent, **{override: ["30", 0]})
|
||||
prompt["30"] = {"class_type": "SomeScript", "inputs": {}}
|
||||
result = _inspect({"prompt": prompt})
|
||||
self.assertEqual(result["reason_code"], "unsupported-node")
|
||||
self.assertEqual(
|
||||
result["node"], {"id": "30", "class_type": "SomeScript"}
|
||||
)
|
||||
|
||||
def test_chained_samplers_are_stages(self):
|
||||
prompt = _sampled("ClownsharKSampler_Beta")
|
||||
prompt["31"] = dict(prompt["3"], inputs=dict(prompt["3"]["inputs"]))
|
||||
prompt["31"]["inputs"]["latent_image"] = ["3", 0]
|
||||
prompt["8"]["inputs"]["samples"] = ["31", 0]
|
||||
# Unanchored, two listed samplers are two branches, as for KSampler.
|
||||
self.assertEqual(_inspect({"prompt": prompt})["state"], "ambiguous")
|
||||
result = _inspect({"prompt": prompt}, output_node_id="9")
|
||||
self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
|
||||
|
||||
def test_seed_variance_enhancer_passes_its_conditioning(self):
|
||||
prompt = _f02(
|
||||
**{
|
||||
"20": {
|
||||
"class_type": "SeedVarianceEnhancer",
|
||||
"inputs": {"conditioning": ["6", 0], "seed": 1, "strength": 20.0},
|
||||
}
|
||||
}
|
||||
)
|
||||
prompt["3"]["inputs"]["positive"] = ["20", 0]
|
||||
self.assertEqual(_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG))
|
||||
|
||||
def test_overwritten_text_behind_seed_variance_is_still_refused(self):
|
||||
# R1 before R6: the recon's 24 files differ from this only in names.
|
||||
metadata = _overwritten()
|
||||
metadata["prompt"]["20"] = {
|
||||
"class_type": "SeedVarianceEnhancer",
|
||||
"inputs": {"conditioning": ["17", 0], "seed": 1},
|
||||
}
|
||||
metadata["prompt"]["3"]["inputs"]["positive"] = ["20", 0]
|
||||
result = _inspect(metadata)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON)
|
||||
|
||||
def test_instantid_advanced_outputs(self):
|
||||
prompt = _f02(
|
||||
**{
|
||||
"20": {
|
||||
"class_type": "ApplyInstantIDAdvanced",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"ip_weight": 0.8,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
prompt["3"]["inputs"].update(positive=["20", 1], negative=["20", 2])
|
||||
self.assertEqual(_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG))
|
||||
prompt["3"]["inputs"]["positive"] = ["20", 0] # output 0 is the MODEL
|
||||
self.assertEqual(
|
||||
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
|
||||
)
|
||||
|
||||
def test_crystools_conditioning_switch(self):
|
||||
def switch(condition) -> dict:
|
||||
prompt = _f02(
|
||||
**{
|
||||
"11": _pm_encoder("a dog"),
|
||||
"20": {
|
||||
"class_type": "Switch conditioning [Crystools]",
|
||||
"inputs": {
|
||||
"on_true": ["6", 0],
|
||||
"on_false": ["11", 0],
|
||||
"boolean": condition,
|
||||
},
|
||||
},
|
||||
"21": {"class_type": "PrimitiveBoolean", "inputs": {"value": True}},
|
||||
}
|
||||
)
|
||||
prompt["3"]["inputs"]["positive"] = ["20", 0]
|
||||
return _inspect({"prompt": prompt})
|
||||
|
||||
self.assertEqual(_texts(switch(True))[0], parity.POS)
|
||||
self.assertEqual(_texts(switch(False))[0], "a dog")
|
||||
result = switch(["21", 0])
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(
|
||||
sorted(_texts(item)[0] for item in result["candidates"]),
|
||||
sorted([parity.POS, "a dog"]),
|
||||
)
|
||||
|
||||
def test_smz_encoder_reads_text_unless_sdxl_may_apply(self):
|
||||
def smz(**inputs) -> dict:
|
||||
node = {
|
||||
"class_type": "smZ CLIPTextEncode",
|
||||
"inputs": {
|
||||
"clip": ["4", 1],
|
||||
"text": "a cat",
|
||||
"text_g": "a cat",
|
||||
"text_l": "a cat",
|
||||
"with_SDXL": False,
|
||||
"parser": "comfy",
|
||||
**inputs,
|
||||
},
|
||||
}
|
||||
return _inspect({"prompt": _f02(**{"6": node})})
|
||||
|
||||
self.assertEqual(_texts(smz(text_g="", text_l="x")), ("a cat", parity.NEG))
|
||||
# with_SDXL: the connected CLIP decides at run time which inputs are read.
|
||||
self.assertEqual(_texts(smz(with_SDXL=True)), ("a cat", parity.NEG))
|
||||
for parts in ({"text_g": "x"}, {"text_l": ""}, {"text": "x"}):
|
||||
with self.subTest(parts=parts):
|
||||
result = smz(with_SDXL=True, **parts)
|
||||
self.assertEqual(result["reason_code"], "runtime-value")
|
||||
self.assertEqual(
|
||||
result["node"], {"id": "6", "class_type": "smZ CLIPTextEncode"}
|
||||
)
|
||||
linked = smz(with_SDXL=["21", 0])
|
||||
self.assertEqual(linked["reason_code"], "unsupported-node")
|
||||
for broken in ({"with_SDXL": "yes"}, {"with_SDXL": None}):
|
||||
with self.subTest(flag=broken):
|
||||
self.assertEqual(smz(**broken)["reason_code"], "malformed")
|
||||
|
||||
def test_string_sources(self):
|
||||
cases = (
|
||||
("ttN text", "text"),
|
||||
("CR Text", "text"),
|
||||
("CR Prompt Text", "prompt"),
|
||||
("TextBoxMira", "text"),
|
||||
)
|
||||
for class_type, name in cases:
|
||||
with self.subTest(node=class_type):
|
||||
prompt = _f02(
|
||||
**{"10": {"class_type": class_type, "inputs": {name: parity.POS}}}
|
||||
)
|
||||
prompt["6"]["inputs"]["text"] = ["10", 0]
|
||||
self.assertEqual(
|
||||
_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG)
|
||||
)
|
||||
# Output 1 of the Comfyroll nodes is their help URL.
|
||||
prompt["6"]["inputs"]["text"] = ["10", 1]
|
||||
self.assertEqual(
|
||||
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
|
||||
)
|
||||
|
||||
def test_default_profile_keeps_them_unsupported(self):
|
||||
for class_type, latent, _ in SAMPLERS:
|
||||
with self.subTest(node=class_type):
|
||||
result = resolve_prompt_pair({"prompt": _sampled(class_type, latent)})
|
||||
self.assertEqual(result["reason"], "No supported sampler pair found")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,486 @@
|
||||
"""Non-resolved prompt outcomes carry one short, truthful line per role."""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from pathlib import Path
|
||||
from dataclasses import replace
|
||||
|
||||
from utils import prompt_pair_resolver
|
||||
from utils.inspection_outcomes import (
|
||||
_UNCODED_LINES,
|
||||
CLASS_NAME_LENGTH,
|
||||
REASON_CODES,
|
||||
outcome_message,
|
||||
project_resolution,
|
||||
)
|
||||
from utils.inspection_response import encode_inspection_response
|
||||
from utils.prompt_inspector import inspect_prompt_evidence
|
||||
from utils.prompt_pair_resolver import (
|
||||
EMPTY_POSITIVE_REASON,
|
||||
INSPECTION_PROFILE,
|
||||
MULTISTAGE_UNATTRIBUTED_REASON,
|
||||
SEMANTICS_UNAVAILABLE_REASON,
|
||||
)
|
||||
|
||||
LIMITS = "Prompt exceeds inspection limits."
|
||||
|
||||
|
||||
def _encode(text: str) -> dict:
|
||||
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["9", 0]}}
|
||||
|
||||
|
||||
def _latent() -> dict:
|
||||
return {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 8, "height": 8, "batch_size": 1},
|
||||
}
|
||||
|
||||
|
||||
def _ksampler(positive: str = "7") -> dict:
|
||||
return {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": [positive, 0],
|
||||
"negative": ["6", 0],
|
||||
"latent_image": ["4", 0],
|
||||
"model": ["9", 0],
|
||||
"seed": 1,
|
||||
"steps": 2,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1.0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _dual_cfg() -> dict:
|
||||
return {
|
||||
"1": _encode("positive"),
|
||||
"6": _encode("negative"),
|
||||
"2": {
|
||||
"class_type": "DualCFGGuider",
|
||||
"inputs": {
|
||||
"cond1": ["1", 0],
|
||||
"cond2": ["1", 0],
|
||||
"negative": ["6", 0],
|
||||
"model": ["9", 0],
|
||||
},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {
|
||||
"guider": ["2", 0],
|
||||
"noise": ["9", 0],
|
||||
"sampler": ["9", 0],
|
||||
"sigmas": ["9", 0],
|
||||
"latent_image": ["4", 0],
|
||||
},
|
||||
},
|
||||
"4": _latent(),
|
||||
}
|
||||
|
||||
|
||||
def _runtime() -> dict:
|
||||
return {
|
||||
"1": {"class_type": "PromptSearchList", "inputs": {"search": "cat"}},
|
||||
"7": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": ["1", 0], "clip": ["9", 0]},
|
||||
},
|
||||
"6": _encode("negative"),
|
||||
"3": _ksampler(),
|
||||
"4": _latent(),
|
||||
}
|
||||
|
||||
|
||||
def _composite() -> dict:
|
||||
graph = _runtime()
|
||||
graph["7"] = {
|
||||
"class_type": "ConditioningCombine",
|
||||
"inputs": {"conditioning_1": ["6", 0], "conditioning_2": ["8", 0]},
|
||||
}
|
||||
graph["8"] = _encode("other")
|
||||
return graph
|
||||
|
||||
|
||||
def _messages(evidence: dict) -> dict:
|
||||
return {role: side.get("message") for role, side in evidence["prompts"].items()}
|
||||
|
||||
|
||||
def _statuses(evidence: dict) -> dict:
|
||||
return {role: side["status"] for role, side in evidence["prompts"].items()}
|
||||
|
||||
|
||||
class TestOutcomeMessages(unittest.TestCase):
|
||||
def assert_unresolved_with(self, evidence: dict, message: str) -> None:
|
||||
self.assertEqual(
|
||||
_statuses(evidence), {"positive": "unresolved", "negative": "unresolved"}
|
||||
)
|
||||
self.assertEqual(
|
||||
_messages(evidence), {"positive": message, "negative": message}
|
||||
)
|
||||
|
||||
def test_unsupported_node_status_names_the_class(self):
|
||||
evidence = inspect_prompt_evidence({"prompt": _dual_cfg()})
|
||||
self.assertEqual(evidence["resolution"]["reason_code"], "unsupported-node")
|
||||
self.assert_unresolved_with(
|
||||
evidence,
|
||||
"Prompt uses a node the inspector does not support (DualCFGGuider).",
|
||||
)
|
||||
|
||||
def test_runtime_and_composite_statuses(self):
|
||||
self.assert_unresolved_with(
|
||||
inspect_prompt_evidence({"prompt": _runtime()}),
|
||||
"Prompt is chosen at run time (PromptSearchList).",
|
||||
)
|
||||
self.assert_unresolved_with(
|
||||
inspect_prompt_evidence({"prompt": _composite()}),
|
||||
"Prompt combines several texts (ConditioningCombine).",
|
||||
)
|
||||
|
||||
def test_limit_status(self):
|
||||
tight = replace(INSPECTION_PROFILE, per_candidate_visits=1, total_visits=1)
|
||||
evidence = inspect_prompt_evidence({"prompt": _runtime()}, profile=tight)
|
||||
self.assertEqual(evidence["resolution"]["reason_code"], "work-limit")
|
||||
self.assert_unresolved_with(evidence, LIMITS)
|
||||
for code in ("work-limit", "depth-limit", "text-limit", "graph-ceiling"):
|
||||
self.assertEqual(
|
||||
outcome_message({"state": "unresolved", "reason_code": code}), LIMITS
|
||||
)
|
||||
|
||||
def test_every_closed_code_and_known_uncoded_reason_has_a_line(self):
|
||||
for code in REASON_CODES:
|
||||
message = outcome_message({"state": "unresolved", "reason_code": code})
|
||||
self.assertIsInstance(message, str, code)
|
||||
self.assertTrue(message.endswith("."), code)
|
||||
self.assertNotIn("could not be resolved", message)
|
||||
for reason, expected in (
|
||||
(EMPTY_POSITIVE_REASON, "Positive prompt is empty."),
|
||||
(
|
||||
SEMANTICS_UNAVAILABLE_REASON,
|
||||
"The inspector's node table is unavailable.",
|
||||
),
|
||||
(
|
||||
MULTISTAGE_UNATTRIBUTED_REASON,
|
||||
"Several sampler stages were found; their prompts were not compared.",
|
||||
),
|
||||
(
|
||||
"Media metadata could not be read",
|
||||
"Metadata in this file could not be read.",
|
||||
),
|
||||
):
|
||||
resolution = {"state": "unresolved", "reason": reason, "reason_code": None}
|
||||
self.assertEqual(outcome_message(resolution), expected)
|
||||
|
||||
def test_named_codes_without_a_class_omit_the_name(self):
|
||||
for code, line in (
|
||||
("unsupported-node", "Prompt uses a node the inspector does not support."),
|
||||
("runtime-value", "Prompt is chosen at run time."),
|
||||
("composite", "Prompt combines several texts."),
|
||||
):
|
||||
for node in (None, {"id": "7", "class_type": None}):
|
||||
resolution = {"state": "unresolved", "reason_code": code, "node": node}
|
||||
self.assertEqual(outcome_message(resolution), line)
|
||||
|
||||
def test_class_name_is_bounded(self):
|
||||
resolution = {
|
||||
"state": "unresolved",
|
||||
"reason_code": "unsupported-node",
|
||||
"node": {"id": "7", "class_type": "N" * 500},
|
||||
}
|
||||
self.assertEqual(
|
||||
outcome_message(resolution),
|
||||
"Prompt uses a node the inspector does not support "
|
||||
f"({'N' * CLASS_NAME_LENGTH}).",
|
||||
)
|
||||
self.assertEqual(CLASS_NAME_LENGTH, 200)
|
||||
|
||||
def test_resolved_ambiguous_absent_and_unknown_outcomes_carry_no_line(self):
|
||||
for resolution in (
|
||||
{"state": "resolved", "reason_code": None},
|
||||
{"state": "ambiguous", "reason_code": "multistage-differs"},
|
||||
{"state": "ambiguous", "reason_code": None, "reason": "Multiple"},
|
||||
{"state": "absent", "reason_code": None, "reason": "No parsed"},
|
||||
{"state": "unresolved", "reason_code": "guess"},
|
||||
{"state": "unresolved", "reason_code": None, "reason": "Anything else"},
|
||||
None,
|
||||
):
|
||||
self.assertIsNone(outcome_message(resolution), resolution)
|
||||
|
||||
def test_inspector_lines_for_no_sampler_malformed_and_empty_positive(self):
|
||||
evidence = inspect_prompt_evidence({"prompt": {"1": _encode("x")}})
|
||||
self.assert_unresolved_with(evidence, "No supported sampler was found.")
|
||||
evidence = inspect_prompt_evidence({"prompt": "not-json"})
|
||||
self.assert_unresolved_with(
|
||||
evidence, "Prompt metadata in this file is malformed."
|
||||
)
|
||||
graph = _runtime()
|
||||
graph["7"] = _encode(" ")
|
||||
evidence = inspect_prompt_evidence({"prompt": graph})
|
||||
self.assertEqual(evidence["resolution"]["reason"], EMPTY_POSITIVE_REASON)
|
||||
self.assert_unresolved_with(evidence, "Positive prompt is empty.")
|
||||
|
||||
def test_a_resolved_pair_with_an_empty_positive_explains_only_that_role(self):
|
||||
side = {"text": " ", "provenance": {"text_source": "graph-inferred"}}
|
||||
negative = {"text": "kept", "provenance": {"text_source": "graph-inferred"}}
|
||||
resolved = {
|
||||
"state": "resolved",
|
||||
"positive": side,
|
||||
"negative": negative,
|
||||
"sampler_id": "3",
|
||||
"reason": "",
|
||||
"reason_code": None,
|
||||
"node": None,
|
||||
}
|
||||
with unittest.mock.patch(
|
||||
"utils.prompt_inspector.resolve_prompt_pair", return_value=resolved
|
||||
):
|
||||
evidence = inspect_prompt_evidence({"prompt": {}})
|
||||
self.assertEqual(
|
||||
evidence["prompts"]["positive"],
|
||||
{
|
||||
"status": "unresolved",
|
||||
"text": None,
|
||||
"source": None,
|
||||
"message": "Positive prompt is empty.",
|
||||
},
|
||||
)
|
||||
self.assertEqual(evidence["prompts"]["negative"]["text"], "kept")
|
||||
self.assertNotIn("message", evidence["prompts"]["negative"])
|
||||
|
||||
def test_candidates_and_absence_keep_their_status_lines(self):
|
||||
graph = _runtime()
|
||||
graph["7"] = _encode("positive")
|
||||
graph["5"] = _ksampler()
|
||||
evidence = inspect_prompt_evidence({"prompt": graph})
|
||||
self.assertEqual(evidence["resolution"]["state"], "ambiguous")
|
||||
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
|
||||
evidence = inspect_prompt_evidence({})
|
||||
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
|
||||
graph = _runtime()
|
||||
graph["7"] = _encode("positive")
|
||||
evidence = inspect_prompt_evidence({"prompt": graph})
|
||||
self.assertEqual(evidence["resolution"]["state"], "resolved")
|
||||
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
|
||||
|
||||
|
||||
class TestResolutionProjection(unittest.TestCase):
|
||||
def test_closed_codes_and_named_nodes_pass_unchanged(self):
|
||||
for code in REASON_CODES:
|
||||
resolution = {
|
||||
"state": "unresolved",
|
||||
"sampler_id": "3",
|
||||
"reason": "r",
|
||||
"reason_code": code,
|
||||
"node": None,
|
||||
}
|
||||
self.assertEqual(project_resolution(resolution), resolution)
|
||||
named = {
|
||||
"state": "unresolved",
|
||||
"sampler_id": None,
|
||||
"reason": "r",
|
||||
"reason_code": "composite",
|
||||
"node": {"id": "7", "class_type": None},
|
||||
}
|
||||
self.assertEqual(project_resolution(named), named)
|
||||
|
||||
def test_unknown_codes_and_unbounded_nodes_fail_closed(self):
|
||||
base = {"state": "unresolved", "sampler_id": None, "reason": "r"}
|
||||
for code, node in (
|
||||
("guess", None),
|
||||
(1, None),
|
||||
("malformed", {"id": "7", "class_type": "X"}),
|
||||
(None, {"id": "7", "class_type": "X"}),
|
||||
("unsupported-node", {"id": 7, "class_type": "X"}),
|
||||
("unsupported-node", {"id": "", "class_type": "X"}),
|
||||
("unsupported-node", {"id": "7"}),
|
||||
("unsupported-node", {"id": "7", "class_type": 7}),
|
||||
("unsupported-node", {"id": "7", "class_type": "X", "extra": 1}),
|
||||
("unsupported-node", "X"),
|
||||
):
|
||||
projected = project_resolution({**base, "reason_code": code, "node": node})
|
||||
self.assertIsNone(projected["node"], (code, node))
|
||||
if code not in REASON_CODES:
|
||||
self.assertIsNone(projected["reason_code"], code)
|
||||
for state in ("resolved", "absent"):
|
||||
projected = project_resolution(
|
||||
{**base, "state": state, "reason_code": "work-limit", "node": None}
|
||||
)
|
||||
self.assertIsNone(projected["reason_code"])
|
||||
long_name = project_resolution(
|
||||
{
|
||||
**base,
|
||||
"reason_code": "runtime-value",
|
||||
"node": {"id": "7" * 300, "class_type": "N" * 300},
|
||||
}
|
||||
)["node"]
|
||||
self.assertEqual(
|
||||
long_name, {"id": "7" * CLASS_NAME_LENGTH, "class_type": "N" * 200}
|
||||
)
|
||||
unnamed = project_resolution(
|
||||
{
|
||||
**base,
|
||||
"reason_code": "composite",
|
||||
"node": {"id": "7", "class_type": ""},
|
||||
}
|
||||
)["node"]
|
||||
self.assertEqual(unnamed, {"id": "7", "class_type": None})
|
||||
|
||||
def test_wire_carries_role_lines_and_projected_resolution(self):
|
||||
evidence = inspect_prompt_evidence({"prompt": _dual_cfg()})
|
||||
resolution = dict(evidence["resolution"], reason_code="guess")
|
||||
body = json.loads(
|
||||
encode_inspection_response(
|
||||
image={"root_index": 0, "path": "a.png", "filename": "a.png"},
|
||||
root_scope="a" * 64,
|
||||
file={
|
||||
"observation": "current-file",
|
||||
"source_label": "output",
|
||||
"media_type": "image",
|
||||
"extension": ".png",
|
||||
"size": 1,
|
||||
"modified_time": 1,
|
||||
},
|
||||
prompts=evidence["prompts"],
|
||||
resolution=resolution,
|
||||
inspection=None,
|
||||
library={"status": "none", "tags": []},
|
||||
)
|
||||
)
|
||||
self.assertEqual(body["prompts"], evidence["prompts"])
|
||||
self.assertIsNone(body["resolution"]["reason_code"])
|
||||
self.assertIsNone(body["resolution"]["node"])
|
||||
|
||||
|
||||
# Uncoded unresolved reasons only the default (ingestion) profile emits; the
|
||||
# inspector never shows them, so they need no line.
|
||||
_DEFAULT_PROFILE_ONLY = frozenset(
|
||||
{"Cyclic or excessive latent ancestry", "Resolution limit reached"}
|
||||
)
|
||||
|
||||
|
||||
def _uncoded_unresolved_reasons() -> set:
|
||||
"""Every literal reason the resolver can return as unresolved without a code.
|
||||
|
||||
Reads the source: ``_Unresolved(reason)`` without a code, and
|
||||
``_result("unresolved", ..., reason=...)`` without ``reason_code``. A reason
|
||||
is a string literal or a module constant naming one.
|
||||
"""
|
||||
source = Path(prompt_pair_resolver.__file__).read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
|
||||
def literal(node):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return node.value
|
||||
if isinstance(node, ast.Name):
|
||||
value = getattr(prompt_pair_resolver, node.id, None)
|
||||
return value if isinstance(value, str) else None
|
||||
return None
|
||||
|
||||
found = set()
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name):
|
||||
continue
|
||||
keywords = {item.arg: item.value for item in node.keywords}
|
||||
if node.func.id == "_Unresolved":
|
||||
if len(node.args) < 2 and "code" not in keywords and node.args:
|
||||
reason = literal(node.args[0])
|
||||
if reason is None:
|
||||
raise AssertionError(f"line {node.lineno}: unreadable reason")
|
||||
found.add(reason)
|
||||
elif node.func.id == "_result" and node.args:
|
||||
state = literal(node.args[0])
|
||||
coded = "reason_code" in keywords or len(node.args) > 6
|
||||
reason = keywords.get(
|
||||
"reason", node.args[4] if len(node.args) > 4 else None
|
||||
)
|
||||
if state == "unresolved" and not coded and reason is not None:
|
||||
text = literal(reason)
|
||||
if text is None:
|
||||
raise AssertionError(f"line {node.lineno}: unreadable reason")
|
||||
found.add(text)
|
||||
return found
|
||||
|
||||
|
||||
class TestEveryUncodedOutcomeHasALine(unittest.TestCase):
|
||||
"""Task 2.7 review M1: a new uncoded unresolved reason fails here."""
|
||||
|
||||
def test_resolver_reasons_without_a_code_have_a_line(self):
|
||||
reasons = _uncoded_unresolved_reasons()
|
||||
self.assertIn(EMPTY_POSITIVE_REASON, reasons, "the scan sees the source")
|
||||
missing = reasons - set(_UNCODED_LINES) - _DEFAULT_PROFILE_ONLY
|
||||
self.assertEqual(missing, set(), "add a code or a line in inspection_outcomes")
|
||||
# The allowance names only reasons the source still emits.
|
||||
self.assertLessEqual(_DEFAULT_PROFILE_ONLY, reasons)
|
||||
|
||||
def test_default_profile_only_reasons_are_coded_in_inspection(self):
|
||||
# The limit outcome is uncoded only on the default profile.
|
||||
source = {"prompt": {"1": {"class_type": "KSampler", "inputs": {}}}}
|
||||
for limited in ({"max_depth": 0}, {"max_text_length": 0}):
|
||||
with self.subTest(**limited):
|
||||
default = prompt_pair_resolver.resolve_prompt_pair(source, **limited)
|
||||
inspected = prompt_pair_resolver.resolve_prompt_pair(
|
||||
source, profile=INSPECTION_PROFILE, **limited
|
||||
)
|
||||
self.assertEqual(default["reason"], "Resolution limit reached")
|
||||
self.assertIsNone(default["reason_code"])
|
||||
self.assertEqual(inspected["reason"], "Resolution limit reached")
|
||||
self.assertIsNotNone(inspected["reason_code"])
|
||||
|
||||
|
||||
class TestRoleMessageBound(unittest.TestCase):
|
||||
"""Task 2.7 review M4: the encoder bounds role lines as the client does."""
|
||||
|
||||
def encode(self, prompts: dict) -> dict:
|
||||
return json.loads(
|
||||
encode_inspection_response(
|
||||
image={"root_index": 0, "path": "a.png", "filename": "a.png"},
|
||||
root_scope="a" * 64,
|
||||
file={
|
||||
"observation": "current-file",
|
||||
"source_label": "output",
|
||||
"media_type": "image",
|
||||
"extension": ".png",
|
||||
"size": 1,
|
||||
"modified_time": 1,
|
||||
},
|
||||
prompts=prompts,
|
||||
resolution={"state": "unresolved", "reason": "x"},
|
||||
inspection=None,
|
||||
library={"status": "none", "tags": []},
|
||||
)
|
||||
)["prompts"]
|
||||
|
||||
def test_role_messages_are_bounded_text_on_unresolved_roles(self):
|
||||
side = {"status": "unresolved", "text": None, "source": None}
|
||||
prompts = self.encode(
|
||||
{
|
||||
"positive": {**side, "message": "x" * 600},
|
||||
"negative": {**side, "message": "\U0001f600" * 300},
|
||||
}
|
||||
)
|
||||
self.assertEqual(prompts["positive"]["message"], "x" * 512)
|
||||
# The client counts UTF-16 code units.
|
||||
self.assertLessEqual(
|
||||
len(prompts["negative"]["message"].encode("utf-16-le")), 1024
|
||||
)
|
||||
self.assertEqual(prompts["negative"]["message"], "\U0001f600" * 256)
|
||||
for message in ("", None, 7):
|
||||
with self.subTest(message=message):
|
||||
prompts = self.encode(
|
||||
{"positive": {**side, "message": message}, "negative": side}
|
||||
)
|
||||
self.assertNotIn("message", prompts["positive"])
|
||||
resolved = {"status": "resolved", "text": "a", "source": "graph-inferred"}
|
||||
prompts = self.encode(
|
||||
{"positive": {**resolved, "message": "line"}, "negative": side}
|
||||
)
|
||||
self.assertNotIn("message", prompts["positive"])
|
||||
self.assertEqual(prompts["negative"], side)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,838 @@
|
||||
"""The declarative inspection node semantics table and its loader (#240, spec 7).
|
||||
|
||||
``web/js/inspection-semantics.json`` is the single table. Python validates it at
|
||||
import and fails loudly; the browser facts tables are checked against its
|
||||
``facts`` section by ``tests/js/inspection-semantics-sync.test.js``.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
|
||||
from utils import inspection_semantics as semantics
|
||||
from utils.inspection_semantics import SemanticsError, load_semantics
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
TABLE = REPO / "web" / "js" / "inspection-semantics.json"
|
||||
|
||||
|
||||
def _minimal():
|
||||
"""A small valid table; mutation tests break exactly one thing in a copy."""
|
||||
return {
|
||||
"schema": 1,
|
||||
"samplers": {
|
||||
"KSampler": {
|
||||
"strategy": "ksampler",
|
||||
"conditioning": {"positive": "positive", "negative": "negative"},
|
||||
"latent": "latent_image",
|
||||
"facts": "ksampler",
|
||||
}
|
||||
},
|
||||
"guiders": {"BasicGuider": {"strategy": "basic", "positive": "conditioning"}},
|
||||
"conditioning": [
|
||||
{
|
||||
"class": "FluxGuidance",
|
||||
"slot": 0,
|
||||
"strategy": "passthrough",
|
||||
"input": "conditioning",
|
||||
}
|
||||
],
|
||||
"selectors": {},
|
||||
"composites": {},
|
||||
"encoders": [
|
||||
{
|
||||
"class": "CLIPTextEncode",
|
||||
"slot": 0,
|
||||
"strategy": "single",
|
||||
"inputs": ["text"],
|
||||
}
|
||||
],
|
||||
"strings": [],
|
||||
"runtime": [],
|
||||
"savers": {},
|
||||
"non_file_outputs": [],
|
||||
"stage_inputs": {"shaped": ["positive"], "with_model": ["samples"]},
|
||||
"facts": {
|
||||
"configuration_outputs": {},
|
||||
"model_outputs": {},
|
||||
"model_passthroughs": [],
|
||||
"scalar_outputs": {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class _TableCase(unittest.TestCase):
|
||||
def load_text(self, text):
|
||||
# Windows: the file handle is closed before the directory is removed.
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
path = Path(root) / "semantics.json"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return load_semantics(path)
|
||||
|
||||
def load_doc(self, doc):
|
||||
return self.load_text(json.dumps(doc))
|
||||
|
||||
def assertRejected(self, doc, fragment):
|
||||
with self.assertRaises(SemanticsError) as caught:
|
||||
self.load_doc(doc)
|
||||
self.assertIn(fragment, str(caught.exception))
|
||||
|
||||
|
||||
class SpecEntriesTest(unittest.TestCase):
|
||||
def test_semantics_file_validates_and_lists_spec_entries(self):
|
||||
table = load_semantics()
|
||||
|
||||
self.assertEqual(semantics.SEMANTICS_PATH, TABLE)
|
||||
self.assertEqual(
|
||||
semantics.SEMANTICS_PATH,
|
||||
Path(semantics.__file__).resolve().parents[1]
|
||||
/ "web"
|
||||
/ "js"
|
||||
/ "inspection-semantics.json",
|
||||
)
|
||||
|
||||
pair = {"positive": "positive", "negative": "negative"}
|
||||
for name in ("KSampler", "KSamplerAdvanced"):
|
||||
self.assertEqual(
|
||||
table.samplers[name],
|
||||
{
|
||||
"strategy": "ksampler",
|
||||
"conditioning": pair,
|
||||
"latent": "latent_image",
|
||||
"facts": "ksampler",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
table.samplers["SamplerCustom"],
|
||||
{
|
||||
"strategy": "ksampler",
|
||||
"conditioning": pair,
|
||||
"latent": "latent_image",
|
||||
"facts": "custom",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
table.samplers["SamplerCustomAdvanced"],
|
||||
{
|
||||
"strategy": "custom-advanced",
|
||||
"conditioning": {"guider": "guider"},
|
||||
"latent": "latent_image",
|
||||
"facts": "custom-advanced",
|
||||
},
|
||||
)
|
||||
# D19: FaceDetailer is a stage over its upstream image, ksampler-shaped facts.
|
||||
# A non-empty wildcard replaces the detail prompt (Impact core.py:267).
|
||||
self.assertEqual(
|
||||
table.samplers["FaceDetailer"],
|
||||
{
|
||||
"strategy": "detailer-stage",
|
||||
"conditioning": pair,
|
||||
"image_stage": "image",
|
||||
"facts": "ksampler",
|
||||
"prompt_overrides": ("wildcard",),
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
table.guiders["CFGGuider"],
|
||||
{"strategy": "cfg", "positive": "positive", "negative": "negative"},
|
||||
)
|
||||
self.assertEqual(
|
||||
table.guiders["BasicGuider"],
|
||||
{
|
||||
"strategy": "basic",
|
||||
"positive": "conditioning",
|
||||
"absent_reason": "Guider has no negative input",
|
||||
},
|
||||
)
|
||||
# D17: DualCFGGuider has two positives and stays an unsupported node.
|
||||
self.assertNotIn("DualCFGGuider", table.guiders)
|
||||
self.assertNotIn("DualCFGGuider", table.all_classes)
|
||||
|
||||
single_input = (
|
||||
"FluxGuidance",
|
||||
"ReferenceLatent",
|
||||
"ConditioningSetArea",
|
||||
"ConditioningSetAreaPercentage",
|
||||
"ConditioningSetAreaStrength",
|
||||
"ConditioningSetMask",
|
||||
"ConditioningSetTimestepRange",
|
||||
"ControlNetApply",
|
||||
)
|
||||
for name in single_input:
|
||||
self.assertEqual(
|
||||
table.conditioning[(name, 0)],
|
||||
{"strategy": "passthrough", "input": "conditioning"},
|
||||
)
|
||||
self.assertNotIn((name, 1), table.conditioning)
|
||||
for name in (
|
||||
"ControlNetApplyAdvanced",
|
||||
"ControlNetApplySD3",
|
||||
"InpaintModelConditioning",
|
||||
"WanImageToVideo",
|
||||
):
|
||||
self.assertEqual(
|
||||
table.conditioning[(name, 0)],
|
||||
{"strategy": "passthrough", "input": "positive"},
|
||||
)
|
||||
self.assertEqual(
|
||||
table.conditioning[(name, 1)],
|
||||
{"strategy": "passthrough", "input": "negative"},
|
||||
)
|
||||
# Slot 2 (a latent) is not conditioning; spec 7.4 handles origins.
|
||||
self.assertNotIn((name, 2), table.conditioning)
|
||||
self.assertEqual(
|
||||
table.conditioning[("ConditioningZeroOut", 0)],
|
||||
{"strategy": "zeroed", "input": "conditioning"},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
table.selectors["easy ifElse"],
|
||||
{
|
||||
"strategy": "boolean-select",
|
||||
"condition": "boolean",
|
||||
"branches": {"true": "on_true", "false": "on_false"},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
table.composites["ConditioningCombine"],
|
||||
{"strategy": "combine", "inputs": ("conditioning_1", "conditioning_2")},
|
||||
)
|
||||
for name in ("ConditioningConcat", "ConditioningAverage"):
|
||||
self.assertEqual(
|
||||
table.composites[name],
|
||||
{
|
||||
"strategy": "combine",
|
||||
"inputs": ("conditioning_to", "conditioning_from"),
|
||||
},
|
||||
)
|
||||
|
||||
encoders = {
|
||||
("CLIPTextEncode", 0): ("single", ("text",)),
|
||||
("CLIPTextEncodeSDXL", 0): ("parts-equal", ("text_g", "text_l")),
|
||||
("CLIPTextEncodeFlux", 0): ("parts-equal", ("clip_l", "t5xxl")),
|
||||
("CLIPTextEncodeSD3", 0): ("parts-equal", ("clip_l", "clip_g", "t5xxl")),
|
||||
("TextEncodeQwenImageEdit", 0): ("single", ("prompt",)),
|
||||
("TextEncodeQwenImageEditPlus", 0): ("single", ("prompt",)),
|
||||
("TextEncodeQwenImage21", 0): ("single", ("prompt",)),
|
||||
("TextEncodeQwenImage21", 1): ("single", ("negative_prompt",)),
|
||||
# D11: a plain text encoder, no composition.
|
||||
("KikoTextEncode", 0): ("single", ("text",)),
|
||||
}
|
||||
for key, (strategy, inputs) in encoders.items():
|
||||
self.assertEqual(
|
||||
table.encoders[key], {"strategy": strategy, "inputs": inputs}, key
|
||||
)
|
||||
self.assertNotIn(("TextEncodeQwenImage21", 2), table.encoders)
|
||||
|
||||
self.assertEqual(
|
||||
table.strings[("StringConstant", 0)],
|
||||
{"strategy": "literal", "inputs": ("string",)},
|
||||
)
|
||||
self.assertEqual(
|
||||
table.strings[("StringConstantMultiline", 0)],
|
||||
{
|
||||
"strategy": "strip-newlines",
|
||||
"inputs": ("string",),
|
||||
"params": {"flag": "strip_newlines"},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
table.strings[("JoinStringMulti", 0)],
|
||||
{
|
||||
"strategy": "join-multi",
|
||||
"inputs": ("string_1",),
|
||||
"params": {
|
||||
"count": "inputcount",
|
||||
"delimiter": "delimiter",
|
||||
"list_flag": "return_list",
|
||||
"input_prefix": "string_",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# D18: decided at run time.
|
||||
self.assertEqual(table.runtime, frozenset({"PromptSearchList"}))
|
||||
|
||||
comfy = "{name}_{counter:05}_.{ext}"
|
||||
for name, image in (
|
||||
("SaveImage", "images"),
|
||||
("SaveAnimatedWEBP", "images"),
|
||||
("SaveAnimatedPNG", "images"),
|
||||
("SaveVideo", "video"),
|
||||
("SaveWEBM", "images"),
|
||||
):
|
||||
self.assertEqual(
|
||||
table.savers[name],
|
||||
{
|
||||
"strategy": "comfy-counter",
|
||||
"prefix_input": "filename_prefix",
|
||||
"image_input": image,
|
||||
"pattern": comfy,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
table.savers["VHS_VideoCombine"],
|
||||
{
|
||||
"strategy": "vhs-counter",
|
||||
"prefix_input": "filename_prefix",
|
||||
"image_input": "images",
|
||||
"pattern": "{name}_{counter:05}.{ext}",
|
||||
},
|
||||
)
|
||||
# Until its naming is verified in pinned source it stays terminal-unknown.
|
||||
self.assertNotIn("KikoSaveImage", table.all_classes)
|
||||
self.assertEqual(
|
||||
table.non_file_outputs,
|
||||
frozenset(
|
||||
{
|
||||
"PreviewImage",
|
||||
"PreviewAny",
|
||||
"SaveImageWebsocket",
|
||||
"Image Comparer (rgthree)",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Not listed this slice: they stay unsupported-node with the class named.
|
||||
for name in (
|
||||
"ImpactWildcardProcessor",
|
||||
"RegexReplace",
|
||||
"LoRAfromText",
|
||||
"XYZPrompt",
|
||||
):
|
||||
self.assertNotIn(name, table.all_classes)
|
||||
# Existing code adapters stay in code, unchanged.
|
||||
for name in ("PromptManager", "PromptManagerPair", "PromptManagerText"):
|
||||
self.assertNotIn(name, table.all_classes)
|
||||
|
||||
def test_legacy_parser_ports_are_listed_with_their_source_inputs(self):
|
||||
# Task 2.7b (R6): input names and slots read from each node's source;
|
||||
# the report lists the file and line for every one.
|
||||
table = load_semantics()
|
||||
pair = {"positive": "positive", "negative": "negative"}
|
||||
for name, image in (
|
||||
("DetailerForEach", "image"),
|
||||
("UltimateSDUpscale", "image"),
|
||||
("UltimateSDUpscaleNoUpscale", "upscaled_image"),
|
||||
):
|
||||
entry = dict(table.samplers[name])
|
||||
self.assertEqual(
|
||||
entry.pop("prompt_overrides", None) is not None,
|
||||
name == "DetailerForEach",
|
||||
name,
|
||||
)
|
||||
self.assertEqual(
|
||||
entry,
|
||||
{
|
||||
"strategy": "detailer-stage",
|
||||
"conditioning": pair,
|
||||
"image_stage": image,
|
||||
"facts": "ksampler",
|
||||
},
|
||||
name,
|
||||
)
|
||||
for (name, slot), source in {
|
||||
("ControlNetInpaintingAliMamaApply", 0): "positive",
|
||||
("ControlNetInpaintingAliMamaApply", 1): "negative",
|
||||
("LTXVConditioning", 0): "positive",
|
||||
("LTXVConditioning", 1): "negative",
|
||||
("FluxKontextMultiReferenceLatentMethod", 0): "conditioning",
|
||||
}.items():
|
||||
self.assertEqual(
|
||||
table.conditioning[(name, slot)],
|
||||
{"strategy": "passthrough", "input": source},
|
||||
)
|
||||
self.assertNotIn(
|
||||
("FluxKontextMultiReferenceLatentMethod", 1), table.conditioning
|
||||
)
|
||||
self.assertEqual(
|
||||
table.encoders[("Text to Conditioning", 0)],
|
||||
{"strategy": "single", "inputs": ("text",)},
|
||||
)
|
||||
for name in ("TextInput", "DisplayText"):
|
||||
self.assertEqual(
|
||||
table.strings[(name, 0)], {"strategy": "literal", "inputs": ("text",)}
|
||||
)
|
||||
|
||||
def test_stage_inputs_are_the_one_source_for_both_stage_walks(self):
|
||||
# Review I-2: Python's resolver and comfy-metadata.js read these lists here.
|
||||
stage_inputs = load_semantics().stage_inputs
|
||||
self.assertEqual(
|
||||
stage_inputs["shaped"],
|
||||
(
|
||||
"latent_image",
|
||||
"guider",
|
||||
"positive",
|
||||
"negative",
|
||||
"pipe",
|
||||
"basic_pipe",
|
||||
"detailer_pipe",
|
||||
"sampler",
|
||||
"sigmas",
|
||||
"noise",
|
||||
"text_embeds",
|
||||
"hyvid_embeds",
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
stage_inputs["with_model"],
|
||||
("conditioning", "neg_conditioning", "samples", "latent"),
|
||||
)
|
||||
# The default profile's own sampler-shape names stay a subset of the table's.
|
||||
from utils.prompt_pair_resolver import _SAMPLER_LIKE_INPUTS
|
||||
|
||||
self.assertLessEqual(set(_SAMPLER_LIKE_INPUTS), set(stage_inputs["shaped"]))
|
||||
|
||||
def test_facts_section_mirrors_the_browser_tables(self):
|
||||
facts = load_semantics().facts
|
||||
self.assertEqual(
|
||||
facts["configuration_outputs"],
|
||||
{
|
||||
"RandomNoise": {"kind": "noise", "fields": ("noise_seed",)},
|
||||
"KSamplerSelect": {"kind": "sampler", "fields": ("sampler_name",)},
|
||||
"BasicScheduler": {"kind": "sigmas", "fields": ("steps", "scheduler")},
|
||||
# D8: Flux2Scheduler has no scheduler input; its type names it.
|
||||
"Flux2Scheduler": {
|
||||
"kind": "sigmas",
|
||||
"fields": ("steps",),
|
||||
"type_fields": ("scheduler",),
|
||||
},
|
||||
"CFGGuider": {"kind": "guider", "fields": ("cfg", "model")},
|
||||
"BasicGuider": {"kind": "guider", "fields": ("model",)},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
facts["model_outputs"],
|
||||
{
|
||||
"CheckpointLoader": "ckpt_name",
|
||||
"CheckpointLoaderSimple": "ckpt_name",
|
||||
"unCLIPCheckpointLoader": "ckpt_name",
|
||||
"UNETLoader": "unet_name",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
facts["model_passthroughs"],
|
||||
(
|
||||
"LoraLoader",
|
||||
"LoraLoaderModelOnly",
|
||||
"ModelSamplingFlux",
|
||||
"ModelSamplingAuraFlow",
|
||||
"ModelSamplingSD3",
|
||||
# Task 2.7b (R9): model in, model out, verified in their sources.
|
||||
"Lora Loader (LoraManager)",
|
||||
"Power Lora Loader (rgthree)",
|
||||
"ModelPatchTorchSettings",
|
||||
"CFGNorm",
|
||||
"PerturbedAttentionGuidance",
|
||||
"CR Apply LoRA Stack",
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
facts["scalar_outputs"],
|
||||
{
|
||||
"PrimitiveInt": "number",
|
||||
"PrimitiveFloat": "number",
|
||||
"PrimitiveString": "string",
|
||||
"PrimitiveStringMultiline": "string",
|
||||
},
|
||||
)
|
||||
|
||||
def test_table_is_loaded_lazily_and_once(self):
|
||||
# Importing the module reads nothing; the inspection path asks for it.
|
||||
self.assertFalse(hasattr(semantics, "SEMANTICS"))
|
||||
semantics.get_semantics.cache_clear()
|
||||
first = semantics.get_semantics()
|
||||
self.assertIs(first, semantics.get_semantics())
|
||||
self.assertEqual(first, load_semantics())
|
||||
|
||||
def test_loaded_table_is_frozen(self):
|
||||
table = load_semantics()
|
||||
self.assertIsInstance(table.samplers, MappingProxyType)
|
||||
self.assertIsInstance(table.samplers["KSampler"], MappingProxyType)
|
||||
with self.assertRaises(TypeError):
|
||||
table.samplers["Other"] = {}
|
||||
with self.assertRaises(TypeError):
|
||||
table.samplers["KSampler"]["latent"] = "x"
|
||||
with self.assertRaises(AttributeError):
|
||||
table.samplers = {}
|
||||
self.assertIsInstance(table.composites["ConditioningCombine"]["inputs"], tuple)
|
||||
|
||||
|
||||
class ValidationTest(_TableCase):
|
||||
def test_minimal_table_loads(self):
|
||||
table = self.load_doc(_minimal())
|
||||
self.assertEqual(table.encoders[("CLIPTextEncode", 0)]["inputs"], ("text",))
|
||||
self.assertEqual(table.runtime, frozenset())
|
||||
|
||||
def test_unknown_strategy_rejected(self):
|
||||
for category, mutate in (
|
||||
("samplers", lambda d: d["samplers"]["KSampler"].update(strategy="dual")),
|
||||
# A known strategy of another category is still unknown here.
|
||||
("conditioning", lambda d: d["conditioning"][0].update(strategy="single")),
|
||||
("encoders", lambda d: d["encoders"][0].update(strategy="guess")),
|
||||
("guiders", lambda d: d["guiders"]["BasicGuider"].update(strategy=None)),
|
||||
):
|
||||
with self.subTest(category=category):
|
||||
doc = _minimal()
|
||||
mutate(doc)
|
||||
self.assertRejected(doc, "unknown strategy")
|
||||
|
||||
def test_strategies_are_the_closed_spec_set(self):
|
||||
self.assertEqual(
|
||||
dict(semantics.STRATEGIES),
|
||||
{
|
||||
"samplers": frozenset(
|
||||
{"ksampler", "custom-advanced", "detailer-stage"}
|
||||
),
|
||||
"guiders": frozenset({"cfg", "basic"}),
|
||||
"conditioning": frozenset({"passthrough", "zeroed"}),
|
||||
"selectors": frozenset({"boolean-select"}),
|
||||
"composites": frozenset({"combine"}),
|
||||
"encoders": frozenset(
|
||||
{"single", "parts-equal", "pm-compose", "flag-parts"}
|
||||
),
|
||||
"strings": frozenset(
|
||||
{"literal", "strip-newlines", "join-multi", "concat"}
|
||||
),
|
||||
"savers": frozenset({"comfy-counter", "vhs-counter"}),
|
||||
},
|
||||
)
|
||||
with self.assertRaises(TypeError):
|
||||
semantics.STRATEGIES["samplers"] = frozenset()
|
||||
|
||||
def test_duplicate_output_key_rejected(self):
|
||||
doc = _minimal()
|
||||
doc["encoders"].append(copy.deepcopy(doc["encoders"][0]))
|
||||
self.assertRejected(doc, "duplicate")
|
||||
|
||||
doc = _minimal()
|
||||
doc["conditioning"].append(
|
||||
{
|
||||
"class": "CLIPTextEncode",
|
||||
"slot": 0,
|
||||
"strategy": "passthrough",
|
||||
"input": "conditioning",
|
||||
}
|
||||
)
|
||||
self.assertRejected(doc, "duplicate")
|
||||
|
||||
doc = _minimal()
|
||||
doc["runtime"] = ["KSampler"]
|
||||
self.assertRejected(doc, "duplicate")
|
||||
|
||||
doc = _minimal()
|
||||
doc["non_file_outputs"] = ["PreviewImage", "PreviewImage"]
|
||||
self.assertRejected(doc, "duplicate")
|
||||
|
||||
# JSON objects with a repeated key would silently keep the last one.
|
||||
text = json.dumps(_minimal())
|
||||
entry = json.dumps(_minimal()["samplers"]["KSampler"])
|
||||
text = text.replace(
|
||||
'"samplers": {', '"samplers": {"KSampler": ' + entry + ", ", 1
|
||||
)
|
||||
with self.assertRaises(SemanticsError) as caught:
|
||||
self.load_text(text)
|
||||
self.assertIn("duplicate", str(caught.exception))
|
||||
|
||||
def test_slot_must_be_nonnegative_int(self):
|
||||
for slot in (-1, 1.5, True, "0", None, 2**31):
|
||||
with self.subTest(slot=slot):
|
||||
doc = _minimal()
|
||||
doc["encoders"][0]["slot"] = slot
|
||||
self.assertRejected(doc, "slot")
|
||||
doc = _minimal()
|
||||
del doc["conditioning"][0]["slot"]
|
||||
self.assertRejected(doc, "slot")
|
||||
|
||||
def test_missing_input_name_rejected(self):
|
||||
cases = (
|
||||
lambda d: d["samplers"]["KSampler"]["conditioning"].pop("negative"),
|
||||
lambda d: d["samplers"]["KSampler"].pop("latent"),
|
||||
lambda d: d["guiders"]["BasicGuider"].update(positive=""),
|
||||
lambda d: d["conditioning"][0].pop("input"),
|
||||
lambda d: d["encoders"][0].update(inputs=[]),
|
||||
lambda d: d["encoders"][0].update(inputs=["text", "text"]),
|
||||
lambda d: d["encoders"][0].update(inputs=[3]),
|
||||
lambda d: d["encoders"][0].update(inputs="text"),
|
||||
)
|
||||
for index, mutate in enumerate(cases):
|
||||
with self.subTest(case=index):
|
||||
doc = _minimal()
|
||||
mutate(doc)
|
||||
self.assertRejected(doc, "input")
|
||||
|
||||
def test_entry_shape_is_strict(self):
|
||||
cases = (
|
||||
(lambda d: d.update(extra={}), "category"),
|
||||
(lambda d: d.pop("guiders"), "category"),
|
||||
(lambda d: d.update(schema=2), "schema"),
|
||||
(lambda d: d["samplers"]["KSampler"].update(note="x"), "field"),
|
||||
(lambda d: d["samplers"]["KSampler"].update(facts="flux"), "facts"),
|
||||
(lambda d: d["guiders"]["BasicGuider"].update(negative="n"), "field"),
|
||||
(lambda d: d["encoders"][0].update({"class": ""}), "class"),
|
||||
(lambda d: d["facts"].update(extra={}), "facts"),
|
||||
(
|
||||
lambda d: d["facts"]["scalar_outputs"].update(PrimitiveInt="int"),
|
||||
"facts",
|
||||
),
|
||||
(lambda d: d.pop("stage_inputs"), "category"),
|
||||
(lambda d: d["stage_inputs"].update(extra=["x"]), "stage_inputs"),
|
||||
(lambda d: d["stage_inputs"].pop("with_model"), "stage_inputs"),
|
||||
(lambda d: d["stage_inputs"].update(shaped=[]), "input name"),
|
||||
(lambda d: d["stage_inputs"].update(shaped="pipe"), "input name"),
|
||||
(lambda d: d["stage_inputs"].update(with_model=["positive"]), "both"),
|
||||
(lambda d: d["stage_inputs"].update(shaped=["model"]), "model"),
|
||||
(
|
||||
lambda d: d["facts"]["configuration_outputs"].update(
|
||||
X={"kind": "sigmas", "fields": ["steps"], "type_fields": []}
|
||||
),
|
||||
"input name",
|
||||
),
|
||||
(
|
||||
lambda d: d["facts"]["configuration_outputs"].update(
|
||||
X={"kind": "sigmas", "fields": ["steps"], "type_fields": ["steps"]}
|
||||
),
|
||||
"type_fields",
|
||||
),
|
||||
(
|
||||
lambda d: d["facts"]["configuration_outputs"].update(
|
||||
X={"kind": "sigmas", "fields": ["steps"], "names": ["x"]}
|
||||
),
|
||||
"field",
|
||||
),
|
||||
)
|
||||
for index, (mutate, fragment) in enumerate(cases):
|
||||
with self.subTest(case=index):
|
||||
doc = _minimal()
|
||||
mutate(doc)
|
||||
self.assertRejected(doc, fragment)
|
||||
|
||||
def test_strategy_specific_requirements(self):
|
||||
doc = _minimal()
|
||||
doc["samplers"]["X"] = {
|
||||
"strategy": "custom-advanced",
|
||||
"conditioning": {"positive": "positive", "negative": "negative"},
|
||||
"latent": "latent_image",
|
||||
"facts": "custom-advanced",
|
||||
}
|
||||
self.assertRejected(doc, "input")
|
||||
|
||||
doc = _minimal()
|
||||
doc["samplers"]["D"] = {
|
||||
"strategy": "detailer-stage",
|
||||
"conditioning": {"positive": "positive", "negative": "negative"},
|
||||
"facts": "ksampler",
|
||||
}
|
||||
self.assertRejected(doc, "input")
|
||||
|
||||
doc = _minimal()
|
||||
doc["guiders"]["G"] = {"strategy": "cfg", "positive": "positive"}
|
||||
self.assertRejected(doc, "input")
|
||||
|
||||
doc = _minimal()
|
||||
doc["encoders"][0].update(strategy="parts-equal", inputs=["text_g"])
|
||||
self.assertRejected(doc, "input")
|
||||
|
||||
doc = _minimal()
|
||||
doc["savers"]["S"] = {
|
||||
"strategy": "comfy-counter",
|
||||
"prefix_input": "filename_prefix",
|
||||
"image_input": "images",
|
||||
"pattern": "{name}_{counter:05}.{ext}",
|
||||
}
|
||||
self.assertRejected(doc, "pattern")
|
||||
|
||||
doc = _minimal()
|
||||
doc["selectors"]["easy ifElse"] = {
|
||||
"strategy": "boolean-select",
|
||||
"condition": "boolean",
|
||||
"branches": {"true": "on_true"},
|
||||
}
|
||||
self.assertRejected(doc, "input")
|
||||
|
||||
doc = _minimal()
|
||||
doc["strings"].append(
|
||||
{
|
||||
"class": "JoinStringMulti",
|
||||
"slot": 0,
|
||||
"strategy": "join-multi",
|
||||
"inputs": ["string_1"],
|
||||
"params": {"count": "inputcount"},
|
||||
}
|
||||
)
|
||||
self.assertRejected(doc, "params")
|
||||
|
||||
def test_unreadable_table_fails_loudly(self):
|
||||
with self.assertRaises(SemanticsError):
|
||||
self.load_text("{not json")
|
||||
with self.assertRaises(SemanticsError):
|
||||
self.load_text("[]")
|
||||
with self.assertRaises(SemanticsError):
|
||||
self.load_text(
|
||||
json.dumps(_minimal()).replace('"schema": 1', '"schema": NaN')
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
with self.assertRaises(SemanticsError):
|
||||
load_semantics(Path(root) / "missing.json")
|
||||
|
||||
def test_oversized_table_is_rejected_before_parsing(self):
|
||||
text = json.dumps(_minimal()) + " " * semantics.MAX_TABLE_BYTES
|
||||
with self.assertRaises(SemanticsError) as caught:
|
||||
self.load_text(text)
|
||||
self.assertIn("exceeds", str(caught.exception))
|
||||
|
||||
def test_deeply_nested_table_is_a_semantics_error(self):
|
||||
# Past the nesting bound, and far past the interpreter's recursion limit.
|
||||
for depth in (semantics.MAX_NESTING + 1, 100_000):
|
||||
with self.subTest(depth=depth):
|
||||
nested = "[" * depth + "]" * depth
|
||||
text = json.dumps(_minimal()).replace(
|
||||
'"selectors": {}', '"selectors": ' + nested
|
||||
)
|
||||
with self.assertRaises(SemanticsError) as caught:
|
||||
self.load_text(text)
|
||||
self.assertIn("nest", str(caught.exception))
|
||||
self.assertGreater(semantics.MAX_NESTING, _depth(json.loads(TABLE.read_text())))
|
||||
# Whichever interpreter limit the parser hits, it is a SemanticsError.
|
||||
with unittest.mock.patch.object(
|
||||
semantics.json, "loads", side_effect=RecursionError
|
||||
):
|
||||
with self.assertRaises(SemanticsError) as caught:
|
||||
self.load_doc(_minimal())
|
||||
self.assertIn("nests deeper", str(caught.exception))
|
||||
|
||||
|
||||
def _depth(value, level=1):
|
||||
children = value.values() if isinstance(value, dict) else value
|
||||
if not isinstance(value, (dict, list)):
|
||||
return level - 1
|
||||
return max([level] + [_depth(item, level + 1) for item in children])
|
||||
|
||||
|
||||
class SamplerOptionsTest(_TableCase):
|
||||
"""Task 2.7b: optional sampler lists are input names, or the table fails."""
|
||||
|
||||
def test_upstream_verified_ports_are_listed_with_their_source_inputs(self):
|
||||
# Not installed here: read from the pinned upstream commits the saved
|
||||
# workflows name (the report lists repository, commit, file and line).
|
||||
table = load_semantics()
|
||||
pair = {"positive": "positive", "negative": "negative"}
|
||||
for name, latent, prompt_overrides, fact_overrides in (
|
||||
(
|
||||
"ClownsharKSampler_Beta",
|
||||
"latent_image",
|
||||
("options",),
|
||||
("options", "sigmas"),
|
||||
),
|
||||
("KSampler (Efficient)", "latent_image", ("script",), ("script",)),
|
||||
("ttN KSampler_v2", "latent", ("adv_xyPlot",), ("adv_xyPlot",)),
|
||||
):
|
||||
self.assertEqual(
|
||||
table.samplers[name],
|
||||
{
|
||||
"strategy": "ksampler",
|
||||
"conditioning": pair,
|
||||
"latent": latent,
|
||||
"facts": "ksampler",
|
||||
"prompt_overrides": prompt_overrides,
|
||||
"fact_overrides": fact_overrides,
|
||||
},
|
||||
name,
|
||||
)
|
||||
for (name, slot), source in {
|
||||
("SeedVarianceEnhancer", 0): "conditioning",
|
||||
("ApplyInstantIDAdvanced", 1): "positive",
|
||||
("ApplyInstantIDAdvanced", 2): "negative",
|
||||
}.items():
|
||||
self.assertEqual(
|
||||
table.conditioning[(name, slot)],
|
||||
{"strategy": "passthrough", "input": source},
|
||||
)
|
||||
self.assertNotIn(("ApplyInstantIDAdvanced", 0), table.conditioning)
|
||||
self.assertEqual(
|
||||
table.selectors["Switch conditioning [Crystools]"],
|
||||
table.selectors["easy ifElse"],
|
||||
)
|
||||
self.assertEqual(
|
||||
table.encoders[("smZ CLIPTextEncode", 0)],
|
||||
{
|
||||
"strategy": "flag-parts",
|
||||
"inputs": ("text", "text_g", "text_l"),
|
||||
"params": {"flag": "with_SDXL"},
|
||||
},
|
||||
)
|
||||
for name, source in (
|
||||
("ttN text", "text"),
|
||||
("CR Text", "text"),
|
||||
("CR Prompt Text", "prompt"),
|
||||
("TextBoxMira", "text"),
|
||||
):
|
||||
self.assertEqual(
|
||||
table.strings[(name, 0)], {"strategy": "literal", "inputs": (source,)}
|
||||
)
|
||||
self.assertNotIn((name, 1), table.strings)
|
||||
|
||||
def test_flag_parts_needs_a_flag_and_two_inputs(self):
|
||||
doc = _minimal()
|
||||
entry = {
|
||||
"class": "smZ CLIPTextEncode",
|
||||
"slot": 0,
|
||||
"strategy": "flag-parts",
|
||||
"inputs": ["text", "text_g"],
|
||||
"params": {"flag": "with_SDXL"},
|
||||
}
|
||||
doc["encoders"].append(entry)
|
||||
self.assertEqual(
|
||||
self.load_doc(doc).encoders[("smZ CLIPTextEncode", 0)]["params"],
|
||||
{"flag": "with_SDXL"},
|
||||
)
|
||||
for change in (
|
||||
{"params": {}},
|
||||
{"params": {"flag": "x", "other": "y"}},
|
||||
{"params": {"flag": ""}},
|
||||
{"params": None},
|
||||
{"inputs": ["text"]},
|
||||
{"compose": True},
|
||||
):
|
||||
with self.subTest(change=change):
|
||||
broken = _minimal()
|
||||
broken["encoders"].append({**entry, **change})
|
||||
with self.assertRaises(SemanticsError):
|
||||
self.load_doc(broken)
|
||||
broken = _minimal()
|
||||
broken["encoders"].append(
|
||||
{key: item for key, item in entry.items() if key != "params"}
|
||||
)
|
||||
with self.assertRaises(SemanticsError):
|
||||
self.load_doc(broken)
|
||||
|
||||
def test_prompt_overrides_are_input_names(self):
|
||||
doc = _minimal()
|
||||
doc["samplers"]["KSampler"]["prompt_overrides"] = ["wildcard"]
|
||||
sampler = self.load_doc(doc).samplers["KSampler"]
|
||||
self.assertEqual(sampler["prompt_overrides"], ("wildcard",))
|
||||
for value in ([], "wildcard", [""], ["a", "a"], [1]):
|
||||
with self.subTest(value=value):
|
||||
doc["samplers"]["KSampler"]["prompt_overrides"] = value
|
||||
with self.assertRaises(SemanticsError):
|
||||
self.load_doc(doc)
|
||||
doc["samplers"]["KSampler"].pop("prompt_overrides")
|
||||
doc["samplers"]["KSampler"]["fact_overrides"] = ["script", "sigmas"]
|
||||
sampler = self.load_doc(doc).samplers["KSampler"]
|
||||
self.assertEqual(sampler["fact_overrides"], ("script", "sigmas"))
|
||||
doc["samplers"]["KSampler"]["fact_overrides"] = ["script", "script"]
|
||||
with self.assertRaises(SemanticsError):
|
||||
self.load_doc(doc)
|
||||
doc["samplers"]["KSampler"].pop("fact_overrides")
|
||||
doc["samplers"]["KSampler"]["overrides"] = ["wildcard"]
|
||||
self.assertRejected(doc, "unknown field")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -487,6 +487,115 @@ class TestLibrary(unittest.TestCase):
|
||||
rotate_history_epoch(self.db.model.get_connection())
|
||||
self.assertNotEqual(token(), before)
|
||||
|
||||
def test_library_items_expose_run_count_and_last_used(self):
|
||||
import datetime
|
||||
|
||||
used = self.save("Used")
|
||||
never = self.save("Never")
|
||||
self.assertTrue(self.db.record_prompt_use(used))
|
||||
self.assertTrue(self.db.record_prompt_use(used, times=2))
|
||||
legacy = self.save("Legacy naive")
|
||||
offset = self.save("Offset")
|
||||
broken = self.save("Broken")
|
||||
zulu = self.save("Zulu")
|
||||
# Parse but cannot convert to UTC: past the datetime range either way.
|
||||
floor = self.save("Floor")
|
||||
ceiling = self.save("Ceiling")
|
||||
with self.db.model.get_connection() as connection:
|
||||
for prompt_id, value in (
|
||||
(legacy, "2026-10-07 18:39:06"),
|
||||
(zulu, "2026-10-07T18:39:06Z"),
|
||||
(floor, "0001-01-01T00:00:00+01:00"),
|
||||
(ceiling, "9999-12-31T23:59:59-01:00"),
|
||||
(offset, "2026-10-07T20:39:06.250+02:00"),
|
||||
(broken, "yesterday"),
|
||||
):
|
||||
connection.execute(
|
||||
"UPDATE prompts SET last_used_at=?,run_count=1 WHERE id=?",
|
||||
(value, prompt_id),
|
||||
)
|
||||
items = {row["id"]: row for row in self.db.list_library()["items"]}
|
||||
self.assertEqual(items[used]["run_count"], 3)
|
||||
stamp = datetime.datetime.fromisoformat(items[used]["last_used_at"])
|
||||
self.assertEqual(stamp.utcoffset(), datetime.timedelta(0))
|
||||
self.assertLess(
|
||||
abs(datetime.datetime.now(datetime.timezone.utc) - stamp),
|
||||
datetime.timedelta(minutes=5),
|
||||
)
|
||||
self.assertEqual(items[never]["run_count"], 0)
|
||||
self.assertIsNone(items[never]["last_used_at"])
|
||||
self.assertEqual(items[legacy]["last_used_at"], "2026-10-07T18:39:06+00:00")
|
||||
self.assertEqual(
|
||||
items[offset]["last_used_at"], "2026-10-07T18:39:06.250000+00:00"
|
||||
)
|
||||
self.assertIsNone(items[broken]["last_used_at"])
|
||||
self.assertEqual(items[zulu]["last_used_at"], "2026-10-07T18:39:06+00:00")
|
||||
self.assertIsNone(items[floor]["last_used_at"])
|
||||
self.assertIsNone(items[ceiling]["last_used_at"])
|
||||
self.assertIsNone(
|
||||
self.db.get_library_positive(floor)["positive"]["last_used_at"]
|
||||
)
|
||||
self.assertEqual(type(items[broken]["run_count"]), int)
|
||||
detail = self.db.get_library_positive(used)["positive"]
|
||||
self.assertEqual(detail["run_count"], 3)
|
||||
self.assertEqual(detail["last_used_at"], items[used]["last_used_at"])
|
||||
|
||||
def test_variant_total_matches_filtered_cards(self):
|
||||
archived = self.db.save_prompt_pair("Cat", "Blur", category="A")
|
||||
self.db.save_prompt_pair("Cat", "Noise", category="A")
|
||||
self.db.save_prompt_pair("Cat", "Grain", category="B")
|
||||
self.db.archive_prompt_pair(archived)
|
||||
self.db.save_prompt_pair("Dog", "Blur", category="B")
|
||||
self.save("Legacy")
|
||||
everything = self.db.list_library()
|
||||
self.assertEqual(everything["variant_total"], 3)
|
||||
self.assertEqual(
|
||||
everything["variant_total"],
|
||||
sum(row["variant_count"] for row in everything["items"]),
|
||||
)
|
||||
cats = self.db.list_library(text="Cat")
|
||||
self.assertEqual(
|
||||
(cats["total"], cats["variant_total"]),
|
||||
(1, 2),
|
||||
)
|
||||
self.assertEqual(self.db.list_library(category="B")["variant_total"], 3)
|
||||
self.assertEqual(self.db.list_library(text="nothing")["variant_total"], 0)
|
||||
first = self.db.list_library(limit=1)
|
||||
self.assertEqual(first["variant_total"], 3)
|
||||
# A later parent is past the cursor's ceiling, like the total.
|
||||
self.db.save_prompt_pair("Bird", "Blur")
|
||||
second = self.db.list_library(limit=1, after=first["next_key"])
|
||||
self.assertEqual(
|
||||
(second["total"], second["variant_total"]),
|
||||
(first["total"], 3),
|
||||
)
|
||||
|
||||
def test_edit_token_unchanged_by_usage_fields(self):
|
||||
positive = self.save("Cat", category="A", tags=["a"], rating=3)
|
||||
|
||||
def parent():
|
||||
return self.db.get_library_positive(positive)["positive"]
|
||||
|
||||
before = parent()
|
||||
listed = self.db.list_library()["items"][0]
|
||||
self.assertEqual(listed["edit_token"], before["edit_token"])
|
||||
self.db.record_prompt_use(positive, times=4)
|
||||
with self.db.model.get_connection() as connection:
|
||||
connection.execute(
|
||||
"UPDATE prompts SET last_used_at='2001-01-01T00:00:00+00:00' "
|
||||
"WHERE id=?",
|
||||
(positive,),
|
||||
)
|
||||
after = parent()
|
||||
self.assertNotEqual(
|
||||
(after["run_count"], after["last_used_at"]),
|
||||
(before["run_count"], before["last_used_at"]),
|
||||
)
|
||||
self.assertEqual(after["edit_token"], before["edit_token"])
|
||||
self.assertEqual(
|
||||
self.db.list_library()["items"][0]["edit_token"], before["edit_token"]
|
||||
)
|
||||
|
||||
def test_library_snapshot_reads_epoch_and_ceiling_in_one_select_and_ends(self):
|
||||
self.save("Cat")
|
||||
connection = self.db.model.get_connection()
|
||||
@@ -1584,3 +1693,16 @@ class TestLibrary(unittest.TestCase):
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "does not belong to this positive"):
|
||||
self.db.list_library_images(positive, pair_id=foreign, archive="all")
|
||||
|
||||
|
||||
class DisplayTitleUnits(unittest.TestCase):
|
||||
"""The first-line title is cut like the Library's JavaScript title()."""
|
||||
|
||||
def test_title_counts_utf16_units_and_never_splits_a_surrogate_pair(self):
|
||||
from database.library import display_title
|
||||
|
||||
self.assertEqual(display_title((), " short line \nsecond"), "short line")
|
||||
self.assertEqual(display_title((), "a" * 80), "a" * 80)
|
||||
self.assertEqual(display_title((), "a" * 81), "a" * 80 + "…")
|
||||
self.assertEqual(display_title((), "\U0001f600" * 80), "\U0001f600" * 40 + "…")
|
||||
self.assertEqual(display_title((), "a" * 79 + "\U0001f600b"), "a" * 79 + "…")
|
||||
|
||||
+67
-9
@@ -17,6 +17,14 @@ def graph(positive="cat", negative="blur"):
|
||||
}
|
||||
|
||||
|
||||
def sampled_graph(positive="cat", negative="blur"):
|
||||
"""A KSampler with a model link, as real workflows have."""
|
||||
g = graph(positive, negative)
|
||||
g["4"] = {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}}
|
||||
g["3"]["inputs"]["model"] = ["4", 0]
|
||||
return g
|
||||
|
||||
|
||||
class TestPairScan(AdminAPITestCase):
|
||||
def test_pair_scan_repeated_is_unchanged_and_empty_negative_is_supported(self):
|
||||
path = self.output_dir / "pair.png"
|
||||
@@ -28,16 +36,66 @@ class TestPairScan(AdminAPITestCase):
|
||||
self.assertEqual(pairs[0]["negative_text"], "")
|
||||
self.assertEqual(pairs[0]["run_count"], 0)
|
||||
|
||||
def test_ambiguous_sampler_scan_does_not_save_a_guess(self):
|
||||
g = graph()
|
||||
g["4"] = {**g["3"]}
|
||||
meta = {"prompt": json.dumps(g)}
|
||||
self.assertEqual(
|
||||
self.api._ingest_scanned_file(self.output_dir / "multi.png", meta),
|
||||
"ambiguous",
|
||||
)
|
||||
def _scanned(self, name, prompt_graph):
|
||||
meta = {"prompt": json.dumps(prompt_graph)}
|
||||
return self.api._ingest_scanned_file(self.output_dir / name, meta)
|
||||
|
||||
def _texts(self):
|
||||
return [p["text"] for p in self.api.db.get_recent_prompts()["prompts"]]
|
||||
|
||||
def test_samplers_sharing_one_prompt_link_that_prompt(self):
|
||||
g = sampled_graph()
|
||||
g["5"] = {**g["3"]}
|
||||
self.assertEqual(self._scanned("multi.png", g), "added")
|
||||
self.assertEqual(self._texts(), ["cat"])
|
||||
|
||||
def test_samplers_with_different_prompts_link_the_first_sampler_prompt(self):
|
||||
# The prompt the inspector and the legacy panels show for this file.
|
||||
g = sampled_graph()
|
||||
g["6"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "dog"}}
|
||||
g["12"] = {**g["3"], "inputs": {**g["3"]["inputs"], "positive": ["6", 0]}}
|
||||
self.assertEqual(self._scanned("different.png", g), "added")
|
||||
self.assertEqual(self._texts(), ["cat"])
|
||||
|
||||
def test_an_unsupported_pass_through_still_links_the_traced_prompt(self):
|
||||
g = sampled_graph()
|
||||
g["7"] = {
|
||||
"class_type": "MyConditioningTweak",
|
||||
"inputs": {"conditioning": ["1", 0]},
|
||||
}
|
||||
g["3"]["inputs"]["positive"] = ["7", 0]
|
||||
g["5"] = {**g["3"]}
|
||||
self.assertEqual(self._scanned("tweak.png", g), "added")
|
||||
self.assertEqual(self._texts(), ["cat"])
|
||||
|
||||
def test_a_graph_without_a_traceable_prompt_saves_nothing(self):
|
||||
g = sampled_graph()
|
||||
g["5"] = {**g["3"]}
|
||||
for sampler in ("3", "5"):
|
||||
g[sampler]["inputs"]["positive"] = ["9", 0]
|
||||
g["9"] = {
|
||||
"class_type": "BasicGuider",
|
||||
"inputs": {"width": 2048, "height": 2048},
|
||||
}
|
||||
self.assertEqual(self._scanned("untraceable.png", g), "ambiguous")
|
||||
self.assertEqual(self.api.db.list_prompt_pairs()["total"], 0)
|
||||
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
|
||||
self.assertEqual(self._texts(), [])
|
||||
|
||||
def test_numeric_widgets_are_never_stored_as_a_prompt(self):
|
||||
# The pre-redesign scan stored "2048" from a resolution widget here.
|
||||
g = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": ["8", 0]}},
|
||||
"8": {"class_type": "DisplayText", "inputs": {"text_size": 2048}},
|
||||
"3": {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {"guider": ["4", 0], "sigmas": ["5", 0]},
|
||||
},
|
||||
"4": {"class_type": "BasicGuider", "inputs": {"conditioning": ["1", 0]}},
|
||||
}
|
||||
self.assertIn(
|
||||
self._scanned("numbers.png", g), ("ambiguous", "unresolved", "found")
|
||||
)
|
||||
self.assertEqual(self._texts(), [])
|
||||
|
||||
def test_batch_exposes_pair_counts_and_actual_unchanged_status(self):
|
||||
meta = {"parameters": "cat\nNegative prompt: blur\nSteps: 20, Sampler: Euler"}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
"""The concept fixture files carry the concept inspector facts (app.js:400-414)."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from tests.browser.workspace_concept_fixture import (
|
||||
CONCEPT_MODEL,
|
||||
CONCEPT_PROMPTS,
|
||||
write_concept_png,
|
||||
)
|
||||
from utils.prompt_inspector import inspect_prompt_evidence, read_inspection_file
|
||||
|
||||
|
||||
class TestWorkspaceConceptFixture(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.tmp.name)
|
||||
|
||||
def write(self, prompt_index, round_index):
|
||||
prompt = CONCEPT_PROMPTS[prompt_index]
|
||||
path = Path(self.tmp.name) / f"{prompt.id}_{round_index + 1:05}.png"
|
||||
image = write_concept_png(path, prompt_index, round_index)
|
||||
return path, image
|
||||
|
||||
def test_each_image_carries_execution_evidence_and_the_concept_facts(self):
|
||||
for prompt_index, round_index in ((0, 0), (0, 2), (3, 1), (5, 3)):
|
||||
with self.subTest(prompt=prompt_index, round=round_index):
|
||||
prompt = CONCEPT_PROMPTS[prompt_index]
|
||||
path, image = self.write(prompt_index, round_index)
|
||||
negative = prompt.negatives[round_index % len(prompt.negatives)][1]
|
||||
inspection = read_inspection_file(path)
|
||||
evidence = inspect_prompt_evidence(inspection.metadata)
|
||||
# D21: "execution" is the source the panel badges "Execution recorded".
|
||||
self.assertEqual(
|
||||
evidence["prompts"]["positive"],
|
||||
{
|
||||
"status": "resolved",
|
||||
"text": prompt.positive,
|
||||
"source": "execution",
|
||||
},
|
||||
)
|
||||
self.assertEqual(evidence["prompts"]["negative"]["text"], negative)
|
||||
self.assertEqual(evidence["prompts"]["negative"]["source"], "execution")
|
||||
graph = json.loads(inspection.metadata["prompt"])
|
||||
sampler = next(
|
||||
node["inputs"]
|
||||
for node in graph.values()
|
||||
if node["class_type"] == "KSampler"
|
||||
)
|
||||
self.assertEqual(
|
||||
sampler["seed"], 42001 + prompt_index * 100 + round_index
|
||||
)
|
||||
self.assertEqual(sampler["seed"], image["seed"])
|
||||
self.assertEqual(
|
||||
(sampler["sampler_name"], sampler["scheduler"]),
|
||||
("dpmpp_2m", "karras"),
|
||||
)
|
||||
self.assertEqual((sampler["steps"], sampler["cfg"]), (28, 6.5))
|
||||
loader = graph[sampler["model"][0]]
|
||||
self.assertEqual(loader["class_type"], "CheckpointLoaderSimple")
|
||||
self.assertEqual(loader["inputs"]["ckpt_name"], CONCEPT_MODEL)
|
||||
self.assertEqual(CONCEPT_MODEL, "illustrative-model.safetensors")
|
||||
with Image.open(path) as source:
|
||||
self.assertEqual(source.size, (image["width"], image["height"]))
|
||||
|
||||
def test_concept_dimensions_follow_the_prompt_index(self):
|
||||
expected = [(1536, 1152), (1024, 1536), (1280, 1280)]
|
||||
for prompt_index, size in enumerate(expected):
|
||||
_, image = self.write(prompt_index, 1)
|
||||
self.assertEqual((image["width"], image["height"]), size)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Vendored
+120
@@ -0,0 +1,120 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, copyFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import { lintOverlay } from '../../tools/vendor/sync-concept-css.mjs';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const script = join(root, 'tools/vendor/sync-concept-css.mjs');
|
||||
const concept = join(root, 'docs/concepts/workspace/style.css');
|
||||
const copy = join(root, 'web/js/workspace/concept.css');
|
||||
const sha = path => createHash('sha256').update(readFileSync(path)).digest('hex');
|
||||
|
||||
function walk(dir) {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
||||
const path = join(dir, entry.name);
|
||||
return entry.isDirectory() ? walk(path) : [path];
|
||||
});
|
||||
}
|
||||
|
||||
test('concept copy is byte-identical', () => {
|
||||
assert.ok(existsSync(copy), 'web/js/workspace/concept.css is missing; run sync-concept-css.mjs --write');
|
||||
assert.equal(sha(copy), sha(concept));
|
||||
const result = spawnSync(process.execPath, [script, '--check'], { cwd: root, encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
|
||||
test('check fails on a one-byte drift', () => {
|
||||
const temp = mkdtempSync(join(tmpdir(), 'concept-css-'));
|
||||
try {
|
||||
mkdirSync(join(temp, 'docs/concepts/workspace'), { recursive: true });
|
||||
mkdirSync(join(temp, 'web/js/workspace'), { recursive: true });
|
||||
copyFileSync(concept, join(temp, 'docs/concepts/workspace/style.css'));
|
||||
writeFileSync(join(temp, 'web/js/workspace/concept.css'), Buffer.concat([readFileSync(concept), Buffer.from(' ')]));
|
||||
const run = () => spawnSync(process.execPath, [script, '--check', '--root', temp], { encoding: 'utf8' });
|
||||
const drifted = run();
|
||||
assert.equal(drifted.status, 1);
|
||||
assert.match(drifted.stderr, /drift/i);
|
||||
assert.equal(spawnSync(process.execPath, [script, '--write', '--root', temp]).status, 0);
|
||||
assert.equal(run().status, 0);
|
||||
} finally { rmSync(temp, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('overlay lint requires a decision marker on concept selectors', () => {
|
||||
const conceptCss = '.card, .row { color: red; }\n@media (max-width: 700px) { .card .title { margin: 0; } }\n';
|
||||
assert.deepEqual(lintOverlay(conceptCss, '.error-row { color: red; }').violations, []);
|
||||
assert.equal(lintOverlay(conceptCss, '.x, .card { color: blue; }').violations.length, 1);
|
||||
assert.equal(lintOverlay(conceptCss, '@media (max-width: 700px) { .card .title { margin: 1px; } }').violations.length, 1);
|
||||
assert.deepEqual(lintOverlay(conceptCss, '/* D12 */\n.card { color: blue; }').violations, []);
|
||||
assert.deepEqual(lintOverlay(conceptCss, '.card { /* D3 */ color: blue; }').violations, []);
|
||||
assert.equal(lintOverlay(conceptCss, '/* tweak */ .card { color: blue; }').violations.length, 1);
|
||||
assert.equal(lintOverlay(conceptCss, '/* D1 */ .card { color: blue; }\n.row { gap: 1px; }').violations.length, 1);
|
||||
});
|
||||
|
||||
test('overlay lint accepts only this spec\'s (#249) decision numbers', () => {
|
||||
const conceptCss = '.card { color: red; }\n';
|
||||
assert.deepEqual(lintOverlay(conceptCss, '/* D12 of #249 */ .card { color: blue; }').violations, []);
|
||||
assert.deepEqual(lintOverlay(conceptCss, '/* #249 D3: reason */ .card { color: blue; }').violations, []);
|
||||
// Another issue's decision numbers are not #249 rulings, so they never mark a concept override.
|
||||
assert.equal(lintOverlay(conceptCss, '/* D5 (#245) */ .card { color: blue; }').violations.length, 1);
|
||||
assert.equal(lintOverlay(conceptCss, '/* D2 of #240 and #249 */ .card { color: blue; }').violations.length, 1);
|
||||
assert.equal(lintOverlay(conceptCss, '/* #245 decision 5 */ .card { color: blue; }').violations.length, 1);
|
||||
assert.equal(lintOverlay(conceptCss, '/* AD5 */ .card { color: blue; }').violations.length, 1);
|
||||
});
|
||||
|
||||
test('overlay rules on concept selectors carry a decision marker', () => {
|
||||
const overlay = join(root, 'web/js/workspace/workspace.css');
|
||||
assert.ok(existsSync(overlay), 'web/js/workspace/workspace.css is missing');
|
||||
const { violations } = lintOverlay(readFileSync(concept, 'utf8'), readFileSync(overlay, 'utf8'));
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
// PRs C and D lint each page stylesheet they keep: a page rule on a concept selector is an override like the overlay's.
|
||||
test('kept page stylesheets mark their concept-selector overrides', () => {
|
||||
for (const name of ['images.css', 'library.css', 'inspector.css']) {
|
||||
const { violations } = lintOverlay(readFileSync(concept, 'utf8'), readFileSync(join(root, 'web/js/workspace', name), 'utf8'));
|
||||
assert.deepEqual(violations, [], name);
|
||||
}
|
||||
});
|
||||
|
||||
// #249 C8: the Images page renders the concept DOM, so the replaced production rules stay retired.
|
||||
test('images.css keeps no retired Images page rules', () => {
|
||||
const css = readFileSync(join(root, 'web/js/workspace/images.css'), 'utf8');
|
||||
for (const retired of ['.image-facts', '.image-card', '.image-grid', '.image-filters', '.image-toolbar', '.image-preview', '.image-collection', '.image-continuation']) {
|
||||
assert.equal(css.includes(retired), false, `${retired} is retired`);
|
||||
}
|
||||
});
|
||||
|
||||
test('only workspace.html loads workspace CSS', () => {
|
||||
const offenders = walk(join(root, 'web'))
|
||||
.filter(path => /\.(html|js|mjs)$/.test(path) && !path.includes('/lib/') && !path.includes('/shared/'))
|
||||
.filter(path => /\b(concept|workspace)\.css\b/.test(readFileSync(path, 'utf8')))
|
||||
.map(path => path.slice(root.length + 1));
|
||||
assert.ok(offenders.every(path => path === 'web/workspace.html'), `unexpected consumers: ${offenders.join(', ')}`);
|
||||
});
|
||||
|
||||
// Task A2 order: the concept's own order (toolkit, Viewer.js, concept) with the page stylesheets that their own PRs
|
||||
// retire, then the production overlay last.
|
||||
test('workspace.html loads controls, viewer, concept, page CSS and the overlay in order', () => {
|
||||
const html = readFileSync(join(root, 'web/workspace.html'), 'utf8');
|
||||
const hrefs = [...html.matchAll(/<link[^>]+rel="stylesheet"[^>]+href="([^"]+)"/g)].map(match => match[1]);
|
||||
assert.deepEqual(hrefs, [
|
||||
'js/shared/toolkit/controls.css',
|
||||
'lib/viewerjs/viewer.min.css',
|
||||
'js/workspace/concept.css',
|
||||
'js/workspace/images.css',
|
||||
'js/workspace/library.css',
|
||||
'js/workspace/inspector.css',
|
||||
'js/workspace/workspace.css',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the retired shell, vocabulary and editor stylesheets stay deleted', () => {
|
||||
for (const name of ['style.css', 'vocabulary.css', 'library-editor.css']) {
|
||||
assert.equal(existsSync(join(root, 'web/js/workspace', name)), false, `web/js/workspace/${name} must not come back`);
|
||||
}
|
||||
});
|
||||
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
|
||||
// Mount-aware URLs (#164): Prompt Manager runs behind reverse proxies with a path prefix, so every src, href or
|
||||
// new URL() the workspace builds from a "/prompt_manager/..." literal must go through resolveUrl (or the api client).
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const SINK = /(\.(?:src|href)\s*=|setAttribute\(\s*['"](?:src|href)['"]\s*,|new URL\()/g;
|
||||
const RAW = /['"`]\/prompt_manager\//;
|
||||
|
||||
export function rawUrlSinks(source) {
|
||||
const found = [];
|
||||
source.split('\n').forEach((line, index) => {
|
||||
for (const match of line.matchAll(SINK)) {
|
||||
const rest = line.slice(match.index + match[0].length);
|
||||
const literal = rest.search(RAW);
|
||||
if (literal !== -1 && !/\bresolve(?:Url)?\s*\(/.test(rest.slice(0, literal))) found.push(`${index + 1}: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
test('the scanner flags raw Prompt Manager URLs in src, href and new URL()', () => {
|
||||
assert.equal(rawUrlSinks("img.src = '/prompt_manager/images/serve/a.png';").length, 1);
|
||||
assert.equal(rawUrlSinks('link.href = `/prompt_manager/admin`;').length, 1);
|
||||
assert.equal(rawUrlSinks("node.setAttribute('href', '/prompt_manager/gallery');").length, 1);
|
||||
assert.equal(rawUrlSinks("const url = new URL('/prompt_manager/x', location.href);").length, 1);
|
||||
assert.deepEqual(rawUrlSinks("img.src = resolveUrl('/prompt_manager/images/serve/a.png');"), []);
|
||||
assert.deepEqual(rawUrlSinks('const u = new URL(resolve(`/prompt_manager/history/${id}`), location.href);'), []);
|
||||
assert.deepEqual(rawUrlSinks("link.href = resolveUrl(route.href);"), []);
|
||||
});
|
||||
|
||||
test('workspace modules build Prompt Manager src, href and URLs only through resolveUrl', () => {
|
||||
const dir = join(root, 'web/js/workspace');
|
||||
const offenders = readdirSync(dir).filter(name => name.endsWith('.mjs'))
|
||||
.flatMap(name => rawUrlSinks(readFileSync(join(dir, name), 'utf8')).map(hit => `${name}:${hit}`));
|
||||
assert.deepEqual(offenders, []);
|
||||
});
|
||||
+50
-2
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"comfy-metadata.js": {
|
||||
"lines": 97,
|
||||
"branches": 91,
|
||||
"branches": 92,
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
},
|
||||
@@ -161,6 +161,12 @@
|
||||
"functions": 88,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/heading.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/history-media.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 86,
|
||||
@@ -191,6 +197,24 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-category.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-export.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-selection.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
@@ -205,7 +229,7 @@
|
||||
},
|
||||
"workspace/index.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
"branches": 96,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -293,6 +317,24 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/toast.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 64,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/toolbar.mjs": {
|
||||
"lines": 94,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 94
|
||||
},
|
||||
"workspace/topbar.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 83,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/viewer-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
@@ -316,5 +358,11 @@
|
||||
"branches": 91,
|
||||
"functions": 93,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/workspace-dialogs.mjs": {
|
||||
"lines": 97,
|
||||
"branches": 95,
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+2
-1
@@ -5,7 +5,8 @@
|
||||
"description": "Maintainer-only tools for reproducible browser dependencies",
|
||||
"scripts": {
|
||||
"sync": "node vendor-viewerjs.mjs",
|
||||
"check": "node vendor-viewerjs.mjs --check && node vendor-toolkit.mjs --check",
|
||||
"check": "node vendor-viewerjs.mjs --check && node vendor-toolkit.mjs --check && node sync-concept-css.mjs --check",
|
||||
"sync:concept": "node sync-concept-css.mjs --write",
|
||||
"sync:toolkit": "node vendor-toolkit.mjs",
|
||||
"update": "node update-viewerjs.mjs",
|
||||
"test": "node --test ../../tests/vendor/*.test.mjs",
|
||||
|
||||
Vendored
+130
@@ -0,0 +1,130 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { repositoryRoot } from './vendor-viewerjs.mjs';
|
||||
|
||||
const CONCEPT = 'docs/concepts/workspace/style.css';
|
||||
const COPY = 'web/js/workspace/concept.css';
|
||||
const MARKER = /\/\*[^*]*\bD\d+\b[^*]*\*\//;
|
||||
const SPEC_ISSUE = '#249';
|
||||
|
||||
// A decision marker names one of this spec's (#249) D-numbers. A comment that cites any other issue is that issue's
|
||||
// ruling (for example #245's own D5), so it never marks a concept override here.
|
||||
function isDecisionMarker(comment) {
|
||||
return MARKER.test(comment) && (comment.match(/#\d+\b/g) ?? []).every(issue => issue === SPEC_ISSUE);
|
||||
}
|
||||
|
||||
// Minimal tokenizer: walks top-level rules and the bodies of @media/@supports,
|
||||
// returning each rule's normalized selectors and the comments attached to it
|
||||
// (those before its selector and those inside its block).
|
||||
function parseRules(css) {
|
||||
const rules = [];
|
||||
let index = 0;
|
||||
const length = css.length;
|
||||
function readBlock(start) {
|
||||
let depth = 0;
|
||||
let cursor = start;
|
||||
for (; cursor < length; cursor += 1) {
|
||||
if (css.startsWith('/*', cursor)) { cursor = commentEnd(cursor) - 1; continue; }
|
||||
const char = css[cursor];
|
||||
if (char === '"' || char === "'") { cursor = stringEnd(cursor) - 1; continue; }
|
||||
if (char === '{') depth += 1;
|
||||
else if (char === '}') { depth -= 1; if (depth === 0) return cursor; }
|
||||
}
|
||||
return length - 1;
|
||||
}
|
||||
function commentEnd(at) { const end = css.indexOf('*/', at + 2); return end === -1 ? length : end + 2; }
|
||||
function stringEnd(at) {
|
||||
for (let cursor = at + 1; cursor < length; cursor += 1) {
|
||||
if (css[cursor] === '\\') cursor += 1;
|
||||
else if (css[cursor] === css[at]) return cursor + 1;
|
||||
}
|
||||
return length;
|
||||
}
|
||||
function walk(from, to) {
|
||||
let cursor = from;
|
||||
let comments = [];
|
||||
let prelude = '';
|
||||
while (cursor < to) {
|
||||
if (css.startsWith('/*', cursor)) {
|
||||
const end = commentEnd(cursor);
|
||||
comments.push(css.slice(cursor, end));
|
||||
cursor = end;
|
||||
continue;
|
||||
}
|
||||
const char = css[cursor];
|
||||
if (char === ';') { prelude = ''; comments = []; cursor += 1; continue; }
|
||||
if (char === '{') {
|
||||
const close = readBlock(cursor);
|
||||
const head = prelude.trim();
|
||||
const body = css.slice(cursor + 1, close);
|
||||
if (/^@(media|supports|layer)\b/i.test(head)) walk(cursor + 1, close);
|
||||
else if (!head.startsWith('@')) {
|
||||
rules.push({ selectors: splitSelectors(head), comments: [...comments, ...(body.match(/\/\*[\s\S]*?\*\//g) ?? [])] });
|
||||
}
|
||||
prelude = ''; comments = []; cursor = close + 1;
|
||||
continue;
|
||||
}
|
||||
prelude += char;
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
walk(index, length);
|
||||
return rules;
|
||||
}
|
||||
|
||||
function splitSelectors(head) {
|
||||
const out = [];
|
||||
let depth = 0;
|
||||
let current = '';
|
||||
for (const char of head) {
|
||||
if (char === '(' || char === '[') depth += 1;
|
||||
else if (char === ')' || char === ']') depth -= 1;
|
||||
if (char === ',' && depth === 0) { out.push(current); current = ''; } else current += char;
|
||||
}
|
||||
out.push(current);
|
||||
return out.map(selector => selector.replace(/\s+/g, ' ').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function lintOverlay(conceptCss, overlayCss) {
|
||||
const owned = new Set(parseRules(conceptCss).flatMap(rule => rule.selectors));
|
||||
const violations = [];
|
||||
for (const rule of parseRules(overlayCss)) {
|
||||
const shared = rule.selectors.filter(selector => owned.has(selector));
|
||||
if (shared.length && !rule.comments.some(isDecisionMarker)) {
|
||||
violations.push(`${shared.join(', ')}: overlay rule on a concept selector needs a /* D<n> */ (#249) decision marker`);
|
||||
}
|
||||
}
|
||||
return { violations };
|
||||
}
|
||||
|
||||
export function syncConceptCss({ root = repositoryRoot, check = false } = {}) {
|
||||
const source = readFileSync(join(root, CONCEPT));
|
||||
const target = join(root, COPY);
|
||||
if (check) {
|
||||
let current = null;
|
||||
try { current = readFileSync(target); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||||
if (!current) throw new Error(`Concept stylesheet drift: ${COPY} is missing; run sync-concept-css.mjs --write`);
|
||||
if (!current.equals(source)) {
|
||||
let offset = 0;
|
||||
while (offset < source.length && offset < current.length && source[offset] === current[offset]) offset += 1;
|
||||
throw new Error(`Concept stylesheet drift: ${COPY} (${current.length} bytes) differs from ${CONCEPT} (${source.length} bytes) at byte ${offset}; run sync-concept-css.mjs --write`);
|
||||
}
|
||||
} else {
|
||||
mkdirSync(join(root, 'web/js/workspace'), { recursive: true });
|
||||
writeFileSync(target, source);
|
||||
}
|
||||
return source.length;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
try {
|
||||
const args = process.argv.slice(2);
|
||||
const rootAt = args.indexOf('--root');
|
||||
const root = rootAt === -1 ? repositoryRoot : resolve(args[rootAt + 1]);
|
||||
const mode = args.filter((arg, at) => arg.startsWith('--') && arg !== '--root' && !(rootAt !== -1 && at === rootAt + 1));
|
||||
if (mode.length !== 1 || !['--write', '--check'].includes(mode[0])) throw new Error('Usage: sync-concept-css.mjs --write | --check [--root <dir>]');
|
||||
const bytes = syncConceptCss({ root, check: mode[0] === '--check' });
|
||||
console.log(`Concept stylesheet ${mode[0] === '--check' ? 'verified' : 'written'} (${bytes} bytes)`);
|
||||
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Workspace card facts from the metadata records a header open already loaded.
|
||||
|
||||
The card shows what the inspector shows, so it reads exactly what the
|
||||
inspector's browser parser reads: each ``prompt``/``workflow`` record through
|
||||
the inspection projection (``project_graph``: strict JSON, the depth and node
|
||||
bounds, big integers and decimals as text), and only graphs it parsed, through
|
||||
the shared parser (``extract_generation_params``, the Python twin of the
|
||||
browser parser the inspector fills from since #251). No separate seed
|
||||
heuristic, no graph resolution and no extra file reads. One parse feeds both
|
||||
the seed and the positive that confirms a first-line card title.
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
|
||||
from .generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
)
|
||||
from .inspection_response import project_graph
|
||||
|
||||
__all__ = [
|
||||
"MAX_RECORD_BYTES",
|
||||
"MAX_SEED_DIGITS",
|
||||
"card_seed",
|
||||
"header_params",
|
||||
"parsed_positive",
|
||||
]
|
||||
|
||||
# Each record is parsed only up to this many UTF-8 bytes; past it, nothing is read.
|
||||
MAX_RECORD_BYTES = 2 * 1024 * 1024
|
||||
_RECORD_KEYS = ("prompt", "workflow")
|
||||
# A digit-string seed has at most this many digits (2**64 has 20). Longer text
|
||||
# is no seed, and never reaches int(), whose max-str-digits guard raises.
|
||||
MAX_SEED_DIGITS = 20
|
||||
_INTEGER = re.compile(r"-?[0-9]{1,%d}\Z" % MAX_SEED_DIGITS)
|
||||
|
||||
|
||||
def _utf8_size_over(text: str, limit: int) -> bool:
|
||||
"""True when ``text`` is over ``limit`` UTF-8 bytes, encoding only if unclear.
|
||||
|
||||
A code point takes one to four bytes, so most records are decided from
|
||||
their length alone without copying them.
|
||||
"""
|
||||
if len(text) > limit:
|
||||
return True
|
||||
if len(text) * 4 <= limit:
|
||||
return False
|
||||
return len(text.encode("utf-8", "surrogatepass")) > limit
|
||||
|
||||
|
||||
def header_params(info) -> dict | None:
|
||||
"""The shared parser's result for the inspector's view of ``info``'s records.
|
||||
|
||||
``info`` is a Pillow ``Image.info`` (or the inspection read's metadata).
|
||||
A record the projection does not parse is left out, as the inspector
|
||||
leaves it out. None when no record parses, a record is not text or is
|
||||
over ``MAX_RECORD_BYTES``, or the parser cannot use the graphs.
|
||||
"""
|
||||
if not isinstance(info, Mapping):
|
||||
return None
|
||||
graphs = {}
|
||||
for key in _RECORD_KEYS:
|
||||
text = info.get(key)
|
||||
if text is None:
|
||||
continue
|
||||
if not isinstance(text, str) or _utf8_size_over(text, MAX_RECORD_BYTES):
|
||||
return None
|
||||
graph = project_graph(text, key)
|
||||
if graph["status"] == "parsed":
|
||||
graphs[key] = graph["value"]
|
||||
if not graphs:
|
||||
return None
|
||||
try:
|
||||
return extract_generation_params(graphs)
|
||||
except (GenerationParamsError, RecursionError):
|
||||
# Defence in depth: a parser failure gives no card facts, never a 500.
|
||||
return None
|
||||
|
||||
|
||||
def card_seed(params: dict | None) -> str | None:
|
||||
"""The parsed seed as a decimal string, else None.
|
||||
|
||||
Always text: ``JSON.parse`` in the browser rounds a number past 2**53, and
|
||||
ComfyUI's randomized seeds are often that large. The projection carries
|
||||
integers beyond 2**53 as their exact digits, so a digit string is the
|
||||
recorded seed (the inspector shows it too), up to ``MAX_SEED_DIGITS``
|
||||
digits. A placeholder, other or longer text, a boolean or a decimal gives
|
||||
None.
|
||||
"""
|
||||
if not isinstance(params, Mapping):
|
||||
return None
|
||||
value = params.get("seed")
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
if isinstance(value, str) and _INTEGER.match(value):
|
||||
return str(int(value))
|
||||
except ValueError:
|
||||
# Python's integer string conversion limit: no seed, never a 500.
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def parsed_positive(params: dict | None) -> str | None:
|
||||
"""The parsed positive prompt text, or None for the placeholder or blank."""
|
||||
if not isinstance(params, Mapping):
|
||||
return None
|
||||
text = params.get("positivePrompt")
|
||||
if not isinstance(text, str) or text == PLACEHOLDERS["positivePrompt"]:
|
||||
return None
|
||||
return text if text.strip() else None
|
||||
@@ -0,0 +1,966 @@
|
||||
"""Generation parameters traced from ComfyUI metadata: the browser parser in Python.
|
||||
|
||||
A port of ``extractGenerationParams`` in ``web/js/comfy-metadata.js``, the parser
|
||||
behind the gallery, admin and metadata pages. For the same records it returns
|
||||
what that function returns; ``tests/test_generation_params_js_parity.py``
|
||||
enforces it against the JavaScript itself.
|
||||
|
||||
It traces the API ``prompt`` graph from the sampler: the positive and negative
|
||||
inputs back to their text nodes, the model link back to a loader, and the
|
||||
sampler settings, following primitives and pass-through nodes. Fields the API
|
||||
graph cannot give are read from the UI ``workflow`` graph.
|
||||
|
||||
The result depends on JavaScript value semantics, so those are reproduced
|
||||
explicitly: property order, truthiness, ``String()``, ``trim()``, strict
|
||||
equality, ``Map``/``Set`` keys, and the inputs on which the JavaScript throws
|
||||
(raised here as ``GenerationParamsError``). The recursive tracing runs on an
|
||||
explicit stack, so a crafted graph cannot exhaust the Python stack.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from types import MappingProxyType
|
||||
|
||||
__all__ = [
|
||||
"PLACEHOLDERS",
|
||||
"GenerationParamsError",
|
||||
"extract_generation_params",
|
||||
"extract_from_records",
|
||||
"parse_js_json",
|
||||
]
|
||||
|
||||
PLACEHOLDERS = MappingProxyType(
|
||||
{
|
||||
"positivePrompt": "No prompt found",
|
||||
"negativePrompt": "No negative prompt found",
|
||||
"checkpoint": "Unknown",
|
||||
"steps": "Unknown",
|
||||
"cfgScale": "Unknown",
|
||||
"sampler": "Unknown",
|
||||
"seed": "Unknown",
|
||||
}
|
||||
)
|
||||
|
||||
# Upper bound on nodes expanded per lookup; crafted files can contain any graph.
|
||||
MAX_VISITS = 500
|
||||
MODEL_NAME_KEYS = ("ckpt_name", "unet_name", "model_name", "gguf_name")
|
||||
SEED_CONTROL_VALUES = frozenset(("fixed", "increment", "decrement", "randomize"))
|
||||
PASS_THROUGH_TYPES = frozenset(("Reroute", "GetNode", "SetNode"))
|
||||
MODE_MUTED = 2
|
||||
MODE_BYPASSED = 4
|
||||
# Widget order for core nodes, used when a workflow carries no widget names.
|
||||
PROMPT_MANAGER_WIDGETS = (
|
||||
"text",
|
||||
"category",
|
||||
"tags",
|
||||
"search_text",
|
||||
"prepend_text",
|
||||
"append_text",
|
||||
)
|
||||
WIDGET_POSITIONS = {
|
||||
"KSampler": ("seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"),
|
||||
"KSamplerAdvanced": (
|
||||
"add_noise",
|
||||
"noise_seed",
|
||||
"steps",
|
||||
"cfg",
|
||||
"sampler_name",
|
||||
"scheduler",
|
||||
"start_at_step",
|
||||
"end_at_step",
|
||||
"return_with_leftover_noise",
|
||||
),
|
||||
"CLIPTextEncode": ("text",),
|
||||
"PromptManager": PROMPT_MANAGER_WIDGETS,
|
||||
"PromptManagerText": PROMPT_MANAGER_WIDGETS,
|
||||
"PromptManagerFocused": PROMPT_MANAGER_WIDGETS,
|
||||
"PromptManagerSplit": PROMPT_MANAGER_WIDGETS,
|
||||
"PromptManagerPair": (
|
||||
"text",
|
||||
"negative_text",
|
||||
"category",
|
||||
"tags",
|
||||
"search_text",
|
||||
"prepend_text",
|
||||
"append_text",
|
||||
),
|
||||
"CheckpointLoader": ("config_name", "ckpt_name"),
|
||||
"UNETLoader": ("unet_name", "weight_dtype"),
|
||||
}
|
||||
|
||||
# JavaScript regular expressions without the ``u`` flag fold case in ASCII only.
|
||||
_FLAGS = re.IGNORECASE | re.ASCII
|
||||
TEXT_KEYS = re.compile(r"(text|string|prompt|value)(_?[a-z0-9]+)?", _FLAGS)
|
||||
_PROMPT_MANAGER = re.compile(r"promptmanager", _FLAGS)
|
||||
_KSAMPLER = re.compile(r"ksampler", _FLAGS)
|
||||
_CHECKPOINT_LOADER = re.compile(r"checkpointloader", _FLAGS)
|
||||
_UNET = re.compile(r"unet", _FLAGS)
|
||||
_SEED_NAME = re.compile(r"seed\Z")
|
||||
_ARRAY_INDEX = re.compile(r"(?:0|[1-9][0-9]*)\Z")
|
||||
_MAX_ARRAY_INDEX = 2**32 - 2
|
||||
_MAX_SAFE_INTEGER = 2**53
|
||||
# String.prototype.trim() removes WhiteSpace and LineTerminator code points.
|
||||
_JS_WHITESPACE = "\t\n\v\f\r " "
"
|
||||
# Properties every object literal inherits; reading one yields a function or object.
|
||||
_OBJECT_PROTOTYPE_KEYS = frozenset(
|
||||
(
|
||||
"constructor",
|
||||
"hasOwnProperty",
|
||||
"isPrototypeOf",
|
||||
"propertyIsEnumerable",
|
||||
"toLocaleString",
|
||||
"toString",
|
||||
"valueOf",
|
||||
"__proto__",
|
||||
"__defineGetter__",
|
||||
"__defineSetter__",
|
||||
"__lookupGetter__",
|
||||
"__lookupSetter__",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class GenerationParamsError(ValueError):
|
||||
"""The browser parser throws on this input, so there is no result."""
|
||||
|
||||
|
||||
class _Undefined:
|
||||
"""JavaScript ``undefined``: a missing property, distinct from JSON ``null``."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def __repr__(self):
|
||||
return "undefined"
|
||||
|
||||
|
||||
UNDEFINED = _Undefined()
|
||||
|
||||
|
||||
# --- JavaScript value semantics -------------------------------------------------
|
||||
|
||||
|
||||
def _is_number(value):
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def _nullish(value):
|
||||
return value is None or value is UNDEFINED
|
||||
|
||||
|
||||
def _truthy(value):
|
||||
if value is None or value is UNDEFINED or value is False:
|
||||
return False
|
||||
if _is_number(value):
|
||||
return value != 0 and not (isinstance(value, float) and math.isnan(value))
|
||||
if isinstance(value, str):
|
||||
return value != ""
|
||||
return True
|
||||
|
||||
|
||||
def _typeof(value):
|
||||
if value is UNDEFINED:
|
||||
return "undefined"
|
||||
if isinstance(value, bool):
|
||||
return "boolean"
|
||||
if _is_number(value):
|
||||
return "number"
|
||||
if isinstance(value, str):
|
||||
return "string"
|
||||
return "object"
|
||||
|
||||
|
||||
def _number_string(value):
|
||||
"""``String(number)`` for a double, per the ECMAScript Number::toString rules."""
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
if math.isnan(value):
|
||||
return "NaN"
|
||||
if math.isinf(value):
|
||||
return "Infinity" if value > 0 else "-Infinity"
|
||||
if value == 0:
|
||||
return "0"
|
||||
sign = "-" if value < 0 else ""
|
||||
text = repr(abs(value))
|
||||
mantissa, _, exponent = text.partition("e")
|
||||
whole, _, fraction = mantissa.partition(".")
|
||||
raw = whole + fraction
|
||||
leading = len(raw) - len(raw.lstrip("0"))
|
||||
digits = raw.lstrip("0").rstrip("0")
|
||||
point = len(whole) - leading + int(exponent or 0)
|
||||
count = len(digits)
|
||||
if count <= point <= 21:
|
||||
body = digits + "0" * (point - count)
|
||||
elif 0 < point <= 21:
|
||||
body = digits[:point] + "." + digits[point:]
|
||||
elif -6 < point <= 0:
|
||||
body = "0." + "0" * -point + digits
|
||||
else:
|
||||
power = point - 1
|
||||
mark = ("+" if power >= 0 else "-") + str(abs(power))
|
||||
body = (digits if count == 1 else digits[0] + "." + digits[1:]) + "e" + mark
|
||||
return sign + body
|
||||
|
||||
|
||||
def _js_string(value):
|
||||
"""``String(value)`` for any JSON-derived value."""
|
||||
if value is UNDEFINED:
|
||||
return "undefined"
|
||||
if value is None:
|
||||
return "null"
|
||||
if value is True:
|
||||
return "true"
|
||||
if value is False:
|
||||
return "false"
|
||||
if _is_number(value):
|
||||
return _number_string(value)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return _array_string(value)
|
||||
return "[object Object]"
|
||||
|
||||
|
||||
def _array_string(value):
|
||||
"""``String(array)``: ``join(",")`` applied to nested arrays, on an explicit stack.
|
||||
|
||||
null and undefined elements join as "". A crafted file can nest arrays far
|
||||
deeper than the Python stack allows, so this never recurses.
|
||||
"""
|
||||
out = []
|
||||
stack = [(value, 0)]
|
||||
while stack:
|
||||
items, index = stack.pop()
|
||||
if index == len(items):
|
||||
continue
|
||||
if index:
|
||||
out.append(",")
|
||||
stack.append((items, index + 1))
|
||||
item = items[index]
|
||||
if isinstance(item, list):
|
||||
stack.append((item, 0))
|
||||
elif not _nullish(item):
|
||||
out.append(_js_string(item))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _key(value):
|
||||
return value if isinstance(value, str) else _js_string(value)
|
||||
|
||||
|
||||
def _index(key):
|
||||
"""The array index a property key names, or None."""
|
||||
if (
|
||||
_is_number(key)
|
||||
and not isinstance(key, bool)
|
||||
and float(key).is_integer()
|
||||
and key >= 0
|
||||
):
|
||||
number = int(key)
|
||||
return number if number <= _MAX_ARRAY_INDEX else None
|
||||
text = _key(key)
|
||||
if _ARRAY_INDEX.match(text) and int(text) <= _MAX_ARRAY_INDEX:
|
||||
return int(text)
|
||||
return None
|
||||
|
||||
|
||||
def _get(obj, key):
|
||||
"""Property read ``obj[key]``; reading from null or undefined throws."""
|
||||
if obj is None or obj is UNDEFINED:
|
||||
raise GenerationParamsError(f"cannot read {key!r} of {_js_string(obj)}")
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(_key(key), UNDEFINED)
|
||||
if isinstance(obj, (list, str)):
|
||||
if key == "length":
|
||||
return len(obj)
|
||||
index = _index(key)
|
||||
return obj[index] if index is not None and index < len(obj) else UNDEFINED
|
||||
return UNDEFINED
|
||||
|
||||
|
||||
def _has(obj, key):
|
||||
"""``key in obj``; throws on primitives."""
|
||||
if isinstance(obj, dict):
|
||||
return _key(key) in obj
|
||||
if isinstance(obj, list):
|
||||
index = _index(key)
|
||||
return key == "length" or (index is not None and index < len(obj))
|
||||
raise GenerationParamsError(f"cannot use 'in' on {_typeof(obj)}")
|
||||
|
||||
|
||||
def _own(obj, key):
|
||||
"""``Object.prototype.hasOwnProperty.call(obj, key)``."""
|
||||
if obj is None or obj is UNDEFINED:
|
||||
raise GenerationParamsError("hasOwnProperty on null or undefined")
|
||||
if isinstance(obj, dict):
|
||||
return _key(key) in obj
|
||||
if isinstance(obj, (list, str)):
|
||||
index = _index(key)
|
||||
return key == "length" or (index is not None and index < len(obj))
|
||||
return False
|
||||
|
||||
|
||||
def _ordered_keys(obj):
|
||||
"""Own property order: array indexes ascending, then other keys as inserted."""
|
||||
indexes = sorted(
|
||||
(k for k in obj if _ARRAY_INDEX.match(k) and int(k) <= _MAX_ARRAY_INDEX),
|
||||
key=int,
|
||||
)
|
||||
marked = set(indexes)
|
||||
return indexes + [k for k in obj if k not in marked]
|
||||
|
||||
|
||||
def _entries(obj):
|
||||
"""``Object.entries(obj)``."""
|
||||
if obj is None or obj is UNDEFINED:
|
||||
raise GenerationParamsError("Object.entries of null or undefined")
|
||||
if isinstance(obj, dict):
|
||||
return [(k, obj[k]) for k in _ordered_keys(obj)]
|
||||
if isinstance(obj, (list, str)):
|
||||
return [(str(i), item) for i, item in enumerate(obj)]
|
||||
return []
|
||||
|
||||
|
||||
def _values(obj):
|
||||
"""``Object.values(obj)``."""
|
||||
return [value for _, value in _entries(obj)]
|
||||
|
||||
|
||||
def _strict_equal(left, right):
|
||||
"""``left === right``."""
|
||||
if left is UNDEFINED or right is UNDEFINED or left is None or right is None:
|
||||
return left is right
|
||||
if isinstance(left, bool) or isinstance(right, bool):
|
||||
return isinstance(left, bool) and isinstance(right, bool) and left == right
|
||||
if _is_number(left) and _is_number(right):
|
||||
return left == right
|
||||
if isinstance(left, str) and isinstance(right, str):
|
||||
return left == right
|
||||
return left is right
|
||||
|
||||
|
||||
def _same_value_key(value):
|
||||
"""A hashable key with ``Map``/``Set`` (SameValueZero) equality."""
|
||||
if value is UNDEFINED:
|
||||
return ("undefined", None)
|
||||
if value is None:
|
||||
return ("null", None)
|
||||
if isinstance(value, bool):
|
||||
return ("boolean", value)
|
||||
if _is_number(value):
|
||||
return (
|
||||
("nan", None)
|
||||
if isinstance(value, float) and math.isnan(value)
|
||||
else ("number", value)
|
||||
)
|
||||
if isinstance(value, str):
|
||||
return ("string", value)
|
||||
return ("object", id(value))
|
||||
|
||||
|
||||
def _trim(text):
|
||||
return text.strip(_JS_WHITESPACE)
|
||||
|
||||
|
||||
def _first_truthy(*values):
|
||||
"""``a || b || ...``: the first truthy operand, else the last."""
|
||||
for value in values:
|
||||
if _truthy(value):
|
||||
return value
|
||||
return values[-1]
|
||||
|
||||
|
||||
def _and_chain(obj, *steps):
|
||||
"""``a && a[k1] && a[k1][k2] ...``: stops at the first falsy operand."""
|
||||
value = obj
|
||||
for step in steps:
|
||||
if not _truthy(value):
|
||||
return value
|
||||
value = _get(value, step)
|
||||
return value
|
||||
|
||||
|
||||
def _coalesce(value, fallback):
|
||||
"""``value ?? fallback()``; the fallback runs only when needed."""
|
||||
return fallback() if _nullish(value) else value
|
||||
|
||||
|
||||
def _array(value):
|
||||
"""A value used with an Array method; anything else throws."""
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
raise GenerationParamsError(f"{_typeof(value)} has no array methods")
|
||||
|
||||
|
||||
def _iterate(value):
|
||||
"""``for (const x of value)``: arrays and strings iterate, other values throw."""
|
||||
if isinstance(value, (list, str)):
|
||||
return value
|
||||
raise GenerationParamsError(f"{_typeof(value)} is not iterable")
|
||||
|
||||
|
||||
def _set(obj, key, value):
|
||||
"""``obj[key] = value`` on an object literal (``__proto__`` is not an own key)."""
|
||||
name = _key(key)
|
||||
if name == "__proto__" and name not in obj:
|
||||
return
|
||||
obj[name] = value
|
||||
|
||||
|
||||
def _matches(pattern, value):
|
||||
"""``pattern.test(value || "")``."""
|
||||
return pattern.search(_js_string(value if _truthy(value) else "")) is not None
|
||||
|
||||
|
||||
def _run(generator):
|
||||
"""Drive generator-based recursion on an explicit stack, not the call stack."""
|
||||
stack = [generator]
|
||||
value = None
|
||||
while stack:
|
||||
try:
|
||||
request = stack[-1].send(value)
|
||||
except StopIteration as done:
|
||||
stack.pop()
|
||||
value = done.value
|
||||
continue
|
||||
stack.append(request)
|
||||
value = None
|
||||
return value
|
||||
|
||||
|
||||
# --- Tracing ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def _is_link(value):
|
||||
return (
|
||||
isinstance(value, list)
|
||||
and len(value) == 2
|
||||
and (isinstance(value[0], str) or _is_number(value[0]))
|
||||
and _is_number(value[1])
|
||||
)
|
||||
|
||||
|
||||
def _is_sampler(node):
|
||||
"""A KSampler or a guider (CFGGuider); the model link excludes pass-throughs."""
|
||||
if not _truthy(node):
|
||||
return False
|
||||
inputs = _get(node, "inputs")
|
||||
return (
|
||||
_truthy(inputs)
|
||||
and _is_link(_get(inputs, "positive"))
|
||||
and _is_link(_get(inputs, "negative"))
|
||||
and _is_link(_get(inputs, "model"))
|
||||
)
|
||||
|
||||
|
||||
def _is_custom_sampler(node):
|
||||
"""SamplerCustomAdvanced-style nodes split settings across noise/sampler/sigmas."""
|
||||
if not _truthy(node):
|
||||
return False
|
||||
inputs = _get(node, "inputs")
|
||||
return (
|
||||
_truthy(inputs)
|
||||
and _is_link(_get(inputs, "guider"))
|
||||
and _is_link(_get(inputs, "sigmas"))
|
||||
)
|
||||
|
||||
|
||||
def _workflow_nodes(workflow):
|
||||
if not _truthy(workflow):
|
||||
return []
|
||||
nodes = _get(workflow, "nodes")
|
||||
return nodes if isinstance(nodes, list) else []
|
||||
|
||||
|
||||
def _widgets_of(node):
|
||||
values = _get(node, "widgets_values")
|
||||
return values if isinstance(values, list) else []
|
||||
|
||||
|
||||
class _Context:
|
||||
"""The graph being traced and recovered PromptManager widget text."""
|
||||
|
||||
__slots__ = ("graph", "widget_text")
|
||||
|
||||
def __init__(self, graph, widget_text):
|
||||
self.graph = graph
|
||||
self.widget_text = widget_text
|
||||
|
||||
|
||||
def _create_context(graph, workflow):
|
||||
"""Recover PromptManager text overwritten by the pre-3.2.3 SaveImage patch.
|
||||
|
||||
The signature is two or more PromptManager nodes with identical API text
|
||||
whose workflow widgets differ; only those get their widget text back.
|
||||
Otherwise the API graph is what actually ran and always wins.
|
||||
"""
|
||||
widgets = {}
|
||||
for node in _workflow_nodes(workflow):
|
||||
text = _get(_widgets_of(node), 0)
|
||||
node_type = _get(node, "type")
|
||||
if (
|
||||
not _strict_equal(node_type, "PromptManagerPair")
|
||||
and _matches(_PROMPT_MANAGER, node_type)
|
||||
and isinstance(text, str)
|
||||
):
|
||||
widgets[_js_string(_get(node, "id"))] = text
|
||||
by_api_text = {}
|
||||
for node_id in widgets:
|
||||
api_text = _and_chain(graph, node_id, "inputs", "text")
|
||||
if isinstance(api_text, str):
|
||||
by_api_text.setdefault(api_text, []).append(node_id)
|
||||
widget_text = {}
|
||||
for ids in by_api_text.values():
|
||||
if len(ids) > 1 and len({widgets[node_id] for node_id in ids}) > 1:
|
||||
for node_id in ids:
|
||||
widget_text[node_id] = widgets[node_id]
|
||||
return _Context(graph, widget_text)
|
||||
|
||||
|
||||
def _enter(context, link, seen):
|
||||
"""``(node_id, node)`` for a link; None when unresolved or already expanded."""
|
||||
if not _is_link(link) or len(seen) >= MAX_VISITS:
|
||||
return None
|
||||
node_id = _js_string(link[0])
|
||||
node = _get(context.graph, node_id)
|
||||
key = f"{node_id}:{_js_string(link[1])}"
|
||||
if not _truthy(node) or not _truthy(_get(node, "inputs")) or key in seen:
|
||||
return None
|
||||
seen.add(key)
|
||||
return node_id, node
|
||||
|
||||
|
||||
def _resolve_scalar(context, value, name, seen):
|
||||
"""A scalar input, following a link; an input named like the field wins."""
|
||||
if not _is_link(value):
|
||||
return value
|
||||
entered = _enter(context, value, seen)
|
||||
if entered is None:
|
||||
return UNDEFINED
|
||||
inputs = _get(entered[1], "inputs")
|
||||
if _truthy(name) and _own(inputs, name):
|
||||
return (yield _resolve_scalar(context, _get(inputs, name), name, seen))
|
||||
for item in _values(inputs):
|
||||
resolved = yield _resolve_scalar(context, item, name, seen)
|
||||
if resolved is not UNDEFINED and _typeof(resolved) != "object":
|
||||
return resolved
|
||||
return UNDEFINED
|
||||
|
||||
|
||||
def _resolve_linked_input(context, link, keys):
|
||||
"""The first of ``keys`` on the node a link points to, resolving further links."""
|
||||
seen = set()
|
||||
entered = _enter(context, link, seen)
|
||||
if entered is None:
|
||||
return UNDEFINED
|
||||
inputs = _get(entered[1], "inputs")
|
||||
key = next((k for k in keys if _has(inputs, k)), None)
|
||||
if key is None:
|
||||
return UNDEFINED
|
||||
return (yield _resolve_scalar(context, _get(inputs, key), key, seen))
|
||||
|
||||
|
||||
def _resolve_string(context, value, seen):
|
||||
"""A string input; text-producing nodes may be chained (concat, primitives)."""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
entered = _enter(context, value, seen)
|
||||
if entered is None:
|
||||
return ""
|
||||
node_id, node = entered
|
||||
inputs = _get(node, "inputs")
|
||||
if _strict_equal(_get(node, "class_type"), "PromptManagerPair"):
|
||||
if _strict_equal(value[1], 3):
|
||||
return (yield _resolve_string(context, _get(inputs, "negative_text"), seen))
|
||||
if _strict_equal(value[1], 2):
|
||||
return (yield _compose_node_text(context, node_id, node, seen))
|
||||
return ""
|
||||
if _has(inputs, "text"):
|
||||
return (yield _compose_node_text(context, node_id, node, seen))
|
||||
delimiter = _get(inputs, "delimiter")
|
||||
if not isinstance(delimiter, str):
|
||||
delimiter = " "
|
||||
parts = []
|
||||
for key, item in _entries(inputs):
|
||||
if TEXT_KEYS.fullmatch(key):
|
||||
parts.append((yield _resolve_string(context, item, seen)))
|
||||
return delimiter.join(part for part in parts if _trim(part))
|
||||
|
||||
|
||||
def _compose_node_text(context, node_id, node, seen):
|
||||
"""An encoder's text, joined like PromptManager: prepend + text + append."""
|
||||
inputs = _get(node, "inputs")
|
||||
if node_id in context.widget_text:
|
||||
raw = context.widget_text[node_id]
|
||||
else:
|
||||
raw = yield _resolve_string(context, _get(inputs, "text"), seen)
|
||||
text = _trim(raw)
|
||||
if not text:
|
||||
return ""
|
||||
# One shared set even across siblings keeps crafted chains linear.
|
||||
prepend = _trim(
|
||||
(yield _resolve_string(context, _get(inputs, "prepend_text"), seen))
|
||||
)
|
||||
append = _trim((yield _resolve_string(context, _get(inputs, "append_text"), seen)))
|
||||
# Older saves stored the already-combined prompt in ``text``.
|
||||
parts = (
|
||||
prepend if prepend and not text.startswith(prepend) else "",
|
||||
text,
|
||||
append if append and not text.endswith(append) else "",
|
||||
)
|
||||
return " ".join(part for part in parts if part)
|
||||
|
||||
|
||||
def _trace_conditioning(context, link, role, seen):
|
||||
"""Follow a conditioning link upstream to the encoder that produced it."""
|
||||
entered = _enter(context, link, seen)
|
||||
if entered is None:
|
||||
return ""
|
||||
node_id, node = entered
|
||||
inputs = _get(node, "inputs")
|
||||
if _strict_equal(_get(node, "class_type"), "PromptManagerPair"):
|
||||
if _strict_equal(link[1], 1):
|
||||
return (yield _resolve_string(context, _get(inputs, "negative_text"), seen))
|
||||
if _strict_equal(link[1], 0):
|
||||
return (yield _compose_node_text(context, node_id, node, seen))
|
||||
return ""
|
||||
if _has(inputs, "text"):
|
||||
return (yield _compose_node_text(context, node_id, node, seen))
|
||||
# Pass-through nodes (ControlNet apply, conditioning combine/set area, ...).
|
||||
following = _first_truthy(
|
||||
_get(inputs, role),
|
||||
_get(inputs, "conditioning"),
|
||||
_get(inputs, "conditioning_to"),
|
||||
_get(inputs, "conditioning_1"),
|
||||
)
|
||||
return (yield _trace_conditioning(context, following, role, seen))
|
||||
|
||||
|
||||
def _trace_model_name(context, link, seen):
|
||||
"""Follow the model link upstream (through LoRA loaders etc.) to a loader."""
|
||||
entered = _enter(context, link, seen)
|
||||
if entered is None:
|
||||
return UNDEFINED
|
||||
inputs = _get(entered[1], "inputs")
|
||||
key = next((k for k in MODEL_NAME_KEYS if isinstance(_get(inputs, k), str)), None)
|
||||
if key is not None:
|
||||
return _get(inputs, key)
|
||||
return (yield _trace_model_name(context, _get(inputs, "model"), seen))
|
||||
|
||||
|
||||
def _find_any_model_name(nodes):
|
||||
for node in nodes:
|
||||
inputs = _and_chain(node, "inputs")
|
||||
if not _truthy(inputs):
|
||||
inputs = {}
|
||||
key = next(
|
||||
(k for k in MODEL_NAME_KEYS if isinstance(_get(inputs, k), str)), None
|
||||
)
|
||||
if key is not None:
|
||||
return _get(inputs, key)
|
||||
return UNDEFINED
|
||||
|
||||
|
||||
def _from_prompt_graph(graph, workflow):
|
||||
context = _create_context(graph, workflow)
|
||||
nodes = _values(graph)
|
||||
custom = next((node for node in nodes if _is_custom_sampler(node)), UNDEFINED)
|
||||
guider = UNDEFINED
|
||||
if custom is not UNDEFINED:
|
||||
entered = _enter(context, _get(_get(custom, "inputs"), "guider"), set())
|
||||
guider = entered[1] if entered is not None else UNDEFINED
|
||||
sampler = (
|
||||
guider
|
||||
if _is_sampler(guider)
|
||||
else next((n for n in nodes if _is_sampler(n)), UNDEFINED)
|
||||
)
|
||||
|
||||
if sampler is UNDEFINED:
|
||||
loose = next(
|
||||
(
|
||||
n
|
||||
for n in nodes
|
||||
if _truthy(n)
|
||||
and _truthy(_get(n, "inputs"))
|
||||
and _matches(_KSAMPLER, _get(n, "class_type"))
|
||||
),
|
||||
UNDEFINED,
|
||||
)
|
||||
inputs = _and_chain(loose, "inputs")
|
||||
if not _truthy(inputs):
|
||||
inputs = {}
|
||||
seed = _coalesce(_get(inputs, "seed"), lambda: _get(inputs, "noise_seed"))
|
||||
return {
|
||||
"checkpoint": _find_any_model_name(nodes),
|
||||
"seed": (yield _resolve_scalar(context, seed, "seed", set())),
|
||||
"steps": (
|
||||
yield _resolve_scalar(context, _get(inputs, "steps"), "steps", set())
|
||||
),
|
||||
"cfgScale": (
|
||||
yield _resolve_scalar(context, _get(inputs, "cfg"), "cfg", set())
|
||||
),
|
||||
"sampler": (
|
||||
yield _resolve_scalar(
|
||||
context, _get(inputs, "sampler_name"), "sampler_name", set()
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
inputs = _get(sampler, "inputs")
|
||||
|
||||
def from_custom(field, keys):
|
||||
if custom is UNDEFINED:
|
||||
return UNDEFINED
|
||||
return _run(
|
||||
_resolve_linked_input(context, _get(_get(custom, "inputs"), field), keys)
|
||||
)
|
||||
|
||||
result = {
|
||||
"positivePrompt": (
|
||||
yield _trace_conditioning(
|
||||
context, _get(inputs, "positive"), "positive", set()
|
||||
)
|
||||
),
|
||||
"negativePrompt": (
|
||||
yield _trace_conditioning(
|
||||
context, _get(inputs, "negative"), "negative", set()
|
||||
)
|
||||
),
|
||||
}
|
||||
model = yield _trace_model_name(context, _get(inputs, "model"), set())
|
||||
result["checkpoint"] = model if _truthy(model) else _find_any_model_name(nodes)
|
||||
seed_value = _coalesce(_get(inputs, "seed"), lambda: _get(inputs, "noise_seed"))
|
||||
seed_name = "seed" if _has(inputs, "seed") else "noise_seed"
|
||||
seed = yield _resolve_scalar(context, seed_value, seed_name, set())
|
||||
result["seed"] = _coalesce(
|
||||
seed, lambda: from_custom("noise", ("noise_seed", "seed"))
|
||||
)
|
||||
steps = yield _resolve_scalar(context, _get(inputs, "steps"), "steps", set())
|
||||
result["steps"] = _coalesce(steps, lambda: from_custom("sigmas", ("steps",)))
|
||||
result["cfgScale"] = yield _resolve_scalar(
|
||||
context, _get(inputs, "cfg"), "cfg", set()
|
||||
)
|
||||
sampler_name = yield _resolve_scalar(
|
||||
context, _get(inputs, "sampler_name"), "sampler_name", set()
|
||||
)
|
||||
result["sampler"] = _coalesce(
|
||||
sampler_name, lambda: from_custom("sampler", ("sampler_name",))
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# --- UI workflow graph -------------------------------------------------------------
|
||||
|
||||
|
||||
def _widget_names(node):
|
||||
inputs = _first_truthy(_get(node, "inputs"), [])
|
||||
from_inputs = [
|
||||
_get(_get(item, "widget"), "name")
|
||||
for item in _array(inputs)
|
||||
if _truthy(item)
|
||||
and _truthy(_get(item, "widget"))
|
||||
and _truthy(_get(_get(item, "widget"), "name"))
|
||||
]
|
||||
if from_inputs:
|
||||
return from_inputs
|
||||
node_type = _get(node, "type")
|
||||
key = _js_string(node_type)
|
||||
if key in _OBJECT_PROTOTYPE_KEYS:
|
||||
raise GenerationParamsError(f"widget positions of {key!r} are not iterable")
|
||||
if key in WIDGET_POSITIONS:
|
||||
return list(WIDGET_POSITIONS[key])
|
||||
if _matches(_CHECKPOINT_LOADER, node_type):
|
||||
return ["ckpt_name"]
|
||||
if _matches(_UNET, node_type):
|
||||
return ["unet_name"]
|
||||
return []
|
||||
|
||||
|
||||
def _named_widgets(node):
|
||||
"""Map widget values to input names, skipping control_after_generate."""
|
||||
named = _get(node, "widgets_values_named")
|
||||
if _truthy(named) and isinstance(named, dict):
|
||||
return dict(named)
|
||||
values = _widgets_of(node)
|
||||
out = {}
|
||||
position = 0
|
||||
for name in _iterate(_widget_names(node)):
|
||||
if position >= len(values):
|
||||
break
|
||||
_set(out, name, values[position])
|
||||
position += 1
|
||||
following = values[position] if position < len(values) else UNDEFINED
|
||||
if (
|
||||
_SEED_NAME.search(_js_string(name))
|
||||
and isinstance(following, str)
|
||||
and following in SEED_CONTROL_VALUES
|
||||
):
|
||||
position += 1
|
||||
return out
|
||||
|
||||
|
||||
def _workflow_to_graph(workflow):
|
||||
"""Convert a UI workflow ({nodes, links}) into the API graph shape.
|
||||
|
||||
Set/Get pairs, Reroutes and bypassed nodes resolve to their real source;
|
||||
muted nodes are dropped.
|
||||
"""
|
||||
nodes = [
|
||||
n
|
||||
for n in _workflow_nodes(workflow)
|
||||
if _truthy(n) and not _nullish(_get(n, "id"))
|
||||
]
|
||||
by_id = {}
|
||||
for node in nodes:
|
||||
by_id[_js_string(_get(node, "id"))] = node
|
||||
links = {}
|
||||
raw_links = _get(workflow, "links")
|
||||
for link in raw_links if isinstance(raw_links, list) else []:
|
||||
if isinstance(link, list):
|
||||
links[_same_value_key(_get(link, 0))] = (
|
||||
_get(link, 1),
|
||||
_get(link, 2),
|
||||
_get(link, 5),
|
||||
)
|
||||
elif _truthy(link) and _typeof(link) == "object":
|
||||
links[_same_value_key(_get(link, "id"))] = (
|
||||
_get(link, "origin_id"),
|
||||
_get(link, "origin_slot"),
|
||||
_get(link, "type"),
|
||||
)
|
||||
setters = {}
|
||||
for node in nodes:
|
||||
if _strict_equal(_get(node, "type"), "SetNode"):
|
||||
setters[_same_value_key(_get(_widgets_of(node), 0))] = _and_chain(
|
||||
node, "inputs", 0, "link"
|
||||
)
|
||||
|
||||
def source(link_id):
|
||||
hops = 0
|
||||
while True:
|
||||
link = links.get(_same_value_key(link_id))
|
||||
node = by_id.get(_js_string(link[0])) if link is not None else None
|
||||
if (
|
||||
node is None
|
||||
or _strict_equal(_get(node, "mode"), MODE_MUTED)
|
||||
or hops > MAX_VISITS
|
||||
):
|
||||
return UNDEFINED
|
||||
hops += 1
|
||||
node_type = _get(node, "type")
|
||||
if _strict_equal(node_type, "Reroute"):
|
||||
link_id = _and_chain(node, "inputs", 0, "link")
|
||||
continue
|
||||
if _strict_equal(node_type, "GetNode"):
|
||||
link_id = setters.get(
|
||||
_same_value_key(_get(_widgets_of(node), 0)), UNDEFINED
|
||||
)
|
||||
continue
|
||||
if _strict_equal(_get(node, "mode"), MODE_BYPASSED):
|
||||
link_type = link[2]
|
||||
same = next(
|
||||
(
|
||||
item
|
||||
for item in _array(_first_truthy(_get(node, "inputs"), []))
|
||||
if _truthy(item)
|
||||
and not _nullish(_get(item, "link"))
|
||||
and (
|
||||
not _truthy(link_type)
|
||||
or _strict_equal(link_type, "*")
|
||||
or _strict_equal(_get(item, "type"), link_type)
|
||||
)
|
||||
),
|
||||
UNDEFINED,
|
||||
)
|
||||
if same is UNDEFINED:
|
||||
return UNDEFINED
|
||||
link_id = _get(same, "link")
|
||||
continue
|
||||
return [_js_string(_get(node, "id")), link[1]]
|
||||
|
||||
graph = {}
|
||||
for node in nodes:
|
||||
mode = _get(node, "mode")
|
||||
node_type = _get(node, "type")
|
||||
if (
|
||||
_strict_equal(mode, MODE_MUTED)
|
||||
or _strict_equal(mode, MODE_BYPASSED)
|
||||
or (isinstance(node_type, str) and node_type in PASS_THROUGH_TYPES)
|
||||
):
|
||||
continue
|
||||
inputs = _named_widgets(node)
|
||||
for item in _iterate(_first_truthy(_get(node, "inputs"), [])):
|
||||
if not _truthy(item) or _nullish(_get(item, "link")):
|
||||
continue
|
||||
found = source(_get(item, "link"))
|
||||
if _truthy(found):
|
||||
_set(inputs, _get(item, "name"), found)
|
||||
_set(graph, _get(node, "id"), {"class_type": node_type, "inputs": inputs})
|
||||
return graph
|
||||
|
||||
|
||||
# --- Public API ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _has_value(value):
|
||||
return not _nullish(value) and not (isinstance(value, str) and value == "")
|
||||
|
||||
|
||||
def extract_generation_params(data):
|
||||
"""Prompts, model and sampler settings from parsed ``prompt``/``workflow`` records.
|
||||
|
||||
``data`` holds the parsed records (``{"prompt": ..., "workflow": ...}``). Each
|
||||
field falls back to its placeholder when neither graph provides it.
|
||||
Raises ``GenerationParamsError`` where the browser parser throws.
|
||||
"""
|
||||
data = data if _truthy(data) else {}
|
||||
prompt = _get(data, "prompt")
|
||||
workflow = _get(data, "workflow")
|
||||
primary = {}
|
||||
if _truthy(prompt) and _typeof(prompt) == "object":
|
||||
primary = _run(_from_prompt_graph(prompt, workflow))
|
||||
fallback = {}
|
||||
if _truthy(workflow):
|
||||
fallback = _run(_from_prompt_graph(_workflow_to_graph(workflow), None))
|
||||
merged = {}
|
||||
for key, placeholder in PLACEHOLDERS.items():
|
||||
value = primary.get(key, UNDEFINED)
|
||||
if not _has_value(value):
|
||||
value = fallback.get(key, UNDEFINED)
|
||||
merged[key] = value if _has_value(value) else placeholder
|
||||
return merged
|
||||
|
||||
|
||||
def _reject_constant(name):
|
||||
raise ValueError(f"{name} is not JSON")
|
||||
|
||||
|
||||
def _double_integer(text):
|
||||
"""Integers parse as doubles in JavaScript; beyond 2**53 they round."""
|
||||
value = int(text)
|
||||
return value if -_MAX_SAFE_INTEGER <= value <= _MAX_SAFE_INTEGER else float(text)
|
||||
|
||||
|
||||
def parse_js_json(text):
|
||||
"""``JSON.parse(text)``, or None where it throws (or the text is ``null``)."""
|
||||
if not isinstance(text, str):
|
||||
return None
|
||||
try:
|
||||
return json.loads(
|
||||
text, parse_constant=_reject_constant, parse_int=_double_integer
|
||||
)
|
||||
except (ValueError, RecursionError):
|
||||
return None
|
||||
|
||||
|
||||
def extract_from_records(prompt_text, workflow_text):
|
||||
"""``extract_generation_params`` for raw ``prompt``/``workflow`` record text.
|
||||
|
||||
A record that is absent or fails to parse is treated as absent, as the
|
||||
browser pages do.
|
||||
"""
|
||||
data = {}
|
||||
for key, text in (("prompt", prompt_text), ("workflow", workflow_text)):
|
||||
if isinstance(text, str):
|
||||
data[key] = parse_js_json(text)
|
||||
return extract_generation_params(data)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""User-facing lines for non-resolved prompt outcomes, from the closed vocabulary.
|
||||
|
||||
The inspector shows one line per unresolved role in place of the generic
|
||||
"Prompt could not be resolved." Ambiguous and absent roles keep their status
|
||||
copy, so a candidate is never presented as a confirmed prompt. The wire
|
||||
projection accepts exactly the closed ``reason_code`` vocabulary and names a
|
||||
blocking node only beside a code that names one.
|
||||
"""
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from .prompt_pair_resolver import (
|
||||
EMPTY_POSITIVE_REASON,
|
||||
MULTISTAGE_UNATTRIBUTED_REASON,
|
||||
SEMANTICS_UNAVAILABLE_REASON,
|
||||
)
|
||||
|
||||
REASON_CODES = frozenset(
|
||||
{
|
||||
"work-limit",
|
||||
"depth-limit",
|
||||
"text-limit",
|
||||
"graph-ceiling",
|
||||
"unsupported-node",
|
||||
"runtime-value",
|
||||
"composite",
|
||||
"no-sampler",
|
||||
"malformed",
|
||||
"multistage-differs",
|
||||
}
|
||||
)
|
||||
LIMIT_CODES = frozenset({"work-limit", "depth-limit", "text-limit", "graph-ceiling"})
|
||||
# Codes whose outcome names the blocking node: ``node: {id, class_type}``.
|
||||
NAMED_CODES = frozenset({"unsupported-node", "runtime-value", "composite"})
|
||||
# Bound for a node ID or class name on the wire and in a line.
|
||||
CLASS_NAME_LENGTH = 200
|
||||
# Bound for a role's line on the wire, in UTF-16 code units (the client's count).
|
||||
MESSAGE_LENGTH = 512
|
||||
UNREADABLE_METADATA_REASON = "Media metadata could not be read"
|
||||
_NODE_KEYS = frozenset({"id", "class_type"})
|
||||
|
||||
_NAMED_LINES = {
|
||||
"unsupported-node": "Prompt uses a node the inspector does not support",
|
||||
"runtime-value": "Prompt is chosen at run time",
|
||||
"composite": "Prompt combines several texts",
|
||||
}
|
||||
_CODED_LINES = {
|
||||
**dict.fromkeys(LIMIT_CODES, "Prompt exceeds inspection limits."),
|
||||
"no-sampler": "No supported sampler was found.",
|
||||
"malformed": "Prompt metadata in this file is malformed.",
|
||||
"multistage-differs": "Sampler stages have different prompts.",
|
||||
}
|
||||
# Uncoded outcomes, keyed by the resolver's exact reason.
|
||||
_UNCODED_LINES = {
|
||||
EMPTY_POSITIVE_REASON: "Positive prompt is empty.",
|
||||
MULTISTAGE_UNATTRIBUTED_REASON: (
|
||||
"Several sampler stages were found; their prompts were not compared."
|
||||
),
|
||||
SEMANTICS_UNAVAILABLE_REASON: "The inspector's node table is unavailable.",
|
||||
UNREADABLE_METADATA_REASON: "Metadata in this file could not be read.",
|
||||
}
|
||||
|
||||
|
||||
def _text(value: Any) -> Optional[str]:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _class_name(node: Any) -> Optional[str]:
|
||||
if not isinstance(node, dict):
|
||||
return None
|
||||
name = node.get("class_type")
|
||||
return name[:CLASS_NAME_LENGTH] if isinstance(name, str) and name else None
|
||||
|
||||
|
||||
def outcome_message(resolution: Any) -> Optional[str]:
|
||||
"""One line for an unresolved outcome, or ``None`` to keep the status copy."""
|
||||
if not isinstance(resolution, dict) or resolution.get("state") != "unresolved":
|
||||
return None
|
||||
code = _text(resolution.get("reason_code"))
|
||||
if code in _NAMED_LINES:
|
||||
name = _class_name(resolution.get("node"))
|
||||
return f"{_NAMED_LINES[code]} ({name})." if name else f"{_NAMED_LINES[code]}."
|
||||
if code is None:
|
||||
return _UNCODED_LINES.get(_text(resolution.get("reason")))
|
||||
return _CODED_LINES.get(code)
|
||||
|
||||
|
||||
def _node(code: Optional[str], node: Any) -> Optional[dict]:
|
||||
if code not in NAMED_CODES or not isinstance(node, dict) or set(node) != _NODE_KEYS:
|
||||
return None
|
||||
node_id, name = node["id"], node["class_type"]
|
||||
if not isinstance(node_id, str) or not node_id:
|
||||
return None
|
||||
if name is not None and not isinstance(name, str):
|
||||
return None
|
||||
return {
|
||||
"id": node_id[:CLASS_NAME_LENGTH],
|
||||
"class_type": name[:CLASS_NAME_LENGTH] if name else None,
|
||||
}
|
||||
|
||||
|
||||
def project_resolution(resolution: dict) -> dict:
|
||||
"""Fail closed: an unknown or contradictory code and its node are dropped."""
|
||||
code = _text(resolution.get("reason_code"))
|
||||
if code not in REASON_CODES or resolution.get("state") in ("resolved", "absent"):
|
||||
code = None
|
||||
return {
|
||||
**resolution,
|
||||
"reason_code": code,
|
||||
"node": _node(code, resolution.get("node")),
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
import json
|
||||
import math
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional
|
||||
|
||||
from .inspection_outcomes import MESSAGE_LENGTH, project_resolution
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .prompt_inspector import InspectionRead
|
||||
@@ -159,6 +161,21 @@ def _graph(value: object, *, workflow: bool = False) -> dict:
|
||||
return {"status": "parsed", "value": value}
|
||||
|
||||
|
||||
def project_graph(text: str, kind: Literal["prompt", "workflow"]) -> dict:
|
||||
"""One record's graph as the inspector projects it: ``{status, value}``.
|
||||
|
||||
``status`` is ``parsed`` (``value`` is the projected graph the browser
|
||||
parser receives), ``limited`` (past the depth or node bound) or
|
||||
``malformed``. Shared with the workspace card so both read one bound.
|
||||
"""
|
||||
try:
|
||||
return _graph(_strict_projection(text, kind=kind), workflow=kind == "workflow")
|
||||
except _ProjectionLimit:
|
||||
return {"status": "limited", "value": None}
|
||||
except (ValueError, TypeError, KeyError, OverflowError):
|
||||
return {"status": "malformed", "value": None}
|
||||
|
||||
|
||||
def _metadata(inspection: "InspectionRead | None") -> dict:
|
||||
formats = (
|
||||
{}
|
||||
@@ -211,16 +228,15 @@ def _metadata(inspection: "InspectionRead | None") -> dict:
|
||||
graph["status"] = failed or "limited"
|
||||
elif formats.get(key) == "invalid":
|
||||
graph["status"] = "malformed"
|
||||
elif carrier is None and formats.get(key) == "reserialized-json":
|
||||
graph["status"] = "limited"
|
||||
elif carrier is None:
|
||||
graph = project_graph(raw.get(key), key)
|
||||
else:
|
||||
try:
|
||||
if carrier is not None:
|
||||
value = carrier.get(key)
|
||||
if isinstance(value, str):
|
||||
value = _strict_projection(value, kind=key)
|
||||
elif formats.get(key) == "reserialized-json":
|
||||
raise _ProjectionLimit()
|
||||
else:
|
||||
value = _strict_projection(raw[key], kind=key)
|
||||
value = carrier.get(key)
|
||||
if isinstance(value, str):
|
||||
value = _strict_projection(value, kind=key)
|
||||
graph = _graph(value, workflow=key == "workflow")
|
||||
except _ProjectionLimit:
|
||||
graph["status"] = "limited"
|
||||
@@ -312,6 +328,29 @@ def _library_projection(library: dict) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def _bounded_message(value: Any) -> Optional[str]:
|
||||
"""A role line as the client accepts it: 1 to 512 UTF-16 code units."""
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
value = value[:MESSAGE_LENGTH]
|
||||
while len(value.encode("utf-16-le", "surrogatepass")) > 2 * MESSAGE_LENGTH:
|
||||
value = value[:-1]
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_messages(prompts: dict) -> dict:
|
||||
"""Keep a role ``message`` only as bounded text on a non-resolved role."""
|
||||
bounded = {}
|
||||
for role, side in prompts.items():
|
||||
if isinstance(side, dict) and "message" in side:
|
||||
side = {key: item for key, item in side.items() if key != "message"}
|
||||
message = _bounded_message(prompts[role]["message"])
|
||||
if message is not None and side.get("status") != "resolved":
|
||||
side["message"] = message
|
||||
bounded[role] = side
|
||||
return bounded
|
||||
|
||||
|
||||
def _encode_inspection(
|
||||
*,
|
||||
identity: dict,
|
||||
@@ -328,8 +367,8 @@ def _encode_inspection(
|
||||
"inspection_version": 1,
|
||||
**identity,
|
||||
"file": _file_projection(file, inspection),
|
||||
"prompts": prompts,
|
||||
"resolution": resolution,
|
||||
"prompts": _bounded_messages(prompts),
|
||||
"resolution": project_resolution(resolution),
|
||||
"metadata": metadata,
|
||||
"library": _library_projection(library),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
"""Load and validate the declarative inspection node semantics table (#240).
|
||||
|
||||
The table lives once in ``web/js/inspection-semantics.json`` so a browser-local
|
||||
resolver can load the same data. It maps node classes (or ``(class, output
|
||||
slot)`` pairs) to a named strategy from the closed ``STRATEGIES`` set plus the
|
||||
exact input names that strategy reads. Code implements each strategy; adding a
|
||||
node that reuses one is a data-only change.
|
||||
|
||||
Only the inspection resolver profile consumes this table, through
|
||||
``get_semantics()``, which reads and validates it on first use and caches only a
|
||||
valid result. Importing this module reads nothing, and the default (ingestion)
|
||||
profile never imports it, so a missing or bad table can never break ingestion.
|
||||
An invalid table raises ``SemanticsError`` so a bad edit fails loudly instead of
|
||||
silently changing what the inspector resolves. The file is bounded in size and
|
||||
nesting before it is validated. Every class appears in exactly one category;
|
||||
classes that are not listed stay ``unsupported-node``.
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Callable, Dict, FrozenSet, Mapping, Optional, Tuple, Union
|
||||
|
||||
SEMANTICS_PATH = (
|
||||
Path(__file__).resolve().parents[1] / "web" / "js" / "inspection-semantics.json"
|
||||
)
|
||||
SCHEMA_VERSION = 1
|
||||
# The real table is a few KiB and four levels deep; these only reject nonsense.
|
||||
MAX_TABLE_BYTES = 1024 * 1024
|
||||
MAX_NESTING = 8
|
||||
_NAME_LENGTH = 128
|
||||
# Comfy output slots are small; this only rejects nonsense, never a real node.
|
||||
_MAX_SLOT = 255
|
||||
|
||||
STRATEGIES: Mapping[str, FrozenSet[str]] = MappingProxyType(
|
||||
{
|
||||
"samplers": frozenset({"ksampler", "custom-advanced", "detailer-stage"}),
|
||||
"guiders": frozenset({"cfg", "basic"}),
|
||||
"conditioning": frozenset({"passthrough", "zeroed"}),
|
||||
"selectors": frozenset({"boolean-select"}),
|
||||
"composites": frozenset({"combine"}),
|
||||
"encoders": frozenset({"single", "parts-equal", "pm-compose", "flag-parts"}),
|
||||
"strings": frozenset({"literal", "strip-newlines", "join-multi", "concat"}),
|
||||
"savers": frozenset({"comfy-counter", "vhs-counter"}),
|
||||
}
|
||||
)
|
||||
# Shapes of the facts each sampler class provides (consumed by comfy-metadata.js).
|
||||
FACT_SHAPES = frozenset({"ksampler", "custom", "custom-advanced"})
|
||||
SAVER_PATTERNS = MappingProxyType(
|
||||
{
|
||||
"comfy-counter": "{name}_{counter:05}_.{ext}",
|
||||
"vhs-counter": "{name}_{counter:05}.{ext}",
|
||||
}
|
||||
)
|
||||
_SAMPLER_OPTIONS = frozenset({"prompt_overrides", "fact_overrides"})
|
||||
_CONFIGURATION_KINDS = frozenset({"noise", "sampler", "sigmas", "guider"})
|
||||
_SCALAR_KINDS = frozenset({"number", "string"})
|
||||
_TYPE_FIELDS = frozenset({"type_fields"})
|
||||
_CLASS_CATEGORIES = ("samplers", "guiders", "selectors", "composites", "savers")
|
||||
_SLOT_CATEGORIES = ("conditioning", "encoders", "strings")
|
||||
_SET_CATEGORIES = ("runtime", "non_file_outputs")
|
||||
_FACTS_TABLES = (
|
||||
"configuration_outputs",
|
||||
"model_outputs",
|
||||
"model_passthroughs",
|
||||
"scalar_outputs",
|
||||
)
|
||||
_CATEGORIES = frozenset(
|
||||
("schema",)
|
||||
+ _CLASS_CATEGORIES
|
||||
+ _SLOT_CATEGORIES
|
||||
+ _SET_CATEGORIES
|
||||
+ ("stage_inputs", "facts")
|
||||
)
|
||||
# Input names that make an unlisted node a possible sampler stage in an ancestry
|
||||
# walk (``shaped``: pipes, video embeds and custom-sampler parts carry a prompt
|
||||
# without a ``positive`` link), and those that do so beside a linked ``model``
|
||||
# (``with_model``: XlabsSampler's ``conditioning``, video samplers' ``samples``).
|
||||
# ``model`` alone (ModelSampling*) or with ``clip`` (LoraLoader) is a model patch.
|
||||
# The resolver and comfy-metadata.js both read these lists.
|
||||
_STAGE_INPUT_LISTS = ("shaped", "with_model")
|
||||
_STRING_PARAMS = MappingProxyType(
|
||||
{
|
||||
"literal": frozenset(),
|
||||
"concat": frozenset(),
|
||||
"strip-newlines": frozenset({"flag"}),
|
||||
"join-multi": frozenset({"count", "delimiter", "list_flag", "input_prefix"}),
|
||||
}
|
||||
)
|
||||
|
||||
Frozen = Union[Mapping[str, Any], Tuple[Any, ...], str, int, None]
|
||||
|
||||
|
||||
class SemanticsError(ValueError):
|
||||
"""The semantics table is unreadable or violates its schema."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Semantics:
|
||||
"""Validated, deeply frozen semantics. Slot categories key by ``(class, slot)``."""
|
||||
|
||||
samplers: Mapping[str, Mapping[str, Any]]
|
||||
guiders: Mapping[str, Mapping[str, Any]]
|
||||
conditioning: Mapping[Tuple[str, int], Mapping[str, Any]]
|
||||
selectors: Mapping[str, Mapping[str, Any]]
|
||||
composites: Mapping[str, Mapping[str, Any]]
|
||||
encoders: Mapping[Tuple[str, int], Mapping[str, Any]]
|
||||
strings: Mapping[Tuple[str, int], Mapping[str, Any]]
|
||||
runtime: FrozenSet[str]
|
||||
savers: Mapping[str, Mapping[str, Any]]
|
||||
non_file_outputs: FrozenSet[str]
|
||||
stage_inputs: Mapping[str, Tuple[str, ...]]
|
||||
facts: Mapping[str, Any]
|
||||
all_classes: FrozenSet[str]
|
||||
|
||||
|
||||
def _freeze(value: Any) -> Frozen:
|
||||
if isinstance(value, dict):
|
||||
return MappingProxyType({key: _freeze(item) for key, item in value.items()})
|
||||
if isinstance(value, list):
|
||||
return tuple(_freeze(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def _reject_duplicate_keys(pairs: list) -> dict:
|
||||
result: Dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise SemanticsError(f"duplicate key {key!r}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _reject_constant(name: str) -> None:
|
||||
raise SemanticsError(f"non-finite number {name} is not allowed")
|
||||
|
||||
|
||||
def _name(value: Any, where: str, what: str = "input") -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > _NAME_LENGTH:
|
||||
raise SemanticsError(f"{where}: missing or invalid {what} name {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _names(value: Any, where: str, minimum: int = 1) -> None:
|
||||
if not isinstance(value, list) or len(value) < minimum:
|
||||
raise SemanticsError(f"{where}: needs at least {minimum} input name(s)")
|
||||
for item in value:
|
||||
_name(item, where)
|
||||
if len(set(value)) != len(value):
|
||||
raise SemanticsError(f"{where}: repeated input name")
|
||||
|
||||
|
||||
def _fields(
|
||||
entry: Any, where: str, required: FrozenSet[str], optional: FrozenSet[str]
|
||||
) -> None:
|
||||
if not isinstance(entry, dict):
|
||||
raise SemanticsError(f"{where}: entry must be an object")
|
||||
missing = sorted(required - entry.keys())
|
||||
if missing:
|
||||
raise SemanticsError(f"{where}: missing input field(s) {missing}")
|
||||
unknown = sorted(entry.keys() - required - optional)
|
||||
if unknown:
|
||||
raise SemanticsError(f"{where}: unknown field(s) {unknown}")
|
||||
|
||||
|
||||
def _strategy(entry: Any, category: str, where: str) -> str:
|
||||
strategy = entry.get("strategy") if isinstance(entry, dict) else None
|
||||
if not isinstance(strategy, str) or strategy not in STRATEGIES[category]:
|
||||
raise SemanticsError(f"{where}: unknown strategy {strategy!r}")
|
||||
return strategy
|
||||
|
||||
|
||||
def _check_sampler(entry: dict, strategy: str, where: str) -> None:
|
||||
stage = "image_stage" if strategy == "detailer-stage" else "latent"
|
||||
_fields(
|
||||
entry,
|
||||
where,
|
||||
frozenset({"strategy", "conditioning", stage, "facts"}),
|
||||
_SAMPLER_OPTIONS,
|
||||
)
|
||||
_name(entry[stage], where)
|
||||
# Inputs that, when set, replace the prompt the sampler's conditioning
|
||||
# inputs carry (a detailer's wildcard); the prompt is then not read.
|
||||
# ``fact_overrides`` (read by comfy-metadata.js): inputs that, when set,
|
||||
# replace the settings the facts would read (a script or an XY plot).
|
||||
for option in sorted(_SAMPLER_OPTIONS & entry.keys()):
|
||||
_names(entry[option], f"{where}.{option}")
|
||||
keys = {"guider"} if strategy == "custom-advanced" else {"positive", "negative"}
|
||||
conditioning = entry["conditioning"]
|
||||
_fields(conditioning, where + ".conditioning", frozenset(keys), frozenset())
|
||||
for key in keys:
|
||||
_name(conditioning[key], where + ".conditioning")
|
||||
if entry["facts"] not in FACT_SHAPES:
|
||||
raise SemanticsError(f"{where}: unknown facts shape {entry['facts']!r}")
|
||||
|
||||
|
||||
def _check_guider(entry: dict, strategy: str, where: str) -> None:
|
||||
if strategy == "cfg":
|
||||
_fields(
|
||||
entry, where, frozenset({"strategy", "positive", "negative"}), frozenset()
|
||||
)
|
||||
_name(entry["negative"], where)
|
||||
else:
|
||||
# Negative absent by design; its reason is fixed text, not an input.
|
||||
_fields(
|
||||
entry,
|
||||
where,
|
||||
frozenset({"strategy", "positive"}),
|
||||
frozenset({"absent_reason"}),
|
||||
)
|
||||
if "absent_reason" in entry:
|
||||
_name(entry["absent_reason"], where, "absent reason")
|
||||
_name(entry["positive"], where)
|
||||
|
||||
|
||||
def _check_selector(entry: dict, strategy: str, where: str) -> None:
|
||||
_fields(entry, where, frozenset({"strategy", "condition", "branches"}), frozenset())
|
||||
_name(entry["condition"], where)
|
||||
branches = entry["branches"]
|
||||
_fields(branches, where + ".branches", frozenset({"true", "false"}), frozenset())
|
||||
_name(branches["true"], where)
|
||||
_name(branches["false"], where)
|
||||
|
||||
|
||||
def _check_composite(entry: dict, strategy: str, where: str) -> None:
|
||||
_fields(entry, where, frozenset({"strategy", "inputs"}), frozenset())
|
||||
_names(entry["inputs"], where, minimum=2)
|
||||
|
||||
|
||||
def _check_saver(entry: dict, strategy: str, where: str) -> None:
|
||||
_fields(
|
||||
entry,
|
||||
where,
|
||||
frozenset({"strategy", "prefix_input", "image_input", "pattern"}),
|
||||
frozenset(),
|
||||
)
|
||||
_name(entry["prefix_input"], where)
|
||||
_name(entry["image_input"], where)
|
||||
if entry["pattern"] != SAVER_PATTERNS[strategy]:
|
||||
raise SemanticsError(f"{where}: pattern does not match {strategy}")
|
||||
|
||||
|
||||
def _check_conditioning(entry: dict, strategy: str, where: str) -> None:
|
||||
_fields(entry, where, frozenset({"strategy", "input"}), frozenset())
|
||||
_name(entry["input"], where)
|
||||
|
||||
|
||||
def _check_encoder(entry: dict, strategy: str, where: str) -> None:
|
||||
if strategy == "flag-parts":
|
||||
# A literal boolean ``flag`` false reads the first input; true needs
|
||||
# every input equal (smZ CLIPTextEncode's with_SDXL).
|
||||
_fields(entry, where, frozenset({"strategy", "inputs", "params"}), frozenset())
|
||||
params = entry["params"]
|
||||
if not isinstance(params, dict) or set(params) != {"flag"}:
|
||||
raise SemanticsError(f"{where}: params must be exactly ['flag']")
|
||||
_name(params["flag"], where + ".params")
|
||||
_names(entry["inputs"], where, minimum=2)
|
||||
return
|
||||
_fields(entry, where, frozenset({"strategy", "inputs"}), frozenset({"compose"}))
|
||||
_names(entry["inputs"], where, minimum=2 if strategy == "parts-equal" else 1)
|
||||
if "compose" in entry and not isinstance(entry["compose"], bool):
|
||||
raise SemanticsError(f"{where}: compose must be a boolean")
|
||||
|
||||
|
||||
def _check_string(entry: dict, strategy: str, where: str) -> None:
|
||||
required = _STRING_PARAMS[strategy]
|
||||
if required:
|
||||
_fields(entry, where, frozenset({"strategy", "inputs", "params"}), frozenset())
|
||||
params = entry["params"]
|
||||
if not isinstance(params, dict) or set(params) != required:
|
||||
raise SemanticsError(f"{where}: params must be exactly {sorted(required)}")
|
||||
for value in params.values():
|
||||
_name(value, where + ".params")
|
||||
else:
|
||||
_fields(entry, where, frozenset({"strategy", "inputs"}), frozenset())
|
||||
_names(entry["inputs"], where)
|
||||
|
||||
|
||||
_CHECKS: Mapping[str, Callable[[dict, str, str], None]] = MappingProxyType(
|
||||
{
|
||||
"samplers": _check_sampler,
|
||||
"guiders": _check_guider,
|
||||
"selectors": _check_selector,
|
||||
"composites": _check_composite,
|
||||
"savers": _check_saver,
|
||||
"conditioning": _check_conditioning,
|
||||
"encoders": _check_encoder,
|
||||
"strings": _check_string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _Owners:
|
||||
"""Each class belongs to one category; each (class, slot) is listed once."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._category: Dict[str, str] = {}
|
||||
self._outputs: set = set()
|
||||
|
||||
def claim(self, name: str, category: str, slot: Optional[int] = None) -> None:
|
||||
owner = self._category.setdefault(name, category)
|
||||
if owner != category:
|
||||
raise SemanticsError(f"duplicate class {name!r} in {owner} and {category}")
|
||||
key = (name, slot)
|
||||
if slot is not None and key in self._outputs:
|
||||
raise SemanticsError(f"{category}: duplicate output key {key!r}")
|
||||
self._outputs.add(key)
|
||||
|
||||
def classes(self) -> FrozenSet[str]:
|
||||
return frozenset(self._category)
|
||||
|
||||
|
||||
def _class_category(doc: dict, category: str, owners: _Owners) -> Mapping:
|
||||
table = doc[category]
|
||||
if not isinstance(table, dict):
|
||||
raise SemanticsError(f"category {category} must be an object")
|
||||
result = {}
|
||||
for name, entry in table.items():
|
||||
where = f"{category}[{name!r}]"
|
||||
_name(name, where, "class")
|
||||
strategy = _strategy(entry, category, where)
|
||||
_CHECKS[category](entry, strategy, where)
|
||||
owners.claim(name, category)
|
||||
result[name] = _freeze(entry)
|
||||
return MappingProxyType(result)
|
||||
|
||||
|
||||
def _slot(entry: dict, where: str) -> int:
|
||||
slot = entry.get("slot")
|
||||
# bool is an int subclass; a JSON true is not an output slot.
|
||||
if type(slot) is not int or not 0 <= slot <= _MAX_SLOT:
|
||||
raise SemanticsError(f"{where}: slot must be a non-negative int, not {slot!r}")
|
||||
return slot
|
||||
|
||||
|
||||
def _slot_category(doc: dict, category: str, owners: _Owners) -> Mapping:
|
||||
rows = doc[category]
|
||||
if not isinstance(rows, list):
|
||||
raise SemanticsError(f"category {category} must be a list")
|
||||
result = {}
|
||||
for index, row in enumerate(rows):
|
||||
where = f"{category}[{index}]"
|
||||
if not isinstance(row, dict):
|
||||
raise SemanticsError(f"{where}: entry must be an object")
|
||||
name = _name(row.get("class"), where, "class")
|
||||
slot = _slot(row, where)
|
||||
entry = {
|
||||
key: value for key, value in row.items() if key not in ("class", "slot")
|
||||
}
|
||||
strategy = _strategy(entry, category, where)
|
||||
_CHECKS[category](entry, strategy, where)
|
||||
owners.claim(name, category, slot)
|
||||
result[(name, slot)] = _freeze(entry)
|
||||
return MappingProxyType(result)
|
||||
|
||||
|
||||
def _set_category(doc: dict, category: str, owners: _Owners) -> FrozenSet[str]:
|
||||
rows = doc[category]
|
||||
if not isinstance(rows, list):
|
||||
raise SemanticsError(f"category {category} must be a list")
|
||||
seen: set = set()
|
||||
for name in rows:
|
||||
_name(name, category, "class")
|
||||
if name in seen:
|
||||
raise SemanticsError(f"{category}: duplicate class {name!r}")
|
||||
seen.add(name)
|
||||
owners.claim(name, category)
|
||||
return frozenset(seen)
|
||||
|
||||
|
||||
def _string_map(table: Any, where: str, allowed: Optional[FrozenSet[str]]) -> None:
|
||||
if not isinstance(table, dict):
|
||||
raise SemanticsError(f"{where} must be an object")
|
||||
for name, value in table.items():
|
||||
_name(name, where, "class")
|
||||
_name(value, where)
|
||||
if allowed is not None and value not in allowed:
|
||||
raise SemanticsError(f"{where}[{name!r}]: unknown kind {value!r}")
|
||||
|
||||
|
||||
def _stage_inputs(doc: dict) -> Mapping[str, Tuple[str, ...]]:
|
||||
table = doc["stage_inputs"]
|
||||
if not isinstance(table, dict) or set(table) != set(_STAGE_INPUT_LISTS):
|
||||
raise SemanticsError(
|
||||
f"stage_inputs must have exactly {list(_STAGE_INPUT_LISTS)}"
|
||||
)
|
||||
for key in _STAGE_INPUT_LISTS:
|
||||
_names(table[key], f"stage_inputs.{key}")
|
||||
if "model" in table[key]:
|
||||
raise SemanticsError(f"stage_inputs.{key}: model is a patch input")
|
||||
if set(table["shaped"]) & set(table["with_model"]):
|
||||
raise SemanticsError("stage_inputs: an input is listed in both lists")
|
||||
return _freeze(table)
|
||||
|
||||
|
||||
def _facts(doc: dict) -> Mapping[str, Any]:
|
||||
facts = doc["facts"]
|
||||
if not isinstance(facts, dict) or set(facts) != set(_FACTS_TABLES):
|
||||
raise SemanticsError(f"facts must have exactly {list(_FACTS_TABLES)}")
|
||||
configuration = facts["configuration_outputs"]
|
||||
if not isinstance(configuration, dict):
|
||||
raise SemanticsError("facts.configuration_outputs must be an object")
|
||||
for name, adapter in configuration.items():
|
||||
where = f"facts.configuration_outputs[{name!r}]"
|
||||
_name(name, where, "class")
|
||||
_fields(adapter, where, frozenset({"kind", "fields"}), _TYPE_FIELDS)
|
||||
if adapter["kind"] not in _CONFIGURATION_KINDS:
|
||||
raise SemanticsError(f"{where}: unknown kind {adapter['kind']!r}")
|
||||
_names(adapter["fields"], where)
|
||||
if "type_fields" in adapter:
|
||||
# A fact whose value is the provider's node type (D8: Flux2Scheduler).
|
||||
_names(adapter["type_fields"], where)
|
||||
if set(adapter["type_fields"]) & set(adapter["fields"]):
|
||||
raise SemanticsError(f"{where}: type_fields repeat an input field")
|
||||
_string_map(facts["model_outputs"], "facts.model_outputs", None)
|
||||
_string_map(facts["scalar_outputs"], "facts.scalar_outputs", _SCALAR_KINDS)
|
||||
passthroughs = facts["model_passthroughs"]
|
||||
_names(passthroughs, "facts.model_passthroughs", minimum=0)
|
||||
return _freeze(facts)
|
||||
|
||||
|
||||
def _check_nesting(doc: Any) -> None:
|
||||
"""Reject containers nested deeper than ``MAX_NESTING`` (iterative walk)."""
|
||||
stack = [(doc, 1)]
|
||||
while stack:
|
||||
value, level = stack.pop()
|
||||
if not isinstance(value, (dict, list)):
|
||||
continue
|
||||
if level > MAX_NESTING:
|
||||
raise SemanticsError(f"semantics table nests deeper than {MAX_NESTING}")
|
||||
items = value.values() if isinstance(value, dict) else value
|
||||
stack.extend((item, level + 1) for item in items)
|
||||
|
||||
|
||||
def _validate(doc: Any) -> Semantics:
|
||||
if not isinstance(doc, dict):
|
||||
raise SemanticsError("semantics table must be an object")
|
||||
if set(doc) != _CATEGORIES:
|
||||
missing = sorted(_CATEGORIES - doc.keys())
|
||||
unknown = sorted(doc.keys() - _CATEGORIES)
|
||||
raise SemanticsError(f"category mismatch: missing {missing}, unknown {unknown}")
|
||||
if type(doc["schema"]) is not int or doc["schema"] != SCHEMA_VERSION:
|
||||
raise SemanticsError(f"unsupported schema {doc['schema']!r}")
|
||||
owners = _Owners()
|
||||
tables: Dict[str, Any] = {}
|
||||
for category in _CLASS_CATEGORIES:
|
||||
tables[category] = _class_category(doc, category, owners)
|
||||
for category in _SLOT_CATEGORIES:
|
||||
tables[category] = _slot_category(doc, category, owners)
|
||||
for category in _SET_CATEGORIES:
|
||||
tables[category] = _set_category(doc, category, owners)
|
||||
return Semantics(
|
||||
stage_inputs=_stage_inputs(doc),
|
||||
facts=_facts(doc),
|
||||
all_classes=owners.classes(),
|
||||
**tables,
|
||||
)
|
||||
|
||||
|
||||
def load_semantics(path: Optional[Union[str, Path]] = None) -> Semantics:
|
||||
"""Read and validate a semantics table; ``SemanticsError`` on any problem."""
|
||||
source = Path(path) if path is not None else SEMANTICS_PATH
|
||||
try:
|
||||
with source.open("rb") as stream:
|
||||
data = stream.read(MAX_TABLE_BYTES + 1)
|
||||
if len(data) > MAX_TABLE_BYTES:
|
||||
raise SemanticsError(
|
||||
f"semantics table {source.name} exceeds {MAX_TABLE_BYTES} bytes"
|
||||
)
|
||||
doc = json.loads(
|
||||
data.decode("utf-8"),
|
||||
object_pairs_hook=_reject_duplicate_keys,
|
||||
parse_constant=_reject_constant,
|
||||
)
|
||||
except SemanticsError:
|
||||
raise
|
||||
except RecursionError:
|
||||
raise SemanticsError(
|
||||
f"semantics table {source.name} nests deeper than {MAX_NESTING}"
|
||||
) from None
|
||||
except (OSError, UnicodeDecodeError, ValueError) as error:
|
||||
raise SemanticsError(f"cannot read semantics table {source.name}: {error}")
|
||||
_check_nesting(doc)
|
||||
return _validate(doc)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_semantics() -> Semantics:
|
||||
"""The repository table, loaded on first use; failures are not cached."""
|
||||
return load_semantics()
|
||||
+139
-21
@@ -3,6 +3,7 @@
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
import subprocess # nosec B404
|
||||
import threading
|
||||
@@ -13,8 +14,11 @@ from typing import Any
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from .inspection_outcomes import UNREADABLE_METADATA_REASON, outcome_message
|
||||
from .pair_execution import EXECUTION_KEY
|
||||
from .prompt_pair_resolver import (
|
||||
_FOOTER,
|
||||
EMPTY_POSITIVE_REASON,
|
||||
INSPECTION_PROFILE,
|
||||
ResolverProfile,
|
||||
resolve_prompt_pair,
|
||||
@@ -39,6 +43,23 @@ MAX_PNG_CHUNKS = 10_000
|
||||
_ROLE_METADATA_KEYS = frozenset({"prompt", "parameters", EXECUTION_KEY})
|
||||
_METADATA_KEYS = _ROLE_METADATA_KEYS | {"workflow"}
|
||||
_TEXT_CHUNKS = (b"tEXt", b"zTXt", b"iTXt")
|
||||
# An accumulating batch saver writes one A1111 ``parameters`` record per batch
|
||||
# item, identical except this field (R3); Pillow keeps the last one.
|
||||
# Matched only inside the record's one ``Steps: N, ...`` settings footer line.
|
||||
_BATCH_INDEX = re.compile(r"(?:^|(?<=, ))Batch index: \d+(?=, |$)")
|
||||
|
||||
|
||||
def _batch_normalized(text: str) -> str | None:
|
||||
"""``text`` with its footer's single batch index blanked, else ``None``."""
|
||||
footers = list(_FOOTER.finditer(text))
|
||||
if len(footers) != 1:
|
||||
return None
|
||||
line, count = _BATCH_INDEX.subn("Batch index: #", footers[0].group())
|
||||
if count != 1:
|
||||
return None
|
||||
return text[: footers[0].start()] + line + text[footers[0].end() :]
|
||||
|
||||
|
||||
_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
@@ -121,13 +142,14 @@ class _PngRecords:
|
||||
self.digests: dict[str, tuple[int, bytes]] = {}
|
||||
self.role_encoded = self.role_decoded = 0
|
||||
self.duplicate_workflow = self.duplicate_role = 0
|
||||
self.duplicate_decoded = 0
|
||||
|
||||
def skip(self, key: str, length: int) -> bool:
|
||||
"""Decide from the keyword and length alone whether to seek past a chunk."""
|
||||
if key not in _METADATA_KEYS or key in self.limited:
|
||||
return True # never retained, or a duplicate of a limited workflow
|
||||
if key in self.digests:
|
||||
if self.digests[key][0] != length:
|
||||
if self.digests[key][0] != length and key != "parameters":
|
||||
raise MetadataReadError("Duplicate prompt metadata")
|
||||
if key == "workflow":
|
||||
if self.duplicate_workflow + length > DUPLICATE_WORKFLOW_BYTES:
|
||||
@@ -155,7 +177,11 @@ class _PngRecords:
|
||||
digest = hashlib.sha256(kind + data).digest()
|
||||
if key in self.digests:
|
||||
if self.digests[key][1] != digest:
|
||||
raise MetadataReadError("Duplicate prompt metadata")
|
||||
if key != "parameters":
|
||||
raise MetadataReadError("Duplicate prompt metadata")
|
||||
self.batch_copy(kind, data)
|
||||
# The kept copy is the last one; later repeats compare with it.
|
||||
self.digests[key] = (len(data), digest)
|
||||
return
|
||||
self.digests[key] = (len(data), digest)
|
||||
workflow = key == "workflow"
|
||||
@@ -176,6 +202,27 @@ class _PngRecords:
|
||||
raise MetadataReadError("Metadata exceeds inspection limits")
|
||||
self.found[key] = value
|
||||
|
||||
def batch_copy(self, kind: bytes, data: bytes) -> None:
|
||||
"""Keep a later ``parameters`` copy that differs only in its batch index.
|
||||
|
||||
Each copy must carry exactly one ``Batch index: N`` settings field; any
|
||||
other difference is still a conflict. Decoding every copy draws from the
|
||||
duplicate role budget, so small compressed copies stay bounded.
|
||||
"""
|
||||
_, value = _png_text(kind, data, ROLE_METADATA_BYTES)
|
||||
size = ROLE_METADATA_BYTES + 1 if value is None else len(value.encode())
|
||||
self.duplicate_decoded += size
|
||||
if value is None or self.duplicate_decoded > DUPLICATE_ROLE_BYTES:
|
||||
raise MetadataReadError("Metadata exceeds inspection limits")
|
||||
kept = self.found["parameters"]
|
||||
normalized = _batch_normalized(kept)
|
||||
if normalized is None or normalized != _batch_normalized(value):
|
||||
raise MetadataReadError("Duplicate prompt metadata")
|
||||
self.role_decoded += max(0, size - len(kept.encode()))
|
||||
if self.role_decoded > ROLE_METADATA_BYTES:
|
||||
raise MetadataReadError("Metadata exceeds inspection limits")
|
||||
self.found["parameters"] = value
|
||||
|
||||
|
||||
def _png_metadata(stream: Any, size: int) -> tuple[dict, frozenset, int, int]:
|
||||
"""CRC-check retained text and structural chunks, seeking past everything else.
|
||||
@@ -400,6 +447,70 @@ def _side(status: str, text: str | None = None, source: str | None = None) -> di
|
||||
return {"status": status, "text": text, "source": source}
|
||||
|
||||
|
||||
_EMPTY_POSITIVE = outcome_message(
|
||||
{"state": "unresolved", "reason": EMPTY_POSITIVE_REASON}
|
||||
)
|
||||
|
||||
|
||||
def _explained(prompts: dict, resolution: dict) -> dict:
|
||||
"""Each unresolved role carries the outcome's line; other roles are kept."""
|
||||
message = outcome_message(resolution)
|
||||
if message is None:
|
||||
return prompts
|
||||
return {
|
||||
role: (
|
||||
{**side, "message": message}
|
||||
if side["status"] == "unresolved" and "message" not in side
|
||||
else side
|
||||
)
|
||||
for role, side in prompts.items()
|
||||
}
|
||||
|
||||
|
||||
def unreadable_evidence() -> dict:
|
||||
"""Both roles when the file's metadata could not be read at all."""
|
||||
resolution = {
|
||||
"state": "unresolved",
|
||||
"sampler_id": None,
|
||||
"reason": UNREADABLE_METADATA_REASON,
|
||||
"reason_code": None,
|
||||
"node": None,
|
||||
}
|
||||
prompts = {role: _side("unresolved") for role in ("positive", "negative")}
|
||||
return {"prompts": _explained(prompts, resolution), "resolution": resolution}
|
||||
|
||||
|
||||
def _may_hold_prompt_manager(metadata: dict) -> bool:
|
||||
"""Whether the API graph can show the R1 signature, so the workflow is needed.
|
||||
|
||||
PromptManager classes, or a ``CLIPTextEncode`` carrying PromptManager's own
|
||||
``search_text`` input (an older save rewrote the class).
|
||||
"""
|
||||
graph = metadata.get("prompt")
|
||||
if not isinstance(graph, dict):
|
||||
return False
|
||||
for node in graph.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
name = node.get("class_type")
|
||||
inputs = node.get("inputs")
|
||||
if isinstance(name, str) and "promptmanager" in name.lower():
|
||||
return True
|
||||
if isinstance(inputs, dict) and "search_text" in inputs:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _parsed_workflow(value: Any) -> dict | None:
|
||||
"""The UI workflow as an object, or ``None`` (absent, invalid or not JSON)."""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except (ValueError, RecursionError):
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def inspect_prompt_evidence(
|
||||
metadata: dict,
|
||||
saved: dict | None = None,
|
||||
@@ -413,6 +524,7 @@ def inspect_prompt_evidence(
|
||||
directly with its default profile. ``resolution`` carries the closed
|
||||
``reason_code`` and, for a blocking node, ``node: {id, class_type}``.
|
||||
"""
|
||||
workflow = metadata.get("workflow")
|
||||
metadata = {
|
||||
key: value for key, value in metadata.items() if key in _ROLE_METADATA_KEYS
|
||||
}
|
||||
@@ -447,21 +559,26 @@ def inspect_prompt_evidence(
|
||||
},
|
||||
}
|
||||
canonical = dict(metadata)
|
||||
malformed = {
|
||||
"prompts": _explained(
|
||||
{role: _side("unresolved") for role in ("positive", "negative")},
|
||||
resolution,
|
||||
),
|
||||
"resolution": resolution,
|
||||
}
|
||||
if "prompt" not in canonical and "parameters" not in canonical:
|
||||
return {
|
||||
"prompts": {role: _side("unresolved") for role in ("positive", "negative")},
|
||||
"resolution": resolution,
|
||||
}
|
||||
return malformed
|
||||
if isinstance(canonical.get("prompt"), str):
|
||||
try:
|
||||
canonical["prompt"] = json.loads(canonical["prompt"])
|
||||
except (ValueError, RecursionError):
|
||||
return {
|
||||
"prompts": {
|
||||
role: _side("unresolved") for role in ("positive", "negative")
|
||||
},
|
||||
"resolution": resolution,
|
||||
}
|
||||
return malformed
|
||||
workflow = (
|
||||
_parsed_workflow(workflow) if _may_hold_prompt_manager(canonical) else None
|
||||
)
|
||||
if workflow is not None and "prompt" in canonical:
|
||||
# Only to recognize overwritten PromptManager text (R1); never a role.
|
||||
canonical["workflow"] = workflow
|
||||
resolved = resolve_prompt_pair(canonical, profile=profile)
|
||||
state = resolved["state"]
|
||||
result = {}
|
||||
@@ -475,18 +592,19 @@ def inspect_prompt_evidence(
|
||||
result[role] = _side(
|
||||
"resolved", side["text"], side["provenance"]["text_source"]
|
||||
)
|
||||
elif state == "resolved" and isinstance(side, dict):
|
||||
# A resolved pair withholds only an empty positive.
|
||||
result[role] = {**_side("unresolved"), "message": _EMPTY_POSITIVE}
|
||||
else:
|
||||
result[role] = _side(state if state != "resolved" else "unresolved")
|
||||
return {
|
||||
"prompts": result,
|
||||
"resolution": {
|
||||
"state": state,
|
||||
"sampler_id": resolved.get("sampler_id"),
|
||||
"reason": resolved.get("reason", ""),
|
||||
"reason_code": resolved.get("reason_code"),
|
||||
"node": resolved.get("node"),
|
||||
},
|
||||
resolution = {
|
||||
"state": state,
|
||||
"sampler_id": resolved.get("sampler_id"),
|
||||
"reason": resolved.get("reason", ""),
|
||||
"reason_code": resolved.get("reason_code"),
|
||||
"node": resolved.get("node"),
|
||||
}
|
||||
return {"prompts": _explained(result, resolution), "resolution": resolution}
|
||||
|
||||
|
||||
def inspect_prompt_roles(metadata: dict, saved: dict | None = None) -> dict:
|
||||
|
||||
+1121
-45
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user