Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
728db36ac3 | ||
|
|
5ded3cfd39 | ||
|
|
2b8126b7be | ||
|
|
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
|
||||
|
||||
@@ -26,7 +26,12 @@ jobs:
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
options: --user 1001
|
||||
timeout-minutes: 25
|
||||
# This job runs every page workflow twice (chromium, then touch) on one worker with V8 coverage on, so it
|
||||
# takes about twice a single-browser job. Measured 2026-10-20: whole job 18m26s-24m57s (main 5ded3cf
|
||||
# 22m15s; PR head 6359999 was cancelled at the old 25-minute limit while still passing), coverage step
|
||||
# alone 20m55s-22m17s+, against ~12-15 minutes for the firefox/webkit jobs. 40 minutes leaves headroom
|
||||
# for suite growth while still stopping a hung run; re-measure if a run passes 30 minutes.
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
|
||||
|
||||
+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,618 @@
|
||||
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);
|
||||
// A dispatched press, as on the concept side: a pointer click scrolls the row into view first, and the page's
|
||||
// own observer (rootMargin 250px) can append the final batch and hide the button before the click lands
|
||||
// (WebKit, run 39512528039). The button must still be the enabled one the user would press.
|
||||
const more=page.getByRole('button',{name:'Load more images',exact:true});
|
||||
await expect(more).toBeVisible();await expect(more).toHaveAttribute('aria-disabled','false');
|
||||
await more.dispatchEvent('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()
|
||||
|
||||
@@ -24,6 +24,11 @@ except ImportError:
|
||||
import test_execution_history_writer as fixtures
|
||||
|
||||
|
||||
# Upper bound a blocked fake write waits for its release. Tests prove ordering
|
||||
# with events, so this only caps a failing run and never decides a pass.
|
||||
BLOCK_SECONDS = 10
|
||||
|
||||
|
||||
class OutputWriterTests(fixtures.unittest.TestCase):
|
||||
setUp = fixtures.HistoryWriterTests.setUp
|
||||
cleanup_workers = fixtures.HistoryWriterTests.cleanup_workers
|
||||
@@ -408,59 +413,81 @@ class OutputWriterTests(fixtures.unittest.TestCase):
|
||||
def test_bounded_close_and_early_native_join_keep_inflight_combined_charge(self):
|
||||
for early in (False, True):
|
||||
with self.subTest(early_join=early):
|
||||
writer = self.writer()
|
||||
start = self.start_command(len(self.writers))
|
||||
self.db.write_execution_start(start)
|
||||
entered, release = self.block()
|
||||
original = self.db.write_execution_outputs_for_finish
|
||||
self.assert_bounded_close_keeps_inflight_charge(early)
|
||||
|
||||
def write(*args, **kwargs):
|
||||
entered.set()
|
||||
release.wait(3)
|
||||
return original(*args, **kwargs)
|
||||
def assert_bounded_close_keeps_inflight_charge(self, early):
|
||||
# Every writer in this test charges the process-wide budget, so a
|
||||
# failing subtest must not leave its worker blocked (and charged) for
|
||||
# the next one: release and join it before the next subtest starts.
|
||||
writer = self.writer()
|
||||
self.assertEqual(writer.context_budget.charged_bytes, 0)
|
||||
start = self.start_command(len(self.writers))
|
||||
self.db.write_execution_start(start)
|
||||
entered, release = self.block()
|
||||
left = threading.Event()
|
||||
original = self.db.write_execution_outputs_for_finish
|
||||
|
||||
with patch.object(self.db, "write_execution_outputs_for_finish", write):
|
||||
self.finalization(writer, start)
|
||||
self.assertTrue(entered.wait(1))
|
||||
charge = writer.context_budget.charged_bytes
|
||||
self.finalization(writer, start)
|
||||
clock = fixtures.ManualClock()
|
||||
native_join = writer._thread.join
|
||||
def write(*args, **kwargs):
|
||||
entered.set()
|
||||
release.wait(BLOCK_SECONDS)
|
||||
left.set()
|
||||
return original(*args, **kwargs)
|
||||
|
||||
def return_early(timeout):
|
||||
native_join(0)
|
||||
clock.advance(min(timeout, 0.004))
|
||||
try:
|
||||
with patch.object(self.db, "write_execution_outputs_for_finish", write):
|
||||
self.finalization(writer, start)
|
||||
self.assertTrue(entered.wait(BLOCK_SECONDS))
|
||||
charge = writer.context_budget.charged_bytes
|
||||
self.finalization(writer, start)
|
||||
clock = fixtures.ManualClock()
|
||||
native_join = writer._thread.join
|
||||
|
||||
def sleep(delay):
|
||||
clock.advance(delay)
|
||||
def return_early(timeout):
|
||||
native_join(0)
|
||||
clock.advance(min(timeout, 0.004))
|
||||
|
||||
clock_patch = (
|
||||
patch(
|
||||
"utils.execution_history_writer.time",
|
||||
SimpleNamespace(monotonic=clock, sleep=sleep),
|
||||
)
|
||||
if early
|
||||
else nullcontext()
|
||||
def sleep(delay):
|
||||
clock.advance(delay)
|
||||
|
||||
clock_patch = (
|
||||
patch(
|
||||
"utils.execution_history_writer.time",
|
||||
SimpleNamespace(monotonic=clock, sleep=sleep),
|
||||
)
|
||||
join_patch = (
|
||||
patch.object(writer._thread, "join", return_early)
|
||||
if early
|
||||
else nullcontext()
|
||||
)
|
||||
with clock_patch, join_patch:
|
||||
before = clock() if early else time.monotonic()
|
||||
self.assertFalse(writer.close(0.02))
|
||||
elapsed = (clock() if early else time.monotonic()) - before
|
||||
self.assertGreaterEqual(elapsed, 0.02)
|
||||
if early
|
||||
else nullcontext()
|
||||
)
|
||||
join_patch = (
|
||||
patch.object(writer._thread, "join", return_early)
|
||||
if early
|
||||
else nullcontext()
|
||||
)
|
||||
with clock_patch, join_patch:
|
||||
before = clock() if early else time.monotonic()
|
||||
self.assertFalse(writer.close(0.02))
|
||||
elapsed = (clock() if early else time.monotonic()) - before
|
||||
# close() waits until its own monotonic deadline, so the lower
|
||||
# bound holds on any runner.
|
||||
self.assertGreaterEqual(elapsed, 0.02)
|
||||
if early:
|
||||
# Manual clock: only the patched join/sleep advance it, so
|
||||
# this exact bound proves early joins do not spin forever.
|
||||
self.assertLess(elapsed, 0.2)
|
||||
self.assertEqual(writer.context_budget.charged_bytes, charge)
|
||||
replacement = HistoryWriter(lambda: self.db)
|
||||
self.writers.append(replacement)
|
||||
self.assertIs(replacement.context_budget, writer.context_budget)
|
||||
self.assertIsNone(replacement.context_budget.reserve(8388608))
|
||||
release.set()
|
||||
self.assertTrue(writer.close(2))
|
||||
self.assertEqual(writer.context_budget.charged_bytes, 0)
|
||||
# Bounded, not unbounded: close() gave up while the in-flight
|
||||
# write was still blocked, instead of waiting it out.
|
||||
self.assertFalse(left.is_set())
|
||||
self.assertTrue(writer._thread.is_alive())
|
||||
self.assertEqual(writer.context_budget.charged_bytes, charge)
|
||||
replacement = HistoryWriter(lambda: self.db)
|
||||
self.writers.append(replacement)
|
||||
self.assertIs(replacement.context_budget, writer.context_budget)
|
||||
self.assertIsNone(replacement.context_budget.reserve(8388608))
|
||||
release.set()
|
||||
self.assertTrue(writer.close(2))
|
||||
finally:
|
||||
release.set()
|
||||
writer.close(BLOCK_SECONDS)
|
||||
self.assertEqual(writer.context_budget.charged_bytes, 0)
|
||||
|
||||
def test_pending_saturation_and_invalid_command_release_both_payloads(self):
|
||||
writer = self.writer()
|
||||
@@ -552,11 +579,13 @@ class OutputWriterTests(fixtures.unittest.TestCase):
|
||||
self.db.write_execution_start(start)
|
||||
entered, release = self.block()
|
||||
invalidating, invalidated = threading.Event(), threading.Event()
|
||||
committed = threading.Event()
|
||||
original = outputs_db._capture
|
||||
|
||||
def capture(*args, **kwargs):
|
||||
entered.set()
|
||||
release.wait(3)
|
||||
release.wait(BLOCK_SECONDS)
|
||||
committed.set()
|
||||
return original(*args, **kwargs)
|
||||
|
||||
def invalidate():
|
||||
@@ -566,20 +595,24 @@ class OutputWriterTests(fixtures.unittest.TestCase):
|
||||
|
||||
with patch.object(outputs_db, "_capture", capture):
|
||||
self.finalization(writer, start)
|
||||
self.assertTrue(entered.wait(1))
|
||||
self.assertTrue(entered.wait(BLOCK_SECONDS))
|
||||
invalidator = threading.Thread(target=invalidate)
|
||||
invalidator.start()
|
||||
try:
|
||||
self.assertTrue(invalidating.wait(1))
|
||||
self.assertTrue(invalidating.wait(BLOCK_SECONDS))
|
||||
self.assertFalse(invalidated.wait(0.03))
|
||||
before = time.monotonic()
|
||||
self.assertTrue(writer.submit(self.finish_command(start)))
|
||||
self.assertFalse(writer.close(0))
|
||||
self.assertLess(time.monotonic() - before, 0.1)
|
||||
# submit() and close() returned while the commit still held the
|
||||
# gate: the blocked invalidation has not finished and the
|
||||
# commit is still waiting for its release. Had either call
|
||||
# queued behind the gate, it could only return after both.
|
||||
self.assertFalse(invalidated.is_set())
|
||||
self.assertFalse(committed.is_set())
|
||||
self.assertGreater(writer.context_budget.charged_bytes, 0)
|
||||
finally:
|
||||
release.set()
|
||||
invalidator.join(3)
|
||||
invalidator.join(BLOCK_SECONDS)
|
||||
self.assertFalse(invalidator.is_alive())
|
||||
self.assertTrue(writer.close(2))
|
||||
self.assertEqual(
|
||||
|
||||
@@ -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()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user