Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31b011b8fb | ||
|
|
a2e93fe7e4 | ||
|
|
1e42f9b5aa | ||
|
|
67199b5dba | ||
|
|
74c7331b7e | ||
|
|
00b735d32f | ||
|
|
a1fe6956d1 | ||
|
|
fe2423f5b4 | ||
|
|
6c20f53cc6 | ||
|
|
936a13913e | ||
|
|
290e7a81f5 | ||
|
|
327e7cc762 | ||
|
|
830776efd1 | ||
|
|
bdcf85ccd4 | ||
|
|
d42b7ca1d8 | ||
|
|
fcde3b31d6 | ||
|
|
ea4ed059c3 | ||
|
|
f670c3db76 | ||
|
|
af95f08ef4 | ||
|
|
70283ce4d3 | ||
|
|
bab87cd3c2 | ||
|
|
7545ade87d | ||
|
|
651ca65228 | ||
|
|
d524d142b5 | ||
|
|
da2239dca5 | ||
|
|
9d372b1d8f | ||
|
|
7aab09d6bc | ||
|
|
2295ce0f81 | ||
|
|
8ea15719bd | ||
|
|
d03936478c | ||
|
|
8dcef0f3d9 | ||
|
|
8912937a9a | ||
|
|
d4e6d0c112 | ||
|
|
696711dca1 | ||
|
|
b54302d546 | ||
|
|
0bd4382cf0 | ||
|
|
ed10b881b7 | ||
|
|
cbf102b8a0 | ||
|
|
be70a5f149 | ||
|
|
7d2bd63912 | ||
|
|
6f69304796 | ||
|
|
6802f11497 |
@@ -52,6 +52,9 @@ Thumbs.db
|
||||
/docs/*
|
||||
!/docs/help/
|
||||
!/docs/import-export-contract.md
|
||||
# Curated release highlights — read by .releaserc.js at release time, so it
|
||||
# must be tracked (the release step commits the cleared file back).
|
||||
!/docs/release-notes/
|
||||
|
||||
# Semantic release
|
||||
.releaserc.local.js
|
||||
|
||||
+24
-2
@@ -18,6 +18,21 @@
|
||||
* notes shrink to just the meaningful changes (typically <50 entries
|
||||
* per release).
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
|
||||
// Curated, human-readable highlights for the NEXT release. A maintainer
|
||||
// writes plain-language "what changed for you" bullets here before merging a
|
||||
// release; if the file is empty/absent the release body simply falls back to
|
||||
// the auto-generated grouped changelog. The file is read at release time (in
|
||||
// CI) and cleared by the prepare step below so highlights never repeat.
|
||||
const HIGHLIGHTS_FILE = "docs/release-notes/next.md";
|
||||
let highlights = "";
|
||||
try {
|
||||
highlights = fs.readFileSync(HIGHLIGHTS_FILE, "utf8").trim();
|
||||
} catch {
|
||||
/* no curated highlights for this release */
|
||||
}
|
||||
|
||||
export default {
|
||||
branches: ["main"],
|
||||
plugins: [
|
||||
@@ -80,6 +95,10 @@ export default {
|
||||
"💬 [Discord](https://discord.gg/BFYR9WQdVR) · " +
|
||||
"📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · " +
|
||||
"🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)\n\n" +
|
||||
// Curated, plain-language highlights (when provided) render FIRST
|
||||
// — this is what most readers see. The raw per-commit changelog
|
||||
// stays below, collapsed.
|
||||
(highlights ? `### ✨ What's new\n\n${highlights}\n\n---\n\n` : "") +
|
||||
"<details>\n" +
|
||||
"<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>\n\n",
|
||||
// footerPartial closes the <details> wrapper so the wall of
|
||||
@@ -102,14 +121,17 @@ export default {
|
||||
// @semantic-release/github plugin can attach it to the
|
||||
// release. Zip is built AFTER versioning so the file lands
|
||||
// with the right version in its name.
|
||||
// 3. Clear the consumed highlights file so the next release starts
|
||||
// from a clean slate (the content already lives in this release's
|
||||
// notes + CHANGELOG.md).
|
||||
prepareCmd:
|
||||
"node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='${nextRelease.version}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const t=fs.readFileSync('pyproject.toml','utf8').replace(/^version = \\\".+?\\\"/m,'version = \\\"${nextRelease.version}\\\"');fs.writeFileSync('pyproject.toml',t);\" && node scripts/pack-release.mjs ${nextRelease.version}",
|
||||
"node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='${nextRelease.version}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const t=fs.readFileSync('pyproject.toml','utf8').replace(/^version = \\\".+?\\\"/m,'version = \\\"${nextRelease.version}\\\"');fs.writeFileSync('pyproject.toml',t);\" && node -e \"require('fs').writeFileSync('docs/release-notes/next.md','')\" && node scripts/pack-release.mjs ${nextRelease.version}",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@semantic-release/git",
|
||||
{
|
||||
assets: ["CHANGELOG.md", "package.json", "pyproject.toml"],
|
||||
assets: ["CHANGELOG.md", "package.json", "pyproject.toml", "docs/release-notes/next.md"],
|
||||
// Plain message — `${nextRelease.notes}` here would expand to
|
||||
// the entire changelog, which on a divergent history (where
|
||||
// semantic-release can't find the last tag in main's first-
|
||||
|
||||
@@ -1,3 +1,86 @@
|
||||
## 🎉 Wildcard Pipeline 2.10.2
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
### ✨ What's new
|
||||
|
||||
A bug-fix release for the in-app updater and name validation.
|
||||
|
||||
- **Fixed an updater downgrade.** On 2.10.0, clicking **Update Now** could install an *older* version — it asked ComfyUI Manager for "latest" against a stale cache. The updater now installs the exact new version and refuses to ever move backwards.
|
||||
- **The update badge appears right away** after **Check now** — no page refresh needed.
|
||||
- **Cleaner release notes** in the update dialog (proper formatting instead of raw `<details>` tags).
|
||||
- **Names are validated as you rename them.** Renaming a sub-category or a variable now enforces the same rules as creating one, so invalid names (spaces, reserved characters) are caught immediately instead of failing later on save.
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #10 from DumiFlex/fix/updater-downgrade ([a2e93fe](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a2e93fe)), closes [#10](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/10)
|
||||
* fix(manager): prevent updater downgrade, fix pill reactivity + release notes ([1e42f9b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1e42f9b))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.10.1
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #9 from DumiFlex/fix/subcat-rename-validation ([74c7331](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/74c7331)), closes [#9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/9) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7)
|
||||
* fix(manager): validate names on rename via shared validators (#7) ([00b735d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/00b735d)), closes [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7)
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.10.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #8 from DumiFlex/feat/manager-qol ([fe2423f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fe2423f)), closes [#8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/8)
|
||||
* feat(loop): add optional bypass toggle to seed-lock row ([d524d14](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d524d14))
|
||||
* feat(loop): dashed border on bypassed edit-frame chips ([70283ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/70283ce))
|
||||
* feat(loop): emit only non-bypassed frames with pinned identity ([9d372b1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9d372b1))
|
||||
* feat(loop): mirror bypass_frames in the ts config type ([da2239d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/da2239d))
|
||||
* feat(loop): mirror the loop's bypassed frames in the seed list UI ([f670c3d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f670c3d))
|
||||
* feat(loop): opt-in bypass column in the per-iteration modal ([651ca65](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/651ca65))
|
||||
* feat(loop): parse bypass_frames in the loop config ([7aab09d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7aab09d))
|
||||
* feat(loop): purple wash + dashed bar on bypassed seed rows ([ea4ed05](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ea4ed05))
|
||||
* feat(loop): wire per-frame bypass through the loop widget ([7545ade](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7545ade))
|
||||
* feat(manager): add checkOnLaunch update preference to uiStore ([d42b7ca](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d42b7ca))
|
||||
* feat(manager): add ComfyUI Manager update composable ([327e7cc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/327e7cc))
|
||||
* feat(manager): add safe markdown renderer for release notes ([830776e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/830776e))
|
||||
* feat(manager): add UpdateDialog with changelog + Manager-driven update ([290e7a8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/290e7a8))
|
||||
* feat(manager): add Updates settings card with check-on-launch + check now ([6c20f53](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6c20f53))
|
||||
* feat(manager): bulk select + delete for exceptions + derivation rules (#11) ([ed10b88](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ed10b88)), closes [#11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/11)
|
||||
* feat(manager): bulk select + delete for fixed-values (#11) ([cbf102b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/cbf102b)), closes [#11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/11)
|
||||
* feat(manager): drag-and-drop file import (#5) ([d4e6d0c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d4e6d0c)), closes [#5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/5)
|
||||
* feat(manager): move Target reach above the constraint rule matrix (#10a) ([d039364](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d039364)), closes [#10a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/10a)
|
||||
* feat(manager): open UpdateDialog from the topbar update pill ([936a139](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/936a139))
|
||||
* feat(manager): per-branch collapse + collapse-all for derivation rules (#9) ([8ea1571](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8ea1571)), closes [#9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/9)
|
||||
* feat(manager): per-kind quick-select presets + import-tab mirror (#1) ([8912937](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8912937)), closes [#1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/1)
|
||||
* feat(manager): per-rule branch collapse + collapse rules/branches by default ([fcde3b3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fcde3b3))
|
||||
* feat(manager): relevant + colour-coded bulk sub-category menus ([be70a5f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/be70a5f)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3) [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4) [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* feat(manager): rework release check to launch-once + manual, expose release notes ([bdcf85c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bdcf85c))
|
||||
* feat(manager): sticky list headers across editors ([6802f11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6802f11))
|
||||
* fix(loop): parse parity + edge coverage from final review ([bab87cd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bab87cd))
|
||||
* fix(loop): seed list mirrors the loop's bypassed frames ([af95f08](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/af95f08))
|
||||
* fix(manager): count templates in the import loaded-bar total ([2295ce0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2295ce0))
|
||||
* fix(manager): detect bundle collisions on import instead of blind-adding (#6) ([b54302d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b54302d)), closes [#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6)
|
||||
* fix(manager): fresh dates for imported adds (#12) + live counts after import (#7) ([696711d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/696711d)), closes [#12](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/12) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7) [#12](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/12) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7)
|
||||
* fix(manager): keep bulk-edit toolbar stuck with sticky card header ([6f69304](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f69304))
|
||||
* fix(manager): resolve inline multi-pick {N-M$$sep$$…} in the test runner ([8dcef0f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8dcef0f))
|
||||
* fix(manager): sticky-header subpixel bleed + confusing weight validation ([0bd4382](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0bd4382))
|
||||
* test(engine): un-skip pipeline trace tests after handler migration ([7d2bd63](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7d2bd63))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.9.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-wildcard-pipeline",
|
||||
"version": "2.9.0",
|
||||
"version": "2.10.2",
|
||||
"private": true,
|
||||
"description": "Modular procedural prompt generation for ComfyUI",
|
||||
"author": "DumiFlex",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-wildcard-pipeline"
|
||||
version = "2.9.0"
|
||||
version = "2.10.2"
|
||||
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -221,7 +221,12 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
|
||||
// base-only with a frame-active disabled hint) in the lazy ContextWidget
|
||||
// chunk; entry stays put. Measured 459909 bytes (133 over the 449 ceiling);
|
||||
// minimal bump.
|
||||
const TOTAL_LIMIT = 450 * 1024; // 450 KB
|
||||
// Bumped 450 -> 453 KB on 2026-07-21 for loop bypass frames (#13): the
|
||||
// `bypass_frames` config field + the SeedLockRow bypass toggle, the
|
||||
// SeedListModal opt-in bypass column, and the ContextLoopWidget "N bypassed"
|
||||
// badge. Config field alone landed 34 bytes over 450; the ~3 KB headroom
|
||||
// covers the UI additions in the same feature.
|
||||
const TOTAL_LIMIT = 453 * 1024; // 453 KB
|
||||
|
||||
function gzipSize(path) {
|
||||
return gzipSync(readFileSync(path)).length;
|
||||
|
||||
@@ -148,3 +148,33 @@ describe("ContextLoopWidget frame selector", () => {
|
||||
expect(currentFrame.value).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextLoopWidget bypass (#13)", () => {
|
||||
it("shows an N-bypassed badge on the seeds button", () => {
|
||||
const cfg = { ...emptyContextLoopConfig(), bypass_frames: [1, 3] };
|
||||
const w = mount(ContextLoopWidget, { props: { modelValue: cfg, count: 5 } });
|
||||
expect(w.find('[data-test="loop-seeds-bypass-badge"]').text()).toMatch(/2 bypassed/i);
|
||||
});
|
||||
it("marks bypassed edit-frame chips with a dashed-border class", () => {
|
||||
const cfg = { ...emptyContextLoopConfig(), bypass_frames: [1, 3] };
|
||||
const w = mount(ContextLoopWidget, { props: { modelValue: cfg, count: 5 } });
|
||||
// frame index 1 -> chip #2 (data-test loop-frame-2)
|
||||
expect(w.find('[data-test="loop-frame-2"]').classes()).toContain("wp-loop__chip--bypassed");
|
||||
expect(w.find('[data-test="loop-frame-4"]').classes()).toContain("wp-loop__chip--bypassed");
|
||||
expect(w.find('[data-test="loop-frame-1"]').classes()).not.toContain("wp-loop__chip--bypassed");
|
||||
});
|
||||
|
||||
it("has no bypass badge when none are bypassed", () => {
|
||||
const w = mount(ContextLoopWidget, { props: { modelValue: emptyContextLoopConfig(), count: 5 } });
|
||||
expect(w.find('[data-test="loop-seeds-bypass-badge"]').exists()).toBe(false);
|
||||
});
|
||||
it("writes bypass changes from the modal back into the config", async () => {
|
||||
const cfg = { ...emptyContextLoopConfig(), bypass_frames: [] };
|
||||
const w = mount(ContextLoopWidget, { props: { modelValue: cfg, count: 4 } });
|
||||
await w.get('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
w.findComponent(SeedListModal).vm.$emit("update:bypassFrames", [2]);
|
||||
const calls = w.emitted("update:modelValue")!;
|
||||
const last = calls[calls.length - 1][0] as typeof cfg;
|
||||
expect(last.bypass_frames).toEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,14 @@ function onSeedLocks(next: Record<string, number>): void {
|
||||
emit("update:modelValue", { ...props.modelValue, seed_locks: next });
|
||||
}
|
||||
|
||||
const bypassedCount = computed(() => (props.modelValue.bypass_frames ?? []).length);
|
||||
/** 0-based bypassed frame indices — drives the dashed border on edit-frame
|
||||
* chips so bypassed frames read at a glance without opening the modal. */
|
||||
const bypassSet = computed(() => new Set(props.modelValue.bypass_frames ?? []));
|
||||
function onBypassFrames(next: number[]): void {
|
||||
emit("update:modelValue", { ...props.modelValue, bypass_frames: next });
|
||||
}
|
||||
|
||||
function pickStrategy(s: LoopStrategy): void {
|
||||
if (props.modelValue.strategy === s) return;
|
||||
emit("update:modelValue", { ...props.modelValue, strategy: s });
|
||||
@@ -133,6 +141,7 @@ function toggleTotalInternal(): void {
|
||||
Per-iteration seeds
|
||||
<span class="wp-loop__seedbtn-fill" />
|
||||
<span v-if="lockedCount" class="wp-loop__seedbtn-badge" data-test="loop-seeds-badge">{{ lockedCount }} locked</span>
|
||||
<span v-if="bypassedCount" class="wp-loop__seedbtn-badge" data-test="loop-seeds-bypass-badge">{{ bypassedCount }} bypassed</span>
|
||||
<svg class="wp-loop__seedbtn-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M9 6l6 6-6 6" /></svg>
|
||||
</button>
|
||||
|
||||
@@ -142,8 +151,9 @@ function toggleTotalInternal(): void {
|
||||
<button type="button" class="wp-loop__chip" :class="{ 'wp-loop__chip--active': currentFrame === null }"
|
||||
data-test="loop-frame-base" role="radio" :aria-checked="currentFrame === null" @click="setFrame(null)">base</button>
|
||||
<button v-for="i in frameChips" :key="i" type="button" class="wp-loop__chip"
|
||||
:class="{ 'wp-loop__chip--active': currentFrame === i }"
|
||||
:data-test="`loop-frame-${i + 1}`" role="radio" :aria-checked="currentFrame === i" @click="setFrame(i)">#{{ i + 1 }}</button>
|
||||
:class="{ 'wp-loop__chip--active': currentFrame === i, 'wp-loop__chip--bypassed': bypassSet.has(i) }"
|
||||
:data-test="`loop-frame-${i + 1}`" role="radio" :aria-checked="currentFrame === i"
|
||||
:title="bypassSet.has(i) ? `Frame ${i + 1} is bypassed` : undefined" @click="setFrame(i)">#{{ i + 1 }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -224,8 +234,9 @@ function toggleTotalInternal(): void {
|
||||
<SeedListModal v-if="seedsOpen" :node-name="'WP Context Loop'" :base-seed="baseSeed"
|
||||
:count="count" :strategy="modelValue.strategy" :seed-locks="modelValue.seed_locks ?? {}"
|
||||
:previous-seeds="previousSeeds"
|
||||
:bypass-frames="modelValue.bypass_frames ?? []"
|
||||
:override-hint="!modelValue.override_seed ? 'These seeds apply only when Override Context seed is on.' : ''"
|
||||
@update:seed-locks="onSeedLocks" @close="seedsOpen = false" />
|
||||
@update:seed-locks="onSeedLocks" @update:bypass-frames="onBypassFrames" @close="seedsOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -274,6 +285,14 @@ function toggleTotalInternal(): void {
|
||||
border-color: var(--wp-accent, #c4b5fd);
|
||||
color: var(--wp-accent, #c4b5fd);
|
||||
}
|
||||
/* Bypassed frame — interrupted (dashed) border + dimmed so it reads at a
|
||||
* glance on the node. Composes with --active (dashed overrides the solid). */
|
||||
.wp-loop__chip--bypassed {
|
||||
border-style: dashed;
|
||||
border-color: var(--wp-border-strong, #4a4d55);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.wp-loop__chip--bypassed:hover { opacity: 0.8; }
|
||||
|
||||
.wp-loop__row {
|
||||
display: flex;
|
||||
|
||||
@@ -31,6 +31,7 @@ describe("parseContextLoopConfig", () => {
|
||||
iteration_internal: false,
|
||||
total_internal: false,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
|
||||
});
|
||||
@@ -44,6 +45,7 @@ describe("parseContextLoopConfig", () => {
|
||||
iteration_internal: true,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
|
||||
});
|
||||
@@ -68,6 +70,7 @@ describe("parseContextLoopConfig", () => {
|
||||
iteration_internal: false,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
const round = parseContextLoopConfig(serializeContextLoopConfig(cfg));
|
||||
expect(round).toEqual(cfg);
|
||||
@@ -89,3 +92,22 @@ describe("ContextLoopConfig seed_locks", () => {
|
||||
expect(parseContextLoopConfig(serializeContextLoopConfig(cfg)).seed_locks).toEqual({ "0": 7 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("bypass_frames parse", () => {
|
||||
it("defaults to empty", () => {
|
||||
expect(emptyContextLoopConfig().bypass_frames).toEqual([]);
|
||||
expect(parseContextLoopConfig("{}").bypass_frames).toEqual([]);
|
||||
});
|
||||
it("dedups + sorts, keeps out-of-range, drops junk + negatives", () => {
|
||||
expect(parseContextLoopConfig('{"bypass_frames":[3,1,3,0]}').bypass_frames).toEqual([0, 1, 3]);
|
||||
expect(parseContextLoopConfig('{"bypass_frames":[2,-1,"x",2,999]}').bypass_frames).toEqual([2, 999]);
|
||||
});
|
||||
it("drops coercible non-numbers, not coerce them (Python parity)", () => {
|
||||
// true/"3"/null would coerce to 1/3/0 under Number() — must be dropped.
|
||||
expect(parseContextLoopConfig('{"bypass_frames":[true,"3",null,2]}').bypass_frames).toEqual([2]);
|
||||
});
|
||||
it("malformed collapses to empty", () => {
|
||||
expect(parseContextLoopConfig('{"bypass_frames":"nope"}').bypass_frames).toEqual([]);
|
||||
expect(parseContextLoopConfig("not json").bypass_frames).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,10 @@ export interface ContextLoopConfig {
|
||||
/** 0-based iteration index (stringified) -> pinned seed. Unlocked
|
||||
* indices re-derive from base+strategy. Empty by default. */
|
||||
seed_locks: Record<string, number>;
|
||||
/** 0-based iteration indices to bypass (skip generation + overrides).
|
||||
* Sorted, deduped, non-negative. Out-of-range (>= count) entries are
|
||||
* kept and re-apply if count grows. Empty by default. */
|
||||
bypass_frames: number[];
|
||||
}
|
||||
|
||||
const STRATEGIES = new Set<LoopStrategy>(["sequential", "hash_index", "prime_stride"]);
|
||||
@@ -36,6 +40,7 @@ export function emptyContextLoopConfig(): ContextLoopConfig {
|
||||
iteration_internal: true,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,6 +76,18 @@ export function parseContextLoopConfig(raw: string | null | undefined): ContextL
|
||||
}
|
||||
out.seed_locks = locks;
|
||||
}
|
||||
if (Array.isArray(obj.bypass_frames)) {
|
||||
const frames = new Set<number>();
|
||||
for (const x of obj.bypass_frames) {
|
||||
// Type-guard before accepting — do NOT coerce. Mirrors the Python
|
||||
// `_parse_config` (which rejects non-int via isinstance), so both
|
||||
// parsers degrade corrupt widget JSON identically (`true`, `"3"`,
|
||||
// `null` are dropped on both sides, not coerced to 1/3/0).
|
||||
if (typeof x !== "number" || !Number.isInteger(x) || x < 0) continue;
|
||||
frames.add(x);
|
||||
}
|
||||
out.bypass_frames = [...frames].sort((a, b) => a - b);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -186,3 +186,18 @@ describe("SeedListWidget seeds button", () => {
|
||||
expect(off.findComponent(SeedListModal).props("overrideHint")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SeedListWidget bypass mirror (#13)", () => {
|
||||
it("shows an N-bypassed badge from the loop's bypassedFrames", () => {
|
||||
const w = mount(SeedListWidget, {
|
||||
props: { modelValue: emptySeedListConfig(), bypassedFrames: [1, 3] },
|
||||
});
|
||||
expect(w.find('[data-test="seedlist-seeds-bypass-badge"]').text()).toMatch(/2 bypassed/i);
|
||||
});
|
||||
it("no bypass badge when the loop bypasses nothing", () => {
|
||||
const w = mount(SeedListWidget, {
|
||||
props: { modelValue: emptySeedListConfig(), bypassedFrames: [] },
|
||||
});
|
||||
expect(w.find('[data-test="seedlist-seeds-bypass-badge"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,8 +37,13 @@ const props = withDefaults(
|
||||
* executed `loop_seeds` UI payload by the host glue. Drives the seed
|
||||
* modal's per-frame "lock previous" button. Null until a run lands. */
|
||||
previousSeeds?: number[] | null;
|
||||
/** 0-based frames the wired loop bypasses (resolved by the host glue —
|
||||
* only when Override count from loop is on). Shown READ-ONLY in the
|
||||
* modal (dimmed rows + "N bypassed" badge); bypass is owned by the loop.
|
||||
* Empty when no loop is wired or none are bypassed. */
|
||||
bypassedFrames?: number[];
|
||||
}>(),
|
||||
{ nodeMode: 0, baseSeed: 0, count: 1, previewStrategy: undefined, previousSeeds: null },
|
||||
{ nodeMode: 0, baseSeed: 0, count: 1, previewStrategy: undefined, previousSeeds: null, bypassedFrames: () => [] },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ "update:modelValue": [next: SeedListConfig] }>();
|
||||
@@ -73,6 +78,7 @@ const isBypassed = computed<boolean>(() => props.nodeMode === 4);
|
||||
|
||||
const seedsOpen = ref(false);
|
||||
const lockedCount = computed(() => Object.keys(props.modelValue.seed_locks ?? {}).length);
|
||||
const bypassedCount = computed(() => props.bypassedFrames.length);
|
||||
|
||||
function onSeedLocks(next: Record<string, number>): void {
|
||||
emit("update:modelValue", { ...props.modelValue, seed_locks: next });
|
||||
@@ -153,6 +159,7 @@ function toggleOverrideStrategy(): void {
|
||||
Per-iteration seeds
|
||||
<span class="wp-seedlist__seedbtn-fill" />
|
||||
<span v-if="lockedCount" class="wp-seedlist__seedbtn-badge" data-test="seedlist-seeds-badge">{{ lockedCount }} locked</span>
|
||||
<span v-if="bypassedCount" class="wp-seedlist__seedbtn-badge" data-test="seedlist-seeds-bypass-badge">{{ bypassedCount }} bypassed</span>
|
||||
<svg class="wp-seedlist__seedbtn-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M9 6l6 6-6 6" /></svg>
|
||||
</button>
|
||||
|
||||
@@ -206,6 +213,7 @@ function toggleOverrideStrategy(): void {
|
||||
<SeedListModal v-if="seedsOpen" :node-name="'WP Seed List'" :base-seed="baseSeed"
|
||||
:count="count" :strategy="previewStrategy ?? modelValue.strategy" :seed-locks="modelValue.seed_locks ?? {}"
|
||||
:previous-seeds="previousSeeds"
|
||||
:bypass-frames="bypassedFrames.length ? bypassedFrames : undefined" :bypass-readonly="true"
|
||||
:override-hint="modelValue.override_seed ? 'Base seed comes from the wired loop while Override base seed from loop is on.' : ''"
|
||||
@update:seed-locks="onSeedLocks" @close="seedsOpen = false" />
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,19 @@ const props = defineProps<{
|
||||
* short → that frame's button greys out. Distinct from the computed
|
||||
* `derived` series, which is the UPCOMING seeds from the current base. */
|
||||
previousSeeds?: number[] | null;
|
||||
/** 0-based bypassed frame indices. When PASSED, bypassed rows dim + strike.
|
||||
* Absent → no bypass display at all. */
|
||||
bypassFrames?: number[];
|
||||
/** Read-only bypass (Seed List mirroring a loop): show bypassed rows dimmed
|
||||
* but WITHOUT an interactive toggle — bypass is owned by the loop node.
|
||||
* Default false (Context Loop = interactive toggles). */
|
||||
bypassReadonly?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
"update:seedLocks": [next: Record<string, number>];
|
||||
"update:bypassFrames": [next: number[]];
|
||||
close: [];
|
||||
}>();
|
||||
const emit = defineEmits<{ "update:seedLocks": [next: Record<string, number>]; close: [] }>();
|
||||
|
||||
const derived = computed(() => deriveLoopSeeds(props.baseSeed, Math.max(1, props.count), props.strategy));
|
||||
const lockedCount = computed(() => Object.keys(props.seedLocks).length);
|
||||
@@ -41,6 +52,41 @@ const inactiveLocks = computed(() => {
|
||||
.sort((a, b) => a - b);
|
||||
});
|
||||
|
||||
const bypassEnabled = computed(() => props.bypassFrames !== undefined);
|
||||
/** Interactive toggle only when bypass is enabled AND not read-only. In
|
||||
* read-only mode rows still show the bypassed (dim + strike) state — that's
|
||||
* the `:bypassed` prop, independent of `:bypassable`. */
|
||||
const bypassInteractive = computed(() => bypassEnabled.value && !props.bypassReadonly);
|
||||
const bypassSet = computed(() => new Set(props.bypassFrames ?? []));
|
||||
const bypassCount = computed(() => (props.bypassFrames ?? []).length);
|
||||
/** Number of in-range frames NOT bypassed. */
|
||||
const activeFrameCount = computed(() => {
|
||||
let n = 0;
|
||||
for (let i = 0; i < derived.value.length; i++) if (!bypassSet.value.has(i)) n++;
|
||||
return n;
|
||||
});
|
||||
/** Bypass toggle is disabled for an ACTIVE frame when it's the last one left. */
|
||||
function bypassDisabledFor(i: number): boolean {
|
||||
return !bypassSet.value.has(i) && activeFrameCount.value <= 1;
|
||||
}
|
||||
/** Bypassed indices beyond the current count — dimmed, re-apply if count grows. */
|
||||
const inactiveBypass = computed(() =>
|
||||
[...bypassSet.value].filter((i) => i >= derived.value.length).sort((a, b) => a - b),
|
||||
);
|
||||
/** Real derived seed for any index (out-of-range inactive rows compute it on
|
||||
* the fly since `derived` only spans the active count). PRECONDITION: `i >= 1`
|
||||
* — only ever called for inactive rows where `i >= derived.length >= 1`. At
|
||||
* `i === 0` deriveLoopSeeds hits its `count <= 1` branch and returns the base
|
||||
* seed, NOT the series' derived[0], so do not reuse this for in-range index 0. */
|
||||
function seedAt(i: number): number {
|
||||
return deriveLoopSeeds(props.baseSeed, i + 1, props.strategy)[i] ?? 0;
|
||||
}
|
||||
function onBypass(p: { index: number; bypassed: boolean }): void {
|
||||
const next = new Set(bypassSet.value);
|
||||
if (p.bypassed) next.add(p.index);
|
||||
else next.delete(p.index);
|
||||
emit("update:bypassFrames", [...next].sort((a, b) => a - b));
|
||||
}
|
||||
function onRow(p: { index: number; seed: number | null }): void {
|
||||
const next = { ...props.seedLocks };
|
||||
if (p.seed == null) delete next[String(p.index)];
|
||||
@@ -115,6 +161,7 @@ onBeforeUnmount(() => {
|
||||
<span class="sm__count">{{ Math.max(1, count) }} iterations</span>
|
||||
<span class="sm__strat">{{ strategy }}</span>
|
||||
<span v-if="lockedCount" class="sm__lockcount" data-test="mx-seed-lockcount">{{ lockedCount }} locked</span>
|
||||
<span v-if="bypassEnabled && bypassCount" class="sm__lockcount" data-test="mx-bypass-count">{{ bypassCount }} bypassed</span>
|
||||
<span class="sm__bar-spacer" />
|
||||
<button class="ghost" data-test="mx-seed-lockall" @click="lockAll">Lock all</button>
|
||||
<button class="ghost" data-test="mx-seed-unlockall" @click="unlockAll">Unlock all</button>
|
||||
@@ -133,7 +180,21 @@ onBeforeUnmount(() => {
|
||||
<div class="sm__list">
|
||||
<SeedLockRow v-for="(s, i) in derived" :key="i" :index="i" :derived="s"
|
||||
:locked="Object.prototype.hasOwnProperty.call(seedLocks, String(i))"
|
||||
:seed="seedLocks[String(i)] ?? null" :previous="previousSeeds?.[i] ?? null" @update="onRow" />
|
||||
:seed="seedLocks[String(i)] ?? null" :previous="previousSeeds?.[i] ?? null"
|
||||
:bypassable="bypassInteractive" :bypassed="bypassSet.has(i)"
|
||||
:bypass-disabled="bypassDisabledFor(i)"
|
||||
@update="onRow" @bypass="onBypass" />
|
||||
<template v-if="inactiveBypass.length">
|
||||
<div class="sm__inactive-label" data-test="mx-bypass-inactive">
|
||||
Bypassed · won't apply at count {{ derived.length }}
|
||||
</div>
|
||||
<SeedLockRow v-for="i in inactiveBypass" :key="`inbypass-${i}`" :index="i"
|
||||
:derived="seedAt(i)"
|
||||
:locked="Object.prototype.hasOwnProperty.call(seedLocks, String(i))"
|
||||
:seed="seedLocks[String(i)] ?? null" :previous="previousSeeds?.[i] ?? null"
|
||||
:inactive="true" :bypassable="bypassInteractive" :bypassed="true"
|
||||
@update="onRow" @bypass="onBypass" />
|
||||
</template>
|
||||
<template v-if="inactiveLocks.length">
|
||||
<div class="sm__inactive-label" data-test="mx-seed-inactive">
|
||||
Inactive · won't apply at count {{ derived.length }}
|
||||
|
||||
@@ -15,8 +15,17 @@ const props = defineProps<{
|
||||
* frame — the "lock previous" button greys out. Distinct from `derived`,
|
||||
* which is the UPCOMING seed computed from the current base. */
|
||||
previous?: number | null;
|
||||
/** When true, render a per-frame bypass toggle (Context Loop only). */
|
||||
bypassable?: boolean;
|
||||
/** Whether this frame is currently bypassed. */
|
||||
bypassed?: boolean;
|
||||
/** Disable the bypass toggle (e.g. it's the last active frame). */
|
||||
bypassDisabled?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
update: [payload: { index: number; seed: number | null }];
|
||||
bypass: [payload: { index: number; bypassed: boolean }];
|
||||
}>();
|
||||
const emit = defineEmits<{ update: [payload: { index: number; seed: number | null }] }>();
|
||||
|
||||
const SEED_MAX = (2 ** 50) - 1; // MAX_SAFE_SEED (matches engine)
|
||||
function randomSeed(): number {
|
||||
@@ -40,8 +49,16 @@ function bump(d: 1 | -1) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="srow" :class="{ 'srow--locked': locked, 'srow--inactive': inactive }">
|
||||
<div class="srow" :class="{ 'srow--locked': locked, 'srow--inactive': inactive, 'srow--bypassed': bypassed }">
|
||||
<span class="srow__idx" data-test="seedrow-idx">#{{ index + 1 }}</span>
|
||||
<button v-if="bypassable" type="button" class="toggle toggle--bypass"
|
||||
:class="{ 'toggle--on': bypassed }" :disabled="bypassDisabled || undefined"
|
||||
data-test="seedrow-bypass" role="switch" :aria-checked="bypassed"
|
||||
:title="bypassed ? 'Frame bypassed — click to re-enable' : 'Bypass this frame (skip its generation)'"
|
||||
@click="emit('bypass', { index, bypassed: !bypassed })">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9" /><path d="M5.6 5.6l12.8 12.8" stroke-linecap="round" /></svg>
|
||||
{{ bypassed ? "Bypassed" : "Bypass" }}
|
||||
</button>
|
||||
<button type="button" class="toggle" :class="{ 'toggle--on': locked }"
|
||||
data-test="seedrow-lock" role="switch" :aria-checked="locked" @click="onLock">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -90,6 +107,23 @@ function bump(d: 1 | -1) {
|
||||
/* out-of-range lock: dimmed ("deemed down") but still interactive — hover lifts it. */
|
||||
.srow--inactive { opacity: .5; }
|
||||
.srow--inactive:hover { opacity: .85; }
|
||||
/* Bypassed row — purple-dimmed wash + a dashed left accent bar so it reads
|
||||
* clearly even when the struck-through seed alone is too faint. The dashed
|
||||
* bar echoes the loop node's dashed bypass chips. */
|
||||
.srow--bypassed {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--wp-accent, #c4b5fd) 0 4px,
|
||||
transparent 4px 8px
|
||||
) left / 3px 100% no-repeat,
|
||||
color-mix(in srgb, var(--wp-accent, #c4b5fd) 11%, transparent);
|
||||
border-radius: 5px;
|
||||
}
|
||||
.srow--bypassed .derived__val { text-decoration: line-through; opacity: .65; }
|
||||
.srow--bypassed .srow__idx { color: var(--wp-accent-text, #c4b5fd); opacity: .75; }
|
||||
.toggle--bypass { color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.toggle--bypass.toggle--on { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.10); }
|
||||
.srow__idx { flex-shrink: 0; width: 30px; text-align: right; font: 600 11px var(--wp-font-mono, monospace); color: var(--wp-text-dim, #7a7d88); }
|
||||
.srow--locked .srow__idx { color: var(--wp-accent-text, #c4b5fd); }
|
||||
.srow__fill { flex: 1; }
|
||||
|
||||
@@ -123,3 +123,38 @@ describe("SeedListModal", () => {
|
||||
expect(writes[writes.length - 1]).toBe("#2: 999");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SeedListModal bypass column (#13)", () => {
|
||||
it("shows no bypass toggles when bypassFrames prop is absent (Seed List parity)", () => {
|
||||
const w = modal({ seedLocks: {} });
|
||||
expect(w.findAll('[data-test="seedrow-bypass"]').length).toBe(0);
|
||||
});
|
||||
it("renders a bypass toggle per active row when bypassFrames is passed", () => {
|
||||
const w = modal({ seedLocks: {}, bypassFrames: [] });
|
||||
expect(w.findAll('[data-test="seedrow-bypass"]').length).toBe(4);
|
||||
});
|
||||
it("emits update:bypassFrames when a row is bypassed", async () => {
|
||||
const w = modal({ seedLocks: {}, bypassFrames: [] });
|
||||
await w.findAll('[data-test="seedrow-bypass"]')[1].trigger("click");
|
||||
expect(w.emitted("update:bypassFrames")![0]).toEqual([[1]]);
|
||||
});
|
||||
it("disables the last active frame's bypass toggle", () => {
|
||||
const w = modal({ seedLocks: {}, bypassFrames: [0, 1, 2] });
|
||||
const toggles = w.findAll('[data-test="seedrow-bypass"]');
|
||||
expect(toggles[3].attributes("disabled")).toBeDefined();
|
||||
expect(toggles[0].attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
it("renders out-of-range bypassed frames as inactive rows", () => {
|
||||
const w = modal({ seedLocks: {}, bypassFrames: [9] });
|
||||
expect(w.find('[data-test="mx-bypass-inactive"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SeedListModal read-only bypass (#13 seed-list mirror)", () => {
|
||||
it("dims bypassed rows without an interactive toggle + shows the badge", () => {
|
||||
const w = modal({ seedLocks: {}, bypassFrames: [1], bypassReadonly: true });
|
||||
expect(w.findAll('[data-test="seedrow-bypass"]').length).toBe(0); // no toggles
|
||||
expect(w.findAll(".srow")[1].classes()).toContain("srow--bypassed");
|
||||
expect(w.find('[data-test="mx-bypass-count"]').text()).toMatch(/1 bypassed/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,3 +49,21 @@ describe("SeedLockRow", () => {
|
||||
expect(row().find('[data-test="seedrow-lockprev"]').attributes("disabled")).toBeDefined(); // absent → disabled
|
||||
});
|
||||
});
|
||||
|
||||
describe("SeedLockRow bypass (#13)", () => {
|
||||
it("hides the bypass toggle unless bypassable", () => {
|
||||
expect(row().find('[data-test="seedrow-bypass"]').exists()).toBe(false);
|
||||
expect(row({ bypassable: true }).find('[data-test="seedrow-bypass"]').exists()).toBe(true);
|
||||
});
|
||||
it("emits bypass with the toggled state", async () => {
|
||||
const w = row({ bypassable: true, bypassed: false });
|
||||
await w.get('[data-test="seedrow-bypass"]').trigger("click");
|
||||
expect(w.emitted("bypass")![0]).toEqual([{ index: 0, bypassed: true }]);
|
||||
});
|
||||
it("marks the row bypassed + respects bypassDisabled", () => {
|
||||
const on = row({ bypassable: true, bypassed: true });
|
||||
expect(on.get(".srow").classes()).toContain("srow--bypassed");
|
||||
const dis = row({ bypassable: true, bypassed: false, bypassDisabled: true });
|
||||
expect(dis.get('[data-test="seedrow-bypass"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import { createMemoryHistory, createRouter, type Router } from "vue-router";
|
||||
|
||||
import AppTopbar from "../layout/AppTopbar.vue";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
import * as releaseCheck from "../composables/useReleaseCheck";
|
||||
|
||||
// Community status pill / sign-in / user-menu surfaces moved to the
|
||||
// `feat/community-tab` branch alongside the views that need them. Topbar
|
||||
@@ -109,4 +111,35 @@ describe("AppTopbar.vue", () => {
|
||||
expect(backSpy).toHaveBeenCalledOnce();
|
||||
backSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("clicking the update pill opens the dialog instead of navigating", async () => {
|
||||
// Force an available update so the pill renders, and stub the dialog
|
||||
// to a marker that only appears while open.
|
||||
vi.spyOn(releaseCheck, "useReleaseCheck").mockReturnValue({
|
||||
current: "2.9.0",
|
||||
latestVersion: ref("2.10.0"),
|
||||
hasUpdate: ref(true),
|
||||
severity: ref("minor"),
|
||||
releaseBody: ref("- notes"),
|
||||
releaseUrl: ref("https://x/r"),
|
||||
lastChecked: ref(null),
|
||||
checking: ref(false),
|
||||
checkNow: vi.fn(),
|
||||
} as ReturnType<typeof releaseCheck.useReleaseCheck>);
|
||||
|
||||
const router = makeRouter();
|
||||
await router.isReady();
|
||||
const wrap = mount(AppTopbar, {
|
||||
global: {
|
||||
plugins: [router],
|
||||
stubs: { UpdateDialog: { template: '<div data-test="dlg-stub" v-if="open" />', props: ["open"] } },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrap.find('[data-test="dlg-stub"]').exists()).toBe(false);
|
||||
await wrap.find('[data-test="topbar-update-indicator"]').trigger("click");
|
||||
expect(wrap.find('[data-test="dlg-stub"]').exists()).toBe(true);
|
||||
expect(router.currentRoute.value.path).not.toBe("/dashboard");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -423,4 +423,49 @@ describe("DerivationRuleCard.vue", () => {
|
||||
const wrap = mountCard(makeRule(), 0);
|
||||
expect(wrap.find("datalist").exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe("collapse (#9)", () => {
|
||||
// v-show toggles an inline `display: none`; assert on that directly since
|
||||
// isVisible() is unreliable under jsdom for v-show.
|
||||
const hidden = (w: { attributes: (n: string) => string | undefined }) =>
|
||||
(w.attributes("style") ?? "").includes("display: none");
|
||||
|
||||
it("branches start collapsed; the chevron toggles the body + peek", async () => {
|
||||
const wrap = mountCard(makeRule(), 0);
|
||||
const body = () => wrap.find('[data-test="branch-0-0"] .branch-body');
|
||||
// Collapsed by default → body hidden, peek shown.
|
||||
expect(hidden(body())).toBe(true);
|
||||
expect(wrap.find('[data-test="branch-peek-0-0"]').text()).toBe("$x → $out");
|
||||
// Expand.
|
||||
await wrap.get('[data-test="toggle-branch-0-0"]').trigger("click");
|
||||
expect(hidden(body())).toBe(false);
|
||||
expect(wrap.find('[data-test="branch-peek-0-0"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("per-rule collapse/expand-all-branches buttons toggle every branch", async () => {
|
||||
const wrap = mountCard(makeRule(), 0);
|
||||
const body = () => wrap.find('[data-test="branch-0-0"] .branch-body');
|
||||
// Expand all first (default is collapsed), then collapse all again.
|
||||
await wrap.get('[data-test="expand-branches-0"]').trigger("click");
|
||||
expect(hidden(body())).toBe(false);
|
||||
await wrap.get('[data-test="collapse-branches-0"]').trigger("click");
|
||||
expect(hidden(body())).toBe(true);
|
||||
});
|
||||
|
||||
it("adopts the collapseCommand broadcast (Collapse all / Expand all)", async () => {
|
||||
const wrap = mount(DerivationRuleCard, {
|
||||
props: {
|
||||
modelValue: makeRule(),
|
||||
index: 0,
|
||||
collapseCommand: { nonce: 0, collapsed: false },
|
||||
},
|
||||
});
|
||||
const branches = () => wrap.find(".branches");
|
||||
expect(hidden(branches())).toBe(false);
|
||||
await wrap.setProps({ collapseCommand: { nonce: 1, collapsed: true } });
|
||||
expect(hidden(branches())).toBe(true);
|
||||
await wrap.setProps({ collapseCommand: { nonce: 2, collapsed: false } });
|
||||
expect(hidden(branches())).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import Settings from "../views/Settings.vue";
|
||||
@@ -15,6 +16,22 @@ vi.mock("../api/client", () => ({
|
||||
ApiError: class ApiError extends Error {},
|
||||
}));
|
||||
|
||||
// The Updates card calls useReleaseCheck, which otherwise fires a real
|
||||
// GitHub fetch on mount. Stub it — these tests cover the other cards.
|
||||
vi.mock("../composables/useReleaseCheck", () => ({
|
||||
useReleaseCheck: () => ({
|
||||
current: "2.9.0",
|
||||
latestVersion: ref(null),
|
||||
hasUpdate: ref(false),
|
||||
severity: ref(null),
|
||||
releaseBody: ref(null),
|
||||
releaseUrl: ref(null),
|
||||
lastChecked: ref(null),
|
||||
checking: ref(false),
|
||||
checkNow: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const reloadMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -59,4 +59,47 @@ describe("SelectionToolbar.vue", () => {
|
||||
expect(wrap.emitted("delete-selected")).toBeTruthy();
|
||||
expect(wrap.emitted("clear")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Apply menu hides tags already on every selected row (commonTags)", async () => {
|
||||
const wrap = mount(SelectionToolbar, {
|
||||
props: { count: 2, tags: ["warm", "cool"], commonTags: ["warm"] },
|
||||
});
|
||||
await clickBar(wrap, "Apply sub-category");
|
||||
const labels = wrap.findAll(".wpc-seltoolbar__menuitem").map((b) => b.text());
|
||||
expect(labels).toContain("cool");
|
||||
expect(labels).not.toContain("warm");
|
||||
});
|
||||
|
||||
it("Remove menu lists only tags present on the selection", async () => {
|
||||
const wrap = mount(SelectionToolbar, {
|
||||
props: { count: 2, tags: ["warm", "cool", "neon"], presentTags: ["neon"] },
|
||||
});
|
||||
await clickBar(wrap, "Remove sub-category");
|
||||
const labels = wrap.findAll(".wpc-seltoolbar__menuitem").map((b) => b.text());
|
||||
expect(labels).toEqual(["neon"]);
|
||||
});
|
||||
|
||||
it("Remove menu is empty when the selection carries no tags", async () => {
|
||||
const wrap = mount(SelectionToolbar, {
|
||||
props: { count: 1, tags: ["warm", "cool"], presentTags: [] },
|
||||
});
|
||||
await clickBar(wrap, "Remove sub-category");
|
||||
expect(wrap.findAll(".wpc-seltoolbar__menuitem")).toHaveLength(0);
|
||||
expect(wrap.find(".wpc-seltoolbar__menuempty").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("menu chips carry their tag's axis hue as --chip-hue", async () => {
|
||||
const wrap = mount(SelectionToolbar, {
|
||||
props: {
|
||||
count: 1,
|
||||
tags: ["warm"],
|
||||
tagHues: { warm: "hsl(1 2% 3%)" },
|
||||
},
|
||||
});
|
||||
await clickBar(wrap, "Apply sub-category");
|
||||
const item = wrap
|
||||
.findAll(".wpc-seltoolbar__menuitem")
|
||||
.find((b) => b.text() === "warm");
|
||||
expect(item!.attributes("style")).toContain("--chip-hue: hsl(1 2% 3%)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
import UpdateDialog from "../../components/UpdateDialog.vue";
|
||||
import * as managerUpdate from "../../composables/useComfyManagerUpdate";
|
||||
import * as releaseCheck from "../../composables/useReleaseCheck";
|
||||
|
||||
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = "2.9.0";
|
||||
|
||||
function stubRelease(over: Partial<ReturnType<typeof releaseCheck.useReleaseCheck>> = {}) {
|
||||
vi.spyOn(releaseCheck, "useReleaseCheck").mockReturnValue({
|
||||
current: "2.9.0",
|
||||
latestVersion: ref("2.10.0"),
|
||||
hasUpdate: ref(true),
|
||||
severity: ref("minor"),
|
||||
releaseBody: ref("## What's new\n- Cool thing"),
|
||||
releaseUrl: ref("https://github.com/x/releases/2.10.0"),
|
||||
lastChecked: ref(new Date().toISOString()),
|
||||
checking: ref(false),
|
||||
checkNow: vi.fn(),
|
||||
...over,
|
||||
} as ReturnType<typeof releaseCheck.useReleaseCheck>);
|
||||
}
|
||||
|
||||
function stubManager(over: Partial<ReturnType<typeof managerUpdate.useComfyManagerUpdate>> = {}) {
|
||||
const base = {
|
||||
phase: ref<managerUpdate.UpdatePhase>("idle"),
|
||||
errorKind: ref<managerUpdate.UpdateErrorKind>(null),
|
||||
errorMessage: ref<string | null>(null),
|
||||
probe: vi.fn().mockResolvedValue("available" as const),
|
||||
runUpdate: vi.fn(),
|
||||
reboot: vi.fn(),
|
||||
managerUiUrl: "/manager",
|
||||
};
|
||||
const merged = { ...base, ...over };
|
||||
vi.spyOn(managerUpdate, "useComfyManagerUpdate").mockReturnValue(
|
||||
merged as ReturnType<typeof managerUpdate.useComfyManagerUpdate>,
|
||||
);
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mountDialog() {
|
||||
return mount(UpdateDialog, {
|
||||
props: { open: true },
|
||||
global: { stubs: { teleport: true } },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("UpdateDialog", () => {
|
||||
it("shows the version delta and rendered notes", async () => {
|
||||
stubRelease();
|
||||
stubManager();
|
||||
const wrap = mountDialog();
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("2.9.0");
|
||||
expect(wrap.text()).toContain("2.10.0");
|
||||
expect(wrap.find(".wpc-relnotes").html()).toContain("<li>Cool thing</li>");
|
||||
});
|
||||
|
||||
it("Update Now button calls runUpdate when available + idle", async () => {
|
||||
stubRelease();
|
||||
const mgr = stubManager();
|
||||
const wrap = mountDialog();
|
||||
await flushPromises();
|
||||
await wrap.get('[data-test="update-now"]').trigger("click");
|
||||
expect(mgr.runUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Restart button only in staged phase and calls reboot", async () => {
|
||||
stubRelease();
|
||||
const mgr = stubManager({ phase: ref("staged") });
|
||||
const wrap = mountDialog();
|
||||
await flushPromises();
|
||||
expect(wrap.find('[data-test="update-now"]').exists()).toBe(false);
|
||||
await wrap.get('[data-test="update-restart"]').trigger("click");
|
||||
expect(mgr.reboot).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the fallback card when Manager is absent", async () => {
|
||||
stubRelease();
|
||||
stubManager({ probe: vi.fn().mockResolvedValue("absent" as const) });
|
||||
const wrap = mountDialog();
|
||||
await flushPromises();
|
||||
expect(wrap.find('[data-test="update-fallback"]').exists()).toBe(true);
|
||||
expect(wrap.find('[data-test="update-now"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the fallback card on a forbidden error", async () => {
|
||||
stubRelease();
|
||||
stubManager({ phase: ref("error"), errorKind: ref("forbidden") });
|
||||
const wrap = mountDialog();
|
||||
await flushPromises();
|
||||
expect(wrap.find('[data-test="update-fallback"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("emits close from the Later button", async () => {
|
||||
stubRelease();
|
||||
stubManager();
|
||||
const wrap = mountDialog();
|
||||
await flushPromises();
|
||||
await wrap.get('[data-test="update-later"]').trigger("click");
|
||||
expect(wrap.emitted("close")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import { useBulkSelection } from "../../composables/useBulkSelection";
|
||||
|
||||
describe("useBulkSelection", () => {
|
||||
it("toggles individual ids", () => {
|
||||
const rows = ref(["a", "b", "c"]);
|
||||
const s = useBulkSelection(() => rows.value);
|
||||
expect(s.count.value).toBe(0);
|
||||
s.toggle("a");
|
||||
s.toggle("c");
|
||||
expect(s.count.value).toBe(2);
|
||||
expect(s.isSelected("a")).toBe(true);
|
||||
expect(s.isSelected("b")).toBe(false);
|
||||
s.toggle("a");
|
||||
expect(s.isSelected("a")).toBe(false);
|
||||
expect(s.count.value).toBe(1);
|
||||
});
|
||||
|
||||
it("select-all / clear via toggleAll", () => {
|
||||
const rows = ref(["a", "b"]);
|
||||
const s = useBulkSelection(() => rows.value);
|
||||
expect(s.allSelected.value).toBe(false);
|
||||
s.toggleAll();
|
||||
expect(s.allSelected.value).toBe(true);
|
||||
expect(s.count.value).toBe(2);
|
||||
s.toggleAll(); // all selected → clears
|
||||
expect(s.count.value).toBe(0);
|
||||
});
|
||||
|
||||
it("someSelected drives the indeterminate state", () => {
|
||||
const rows = ref(["a", "b"]);
|
||||
const s = useBulkSelection(() => rows.value);
|
||||
expect(s.someSelected.value).toBe(false);
|
||||
s.toggle("a");
|
||||
expect(s.someSelected.value).toBe(true);
|
||||
expect(s.allSelected.value).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores ids no longer present (count + selectedIds prune)", () => {
|
||||
const rows = ref(["a", "b", "c"]);
|
||||
const s = useBulkSelection(() => rows.value);
|
||||
s.toggleAll();
|
||||
expect(s.count.value).toBe(3);
|
||||
// A row is removed elsewhere; its stale id must stop counting.
|
||||
rows.value = ["a", "b"];
|
||||
expect(s.count.value).toBe(2);
|
||||
expect(s.selectedIds().sort()).toEqual(["a", "b"]);
|
||||
expect(s.allSelected.value).toBe(true);
|
||||
});
|
||||
|
||||
it("toggleMode enters/leaves and leaving clears the selection", () => {
|
||||
const rows = ref(["a"]);
|
||||
const s = useBulkSelection(() => rows.value);
|
||||
s.toggleMode();
|
||||
expect(s.active.value).toBe(true);
|
||||
s.toggle("a");
|
||||
expect(s.count.value).toBe(1);
|
||||
s.toggleMode(); // leave
|
||||
expect(s.active.value).toBe(false);
|
||||
expect(s.count.value).toBe(0);
|
||||
});
|
||||
|
||||
it("allSelected is false for an empty list", () => {
|
||||
const rows = ref<string[]>([]);
|
||||
const s = useBulkSelection(() => rows.value);
|
||||
expect(s.allSelected.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useComfyManagerUpdate } from "../../composables/useComfyManagerUpdate";
|
||||
|
||||
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = "2.9.0";
|
||||
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
/** Route a fetch mock by URL + method. `status` route returns `done`
|
||||
* immediately unless overridden. */
|
||||
function mockManager(opts: {
|
||||
install?: { ok: boolean; status?: number; text?: string };
|
||||
start?: { ok: boolean };
|
||||
status?: unknown;
|
||||
reboot?: () => Promise<Response>;
|
||||
} = {}) {
|
||||
return vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const method = init?.method ?? "GET";
|
||||
if (url.includes("/manager/queue/install")) {
|
||||
const o = opts.install ?? { ok: true };
|
||||
return { ok: o.ok, status: o.status ?? (o.ok ? 200 : 500), text: async () => o.text ?? "" } as Response;
|
||||
}
|
||||
if (url.includes("/manager/queue/start")) {
|
||||
const o = opts.start ?? { ok: true };
|
||||
return { ok: o.ok, status: o.ok ? 200 : 500, text: async () => "" } as Response;
|
||||
}
|
||||
if (url.includes("/manager/queue/status")) {
|
||||
const s = opts.status ?? { total_count: 1, done_count: 1, in_progress: false };
|
||||
return { ok: true, status: 200, json: async () => s } as Response;
|
||||
}
|
||||
if (url.includes("/manager/reboot")) {
|
||||
if (opts.reboot) return opts.reboot();
|
||||
return { ok: true, status: 200, text: async () => "" } as Response;
|
||||
}
|
||||
void method;
|
||||
throw new Error(`unexpected url ${url}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("useComfyManagerUpdate", () => {
|
||||
it("probe returns available on 200 status", async () => {
|
||||
vi.stubGlobal("fetch", mockManager());
|
||||
const u = useComfyManagerUpdate();
|
||||
expect(await u.probe()).toBe("available");
|
||||
});
|
||||
|
||||
it("probe returns absent on network error", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("no manager")));
|
||||
const u = useComfyManagerUpdate();
|
||||
expect(await u.probe()).toBe("absent");
|
||||
});
|
||||
|
||||
it("runUpdate happy path: idle -> installing -> staged", async () => {
|
||||
const fetchMock = mockManager();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const u = useComfyManagerUpdate();
|
||||
const p = u.runUpdate("2.10.0"); // newer than __APP_VERSION__ 2.9.0
|
||||
expect(u.phase.value).toBe("installing");
|
||||
await p;
|
||||
expect(u.phase.value).toBe("staged");
|
||||
// Pins the exact target + uses the fresh (remote) catalog, never "latest"/"cache".
|
||||
const installCall = fetchMock.mock.calls.find((c) => String(c[0]).includes("/manager/queue/install"));
|
||||
const body = JSON.parse((installCall![1] as RequestInit).body as string);
|
||||
expect(body.selected_version).toBe("2.10.0");
|
||||
expect(body.mode).toBe("remote");
|
||||
});
|
||||
|
||||
it("runUpdate refuses a downgrade / non-newer target (2.10.0 bug guard)", async () => {
|
||||
const fetchMock = mockManager();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const u = useComfyManagerUpdate();
|
||||
await u.runUpdate("2.8.0"); // older than installed 2.9.0
|
||||
expect(u.phase.value).toBe("error");
|
||||
expect(u.errorKind.value).toBe("failed");
|
||||
// Never hit the install endpoint.
|
||||
expect(fetchMock.mock.calls.some((c) => String(c[0]).includes("/manager/queue/install"))).toBe(false);
|
||||
});
|
||||
|
||||
it("runUpdate maps install 403 to forbidden error", async () => {
|
||||
vi.stubGlobal("fetch", mockManager({ install: { ok: false, status: 403, text: "security" } }));
|
||||
const u = useComfyManagerUpdate();
|
||||
await u.runUpdate("2.10.0");
|
||||
expect(u.phase.value).toBe("error");
|
||||
expect(u.errorKind.value).toBe("forbidden");
|
||||
});
|
||||
|
||||
it("runUpdate maps a non-403 install failure to failed error", async () => {
|
||||
vi.stubGlobal("fetch", mockManager({ install: { ok: false, status: 500, text: "boom" } }));
|
||||
const u = useComfyManagerUpdate();
|
||||
await u.runUpdate("2.10.0");
|
||||
expect(u.phase.value).toBe("error");
|
||||
expect(u.errorKind.value).toBe("failed");
|
||||
});
|
||||
|
||||
it("reboot sets restarting and treats a dropped connection as soft success", async () => {
|
||||
vi.stubGlobal("fetch", mockManager({ reboot: () => Promise.reject(new Error("socket closed")) }));
|
||||
const u = useComfyManagerUpdate();
|
||||
await u.reboot();
|
||||
expect(u.phase.value).toBe("restarting");
|
||||
expect(u.errorKind.value).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defineComponent, h, nextTick } from "vue";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
|
||||
import { useReleaseCheck, type UpdateSeverity } from "../../composables/useReleaseCheck";
|
||||
import { useReleaseCheck, resetReleaseCheckSession } from "../../composables/useReleaseCheck";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
// __APP_VERSION__ is injected by vite at build time; vitest doesn't
|
||||
// apply the define plugin, so we wire it onto globalThis here.
|
||||
// __APP_VERSION__ is injected by vite at build time; vitest doesn't apply
|
||||
// the define plugin, so we wire it onto globalThis here.
|
||||
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = "1.7.0";
|
||||
|
||||
const STORAGE_KEY = "wp.releaseCheck";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
type CheckResult = ReturnType<typeof useReleaseCheck>;
|
||||
let lastResult: CheckResult | null = null;
|
||||
|
||||
@@ -24,112 +21,144 @@ function host() {
|
||||
setup() {
|
||||
const r = useReleaseCheck();
|
||||
lastResult = r;
|
||||
return () => h("span", { "data-test": "version" }, r.current);
|
||||
return () => h("span", r.current);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Flush onMounted → await fetch → applyLatest → render. */
|
||||
async function settle() {
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function releaseResponse(tag: string, body = "notes", url = "https://x/releases/v") {
|
||||
return { ok: true, json: async () => ({ tag_name: tag, body, html_url: url }) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
setActivePinia(createPinia());
|
||||
resetReleaseCheckSession();
|
||||
});
|
||||
|
||||
describe("useReleaseCheck", () => {
|
||||
it("flags hasUpdate when GitHub returns a newer tag", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ tag_name: "v1.8.0" }),
|
||||
});
|
||||
it("fetches on launch and surfaces version, body, url, lastChecked", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0", "## New", "https://x/r/1.8.0"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const wrap = mount(host());
|
||||
// Two ticks: onMounted -> await fetch -> applyLatest mutation -> render.
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
// Verify it persisted to localStorage so a re-mount uses the cache.
|
||||
const cached = JSON.parse(localStorage.getItem(STORAGE_KEY)!) as {
|
||||
latest_version: string;
|
||||
};
|
||||
expect(cached.latest_version).toBe("1.8.0");
|
||||
await settle();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(lastResult!.hasUpdate.value).toBe(true);
|
||||
expect(lastResult!.latestVersion.value).toBe("1.8.0");
|
||||
expect(lastResult!.releaseBody.value).toBe("## New");
|
||||
expect(lastResult!.releaseUrl.value).toBe("https://x/r/1.8.0");
|
||||
expect(lastResult!.lastChecked.value).not.toBeNull();
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!).latest_version).toBe("1.8.0");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("uses cached value within TTL — no fetch", async () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
checked_at: new Date().toISOString(),
|
||||
latest_version: "1.6.5",
|
||||
}),
|
||||
);
|
||||
const fetchMock = vi.fn();
|
||||
it("does NOT fetch on launch when checkOnLaunch is off", async () => {
|
||||
setActivePinia(createPinia());
|
||||
useUiStore().setCheckOnLaunch(false);
|
||||
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const wrap = mount(host());
|
||||
await nextTick();
|
||||
await settle();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(lastResult!.hasUpdate.value).toBe(false);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("refetches once cache is older than 24h", async () => {
|
||||
const stale = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ checked_at: stale, latest_version: "1.6.5" }),
|
||||
);
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ tag_name: "v1.7.0" }),
|
||||
});
|
||||
it("paints cached value immediately, then refreshes", async () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
checked_at: new Date().toISOString(),
|
||||
latest_version: "1.6.5",
|
||||
body: "old",
|
||||
url: "https://x/r/1.6.5",
|
||||
}));
|
||||
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.9.0", "fresh", "https://x/r/1.9.0"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const wrap = mount(host());
|
||||
await nextTick(); // cache painted before fetch resolves
|
||||
expect(lastResult!.latestVersion.value).toBe("1.6.5");
|
||||
await settle();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // no 24h suppression
|
||||
expect(lastResult!.latestVersion.value).toBe("1.9.0");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("session guard: a second mount does not refetch", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const a = mount(host());
|
||||
await settle();
|
||||
const b = mount(host());
|
||||
await settle();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
a.unmount(); b.unmount();
|
||||
});
|
||||
|
||||
it("checkNow() refetches even within the same session", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const wrap = mount(host());
|
||||
await settle();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
await lastResult!.checkNow();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(lastResult!.checking.value).toBe(false);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("a legacy/malformed cache blob (no latest_version) does not throw", async () => {
|
||||
localStorage.setItem(STORAGE_KEY, '{"v":"1.0"}'); // pre-rework shape
|
||||
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const wrap = mount(host());
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
// Painting the bad cache must not crash; it degrades to no update.
|
||||
expect(lastResult!.hasUpdate.value).toBe(false);
|
||||
expect(lastResult!.latestVersion.value).toBeNull();
|
||||
await settle();
|
||||
// The launch refresh still recovers a real version.
|
||||
expect(lastResult!.latestVersion.value).toBe("1.8.0");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("silently no-ops when GitHub is unreachable", async () => {
|
||||
it("shares state across consumers: checkNow on one lights the other's pill", async () => {
|
||||
// Two independent consumers (like the topbar + Settings). checkOnLaunch
|
||||
// off so only the manual check drives state.
|
||||
setActivePinia(createPinia());
|
||||
useUiStore().setCheckOnLaunch(false);
|
||||
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v2.10.1"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const topbar = mount(host());
|
||||
const topbarResult = lastResult!;
|
||||
const settings = mount(host());
|
||||
const settingsResult = lastResult!;
|
||||
await settle();
|
||||
|
||||
expect(topbarResult.hasUpdate.value).toBe(false);
|
||||
// Manual check from the "settings" consumer...
|
||||
await settingsResult.checkNow();
|
||||
// ...must reflect in the "topbar" consumer without a remount/reload.
|
||||
expect(topbarResult.hasUpdate.value).toBe(true);
|
||||
expect(topbarResult.latestVersion.value).toBe("2.10.1");
|
||||
|
||||
topbar.unmount(); settings.unmount();
|
||||
});
|
||||
|
||||
it("network failure leaves hasUpdate false and does not throw", async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error("offline"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const wrap = mount(host());
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
// No exception bubbled; no cache written.
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it.each<[string, UpdateSeverity]>([
|
||||
["2.0.0", "major"],
|
||||
["1.8.0", "minor"],
|
||||
["1.7.1", "patch"],
|
||||
])("classifies %s over 1.7.0 as %s", async (latest, expectedSeverity) => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ tag_name: `v${latest}` }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const wrap = mount(host());
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
expect(lastResult).not.toBeNull();
|
||||
expect(lastResult!.hasUpdate.value).toBe(true);
|
||||
expect(lastResult!.severity.value).toBe(expectedSeverity);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("severity is null when no update available", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ tag_name: "v1.6.0" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const wrap = mount(host());
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
await settle();
|
||||
expect(lastResult!.hasUpdate.value).toBe(false);
|
||||
expect(lastResult!.severity.value).toBeNull();
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,26 @@ describe("Input.vue", () => {
|
||||
expect(wrap.get("input").attributes("aria-invalid")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits native step=any for number inputs (no off-grid validation popup)", () => {
|
||||
// Regression: `min="0.01" step="0.1"` made the browser reject a default
|
||||
// weight of 1 ("nearest valid values are 0.91 and 1.01"). The native
|
||||
// step is validation-only here (arrows/wheel/chevrons use bump()), so it
|
||||
// must be "any" to never grid-validate. bump() still uses props.step.
|
||||
const wrap = mount(Input, {
|
||||
props: { modelValue: 1, type: "number", step: 0.1, min: 0.01 },
|
||||
});
|
||||
const el = wrap.get("input").element as HTMLInputElement;
|
||||
expect(el.getAttribute("step")).toBe("any");
|
||||
expect(el.validity.stepMismatch).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves step untouched on non-number inputs", () => {
|
||||
const wrap = mount(Input, { props: { modelValue: "x", step: 5 } });
|
||||
// Non-number inputs never grid-validate, and `step` is meaningless on
|
||||
// them, so nativeStep passes props.step straight through.
|
||||
expect(wrap.get("input").attributes("step")).toBe("5");
|
||||
});
|
||||
|
||||
it("numeric stepper rounds to step precision (no float fuzz)", async () => {
|
||||
// Regression: clicking the up chevron on value=1 with step=0.1
|
||||
// historically emitted 1.0000000000000002 (JS float math), which
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderReleaseNotes } from "../../utils/releaseNotes";
|
||||
|
||||
describe("renderReleaseNotes", () => {
|
||||
it("escapes raw HTML so scripts cannot execute", () => {
|
||||
const html = renderReleaseNotes("<script>alert(1)</script>");
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).toContain("<script>");
|
||||
});
|
||||
|
||||
it("renders headings", () => {
|
||||
expect(renderReleaseNotes("## Features")).toContain("<h2>Features</h2>");
|
||||
});
|
||||
|
||||
it("renders unordered lists", () => {
|
||||
const html = renderReleaseNotes("- one\n- two");
|
||||
expect(html).toContain("<ul>");
|
||||
expect(html).toContain("<li>one</li>");
|
||||
expect(html).toContain("<li>two</li>");
|
||||
});
|
||||
|
||||
it("renders bold and inline code", () => {
|
||||
const html = renderReleaseNotes("**big** and `code`");
|
||||
expect(html).toContain("<strong>big</strong>");
|
||||
expect(html).toContain("<code>code</code>");
|
||||
});
|
||||
|
||||
it("renders links with safe rel/target and escapes the href", () => {
|
||||
const html = renderReleaseNotes("[docs](https://example.com/a)");
|
||||
expect(html).toContain('href="https://example.com/a"');
|
||||
expect(html).toContain('target="_blank"');
|
||||
expect(html).toContain('rel="noopener nofollow"');
|
||||
});
|
||||
|
||||
it("neutralizes an href that tries to break out of the attribute", () => {
|
||||
const html = renderReleaseNotes('[x](https://e.com/"><script>alert(1)</script>)');
|
||||
expect(html).not.toContain("<script>");
|
||||
});
|
||||
|
||||
it("renders fenced code blocks as escaped preformatted text", () => {
|
||||
const html = renderReleaseNotes("```\nconst a = 1 < 2;\n```");
|
||||
expect(html).toContain("<pre><code>");
|
||||
expect(html).toContain("1 < 2");
|
||||
});
|
||||
|
||||
it("returns an empty-notes marker for blank input", () => {
|
||||
expect(renderReleaseNotes("")).toContain("No release notes");
|
||||
});
|
||||
|
||||
it("unwraps <details>/<summary>/<b> instead of showing literal tags", () => {
|
||||
const md = "<details>\n<summary><b>📋 Full changelog</b> — details</summary>\n\n- one\n</details>";
|
||||
const html = renderReleaseNotes(md);
|
||||
expect(html).not.toContain("<details>");
|
||||
expect(html).not.toContain("<summary>");
|
||||
expect(html).not.toContain("<details>");
|
||||
// Summary becomes a bold lead line; the list still renders.
|
||||
expect(html).toContain("<strong>");
|
||||
expect(html).toContain("Full changelog");
|
||||
expect(html).toContain("<li>one</li>");
|
||||
});
|
||||
|
||||
it("still neutralizes non-allowlisted raw HTML", () => {
|
||||
const html = renderReleaseNotes("<img src=x onerror=alert(1)>");
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).toContain("<img");
|
||||
});
|
||||
|
||||
it("renders a · separated link header as links", () => {
|
||||
const html = renderReleaseNotes("[Docs](https://e.com/d) · [Discord](https://e.com/x)");
|
||||
expect(html).toContain('href="https://e.com/d"');
|
||||
expect(html).toContain('href="https://e.com/x"');
|
||||
expect(html).toContain("·");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
validateSubcatName,
|
||||
validateRefGrammarName,
|
||||
validateVariableName,
|
||||
isValidVariableName,
|
||||
} from "../../validation/names";
|
||||
|
||||
describe("validation/names", () => {
|
||||
describe("validateSubcatName (re-exported engine-parity rule)", () => {
|
||||
it("accepts a clean tag", () => {
|
||||
expect(validateSubcatName("warm")).toBeNull();
|
||||
});
|
||||
it("rejects whitespace", () => {
|
||||
expect(validateSubcatName("warm tone")).toMatch(/whitespace/i);
|
||||
});
|
||||
it("rejects a reserved word", () => {
|
||||
expect(validateSubcatName("null")).toBeTruthy();
|
||||
});
|
||||
it("rejects a disallowed char", () => {
|
||||
expect(validateSubcatName("a,b")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateRefGrammarName", () => {
|
||||
it("allows spaces (display names)", () => {
|
||||
expect(validateRefGrammarName("My Wildcard")).toBeNull();
|
||||
});
|
||||
it("rejects each reserved ref-grammar char", () => {
|
||||
for (const c of ["{", "}", ":", "#", "@", ","]) {
|
||||
expect(validateRefGrammarName(`a${c}b`)).toContain(c);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("variable name", () => {
|
||||
it("accepts a valid identifier", () => {
|
||||
expect(isValidVariableName("my_var2")).toBe(true);
|
||||
expect(validateVariableName("my_var2")).toBeNull();
|
||||
});
|
||||
it("rejects spaces / leading digit / empty", () => {
|
||||
expect(isValidVariableName("my var")).toBe(false);
|
||||
expect(validateVariableName("my var")).toMatch(/letters, digits/i);
|
||||
expect(isValidVariableName("2fast")).toBe(false);
|
||||
expect(isValidVariableName("")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
import Settings from "../../views/Settings.vue";
|
||||
import * as releaseCheck from "../../composables/useReleaseCheck";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = "2.9.0";
|
||||
(globalThis as unknown as { __APP_LICENSE__: string }).__APP_LICENSE__ = "MIT";
|
||||
|
||||
const checkNow = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
setActivePinia(createPinia());
|
||||
vi.restoreAllMocks();
|
||||
checkNow.mockClear();
|
||||
vi.spyOn(releaseCheck, "useReleaseCheck").mockReturnValue({
|
||||
current: "2.9.0",
|
||||
latestVersion: ref("2.10.0"),
|
||||
hasUpdate: ref(true),
|
||||
severity: ref("minor"),
|
||||
releaseBody: ref(null),
|
||||
releaseUrl: ref(null),
|
||||
lastChecked: ref("2026-07-21T10:00:00.000Z"),
|
||||
checking: ref(false),
|
||||
checkNow,
|
||||
} as ReturnType<typeof releaseCheck.useReleaseCheck>);
|
||||
});
|
||||
|
||||
function mountSettings() {
|
||||
return mount(Settings, {
|
||||
global: { stubs: { Icon: true, BrowserPrefsCard: true, DatabaseCard: true } },
|
||||
});
|
||||
}
|
||||
|
||||
describe("Settings Updates card", () => {
|
||||
it("toggling check-on-launch calls setCheckOnLaunch", async () => {
|
||||
const wrap = mountSettings();
|
||||
const ui = useUiStore();
|
||||
const spy = vi.spyOn(ui, "setCheckOnLaunch");
|
||||
// The Toggle's clickable control is an inner <button role="switch">;
|
||||
// the data-test attribute falls through to the outer <label>.
|
||||
await wrap.get('[data-test="settings-check-on-launch"]').get("button").trigger("click");
|
||||
expect(spy).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("Check now button calls checkNow", async () => {
|
||||
const wrap = mountSettings();
|
||||
await wrap.get('[data-test="settings-check-now"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(checkNow).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an update-available status line", () => {
|
||||
const wrap = mountSettings();
|
||||
expect(wrap.get('[data-test="settings-update-status"]').text()).toContain("2.10.0");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import Checkbox from "../components/ui/Checkbox.vue";
|
||||
import Input from "../components/ui/Input.vue";
|
||||
import Modal from "../components/ui/Modal.vue";
|
||||
import { useCascadeApply, type CascadeApplyRequest } from "./useCascadeApply";
|
||||
import { validateSubcatName, validateRefGrammarName, validateVariableName } from "../validation/names";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -33,18 +34,19 @@ const affected = ref<Array<{ kind: string; id: string; name: string }>>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref<string>("");
|
||||
|
||||
/** Names that flow into the @{uuid#name:subcat} grammar must avoid
|
||||
* the structural chars (and comma, since subcats are comma-listed).
|
||||
* Mirrors `wp_api/_validators.py:REF_GRAMMAR_FORBIDDEN_CHARS`. */
|
||||
/** Validate the typed new name against the SAME canonical validator its
|
||||
* matching "add"/"create" surface uses (see `validation/names`), keyed by
|
||||
* what kind of thing is being renamed. Reusing the shared validators keeps
|
||||
* rename and create in lockstep — previously rename hand-rolled a weaker
|
||||
* check that let invalid sub-category names (e.g. with spaces) through
|
||||
* until save (issue #7), and variable renames weren't validated at all. */
|
||||
const nameError = computed<string | null>(() => {
|
||||
if (props.kind !== "wildcard" && props.kind !== "subcategory") return null;
|
||||
if (props.kind === "subcategory" && newName.value === "null") {
|
||||
return "\"null\" is reserved by the @{uuid:subcat} filter";
|
||||
}
|
||||
const bad = newName.value.match(/[{}:#@,]/);
|
||||
if (bad) {
|
||||
return `Cannot contain "${bad[0]}" (reserved by the @{uuid#name:subcat} ref grammar)`;
|
||||
}
|
||||
// Empty is gated by `canConfirm` (Rename button stays disabled) — don't
|
||||
// surface an error while the field is simply blank.
|
||||
if (!newName.value) return null;
|
||||
if (props.kind === "subcategory") return validateSubcatName(newName.value);
|
||||
if (props.kind === "combine_output_var") return validateVariableName(newName.value);
|
||||
if (props.kind === "wildcard") return validateRefGrammarName(newName.value);
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
@@ -139,6 +139,52 @@ describe("CascadeRenameDialog", () => {
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("blocks a subcategory rename to a name containing a space (issue #7)", async () => {
|
||||
mockComposable.dryRun.mockResolvedValue({ ok: true, affected_count: 0, affected_entities: [] });
|
||||
const wrap = mount(CascadeRenameDialog, {
|
||||
props: { open: true, kind: "subcategory", id: "11111111", initialName: "warm",
|
||||
extra: { subcat_name: "warm" } },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flushPromises();
|
||||
const input = findNameInput();
|
||||
input!.value = "warm tone"; // contains a space — invalid subcat name
|
||||
input!.dispatchEvent(new Event("input"));
|
||||
await flushPromises();
|
||||
// Validation error is surfaced.
|
||||
const err = document.body.querySelector(".wp-cascade-rename__error");
|
||||
expect(err?.textContent ?? "").toContain("whitespace");
|
||||
// Confirm is disabled and clicking it does not apply the rename.
|
||||
const btn = document.body.querySelector("[data-test='cascade-rename-confirm']") as HTMLButtonElement;
|
||||
expect(btn.disabled || btn.getAttribute("aria-disabled") === "true").toBe(true);
|
||||
btn.click();
|
||||
await flushPromises();
|
||||
expect(mockComposable.apply).not.toHaveBeenCalled();
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("blocks a combine output-var rename to a non-identifier name", async () => {
|
||||
mockComposable.dryRun.mockResolvedValue({ ok: true, affected_count: 0, affected_entities: [] });
|
||||
const wrap = mount(CascadeRenameDialog, {
|
||||
props: { open: true, kind: "combine_output_var", id: "22222222", initialName: "outfit",
|
||||
extra: { old_name: "outfit" } },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flushPromises();
|
||||
const input = findNameInput();
|
||||
input!.value = "out fit"; // space — invalid $identifier
|
||||
input!.dispatchEvent(new Event("input"));
|
||||
await flushPromises();
|
||||
const err = document.body.querySelector(".wp-cascade-rename__error");
|
||||
expect(err?.textContent ?? "").toContain("letters, digits");
|
||||
const btn = document.body.querySelector("[data-test='cascade-rename-confirm']") as HTMLButtonElement;
|
||||
expect(btn.disabled || btn.getAttribute("aria-disabled") === "true").toBe(true);
|
||||
btn.click();
|
||||
await flushPromises();
|
||||
expect(mockComposable.apply).not.toHaveBeenCalled();
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("rename button disabled when name empty", async () => {
|
||||
mockComposable.dryRun.mockResolvedValue({ ok: true, affected_count: 0, affected_entities: [] });
|
||||
const wrap = mount(CascadeRenameDialog, {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* BulkDeleteToolbar — minimal bulk-action bar for editors whose rows have
|
||||
* nothing to bulk-set (fixed-values, constraint exceptions, derivation
|
||||
* rules): just "N selected" + Delete + Clear. The richer wildcard bar
|
||||
* (apply/remove sub-category, set weight) is SelectionToolbar.
|
||||
*
|
||||
* Presentational: emits `delete-selected` / `clear`; the host mutates rows +
|
||||
* tracks dirty.
|
||||
*/
|
||||
import Button from "./ui/Button.vue";
|
||||
|
||||
defineProps<{
|
||||
count: number;
|
||||
/** Plural noun for the Delete label, e.g. "values" → "Delete 3 values". */
|
||||
noun?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete-selected"): void;
|
||||
(e: "clear"): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wpc-deltoolbar" role="toolbar" aria-label="Bulk actions">
|
||||
<span class="wpc-deltoolbar__count">{{ count }} selected</span>
|
||||
<div class="wpc-deltoolbar__spacer"></div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
icon="pi-trash"
|
||||
:disabled="count === 0"
|
||||
data-test="bulk-delete"
|
||||
@click="emit('delete-selected')"
|
||||
>Delete{{ noun ? ` ${count} ${noun}` : "" }}</Button>
|
||||
<Button variant="ghost" size="sm" @click="emit('clear')">Clear</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wpc-deltoolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid color-mix(in oklab, var(--wp-accent) 40%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in oklab, var(--wp-accent) 12%, var(--wp-bg-2));
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wpc-deltoolbar__count {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--wp-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wpc-deltoolbar__spacer { flex: 1; }
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import Card from "./ui/Card.vue";
|
||||
import Button from "./ui/Button.vue";
|
||||
import Select from "./ui/Select.vue";
|
||||
@@ -40,6 +40,10 @@ interface Props {
|
||||
uuidToTagGroups?: Map<string, Record<string, string[]>>;
|
||||
/** Default-collapsed when many rules exist (set by editor). */
|
||||
defaultCollapsed?: boolean;
|
||||
/** Broadcast from the editor's Collapse-all / Expand-all buttons. When
|
||||
* `nonce` changes, the card adopts `collapsed` — but the user can still
|
||||
* toggle any card individually afterwards. */
|
||||
collapseCommand?: { nonce: number; collapsed: boolean };
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -57,6 +61,50 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const collapsed = ref<boolean>(props.defaultCollapsed);
|
||||
function toggleCollapsed() { collapsed.value = !collapsed.value; }
|
||||
|
||||
// Editor-driven Collapse-all / Expand-all: adopt the broadcast state whenever
|
||||
// its nonce ticks (individual toggles still work afterwards).
|
||||
watch(
|
||||
() => props.collapseCommand?.nonce,
|
||||
() => {
|
||||
if (props.collapseCommand) collapsed.value = props.collapseCommand.collapsed;
|
||||
},
|
||||
);
|
||||
|
||||
// Per-branch collapse. Branch index → collapsed; the ELSE clause is keyed as
|
||||
// -1. Collapsing hides the condition/action editors and shows a one-line
|
||||
// summary in the branch head.
|
||||
/** Every branch key for this rule (IF/ELIF indices + `-1` for ELSE). */
|
||||
function allBranchKeys(): number[] {
|
||||
const r = props.modelValue;
|
||||
const keys = r.branches.map((_, i) => i);
|
||||
if (r.else) keys.push(-1);
|
||||
return keys;
|
||||
}
|
||||
// Branches start COLLAPSED on open (matches the request for a compact
|
||||
// editor). Newly-added branches aren't in this set, so they open expanded
|
||||
// for immediate editing.
|
||||
const collapsedBranches = ref<Set<number>>(new Set(allBranchKeys()));
|
||||
function toggleBranch(bi: number): void {
|
||||
const next = new Set(collapsedBranches.value);
|
||||
if (next.has(bi)) next.delete(bi);
|
||||
else next.add(bi);
|
||||
collapsedBranches.value = next;
|
||||
}
|
||||
function isBranchCollapsed(bi: number): boolean {
|
||||
return collapsedBranches.value.has(bi);
|
||||
}
|
||||
/** Collapse / expand every branch in THIS rule (the inline per-rule control). */
|
||||
function collapseAllBranches(): void {
|
||||
collapsedBranches.value = new Set(allBranchKeys());
|
||||
}
|
||||
function expandAllBranches(): void {
|
||||
collapsedBranches.value = new Set();
|
||||
}
|
||||
/** Compact `$cond → $target` peek for a collapsed branch head. */
|
||||
function branchPeek(cvar: string, tvar: string): string {
|
||||
return `${cvar ? "$" + cvar : "$?"} → ${tvar ? "$" + tvar : "$?"}`;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: DerivationRule];
|
||||
remove: [];
|
||||
@@ -287,6 +335,26 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
<span v-if="rule.else"> + ELSE</span>
|
||||
</span>
|
||||
<span class="spacer" />
|
||||
<!-- Inline per-rule collapse/expand of THIS rule's conditions. Only
|
||||
useful when the rule card itself is open. -->
|
||||
<span v-if="!collapsed" class="rule-head__branch-ctrls">
|
||||
<Button
|
||||
icon="pi-angle-double-up"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:aria-label="`Collapse all conditions in rule ${ruleNumber}`"
|
||||
:data-test="`collapse-branches-${index}`"
|
||||
@click.stop="collapseAllBranches"
|
||||
/>
|
||||
<Button
|
||||
icon="pi-angle-double-down"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:aria-label="`Expand all conditions in rule ${ruleNumber}`"
|
||||
:data-test="`expand-branches-${index}`"
|
||||
@click.stop="expandAllBranches"
|
||||
/>
|
||||
</span>
|
||||
<Button
|
||||
icon="pi-trash"
|
||||
variant="ghost"
|
||||
@@ -306,9 +374,24 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
:data-test="`branch-${index}-${bi}`"
|
||||
>
|
||||
<div class="branch-head">
|
||||
<button
|
||||
type="button"
|
||||
class="branch-collapse"
|
||||
:aria-expanded="!isBranchCollapsed(bi)"
|
||||
:aria-label="`${isBranchCollapsed(bi) ? 'Expand' : 'Collapse'} branch ${bi + 1} of rule ${ruleNumber}`"
|
||||
:data-test="`toggle-branch-${index}-${bi}`"
|
||||
@click="toggleBranch(bi)"
|
||||
>
|
||||
<i :class="isBranchCollapsed(bi) ? 'pi pi-chevron-right' : 'pi pi-chevron-down'" aria-hidden="true" />
|
||||
</button>
|
||||
<span class="branch-tag" :data-kind="bi === 0 ? 'if' : 'elif'">
|
||||
{{ bi === 0 ? "IF" : "ELIF" }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isBranchCollapsed(bi)"
|
||||
class="branch-peek"
|
||||
:data-test="`branch-peek-${index}-${bi}`"
|
||||
>{{ branchPeek(branch.condition.var, branch.action.target_var) }}</span>
|
||||
<span class="spacer" />
|
||||
<Button
|
||||
v-if="bi > 0"
|
||||
@@ -321,6 +404,7 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-show="!isBranchCollapsed(bi)" class="branch-body">
|
||||
<!-- Compact grid (Proposal B, 2026-05-09 cycle):
|
||||
row 1 = WHEN var + op on a single line so the prose
|
||||
reads "When $age equals". Row 2 = condition value below
|
||||
@@ -476,12 +560,28 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
<div class="dvr-hint" :data-test="`act-hint-${index}-${bi}`">
|
||||
{{ SUPPORTED_SYNTAX_HINT }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ELSE branch -->
|
||||
<div v-if="rule.else" class="branch branch--else" data-kind="else" :data-test="`branch-else-${index}`">
|
||||
<div class="branch-head">
|
||||
<button
|
||||
type="button"
|
||||
class="branch-collapse"
|
||||
:aria-expanded="!isBranchCollapsed(-1)"
|
||||
:aria-label="`${isBranchCollapsed(-1) ? 'Expand' : 'Collapse'} ELSE branch of rule ${ruleNumber}`"
|
||||
:data-test="`toggle-branch-else-${index}`"
|
||||
@click="toggleBranch(-1)"
|
||||
>
|
||||
<i :class="isBranchCollapsed(-1) ? 'pi pi-chevron-right' : 'pi pi-chevron-down'" aria-hidden="true" />
|
||||
</button>
|
||||
<span class="branch-tag" data-kind="else">ELSE</span>
|
||||
<span
|
||||
v-if="isBranchCollapsed(-1)"
|
||||
class="branch-peek"
|
||||
:data-test="`branch-peek-else-${index}`"
|
||||
>→ {{ rule.else.action.target_var ? "$" + rule.else.action.target_var : "$?" }}</span>
|
||||
<span class="spacer" />
|
||||
<Button
|
||||
icon="pi-times"
|
||||
@@ -492,6 +592,7 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
@click="removeElse"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="!isBranchCollapsed(-1)" class="branch-body">
|
||||
<div class="dvr-grid dvr-grid--then">
|
||||
<span class="dvr-label">Then</span>
|
||||
<div class="dvr-var-wrap" :data-test="`else-var-wrap-${index}`">
|
||||
@@ -541,6 +642,7 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
<div class="dvr-hint" :data-test="`else-hint-${index}`">
|
||||
{{ SUPPORTED_SYNTAX_HINT }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -600,6 +702,7 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
color: var(--wp-text-muted, #9ca3af);
|
||||
}
|
||||
.spacer { flex: 1; }
|
||||
.rule-head__branch-ctrls { display: inline-flex; gap: 2px; }
|
||||
|
||||
.branches {
|
||||
display: flex;
|
||||
@@ -627,6 +730,38 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
align-items: center;
|
||||
gap: var(--wp-space-4);
|
||||
}
|
||||
/* Per-branch collapse chevron — mirrors the rule-card head toggle. */
|
||||
.branch-collapse {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--wp-text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.branch-collapse:hover { background: var(--wp-bg-3); color: var(--wp-text); }
|
||||
.branch-collapse:focus-visible { outline: none; box-shadow: var(--wp-focus-ring); }
|
||||
.branch-collapse i { font-size: var(--wp-text-xs); }
|
||||
/* One-line peek shown in a collapsed branch head. */
|
||||
.branch-peek {
|
||||
font-family: var(--wp-font-mono, ui-monospace, monospace);
|
||||
font-size: var(--wp-text-xs);
|
||||
color: var(--wp-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.branch-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--wp-space-4);
|
||||
margin-top: var(--wp-space-4);
|
||||
}
|
||||
|
||||
.branch-tag {
|
||||
font-family: var(--wp-font-mono, ui-monospace, monospace);
|
||||
|
||||
@@ -8,17 +8,31 @@
|
||||
* `tags` is a new sub-category — emitted via apply-tag too; the host decides to
|
||||
* auto-create it (in Ungrouped).
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import Button from "./ui/Button.vue";
|
||||
import Input from "./ui/Input.vue";
|
||||
import { UNGROUPED_HUE } from "../../components/shared/axis-color";
|
||||
|
||||
interface Props {
|
||||
count: number;
|
||||
/** Existing sub-category tags, for the apply / remove menus. */
|
||||
tags: string[];
|
||||
/** Tags already on EVERY selected row — hidden from the Apply menu since
|
||||
* applying one there is a no-op. Absent (undefined) = exclude nothing. */
|
||||
commonTags?: string[];
|
||||
/** Tags present on ≥1 selected row — the Remove menu's candidate list.
|
||||
* Absent (undefined) falls back to `tags`; an explicit empty array means
|
||||
* the selection carries no tags, so Remove is empty. */
|
||||
presentTags?: string[];
|
||||
/** tag → axis hue, so menu chips match the pill/chip colours elsewhere. */
|
||||
tagHues?: Record<string, string>;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
function hueOf(tag: string): string {
|
||||
return props.tagHues?.[tag] ?? UNGROUPED_HUE;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "apply-tag", tag: string): void;
|
||||
(e: "remove-tag", tag: string): void;
|
||||
@@ -40,11 +54,38 @@ function toggle(menu: Exclude<Menu, null>) {
|
||||
}
|
||||
function close() { openMenu.value = null; }
|
||||
|
||||
const filteredTags = computed(() => {
|
||||
// Close the menu when the page scrolls behind it. The toolbar lives in a
|
||||
// sticky header, so an open menu would otherwise hover in place while the
|
||||
// option list scrolls under it — the "can't scroll the page" trap. Scrolls
|
||||
// that originate INSIDE the menu's own list are ignored (that's the list
|
||||
// scrolling itself; `overscroll-behavior: contain` keeps it from chaining).
|
||||
function onDocScroll(e: Event): void {
|
||||
const t = e.target;
|
||||
if (t instanceof Element && t.closest(".wpc-seltoolbar__menulist")) return;
|
||||
close();
|
||||
}
|
||||
watch(openMenu, (open) => {
|
||||
if (open) document.addEventListener("scroll", onDocScroll, true);
|
||||
else document.removeEventListener("scroll", onDocScroll, true);
|
||||
});
|
||||
onBeforeUnmount(() => document.removeEventListener("scroll", onDocScroll, true));
|
||||
|
||||
// Apply menu: every tag EXCEPT those already on all selected rows, so the
|
||||
// list only offers tags that would actually change something. Then narrowed
|
||||
// by the type-to-filter box.
|
||||
const applyList = computed(() => {
|
||||
const excluded = new Set(props.commonTags ?? []);
|
||||
const q = applyFilter.value.trim().toLowerCase();
|
||||
const list = [...props.tags].sort((a, b) => a.localeCompare(b));
|
||||
const list = [...props.tags]
|
||||
.filter((t) => !excluded.has(t))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
return q ? list.filter((t) => t.toLowerCase().includes(q)) : list;
|
||||
});
|
||||
// Remove menu: only tags the selection actually carries. `presentTags`
|
||||
// absent (undefined) falls back to all tags; an explicit [] means none.
|
||||
const removeList = computed(() =>
|
||||
[...(props.presentTags ?? props.tags)].sort((a, b) => a.localeCompare(b)),
|
||||
);
|
||||
// "new tag" = typed name not matching any existing tag (case-insensitive).
|
||||
const newTagIsNovel = computed(() => {
|
||||
const n = newTag.value.trim();
|
||||
@@ -96,13 +137,14 @@ function applyWeight() {
|
||||
</button>
|
||||
<div class="wpc-seltoolbar__menulist">
|
||||
<button
|
||||
v-for="t in filteredTags"
|
||||
v-for="t in applyList"
|
||||
:key="t"
|
||||
type="button"
|
||||
class="wpc-seltoolbar__menuitem"
|
||||
:style="{ '--chip-hue': hueOf(t) }"
|
||||
@click="applyTag(t)"
|
||||
>{{ t }}</button>
|
||||
<div v-if="!filteredTags.length && !newTagIsNovel" class="wpc-seltoolbar__menuempty">No sub-categories yet</div>
|
||||
><span class="wpc-seltoolbar__swatch" aria-hidden="true"></span>{{ t }}</button>
|
||||
<div v-if="!applyList.length && !newTagIsNovel" class="wpc-seltoolbar__menuempty">Every selected row already has all sub-categories</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,13 +157,14 @@ function applyWeight() {
|
||||
<div v-if="openMenu === 'remove'" class="wpc-seltoolbar__menu" role="menu">
|
||||
<div class="wpc-seltoolbar__menulist">
|
||||
<button
|
||||
v-for="t in filteredTags"
|
||||
v-for="t in removeList"
|
||||
:key="t"
|
||||
type="button"
|
||||
class="wpc-seltoolbar__menuitem"
|
||||
:style="{ '--chip-hue': hueOf(t) }"
|
||||
@click="removeTag(t)"
|
||||
>{{ t }}</button>
|
||||
<div v-if="!filteredTags.length" class="wpc-seltoolbar__menuempty">No sub-categories</div>
|
||||
><span class="wpc-seltoolbar__swatch" aria-hidden="true"></span>{{ t }}</button>
|
||||
<div v-if="!removeList.length" class="wpc-seltoolbar__menuempty">Selection has no sub-categories</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,8 +251,16 @@ function applyWeight() {
|
||||
flex-direction: column;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
/* Keep wheel/trackpad scrolling inside the list — at the top/bottom edge
|
||||
* it must NOT chain to the editor body behind (that "nudged the page and
|
||||
* you couldn't recenter"). */
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.wpc-seltoolbar__menuitem {
|
||||
--chip-hue: var(--wp-text-dim, var(--wp-text3));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
@@ -219,8 +270,23 @@ function applyWeight() {
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wpc-seltoolbar__menuitem:hover { background: var(--wp-bg-3); }
|
||||
.wpc-seltoolbar__menuitem--new { color: var(--wp-accent); font-weight: 600; }
|
||||
/* Colour swatch mirroring the axis hue of the pills / option-row chips so a
|
||||
* sub-category reads with the same colour in the menu as everywhere else. */
|
||||
.wpc-seltoolbar__swatch {
|
||||
flex: 0 0 auto;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid color-mix(in srgb, var(--chip-hue) 55%, var(--wp-border));
|
||||
background: color-mix(in srgb, var(--chip-hue) 40%, transparent);
|
||||
}
|
||||
.wpc-seltoolbar__menuitem:hover {
|
||||
background: color-mix(in srgb, var(--chip-hue) 14%, var(--wp-bg-3));
|
||||
}
|
||||
.wpc-seltoolbar__menuitem--new {
|
||||
color: var(--wp-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wpc-seltoolbar__menuempty { padding: 6px 8px; font-size: 12px; color: var(--wp-text-muted); }
|
||||
.wpc-seltoolbar__backdrop {
|
||||
position: fixed;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Update-available dialog. Opened from the topbar update pill. Shows the
|
||||
* current → latest version, a rendered changelog, and drives the update
|
||||
* through ComfyUI Manager (Update Now → installing → staged → explicit
|
||||
* Restart). Falls back to guidance when Manager is absent or blocks the
|
||||
* call (403). Reboot is always a separate, explicit user click.
|
||||
*/
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import Modal from "./ui/Modal.vue";
|
||||
import Button from "./ui/Button.vue";
|
||||
import { useReleaseCheck } from "../composables/useReleaseCheck";
|
||||
import { useComfyManagerUpdate, type ManagerAvailability } from "../composables/useComfyManagerUpdate";
|
||||
import { renderReleaseNotes } from "../utils/releaseNotes";
|
||||
import { GITHUB_REPO } from "../config/links";
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ (e: "close"): void }>();
|
||||
|
||||
const { current, latestVersion, severity, releaseBody, releaseUrl } = useReleaseCheck();
|
||||
const { phase, errorKind, runUpdate, reboot, probe, managerUiUrl } = useComfyManagerUpdate();
|
||||
|
||||
const availability = ref<ManagerAvailability | null>(null);
|
||||
|
||||
// Probe Manager once each time the dialog opens so we know whether to
|
||||
// show Update Now or the guidance fallback.
|
||||
watch(
|
||||
() => props.open,
|
||||
async (isOpen) => {
|
||||
if (isOpen && availability.value === null) {
|
||||
availability.value = await probe();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const notesHtml = computed(() => renderReleaseNotes(releaseBody.value ?? ""));
|
||||
const fullChangelogUrl = computed(() => releaseUrl.value ?? `${GITHUB_REPO}/releases`);
|
||||
|
||||
const severityLabel = computed(() =>
|
||||
severity.value === "major" ? "Major update"
|
||||
: severity.value === "minor" ? "Minor update"
|
||||
: "Patch update",
|
||||
);
|
||||
const severityClass = computed(() =>
|
||||
severity.value === "major" ? "wpc-upd__badge--warn" : "wpc-upd__badge--accent",
|
||||
);
|
||||
|
||||
// Fallback whenever Manager is missing OR the update errored with a
|
||||
// forbidden/failed kind.
|
||||
const showFallback = computed(
|
||||
() => availability.value === "absent" || phase.value === "error",
|
||||
);
|
||||
|
||||
function onClose(): void { emit("close"); }
|
||||
function onModalOpen(v: boolean): void { if (!v) onClose(); }
|
||||
|
||||
/** Kick off the update to the exact version the check found. Passing the
|
||||
* concrete target (not "latest") lets the composable pin it + guard against
|
||||
* a downgrade. */
|
||||
function onUpdateNow(): void {
|
||||
if (latestVersion.value) runUpdate(latestVersion.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="open"
|
||||
title="Update available"
|
||||
size="md"
|
||||
:close-on-backdrop="phase !== 'installing'"
|
||||
@update:open="onModalOpen"
|
||||
>
|
||||
<div class="wpc-upd">
|
||||
<div class="wpc-upd__head">
|
||||
<span class="wpc-upd__ver">v{{ current }}</span>
|
||||
<span class="wpc-upd__arrow" aria-hidden="true">→</span>
|
||||
<span class="wpc-upd__ver wpc-upd__ver--new">v{{ latestVersion }}</span>
|
||||
<span class="wpc-upd__badge" :class="severityClass">{{ severityLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Rendered release notes. `notesHtml` is produced by
|
||||
renderReleaseNotes, which escapes before transforming, so this
|
||||
is the one sanctioned v-html. -->
|
||||
<div class="wpc-relnotes" data-test="update-notes" v-html="notesHtml" />
|
||||
|
||||
<a
|
||||
class="wpc-upd__full"
|
||||
:href="fullChangelogUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-test="update-full-changelog"
|
||||
>Full changelog →</a>
|
||||
|
||||
<!-- Guidance fallback: Manager missing or blocked. -->
|
||||
<div v-if="showFallback" class="wpc-upd__fallback" data-test="update-fallback">
|
||||
<p class="wpc-upd__fallback-title">Update via ComfyUI Manager</p>
|
||||
<p class="wpc-upd__fallback-body">
|
||||
{{ errorKind === "forbidden"
|
||||
? "ComfyUI Manager blocked the in-app update (its security level). Update it from the Manager UI, or pull manually:"
|
||||
: "Automatic update isn't available here. Update from the ComfyUI Manager UI, or pull manually:" }}
|
||||
</p>
|
||||
<a class="wpc-upd__manager-link" :href="managerUiUrl" target="_blank" rel="noopener">Open ComfyUI Manager</a>
|
||||
<pre class="wpc-upd__cmd"><code>cd custom_nodes/ComfyUI-Wildcard-Pipeline
|
||||
git pull
|
||||
# then restart ComfyUI</code></pre>
|
||||
</div>
|
||||
|
||||
<p v-else-if="phase === 'staged'" class="wpc-upd__staged" data-test="update-staged">
|
||||
Update staged. Restart ComfyUI to apply.
|
||||
</p>
|
||||
<p v-else-if="phase === 'restarting'" class="wpc-upd__staged" data-test="update-restarting">
|
||||
Restarting ComfyUI… reopen the browser tab in a moment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="showFallback">
|
||||
<Button variant="secondary" data-test="update-later" @click="onClose">Close</Button>
|
||||
</template>
|
||||
<template v-else-if="phase === 'idle'">
|
||||
<Button variant="secondary" data-test="update-later" @click="onClose">Later</Button>
|
||||
<Button variant="primary" data-test="update-now" @click="onUpdateNow">Update Now</Button>
|
||||
</template>
|
||||
<template v-else-if="phase === 'installing'">
|
||||
<Button variant="primary" :loading="true" :disabled="true" data-test="update-installing">Updating…</Button>
|
||||
</template>
|
||||
<template v-else-if="phase === 'staged'">
|
||||
<Button variant="secondary" data-test="update-later" @click="onClose">Later</Button>
|
||||
<Button variant="primary" data-test="update-restart" @click="reboot">Restart ComfyUI</Button>
|
||||
</template>
|
||||
<template v-else-if="phase === 'restarting'">
|
||||
<Button variant="secondary" data-test="update-later" @click="onClose">Close</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wpc-upd { display: flex; flex-direction: column; gap: var(--wp-space-4); }
|
||||
.wpc-upd__head { display: flex; align-items: center; gap: var(--wp-space-3); flex-wrap: wrap; }
|
||||
.wpc-upd__ver { font: 600 var(--wp-text-sm)/1 var(--wp-font-mono, monospace); color: var(--wp-text-muted); }
|
||||
.wpc-upd__ver--new { color: var(--wp-accent-text); }
|
||||
.wpc-upd__arrow { color: var(--wp-text-dim); }
|
||||
.wpc-upd__badge { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; padding: 2px 7px; border-radius: 999px; }
|
||||
.wpc-upd__badge--accent { color: var(--wp-accent-text); background: color-mix(in oklab, var(--wp-accent-500) 18%, transparent); }
|
||||
.wpc-upd__badge--warn { color: var(--wp-warn, #f59e0b); background: color-mix(in oklab, var(--wp-warn, #f59e0b) 18%, transparent); }
|
||||
.wpc-relnotes { max-height: 320px; overflow-y: auto; font-size: var(--wp-text-sm); line-height: 1.55; color: var(--wp-text-muted); background: var(--wp-bg-2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); padding: var(--wp-space-4); }
|
||||
.wpc-relnotes :deep(h1), .wpc-relnotes :deep(h2), .wpc-relnotes :deep(h3) { color: var(--wp-text); margin: var(--wp-space-3) 0 var(--wp-space-2); font-size: var(--wp-text-sm); font-weight: 700; }
|
||||
.wpc-relnotes :deep(ul) { margin: var(--wp-space-2) 0; padding-left: var(--wp-space-6); }
|
||||
.wpc-relnotes :deep(li) { margin: 2px 0; }
|
||||
.wpc-relnotes :deep(code) { font-family: var(--wp-font-mono, monospace); background: var(--wp-bg-1); padding: 1px 4px; border-radius: 3px; }
|
||||
.wpc-relnotes :deep(pre) { background: var(--wp-bg-1); padding: var(--wp-space-3); border-radius: var(--wp-radius); overflow-x: auto; }
|
||||
.wpc-relnotes :deep(a) { color: var(--wp-accent-text); }
|
||||
.wpc-upd__full { font-size: var(--wp-text-sm); color: var(--wp-accent-text); text-decoration: none; }
|
||||
.wpc-upd__full:hover { color: var(--wp-text); }
|
||||
.wpc-upd__fallback { border: 1px solid var(--wp-border); border-radius: var(--wp-radius); padding: var(--wp-space-4); display: flex; flex-direction: column; gap: var(--wp-space-3); }
|
||||
.wpc-upd__fallback-title { font-weight: 700; color: var(--wp-text); margin: 0; }
|
||||
.wpc-upd__fallback-body { font-size: var(--wp-text-sm); color: var(--wp-text-muted); margin: 0; }
|
||||
.wpc-upd__manager-link { font-size: var(--wp-text-sm); color: var(--wp-accent-text); text-decoration: none; }
|
||||
.wpc-upd__cmd { background: var(--wp-bg-1); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); padding: var(--wp-space-3); font-size: var(--wp-text-sm); overflow-x: auto; margin: 0; }
|
||||
.wpc-upd__staged { font-size: var(--wp-text-sm); color: var(--wp-accent-text); margin: 0; }
|
||||
</style>
|
||||
@@ -1,26 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { useSlots } from "vue";
|
||||
import { useSlots, ref, onMounted, onBeforeUnmount } from "vue";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
padding?: boolean;
|
||||
/** Stick the card header (and its `subheader` slot, e.g. a bulk-edit
|
||||
* toolbar) to the top of the nearest scroll container so its actions stay
|
||||
* reachable when scrolling a long list — new rows always land at the
|
||||
* bottom, so the toolbar must follow. Publishes the measured height of the
|
||||
* whole stuck region as `--wp-card-header-h` so a sticky `<thead>` inside
|
||||
* the body can offset itself right below it (updates as the subheader
|
||||
* toolbar appears/disappears or density changes). */
|
||||
stickyHeader?: boolean;
|
||||
}
|
||||
withDefaults(defineProps<Props>(), { padding: true });
|
||||
const props = withDefaults(defineProps<Props>(), { padding: true, stickyHeader: false });
|
||||
|
||||
const slots = useSlots();
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const stickyEl = ref<HTMLElement | null>(null);
|
||||
let ro: ResizeObserver | null = null;
|
||||
|
||||
function syncStickyHeight(): void {
|
||||
if (!root.value || !stickyEl.value) return;
|
||||
const h = Math.round(stickyEl.value.getBoundingClientRect().height);
|
||||
root.value.style.setProperty("--wp-card-header-h", `${h}px`);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.stickyHeader || !stickyEl.value) return;
|
||||
syncStickyHeight();
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
ro = new ResizeObserver(syncStickyHeight);
|
||||
ro.observe(stickyEl.value);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => { ro?.disconnect(); ro = null; });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wp-card">
|
||||
<header v-if="title || slots.actions" class="wp-card__header">
|
||||
<div class="wp-card__title-wrap">
|
||||
<h2 v-if="title" class="wp-card__title">{{ title }}</h2>
|
||||
<p v-if="subtitle" class="wp-card__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<span class="wp-spacer" />
|
||||
<slot name="actions" />
|
||||
</header>
|
||||
<section ref="root" class="wp-card" :class="{ 'wp-card--sticky-header': stickyHeader }">
|
||||
<div ref="stickyEl" class="wp-card__sticky">
|
||||
<header v-if="title || slots.actions" class="wp-card__header">
|
||||
<div class="wp-card__title-wrap">
|
||||
<h2 v-if="title" class="wp-card__title">{{ title }}</h2>
|
||||
<p v-if="subtitle" class="wp-card__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<span class="wp-spacer" />
|
||||
<slot name="actions" />
|
||||
</header>
|
||||
<slot name="subheader" />
|
||||
</div>
|
||||
<div :class="padding ? 'wp-card__body' : ''">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,17 @@ const inputClasses = computed(() => [
|
||||
]);
|
||||
|
||||
const isNumber = computed(() => props.type === "number");
|
||||
|
||||
/** Native `step` here is ONLY used by the browser for HTML validation —
|
||||
* arrow keys, the wheel, and the custom chevrons all route through `bump()`
|
||||
* (native spin buttons are hidden via CSS), and `bump()` reads `props.step`
|
||||
* itself. A concrete step (e.g. `0.1`) combined with an off-grid `min` or
|
||||
* value makes the browser reject legitimate input: a default weight of `1`
|
||||
* with `min="0.01" step="0.1"` popped up "Please select a valid value. The
|
||||
* two nearest valid values are 0.91 and 1.01." So emit `step="any"` for
|
||||
* number inputs — the browser never grid-validates, while `bump()` keeps
|
||||
* stepping by `props.step`. */
|
||||
const nativeStep = computed(() => (isNumber.value ? "any" : props.step));
|
||||
const wrapped = computed(() => Boolean(props.icon || props.addon) || isNumber.value);
|
||||
|
||||
function onInput(e: Event) {
|
||||
@@ -122,7 +133,7 @@ function bump(direction: 1 | -1) {
|
||||
:aria-invalid="error || undefined"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:step="nativeStep"
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
@wheel="onWheel"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { computed, ref, type ComputedRef, type Ref } from "vue";
|
||||
|
||||
/**
|
||||
* Generic multi-row selection for an editor's bulk mode (select + delete).
|
||||
*
|
||||
* The editor owns its rows; this owns only the "which ids are picked" state
|
||||
* plus the on/off `active` flag. Pass a `getIds` that returns the CURRENT
|
||||
* selectable row ids — every derived value reads through it, so selection
|
||||
* stays correct even as rows are added or removed (a deleted id simply stops
|
||||
* counting; no stale-id bookkeeping needed).
|
||||
*
|
||||
* Mirrors the hand-rolled selection in WildcardEditor so the three simpler
|
||||
* editors (fixed-values, constraint exceptions, derivation rules) share one
|
||||
* implementation instead of copying it three times.
|
||||
*/
|
||||
export interface BulkSelection {
|
||||
/** Bulk mode on/off. */
|
||||
active: Ref<boolean>;
|
||||
/** How many currently-present rows are selected. */
|
||||
count: ComputedRef<number>;
|
||||
/** True when every selectable row is selected (drives the header check). */
|
||||
allSelected: ComputedRef<boolean>;
|
||||
/** True when ≥1 (but maybe not all) rows are selected (indeterminate dash). */
|
||||
someSelected: ComputedRef<boolean>;
|
||||
isSelected: (id: string) => boolean;
|
||||
toggle: (id: string) => void;
|
||||
/** Select all when not all selected; otherwise clear. */
|
||||
toggleAll: () => void;
|
||||
clear: () => void;
|
||||
/** Enter/leave bulk mode; leaving clears the selection. */
|
||||
toggleMode: () => void;
|
||||
exit: () => void;
|
||||
/** The selected ids that still exist, for the delete handler. */
|
||||
selectedIds: () => string[];
|
||||
}
|
||||
|
||||
export function useBulkSelection(getIds: () => string[]): BulkSelection {
|
||||
const active = ref(false);
|
||||
const selected = ref<Set<string>>(new Set());
|
||||
|
||||
const count = computed(
|
||||
() => getIds().filter((id) => selected.value.has(id)).length,
|
||||
);
|
||||
const allSelected = computed(() => {
|
||||
const ids = getIds();
|
||||
return ids.length > 0 && ids.every((id) => selected.value.has(id));
|
||||
});
|
||||
const someSelected = computed(() =>
|
||||
getIds().some((id) => selected.value.has(id)),
|
||||
);
|
||||
|
||||
function isSelected(id: string): boolean {
|
||||
return selected.value.has(id);
|
||||
}
|
||||
function toggle(id: string): void {
|
||||
const next = new Set(selected.value);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
selected.value = next;
|
||||
}
|
||||
function toggleAll(): void {
|
||||
if (allSelected.value) selected.value = new Set();
|
||||
else selected.value = new Set(getIds());
|
||||
}
|
||||
function clear(): void {
|
||||
selected.value = new Set();
|
||||
}
|
||||
function exit(): void {
|
||||
active.value = false;
|
||||
clear();
|
||||
}
|
||||
function toggleMode(): void {
|
||||
if (active.value) exit();
|
||||
else active.value = true;
|
||||
}
|
||||
function selectedIds(): string[] {
|
||||
return getIds().filter((id) => selected.value.has(id));
|
||||
}
|
||||
|
||||
return {
|
||||
active,
|
||||
count,
|
||||
allSelected,
|
||||
someSelected,
|
||||
isSelected,
|
||||
toggle,
|
||||
toggleAll,
|
||||
clear,
|
||||
toggleMode,
|
||||
exit,
|
||||
selectedIds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Drives an in-place update through ComfyUI Manager's same-origin HTTP
|
||||
* API instead of reimplementing git. Flow: queue an install-to-latest
|
||||
* for our registry id, start the queue, poll status to completion, then
|
||||
* (on an explicit user click) reboot ComfyUI.
|
||||
*
|
||||
* Security note: `/manager/queue/install` and `/manager/reboot` are gated
|
||||
* by ComfyUI Manager's `security_level` (default `normal` → allowed;
|
||||
* `strong` → 403). A GET probe can't see that gate, so a 403 surfaces as
|
||||
* `errorKind: "forbidden"` from `runUpdate`; the dialog shows the same
|
||||
* guidance fallback it shows when Manager is absent.
|
||||
*/
|
||||
import { ref } from "vue";
|
||||
|
||||
import { COMFY_REGISTRY_ID } from "../config/links";
|
||||
|
||||
export type ManagerAvailability = "available" | "absent";
|
||||
export type UpdatePhase = "idle" | "installing" | "staged" | "restarting" | "error";
|
||||
export type UpdateErrorKind = "forbidden" | "failed" | null;
|
||||
|
||||
const STATUS_POLL_MS = 1000;
|
||||
const STATUS_MAX_POLLS = 120; // ~2 min ceiling
|
||||
|
||||
interface QueueStatus {
|
||||
total_count?: number;
|
||||
done_count?: number;
|
||||
in_progress?: boolean;
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
/** Parse a `1.2.3` version into numeric segments (prerelease ignored). */
|
||||
function parseSemver(v: string): number[] {
|
||||
return v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
||||
}
|
||||
|
||||
/** True when `target` is a strictly newer version than `current`. Used as a
|
||||
* downgrade guard so a mis-resolved target can never install an older
|
||||
* build over a newer one. */
|
||||
function isStrictlyNewer(target: string, current: string): boolean {
|
||||
const t = parseSemver(target);
|
||||
const c = parseSemver(current);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const diff = (t[i] ?? 0) - (c[i] ?? 0);
|
||||
if (diff !== 0) return diff > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useComfyManagerUpdate(): {
|
||||
phase: ReturnType<typeof ref<UpdatePhase>>;
|
||||
errorKind: ReturnType<typeof ref<UpdateErrorKind>>;
|
||||
errorMessage: ReturnType<typeof ref<string | null>>;
|
||||
probe: () => Promise<ManagerAvailability>;
|
||||
runUpdate: (targetVersion: string) => Promise<void>;
|
||||
reboot: () => Promise<void>;
|
||||
managerUiUrl: string;
|
||||
} {
|
||||
const phase = ref<UpdatePhase>("idle");
|
||||
const errorKind = ref<UpdateErrorKind>(null);
|
||||
const errorMessage = ref<string | null>(null);
|
||||
|
||||
async function probe(): Promise<ManagerAvailability> {
|
||||
try {
|
||||
const res = await fetch("/manager/queue/status", { method: "GET" });
|
||||
return res.ok ? "available" : "absent";
|
||||
} catch {
|
||||
return "absent";
|
||||
}
|
||||
}
|
||||
|
||||
function fail(kind: Exclude<UpdateErrorKind, null>, message: string): void {
|
||||
phase.value = "error";
|
||||
errorKind.value = kind;
|
||||
errorMessage.value = message;
|
||||
}
|
||||
|
||||
async function pollUntilDone(): Promise<boolean> {
|
||||
for (let i = 0; i < STATUS_MAX_POLLS; i++) {
|
||||
try {
|
||||
const res = await fetch("/manager/queue/status", { method: "GET" });
|
||||
if (res.ok) {
|
||||
const s = (await res.json()) as QueueStatus;
|
||||
const total = s.total_count ?? 0;
|
||||
const done = s.done_count ?? 0;
|
||||
const running = s.in_progress ?? false;
|
||||
if (!running && (total === 0 || done >= total)) return true;
|
||||
}
|
||||
} catch {
|
||||
/* transient — keep polling until the ceiling */
|
||||
}
|
||||
await delay(STATUS_POLL_MS);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function runUpdate(targetVersion: string): Promise<void> {
|
||||
phase.value = "installing";
|
||||
errorKind.value = null;
|
||||
errorMessage.value = null;
|
||||
// Downgrade guard. `targetVersion` is the exact release the update check
|
||||
// found (e.g. "2.10.1"). Never proceed unless it is strictly newer than
|
||||
// what's installed. This is the fix for the 2.10.0 downgrade bug, where
|
||||
// `selected_version:"latest"` + `mode:"cache"` let ComfyUI Manager
|
||||
// resolve "latest" against its STALE cached catalog and install an older
|
||||
// version over a newer one.
|
||||
if (!targetVersion || !isStrictlyNewer(targetVersion, __APP_VERSION__)) {
|
||||
fail("failed", `Refusing to install "${targetVersion || "unknown"}" — it is not newer than the installed ${__APP_VERSION__}.`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const installRes = await fetch("/manager/queue/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: COMFY_REGISTRY_ID,
|
||||
version: __APP_VERSION__,
|
||||
// Pin the EXACT target version, not "latest" — we already know it.
|
||||
selected_version: targetVersion,
|
||||
channel: "default",
|
||||
// "remote" fetches fresh registry data so the pinned version
|
||||
// resolves against the live catalog, not Manager's stale cache.
|
||||
mode: "remote",
|
||||
}),
|
||||
});
|
||||
if (!installRes.ok) {
|
||||
const text = await installRes.text().catch(() => "");
|
||||
if (installRes.status === 403) {
|
||||
fail("forbidden", text || "ComfyUI Manager blocked the update (security level).");
|
||||
} else {
|
||||
fail("failed", text || `Install request failed (${installRes.status}).`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const startRes = await fetch("/manager/queue/start", { method: "POST" });
|
||||
if (!startRes.ok) {
|
||||
fail("failed", `Could not start the update queue (${startRes.status}).`);
|
||||
return;
|
||||
}
|
||||
const done = await pollUntilDone();
|
||||
if (!done) {
|
||||
fail("failed", "The update did not finish in time. Check ComfyUI's console.");
|
||||
return;
|
||||
}
|
||||
phase.value = "staged";
|
||||
} catch (e) {
|
||||
fail("failed", e instanceof Error ? e.message : "Unexpected update error.");
|
||||
}
|
||||
}
|
||||
|
||||
async function reboot(): Promise<void> {
|
||||
phase.value = "restarting";
|
||||
try {
|
||||
await fetch("/manager/reboot", { method: "POST" });
|
||||
} catch {
|
||||
// The server drops the socket while restarting — expected. Stay in
|
||||
// `restarting`; the dialog tells the user to reopen ComfyUI.
|
||||
}
|
||||
}
|
||||
|
||||
const managerUiUrl = "/manager";
|
||||
|
||||
return { phase, errorKind, errorMessage, probe, runUpdate, reboot, managerUiUrl };
|
||||
}
|
||||
@@ -1,29 +1,101 @@
|
||||
/**
|
||||
* Release check - once-per-day GitHub Releases poll for "update available".
|
||||
* Release check - GitHub Releases lookup for "update available".
|
||||
*
|
||||
* Hits `https://api.github.com/repos/<owner>/<repo>/releases/latest`
|
||||
* anonymously (unauthenticated requests get 60/hr per IP, plenty for a
|
||||
* single-user dev tool). The result + a timestamp are cached in
|
||||
* localStorage; subsequent calls return the cached value until the TTL
|
||||
* expires, so a topbar re-mount doesn't burn a request per page nav.
|
||||
* single-user dev tool). Cadence is launch-once + manual: on mount we
|
||||
* paint any cached result immediately, then refresh once per app session
|
||||
* IF the user opted into launch checks (`uiStore.checkOnLaunch`). There is
|
||||
* no timed re-poll; `checkNow()` triggers an on-demand refresh. The
|
||||
* localStorage cache exists only to paint instantly before the network
|
||||
* call resolves — it is not a re-check suppressor.
|
||||
*
|
||||
* The composable returns a reactive `hasUpdate` flag + `latestVersion`
|
||||
* so AppTopbar can paint an accent dot without owning the polling
|
||||
* machinery. Network failure (offline, rate limit, 404 on private repo)
|
||||
* fails silently - `hasUpdate` stays false; we never block the UI.
|
||||
* The composable returns reactive `hasUpdate` / `latestVersion` /
|
||||
* `severity` so AppTopbar can paint an accent pill, plus the release
|
||||
* `body` / `html_url` / `lastChecked` for the Update dialog + Settings.
|
||||
* Network failure (offline, rate limit, 404 on private repo) fails
|
||||
* silently - `hasUpdate` stays false; we never block the UI.
|
||||
*/
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
import { GITHUB_REPO } from "../config/links";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
|
||||
const STORAGE_KEY = "wp.releaseCheck";
|
||||
const TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface CachedRelease {
|
||||
/** ISO timestamp of when the check ran. */
|
||||
checked_at: string;
|
||||
/** GitHub release `tag_name` minus a leading `v`. */
|
||||
latest_version: string;
|
||||
body?: string | null;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
/** Module-level singleton state so EVERY consumer (topbar pill + Settings
|
||||
* card) shares ONE reactive source. Without this, each `useReleaseCheck()`
|
||||
* call got its own refs — so a manual "Check now" in Settings updated only
|
||||
* Settings' copy and the topbar pill stayed dark until a page reload. */
|
||||
const latestVersion = ref<string | null>(null);
|
||||
const hasUpdate = ref<boolean>(false);
|
||||
const severity = ref<UpdateSeverity | null>(null);
|
||||
const releaseBody = ref<string | null>(null);
|
||||
const releaseUrl = ref<string | null>(null);
|
||||
const lastChecked = ref<string | null>(null);
|
||||
const checking = ref<boolean>(false);
|
||||
|
||||
/** Guard so the once-per-session launch fetch fires at most once across all
|
||||
* consumers. `checkNow` ignores it. */
|
||||
let sessionFetched = false;
|
||||
|
||||
/** Test-only: reset the shared state + session guard between mounts. */
|
||||
export function resetReleaseCheckSession(): void {
|
||||
sessionFetched = false;
|
||||
latestVersion.value = null;
|
||||
hasUpdate.value = false;
|
||||
severity.value = null;
|
||||
releaseBody.value = null;
|
||||
releaseUrl.value = null;
|
||||
lastChecked.value = null;
|
||||
checking.value = false;
|
||||
}
|
||||
|
||||
/** Apply a candidate latest version to the shared refs. Reads
|
||||
* `__APP_VERSION__` lazily (not a cached const) so tests that assign it
|
||||
* after import see the right value. Guards a missing/malformed version so a
|
||||
* bad cache degrades to "no update" instead of throwing in semverCompare. */
|
||||
function applyLatest(v: string | null): void {
|
||||
const current = __APP_VERSION__;
|
||||
const valid = typeof v === "string" && v.length > 0 && typeof current === "string";
|
||||
latestVersion.value = valid ? v : null;
|
||||
const newer = valid && semverCompare(v as string, current) > 0;
|
||||
hasUpdate.value = newer;
|
||||
severity.value = newer ? classifyBump(current, v as string) : null;
|
||||
}
|
||||
|
||||
/** Fetch fresh, persist, apply to the shared refs. Shared by the launch
|
||||
* fetch + `checkNow`. */
|
||||
async function refresh(): Promise<void> {
|
||||
checking.value = true;
|
||||
try {
|
||||
const fresh = await fetchLatestRelease();
|
||||
if (fresh) {
|
||||
sessionFetched = true;
|
||||
writeCache(fresh);
|
||||
releaseBody.value = fresh.body;
|
||||
releaseUrl.value = fresh.url;
|
||||
lastChecked.value = new Date().toISOString();
|
||||
applyLatest(fresh.version);
|
||||
}
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Manual check — always refetches, ignores the session guard. Because the
|
||||
* refs are shared, this lights the topbar pill + Settings simultaneously. */
|
||||
async function checkNow(): Promise<void> {
|
||||
await refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,28 +156,28 @@ function classifyBump(current: string, latest: string): UpdateSeverity | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the cached blob if it's still fresh. Returns null on cache
|
||||
* miss, parse failure, or TTL expiry - caller refetches on null.
|
||||
* Parse the cached blob. Returns null on cache miss or parse failure.
|
||||
* There is no TTL expiry — the cache is paint-first, never a re-check
|
||||
* suppressor.
|
||||
*/
|
||||
function readCache(): CachedRelease | null {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as CachedRelease;
|
||||
const age = Date.now() - new Date(parsed.checked_at).getTime();
|
||||
if (Number.isNaN(age) || age > TTL_MS) return null;
|
||||
return parsed;
|
||||
return JSON.parse(raw) as CachedRelease;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(latestVersion: string): void {
|
||||
function writeCache(rel: { version: string; body: string | null; url: string | null }): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
const payload: CachedRelease = {
|
||||
checked_at: new Date().toISOString(),
|
||||
latest_version: latestVersion,
|
||||
latest_version: rel.version,
|
||||
body: rel.body,
|
||||
url: rel.url,
|
||||
};
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||
@@ -124,7 +196,7 @@ function repoSlug(): string | null {
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
|
||||
async function fetchLatestRelease(): Promise<string | null> {
|
||||
async function fetchLatestRelease(): Promise<{ version: string; body: string | null; url: string | null } | null> {
|
||||
const slug = repoSlug();
|
||||
if (!slug) return null;
|
||||
try {
|
||||
@@ -132,50 +204,57 @@ async function fetchLatestRelease(): Promise<string | null> {
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { tag_name?: unknown };
|
||||
const body = (await res.json()) as { tag_name?: unknown; body?: unknown; html_url?: unknown };
|
||||
if (typeof body.tag_name !== "string") return null;
|
||||
return normalizeTag(body.tag_name);
|
||||
return {
|
||||
version: normalizeTag(body.tag_name),
|
||||
body: typeof body.body === "string" ? body.body : null,
|
||||
url: typeof body.html_url === "string" ? body.html_url : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue composable. Call from a setup() block - the underlying GitHub
|
||||
* fetch runs on mount, once per 24h. The returned `current` /
|
||||
* `latestVersion` / `hasUpdate` refs stay reactive across the lifetime
|
||||
* of the consumer.
|
||||
* Vue composable. Call from a setup() block — the underlying GitHub fetch
|
||||
* runs once on mount (when `checkOnLaunch` is on and no other consumer has
|
||||
* fetched this session). The returned refs stay reactive across the
|
||||
* lifetime of the consumer. Requires an active Pinia (reads uiStore).
|
||||
*/
|
||||
export function useReleaseCheck(): {
|
||||
current: string;
|
||||
latestVersion: ReturnType<typeof ref<string | null>>;
|
||||
hasUpdate: ReturnType<typeof ref<boolean>>;
|
||||
severity: ReturnType<typeof ref<UpdateSeverity | null>>;
|
||||
releaseBody: ReturnType<typeof ref<string | null>>;
|
||||
releaseUrl: ReturnType<typeof ref<string | null>>;
|
||||
lastChecked: ReturnType<typeof ref<string | null>>;
|
||||
checking: ReturnType<typeof ref<boolean>>;
|
||||
checkNow: () => Promise<void>;
|
||||
} {
|
||||
const current = __APP_VERSION__;
|
||||
const latestVersion = ref<string | null>(null);
|
||||
const hasUpdate = ref<boolean>(false);
|
||||
const severity = ref<UpdateSeverity | null>(null);
|
||||
const ui = useUiStore();
|
||||
|
||||
function applyLatest(v: string | null): void {
|
||||
latestVersion.value = v;
|
||||
const newer = v !== null && semverCompare(v, current) > 0;
|
||||
hasUpdate.value = newer;
|
||||
severity.value = newer && v ? classifyBump(current, v) : null;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
// Paint the cached result immediately (if any) so the pill/dialog
|
||||
// aren't blank while the network call is in flight.
|
||||
const cached = readCache();
|
||||
if (cached) {
|
||||
releaseBody.value = cached.body ?? null;
|
||||
releaseUrl.value = cached.url ?? null;
|
||||
lastChecked.value = cached.checked_at ?? null;
|
||||
applyLatest(cached.latest_version);
|
||||
return;
|
||||
}
|
||||
const fresh = await fetchLatestRelease();
|
||||
if (fresh) {
|
||||
writeCache(fresh);
|
||||
applyLatest(fresh);
|
||||
// Then refresh once per session, only when the user opted into launch
|
||||
// checks. Fire-and-forget; failures are swallowed in refresh.
|
||||
if (ui.checkOnLaunch && !sessionFetched) {
|
||||
void refresh();
|
||||
}
|
||||
});
|
||||
|
||||
return { current, latestVersion, hasUpdate, severity };
|
||||
return {
|
||||
current, latestVersion, hasUpdate, severity,
|
||||
releaseBody, releaseUrl, lastChecked, checking, checkNow,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
*/
|
||||
export const GITHUB_REPO = "https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline";
|
||||
|
||||
/**
|
||||
* ComfyUI Registry node id for this pack — the `id` passed to ComfyUI
|
||||
* Manager's `/manager/queue/install` to update-in-place. Must match
|
||||
* `pyproject.toml` `name` (publisher `dumiflex`). A wrong id silently
|
||||
* no-ops the install.
|
||||
*/
|
||||
export const COMFY_REGISTRY_ID = "comfyui-wildcard-pipeline";
|
||||
|
||||
/**
|
||||
* Wiki landing page. Branch-aware so a wiki-restructure that moves
|
||||
* the entry point updates here rather than at five call sites.
|
||||
|
||||
@@ -622,41 +622,47 @@ function clearAll() {
|
||||
|
||||
// ---------- Quick filter presets ----------
|
||||
|
||||
/**
|
||||
* Select every entity across all 7 buckets. Equivalent to clicking the
|
||||
* select-all checkbox in each section, but in one click. Does NOT walk
|
||||
* the dep graph — the user already has everything, so closure is a
|
||||
* no-op.
|
||||
*/
|
||||
function presetFullLibrary(): void {
|
||||
selection.value = {
|
||||
bundle: new Set(bundles.value.map((b) => b.id)),
|
||||
wildcard: new Set(modulesForBucket("wildcard").map((m) => m.id)),
|
||||
fixed_values: new Set(modulesForBucket("fixed_values").map((m) => m.id)),
|
||||
combine: new Set(modulesForBucket("combine").map((m) => m.id)),
|
||||
derivation: new Set(modulesForBucket("derivation").map((m) => m.id)),
|
||||
constraint: new Set(modulesForBucket("constraint").map((m) => m.id)),
|
||||
category: new Set(categories.value.map((c) => c.id)),
|
||||
template: new Set(templates.value.map((t) => t.id)),
|
||||
function emptySelection(): Record<BucketKey, Set<string>> {
|
||||
return {
|
||||
bundle: new Set(),
|
||||
wildcard: new Set(),
|
||||
fixed_values: new Set(),
|
||||
combine: new Set(),
|
||||
derivation: new Set(),
|
||||
constraint: new Set(),
|
||||
category: new Set(),
|
||||
template: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every id in one bucket, from whichever ref backs it. */
|
||||
function idsForBucket(b: BucketKey): string[] {
|
||||
if (b === "bundle") return bundles.value.map((x) => x.id);
|
||||
if (b === "category") return categories.value.map((x) => x.id);
|
||||
if (b === "template") return templates.value.map((x) => x.id);
|
||||
return modulesForBucket(b).map((x) => x.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear everything and select only wildcard modules. Common quick path
|
||||
* when the user wants to share just their prompt vocabulary without
|
||||
* combines/derivations/constraints attached.
|
||||
* Select every entity across all 8 buckets. Equivalent to clicking the
|
||||
* select-all checkbox in each section, but in one click. Does NOT walk
|
||||
* the dep graph — the user already has everything, so closure is a no-op.
|
||||
*/
|
||||
function presetWildcardsOnly(): void {
|
||||
selection.value = {
|
||||
bundle: new Set(),
|
||||
wildcard: new Set(modulesForBucket("wildcard").map((m) => m.id)),
|
||||
fixed_values: new Set(),
|
||||
combine: new Set(),
|
||||
derivation: new Set(),
|
||||
constraint: new Set(),
|
||||
category: new Set(),
|
||||
template: new Set(),
|
||||
};
|
||||
function presetFullLibrary(): void {
|
||||
const next = emptySelection();
|
||||
for (const b of BUCKETS) next[b.key] = new Set(idsForBucket(b.key));
|
||||
selection.value = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear everything and select only one kind — powers the per-kind quick
|
||||
* presets ("Wildcards", "Templates", …). Lets the user share, say, just
|
||||
* their prompt vocabulary without combines/derivations/constraints.
|
||||
*/
|
||||
function presetKindOnly(b: BucketKey): void {
|
||||
const next = emptySelection();
|
||||
next[b] = new Set(idsForBucket(b));
|
||||
selection.value = next;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -695,18 +701,24 @@ function presetFavoritesOnly(): void {
|
||||
type="button"
|
||||
@click="presetFullLibrary"
|
||||
><i class="pi pi-database" /> Full library</button>
|
||||
<button
|
||||
class="wp-preset-btn"
|
||||
data-test="preset-wildcards"
|
||||
type="button"
|
||||
@click="presetWildcardsOnly"
|
||||
><i class="pi pi-sparkles" /> Wildcards only</button>
|
||||
<button
|
||||
class="wp-preset-btn"
|
||||
data-test="preset-favorites"
|
||||
type="button"
|
||||
@click="presetFavoritesOnly"
|
||||
><i class="pi pi-star-fill" /> Favorites only</button>
|
||||
><i class="pi pi-star-fill" /> Favorites</button>
|
||||
<span class="wp-export-presets__sep" aria-hidden="true"></span>
|
||||
<!-- Per-kind presets: clear everything, select only this kind. Disabled
|
||||
when the library has none of that kind. -->
|
||||
<button
|
||||
v-for="b in BUCKETS"
|
||||
:key="b.key"
|
||||
class="wp-preset-btn"
|
||||
:data-test="`preset-kind-${b.key}`"
|
||||
type="button"
|
||||
:disabled="idsForBucket(b.key).length === 0"
|
||||
@click="presetKindOnly(b.key)"
|
||||
>{{ b.title }}</button>
|
||||
</div>
|
||||
|
||||
<div class="wp-export-tab__sections">
|
||||
@@ -814,6 +826,7 @@ function presetFavoritesOnly(): void {
|
||||
.wp-export-presets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 9px 12px;
|
||||
background: var(--wp-bg-2);
|
||||
@@ -821,6 +834,12 @@ function presetFavoritesOnly(): void {
|
||||
border-radius: var(--wp-radius);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.wp-export-presets__sep {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
margin: 2px 4px;
|
||||
background: var(--wp-border);
|
||||
}
|
||||
.wp-export-presets__label {
|
||||
font-size: var(--wp-text-xs);
|
||||
color: var(--wp-text-dim);
|
||||
@@ -829,32 +848,8 @@ function presetFavoritesOnly(): void {
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wp-preset-btn {
|
||||
background: var(--wp-bg-3);
|
||||
border: 1px solid var(--wp-border);
|
||||
color: var(--wp-text);
|
||||
font-family: var(--wp-font);
|
||||
font-size: var(--wp-text-sm);
|
||||
padding: 4px 11px;
|
||||
border-radius: var(--wp-radius-sm);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wp-preset-btn:hover {
|
||||
background: var(--wp-bg-4);
|
||||
border-color: var(--wp-border-strong);
|
||||
}
|
||||
.wp-preset-btn .pi {
|
||||
font-size: 10px;
|
||||
color: var(--wp-text-muted);
|
||||
}
|
||||
.wp-preset-btn:focus-visible {
|
||||
outline: 2px solid var(--wp-accent-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* `.wp-preset-btn` itself is a shared atom in tokens.css — used by the
|
||||
* export presets bar AND the import picker's mirror. */
|
||||
|
||||
.wp-export-tab__sections {
|
||||
display: flex;
|
||||
|
||||
@@ -270,11 +270,13 @@ const warningIds = computed<Set<string>>(() => {
|
||||
* is an empty record when `libraryRows` is absent (unit tests or
|
||||
* payloads loaded before the library finished loading).
|
||||
*
|
||||
* Bundles + categories are intentionally NOT fed to `detectCollisions`
|
||||
* — bundles have their own MOD-detection flow downstream
|
||||
* (bundle-fingerprint.ts) and categories merge by name. The picker
|
||||
* surfaces a placeholder id-presence conflict for bundles separately
|
||||
* in `badgesForEntity`; categories never get a conflict badge here.
|
||||
* Bundles ARE fed to `detectCollisions` (they share the id→fingerprint
|
||||
* library map with modules) so an existing bundle reads as MODIFIED /
|
||||
* DUPLICATE / EXISTING inline, consistent with how the commit orchestrator
|
||||
* now resolves them. (They were once excluded on the assumption of a
|
||||
* separate `bundle-fingerprint.ts` flow that was never built, which left
|
||||
* existing bundles as blind adds that 400'd on commit.) Categories are still
|
||||
* excluded — they merge by name server-side, no id collision concept.
|
||||
*
|
||||
* Templates run a SEPARATE pass (`detectTemplateCollisions`) because
|
||||
* they collide on a `templateFingerprint` rather than the module
|
||||
@@ -284,10 +286,10 @@ const warningIds = computed<Set<string>>(() => {
|
||||
const collisionStates = computed<Record<string, CollisionState>>(() => {
|
||||
if (!props.libraryRows) return {};
|
||||
const incoming: Array<FingerprintModuleRow & { id: string }> = [];
|
||||
const moduleBuckets: BucketKey[] = [
|
||||
"wildcards", "fixed_values", "combines", "derivations", "constraints",
|
||||
const idBuckets: BucketKey[] = [
|
||||
"bundles", "wildcards", "fixed_values", "combines", "derivations", "constraints",
|
||||
];
|
||||
for (const bk of moduleBuckets) {
|
||||
for (const bk of idBuckets) {
|
||||
const arr = props.payload[bk] as unknown as Array<
|
||||
FingerprintModuleRow & { id: string }
|
||||
>;
|
||||
@@ -315,19 +317,17 @@ const collisionStates = computed<Record<string, CollisionState>>(() => {
|
||||
* badge. Every other bucket runs through `verifyOne` and can carry one.
|
||||
*
|
||||
* Phase-5 collision-state → badge mapping:
|
||||
* - Module buckets:
|
||||
* - Module + bundle buckets (same id→fingerprint detector):
|
||||
* - `no-collision` → NEW (green) — clean import, no decision needed.
|
||||
* - `conflict` → MODIFIED (orange) — uuid match, fingerprint diff.
|
||||
* - `exists-unknown` → EXISTING (drift / amber) — library row present
|
||||
* but stored fingerprint absent (legacy row
|
||||
* pre-fingerprint-backfill). Distinct from
|
||||
* pre-fingerprint-backfill, or a bundle whose
|
||||
* server fingerprint isn't set). Distinct from
|
||||
* MODIFIED so users aren't misled.
|
||||
* - `silent-skip` → (no badge) — true duplicate, auto-excluded at
|
||||
* commit time; badging it would be misleading noise.
|
||||
* - Bundles: id-presence check only — present in library →
|
||||
* EXISTING (drift / amber). Bundle MOD detection runs separately
|
||||
* downstream via bundle-fingerprint.ts so the inline picker badge
|
||||
* never claims "MODIFIED" without proof.
|
||||
* - `silent-skip` → DUPLICATE (dim) — true duplicate, auto-excluded
|
||||
* at commit time; the badge tells the user the row
|
||||
* is already in their library.
|
||||
* - Categories: never get a collision badge (name-merge semantics on
|
||||
* server, no id collision possible).
|
||||
*
|
||||
@@ -344,16 +344,9 @@ function badgesForEntity(entity: PayloadEntity, bucket: BucketKey): StatusBadge[
|
||||
label: `MIGRATED v${entity.migrated_from}→${CURRENT_SCHEMA_VERSION}`,
|
||||
});
|
||||
}
|
||||
if (bucket === "bundles") {
|
||||
if (props.libraryRows?.has(entity.id) === true) {
|
||||
// Id-presence only — we never compute bundle fingerprints in this
|
||||
// picker, so route through the same EXISTING badge as
|
||||
// exists-unknown rather than overclaiming MODIFIED.
|
||||
badges.push({ variant: "drift", label: "EXISTING" });
|
||||
}
|
||||
// Bundles never get a NEW badge here — id-presence semantics only;
|
||||
// the orchestrator's bundle MOD pass surfaces the actual state.
|
||||
} else if (bucket !== "categories") {
|
||||
if (bucket !== "categories") {
|
||||
// Bundles + the five module buckets + templates all carry a
|
||||
// collision state now (categories merge by name → no badge).
|
||||
const state = collisionStates.value[entity.id];
|
||||
if (state === "no-collision") {
|
||||
badges.push({ variant: "new", label: "NEW" });
|
||||
@@ -487,6 +480,28 @@ function deselectAll(): void {
|
||||
selected.value = new Set();
|
||||
}
|
||||
|
||||
// ---------- Quick-select presets (mirror of the export bar) ----------
|
||||
|
||||
/** Select every entity in the loaded payload, across all buckets. */
|
||||
function presetSelectAll(): void {
|
||||
const next = new Set<string>();
|
||||
for (const b of BUCKETS) {
|
||||
for (const e of entitiesForBucket(b.key)) {
|
||||
if (typeof e.id === "string" && e.id.length > 0) next.add(e.id);
|
||||
}
|
||||
}
|
||||
selected.value = next;
|
||||
}
|
||||
|
||||
/** Clear everything and select only the entities of one kind. */
|
||||
function presetKindOnly(bucket: BucketKey): void {
|
||||
const next = new Set<string>();
|
||||
for (const e of entitiesForBucket(bucket)) {
|
||||
if (typeof e.id === "string" && e.id.length > 0) next.add(e.id);
|
||||
}
|
||||
selected.value = next;
|
||||
}
|
||||
|
||||
function emitContinue(): void {
|
||||
if (selected.value.size === 0) return;
|
||||
// Phase 10: no dep-warning confirm dialog on import. Missing-dep
|
||||
@@ -541,6 +556,34 @@ function emitContinue(): void {
|
||||
integrity warnings.
|
||||
</div>
|
||||
|
||||
<!-- Quick-select presets — mirror of the Export tab bar, so the same
|
||||
one-click "select only this kind" affordance exists after a payload
|
||||
is loaded from file/paste (#1). -->
|
||||
<div
|
||||
class="wp-import-presets"
|
||||
role="group"
|
||||
aria-label="Quick selection presets"
|
||||
>
|
||||
<span class="wp-import-presets__label">Quick select</span>
|
||||
<button
|
||||
class="wp-preset-btn"
|
||||
data-test="import-preset-all"
|
||||
type="button"
|
||||
:disabled="totalEntityCount === 0"
|
||||
@click="presetSelectAll"
|
||||
><i class="pi pi-database" /> All</button>
|
||||
<span class="wp-import-presets__sep" aria-hidden="true"></span>
|
||||
<button
|
||||
v-for="b in BUCKETS"
|
||||
:key="b.key"
|
||||
class="wp-preset-btn"
|
||||
:data-test="`import-preset-kind-${b.key}`"
|
||||
type="button"
|
||||
:disabled="entitiesForBucket(b.key).length === 0"
|
||||
@click="presetKindOnly(b.key)"
|
||||
>{{ b.title }}</button>
|
||||
</div>
|
||||
|
||||
<div class="wp-import-picker__sections">
|
||||
<PickerSection
|
||||
v-for="bucket in BUCKETS"
|
||||
@@ -629,6 +672,34 @@ function emitContinue(): void {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Quick-select presets bar (mirror of ExportTab's). The button atom
|
||||
* (`.wp-preset-btn`) is global in tokens.css; only the bar layout is here. */
|
||||
.wp-import-presets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 9px 12px;
|
||||
background: var(--wp-bg-2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.wp-import-presets__label {
|
||||
font-size: var(--wp-text-xs);
|
||||
color: var(--wp-text-dim);
|
||||
letter-spacing: 0.06em;
|
||||
margin-right: 6px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wp-import-presets__sep {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
margin: 2px 4px;
|
||||
background: var(--wp-border);
|
||||
}
|
||||
|
||||
.wp-import-picker__notice {
|
||||
padding: var(--wp-space-4) var(--wp-space-5);
|
||||
margin-bottom: 10px;
|
||||
|
||||
@@ -87,15 +87,23 @@ const fileSourceLabel = computed<string>(() =>
|
||||
lastSource.value === "paste" ? "From paste" : "From file",
|
||||
);
|
||||
|
||||
async function onFilePick(ev: Event): Promise<void> {
|
||||
const target = ev.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
/** Read a picked/dropped file and feed its text into the parse pipeline.
|
||||
* Shared by the file-input and the drag-and-drop path. */
|
||||
async function readAndParse(file: File): Promise<void> {
|
||||
try {
|
||||
const text = await file.text();
|
||||
handleParse(text, "file");
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFilePick(ev: Event): Promise<void> {
|
||||
const target = ev.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
await readAndParse(file);
|
||||
} finally {
|
||||
// Reset the input value so picking the same file twice in a row
|
||||
// still fires a `change` event.
|
||||
@@ -103,6 +111,36 @@ async function onFilePick(ev: Event): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** True while a file is being dragged over the dropzone — drives the
|
||||
* highlight. */
|
||||
const isDragging = ref<boolean>(false);
|
||||
|
||||
function onDragOver(ev: DragEvent): void {
|
||||
// preventDefault is required on BOTH dragover and drop for the browser to
|
||||
// treat this element as a drop target (otherwise it navigates to the file).
|
||||
ev.preventDefault();
|
||||
if (ev.dataTransfer) ev.dataTransfer.dropEffect = "copy";
|
||||
isDragging.value = true;
|
||||
}
|
||||
|
||||
function onDragLeave(ev: DragEvent): void {
|
||||
// Ignore leaves into a descendant — only clear when the pointer actually
|
||||
// exits the dropzone, so the highlight doesn't flicker over child elements.
|
||||
const zone = ev.currentTarget as HTMLElement;
|
||||
const to = ev.relatedTarget as Node | null;
|
||||
if (to && zone.contains(to)) return;
|
||||
isDragging.value = false;
|
||||
}
|
||||
|
||||
async function onDrop(ev: DragEvent): Promise<void> {
|
||||
ev.preventDefault();
|
||||
isDragging.value = false;
|
||||
const file = ev.dataTransfer?.files?.[0];
|
||||
// Non-JSON files fall through to parsePayload, which rejects them with a
|
||||
// clear inline "Invalid payload" message — no need to gate on extension.
|
||||
if (file) await readAndParse(file);
|
||||
}
|
||||
|
||||
function openPaste(): void {
|
||||
pasteOpen.value = true;
|
||||
pasteText.value = "";
|
||||
@@ -140,7 +178,7 @@ function onReplaceFile(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum of all seven entity buckets on a freshly-parsed payload. Used to
|
||||
* Sum of all eight entity buckets on a freshly-parsed payload. Used to
|
||||
* populate the compact-bar entity count so it agrees with the picker
|
||||
* header. `RawPayload`'s buckets all default to empty arrays after
|
||||
* migration so the iteration is safe even on minimal payloads.
|
||||
@@ -154,6 +192,7 @@ function payloadEntityCount(payload: RawPayload): number {
|
||||
+ payload.derivations.length
|
||||
+ payload.constraints.length
|
||||
+ payload.categories.length
|
||||
+ (payload.templates?.length ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -230,29 +269,44 @@ function handleParse(raw: string, source: "file" | "paste"): void {
|
||||
Import a Wildcard Pipeline export file or paste an export payload below.
|
||||
</p>
|
||||
|
||||
<div class="wp-import-tab__entry">
|
||||
<button
|
||||
type="button"
|
||||
class="wp-import-tab__btn wp-import-tab__btn--primary"
|
||||
data-test="import-file-btn"
|
||||
@click="pickFile"
|
||||
>Pick file…</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
class="wp-import-tab__file-hidden"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
data-test="import-file-input"
|
||||
@change="onFilePick"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-import-tab__btn"
|
||||
data-test="import-paste-btn"
|
||||
@click="openPaste"
|
||||
>Paste JSON…</button>
|
||||
<div
|
||||
class="wp-import-tab__dropzone"
|
||||
:class="{ 'wp-import-tab__dropzone--active': isDragging }"
|
||||
data-test="import-dropzone"
|
||||
@dragover="onDragOver"
|
||||
@dragenter="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<div class="wp-import-tab__entry">
|
||||
<button
|
||||
type="button"
|
||||
class="wp-import-tab__btn wp-import-tab__btn--primary"
|
||||
data-test="import-file-btn"
|
||||
@click="pickFile"
|
||||
>Pick file…</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
class="wp-import-tab__file-hidden"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
data-test="import-file-input"
|
||||
@change="onFilePick"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-import-tab__btn"
|
||||
data-test="import-paste-btn"
|
||||
@click="openPaste"
|
||||
>Paste JSON…</button>
|
||||
</div>
|
||||
<p class="wp-import-tab__drophint" aria-hidden="true">
|
||||
<i class="pi pi-download" />
|
||||
<span v-if="isDragging">Drop to import</span>
|
||||
<span v-else>or drag & drop a <code>.json</code> export file here</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -325,12 +379,51 @@ function handleParse(raw: string, source: "file" | "paste"): void {
|
||||
font-size: var(--wp-text-sm);
|
||||
}
|
||||
|
||||
/* Drag-and-drop target wrapping the pick/paste buttons. The whole area
|
||||
* accepts a dropped .json export file; the dashed frame + hint advertise it,
|
||||
* and `--active` lights up while a file is dragged over. */
|
||||
.wp-import-tab__dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--wp-space-5);
|
||||
padding: var(--wp-space-6);
|
||||
border: 1.5px dashed var(--wp-border);
|
||||
border-radius: var(--wp-radius);
|
||||
background: transparent;
|
||||
transition: border-color 0.12s ease, background-color 0.12s ease;
|
||||
}
|
||||
.wp-import-tab__dropzone--active {
|
||||
border-color: var(--wp-accent);
|
||||
background: color-mix(in oklab, var(--wp-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.wp-import-tab__entry {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--wp-space-5);
|
||||
}
|
||||
|
||||
.wp-import-tab__drophint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wp-space-3);
|
||||
margin: 0;
|
||||
color: var(--wp-text-muted);
|
||||
font-size: var(--wp-text-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
.wp-import-tab__dropzone--active .wp-import-tab__drophint {
|
||||
color: var(--wp-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wp-import-tab__drophint code {
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
font-size: var(--wp-text-xs);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--wp-bg-3);
|
||||
}
|
||||
|
||||
.wp-import-tab__btn {
|
||||
height: var(--wp-btn-h);
|
||||
padding: 0 var(--wp-space-6);
|
||||
|
||||
@@ -170,16 +170,15 @@ describe("ExportTab.vue", () => {
|
||||
const label = wrap.get(".wp-export-presets__label");
|
||||
expect(label.text()).toBe("Quick select");
|
||||
expect(label.text()).not.toContain(":");
|
||||
// Each preset button carries its expected pi icon.
|
||||
// The two headline presets carry their pi icon; per-kind presets are
|
||||
// text-only (one button per bucket).
|
||||
expect(
|
||||
wrap.get('[data-test="preset-full"]').find("i.pi.pi-database").exists(),
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrap.get('[data-test="preset-wildcards"]').find("i.pi.pi-sparkles").exists(),
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrap.get('[data-test="preset-favorites"]').find("i.pi.pi-star-fill").exists(),
|
||||
).toBe(true);
|
||||
expect(wrap.get('[data-test="preset-kind-wildcard"]').text()).toBe("Wildcards");
|
||||
});
|
||||
|
||||
it("renders a footer bar with all action buttons + counter", async () => {
|
||||
@@ -660,7 +659,7 @@ describe("ExportTab.vue", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("'Wildcards only' preset clears non-wildcard buckets and selects all wildcards", async () => {
|
||||
it("per-kind 'Wildcards' preset clears non-wildcard buckets and selects all wildcards", async () => {
|
||||
apiAny.modules.list.mockResolvedValue({
|
||||
items: [
|
||||
mkModule({ id: "w1", type: "wildcard", name: "$one" }),
|
||||
@@ -686,7 +685,7 @@ describe("ExportTab.vue", () => {
|
||||
await wrap.get('[data-test="export-tab-row-bundle-b1"] button[role="checkbox"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
await wrap.get('[data-test="preset-wildcards"]').trigger("click");
|
||||
await wrap.get('[data-test="preset-kind-wildcard"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
// Total = 2 wildcards. Bundle/fixed/combine/category all empty.
|
||||
|
||||
@@ -571,6 +571,89 @@ describe("ImportPicker.vue", () => {
|
||||
expect(dup!.variant).toBe("duplicate");
|
||||
});
|
||||
|
||||
// Bundles now run through the same id→fingerprint collision detector as
|
||||
// modules (they were once excluded → existing bundles imported as blind
|
||||
// adds that 400'd on commit). These lock in the badge states.
|
||||
it("adds a NEW badge for a bundle absent from the library", async () => {
|
||||
const wrap = mountPicker({
|
||||
payload: makePayload({ bundles: [{ id: "b1", name: "B1" }] }),
|
||||
libraryRows: new Map(),
|
||||
});
|
||||
await flushPromises();
|
||||
await expandSection(wrap, "bundles");
|
||||
const row = wrap.findAllComponents(PickerRow)[0]!;
|
||||
const badges = row.props("statusBadges") as Array<{ label: string; variant: string }>;
|
||||
expect(badges.find((b) => b.label === "NEW")).toBeDefined();
|
||||
});
|
||||
|
||||
it("adds an EXISTING badge for a bundle present with no library fingerprint", async () => {
|
||||
const wrap = mountPicker({
|
||||
payload: makePayload({ bundles: [{ id: "b1", name: "B1" }] }),
|
||||
libraryRows: new Map([["b1", {}]]),
|
||||
});
|
||||
await flushPromises();
|
||||
await expandSection(wrap, "bundles");
|
||||
const row = wrap.findAllComponents(PickerRow)[0]!;
|
||||
const badges = row.props("statusBadges") as Array<{ label: string; variant: string }>;
|
||||
const existing = badges.find((b) => b.label === "EXISTING");
|
||||
expect(existing).toBeDefined();
|
||||
expect(existing!.variant).toBe("drift");
|
||||
});
|
||||
|
||||
it("adds a MODIFIED badge for a bundle whose fingerprint differs from the library", async () => {
|
||||
const wrap = mountPicker({
|
||||
payload: makePayload({
|
||||
bundles: [{ id: "b1", name: "B1", description: "", tags: [], payload_hash: "aaa" }],
|
||||
}),
|
||||
libraryRows: new Map([["b1", { snapshot_fingerprint: "a-different-fingerprint" }]]),
|
||||
});
|
||||
await flushPromises();
|
||||
await expandSection(wrap, "bundles");
|
||||
const row = wrap.findAllComponents(PickerRow)[0]!;
|
||||
const badges = row.props("statusBadges") as Array<{ label: string; variant: string }>;
|
||||
const mod = badges.find((b) => b.label === "MODIFIED");
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod!.variant).toBe("mod");
|
||||
});
|
||||
|
||||
// Quick-select presets mirrored from the export bar (#1).
|
||||
it("per-kind preset selects only that kind from the payload (#1)", async () => {
|
||||
const wrap = mountPicker({
|
||||
payload: makePayload({
|
||||
wildcards: [{ id: "w1", name: "W1" }, { id: "w2", name: "W2" }],
|
||||
templates: [{ id: "t1", name: "T1" }],
|
||||
}),
|
||||
});
|
||||
await flushPromises();
|
||||
await wrap.get('[data-test="import-preset-kind-wildcards"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(wrap.get('[data-test="import-picker-selected-count"]').text()).toContain("2 of 3");
|
||||
});
|
||||
|
||||
it("'All' preset selects every payload entity (#1)", async () => {
|
||||
const wrap = mountPicker({
|
||||
payload: makePayload({
|
||||
wildcards: [{ id: "w1", name: "W1" }],
|
||||
templates: [{ id: "t1", name: "T1" }],
|
||||
}),
|
||||
});
|
||||
await flushPromises();
|
||||
await wrap.get('[data-test="import-preset-all"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(wrap.get('[data-test="import-picker-selected-count"]').text()).toContain("2 of 2");
|
||||
});
|
||||
|
||||
it("per-kind preset button is disabled for an empty bucket (#1)", async () => {
|
||||
const wrap = mountPicker({
|
||||
payload: makePayload({ wildcards: [{ id: "w1", name: "W1" }] }),
|
||||
});
|
||||
await flushPromises();
|
||||
// No templates in the payload → the Templates preset is disabled.
|
||||
expect(
|
||||
wrap.get('[data-test="import-preset-kind-templates"]').attributes("disabled"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("passes showId=true to PickerRow", async () => {
|
||||
const payload = makePayload({
|
||||
wildcards: [{ id: "abcd1234", name: "a" }],
|
||||
|
||||
@@ -146,6 +146,33 @@ describe("ImportTab.vue", () => {
|
||||
expect(payload.schema_version).toBe(2);
|
||||
});
|
||||
|
||||
it("drag-and-drop — parses a dropped File and emits payload-ready (#5)", async () => {
|
||||
const wrap = mount(ImportTab);
|
||||
const file = new File([makeValidPayloadJson()], "test.json", {
|
||||
type: "application/json",
|
||||
});
|
||||
const zone = wrap.find("[data-test='import-dropzone']");
|
||||
await zone.trigger("drop", { dataTransfer: { files: [file] } });
|
||||
await flushPromises();
|
||||
|
||||
const emitted = wrap.emitted("payload-ready");
|
||||
expect(emitted).toBeTruthy();
|
||||
expect((emitted?.[0]?.[0] as RawPayload).schema_version).toBe(2);
|
||||
});
|
||||
|
||||
it("drag-over lights up the dropzone, drag-leave clears it (#5)", async () => {
|
||||
const wrap = mount(ImportTab);
|
||||
const zone = wrap.find("[data-test='import-dropzone']");
|
||||
const active = "wp-import-tab__dropzone--active";
|
||||
|
||||
await zone.trigger("dragover", { dataTransfer: {} });
|
||||
expect(zone.classes()).toContain(active);
|
||||
|
||||
// relatedTarget outside the zone → highlight clears.
|
||||
await zone.trigger("dragleave", { relatedTarget: document.body });
|
||||
expect(zone.classes()).not.toContain(active);
|
||||
});
|
||||
|
||||
it("paste cancel closes the pane without emitting", async () => {
|
||||
const wrap = mount(ImportTab);
|
||||
await wrap.find("[data-test='import-paste-btn']").trigger("click");
|
||||
@@ -222,6 +249,28 @@ describe("ImportTab.vue", () => {
|
||||
expect(wrap.find("[data-test='import-tab-replace']").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("counts templates in the loaded-bar entity total (#7 follow-up)", async () => {
|
||||
const payload = JSON.stringify({
|
||||
schema_version: 2,
|
||||
bundles: [], wildcards: [], fixed_values: [], combines: [],
|
||||
derivations: [], constraints: [], categories: [],
|
||||
templates: [
|
||||
{ id: "t1", name: "Hero", template_string: "$one" },
|
||||
{ id: "t2", name: "Scene", template_string: "$two" },
|
||||
],
|
||||
});
|
||||
const wrap = mount(ImportTab, { props: { payloadLoaded: false } });
|
||||
await wrap.find("[data-test='import-paste-btn']").trigger("click");
|
||||
await flushPromises();
|
||||
await wrap.find("textarea").setValue(payload);
|
||||
await wrap.find("[data-test='import-paste-confirm']").trigger("click");
|
||||
await flushPromises();
|
||||
await wrap.setProps({ payloadLoaded: true });
|
||||
await flushPromises();
|
||||
// 2 templates were previously excluded from the count → bar read "0".
|
||||
expect(wrap.find("[data-test='import-tab-loaded']").text()).toMatch(/2\s+entities/i);
|
||||
});
|
||||
|
||||
it("compact bar labels source as 'From file' after a file-pick parse", async () => {
|
||||
const wrap = mount(ImportTab, { props: { payloadLoaded: false } });
|
||||
const file = new File([makeValidPayloadJson()], "test.json", {
|
||||
|
||||
@@ -62,6 +62,36 @@ describe("buildCommitPayload", () => {
|
||||
expect((result.renames[0]?.content as { id: string }).id).toBe("w3_new");
|
||||
});
|
||||
|
||||
it("strips server-stamped lifecycle fields from an add so the import reads as fresh (#12)", () => {
|
||||
const sel = emptySelection();
|
||||
sel.wildcards = [
|
||||
{
|
||||
entity: {
|
||||
id: "w1",
|
||||
name: "alpha",
|
||||
payload: { options: [] },
|
||||
tags: [],
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-02T00:00:00Z",
|
||||
version: 7,
|
||||
snapshot_fingerprint: "abc",
|
||||
payload_hash: "def",
|
||||
},
|
||||
decision: { kind: "add" },
|
||||
},
|
||||
];
|
||||
const add = buildCommitPayload(sel).adds[0]!.entity as Record<string, unknown>;
|
||||
// Identity + content survive; the server-owned lifecycle fields are gone
|
||||
// so engine/importer.py falls back to now() + recomputes the fingerprint.
|
||||
expect(add.id).toBe("w1");
|
||||
expect(add.name).toBe("alpha");
|
||||
expect(add.created_at).toBeUndefined();
|
||||
expect(add.updated_at).toBeUndefined();
|
||||
expect(add.version).toBeUndefined();
|
||||
expect(add.snapshot_fingerprint).toBeUndefined();
|
||||
expect(add.payload_hash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("partitions all three decision kinds for templates", () => {
|
||||
const sel = emptySelection();
|
||||
sel.templates = [
|
||||
|
||||
@@ -157,6 +157,32 @@ export interface CommitOk {
|
||||
* preserves identity by design — the user explicitly opted to insert
|
||||
* under the original id.
|
||||
*/
|
||||
/**
|
||||
* Server-stamped lifecycle fields. Stripping them makes the engine importer's
|
||||
* insert paths (`_insert_module` / `_insert_bundle`) fall back to `now()` for
|
||||
* the dates and recompute the fingerprint, instead of copying the SOURCE
|
||||
* library's values verbatim (`entity.get("created_at", now)` etc.). The
|
||||
* server recomputes the fingerprint on insert regardless, so stripping those
|
||||
* is a no-op there — but stripping the dates is what makes an imported row
|
||||
* read as freshly added.
|
||||
*/
|
||||
const LIFECYCLE_FIELDS = [
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"version",
|
||||
"snapshot_fingerprint",
|
||||
"payload_hash",
|
||||
"template_fingerprint",
|
||||
] as const;
|
||||
|
||||
function stripLifecycle(
|
||||
entity: Record<string, unknown> & { id: string },
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...entity };
|
||||
for (const f of LIFECYCLE_FIELDS) delete out[f];
|
||||
return out;
|
||||
}
|
||||
|
||||
function partition(
|
||||
kind: EntityKind,
|
||||
list: ReadonlyArray<{ entity: Record<string, unknown> & { id: string }; decision: Decision }>,
|
||||
@@ -165,28 +191,22 @@ function partition(
|
||||
for (const r of list) {
|
||||
switch (r.decision.kind) {
|
||||
case "add":
|
||||
out.adds.push({ kind, entity: r.entity });
|
||||
// Strip lifecycle fields so the server stamps a fresh created_at:
|
||||
// an imported entity must read as "just added to your library", not
|
||||
// carry the exporter's original date (which made imports sort as
|
||||
// weeks-old — bug #12). Identity (`id`) is preserved by design.
|
||||
out.adds.push({ kind, entity: stripLifecycle(r.entity) });
|
||||
break;
|
||||
case "replace":
|
||||
// Replace overwrites an EXISTING library row; the server's
|
||||
// `_update_module` never touches `created_at` and always stamps
|
||||
// `updated_at = now()`, so the live row keeps its own dates.
|
||||
out.replaces.push({ kind, id: r.entity.id, new_content: r.entity });
|
||||
break;
|
||||
case "rename": {
|
||||
const newContent: Record<string, unknown> = { ...r.entity };
|
||||
const newContent = stripLifecycle(r.entity);
|
||||
newContent.id = r.decision.new_id;
|
||||
newContent.name = r.decision.new_name;
|
||||
// Strip lifecycle fields so the server's now() defaults +
|
||||
// fingerprint/hash recomputation kick in. Leaving stale values
|
||||
// would mis-sort the imported row and cache an inappropriate
|
||||
// fingerprint against the freshly-allocated new_id.
|
||||
delete newContent.created_at;
|
||||
delete newContent.updated_at;
|
||||
delete newContent.version;
|
||||
delete newContent.snapshot_fingerprint;
|
||||
delete newContent.payload_hash;
|
||||
// Templates carry a computed `template_fingerprint` (not the
|
||||
// module `snapshot_fingerprint`); strip it too so a renamed
|
||||
// template can't smuggle a fingerprint keyed to the old id.
|
||||
delete newContent.template_fingerprint;
|
||||
out.renames.push({
|
||||
kind,
|
||||
old_id: r.entity.id,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Icon from "../components/ui/Icon.vue";
|
||||
import UpdateDialog from "../components/UpdateDialog.vue";
|
||||
import { GITHUB_REPO } from "../config/links";
|
||||
import { useBrowserHistory } from "../composables/useBrowserHistory";
|
||||
import { useReleaseCheck } from "../composables/useReleaseCheck";
|
||||
@@ -14,7 +15,7 @@ const tweaks = useTweaksStore();
|
||||
|
||||
// Version is injected at build time from package.json via vite's
|
||||
// `define` config (see vite.config.mts). The release check composable
|
||||
// hits GitHub once per 24h (cached in localStorage) and surfaces a
|
||||
// looks up the latest GitHub release once per app session and surfaces a
|
||||
// `hasUpdate` flag + severity classification (major/minor/patch).
|
||||
const { current: version, latestVersion, hasUpdate, severity } = useReleaseCheck();
|
||||
|
||||
@@ -35,10 +36,14 @@ const versionTooltip = computed(() =>
|
||||
? `v${version} — update v${latestVersion.value} available on GitHub`
|
||||
: `v${version} — open repository on GitHub`,
|
||||
);
|
||||
const releasesUrl = `${GITHUB_REPO}/releases/latest`;
|
||||
const repoUrl = GITHUB_REPO;
|
||||
const logoSrc = `${import.meta.env.BASE_URL}images/favicon.svg`;
|
||||
|
||||
// The update pill opens the in-app Update dialog (changelog + one-click
|
||||
// update via ComfyUI Manager) rather than linking straight to GitHub.
|
||||
const updateDialogOpen = ref(false);
|
||||
function openUpdateDialog(): void { updateDialogOpen.value = true; }
|
||||
|
||||
/** Theme button icon — mirrors prototype (current state, not next). */
|
||||
const themeIcon = computed(() => {
|
||||
switch (ui.themeMode) {
|
||||
@@ -103,18 +108,16 @@ function openPalette(): void {
|
||||
data-test="topbar-version"
|
||||
@click.stop
|
||||
>v{{ version }}</a>
|
||||
<a
|
||||
<button
|
||||
v-if="hasUpdate && latestVersion"
|
||||
type="button"
|
||||
class="wp-topbar__version wp-topbar__update-pill"
|
||||
:class="updatePillClass"
|
||||
:href="releasesUrl"
|
||||
:title="updateTooltip"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
:data-severity="severity ?? undefined"
|
||||
data-test="topbar-update-indicator"
|
||||
@click.stop
|
||||
><span class="wp-topbar__update-arrow" aria-hidden="true">↑</span>v{{ latestVersion }}</a>
|
||||
@click.stop.prevent="openUpdateDialog"
|
||||
><span class="wp-topbar__update-arrow" aria-hidden="true">↑</span>v{{ latestVersion }}</button>
|
||||
</RouterLink>
|
||||
|
||||
<div class="wp-topbar__spacer" />
|
||||
@@ -174,6 +177,8 @@ function openPalette(): void {
|
||||
>
|
||||
<Icon name="pi-cog" />
|
||||
</RouterLink>
|
||||
|
||||
<UpdateDialog :open="updateDialogOpen" @close="updateDialogOpen = false" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -248,15 +253,23 @@ a.wp-topbar__version:hover {
|
||||
* - minor/patch → accent purple (safe to upgrade)
|
||||
* Clicking opens releases/latest in a new tab; click.stop on the
|
||||
* anchor keeps the dashboard nav (outer RouterLink) from firing first. */
|
||||
a.wp-topbar__update-pill {
|
||||
a.wp-topbar__update-pill,
|
||||
button.wp-topbar__update-pill {
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
cursor: pointer;
|
||||
transition: filter 120ms ease;
|
||||
font: inherit;
|
||||
}
|
||||
a.wp-topbar__update-pill:hover {
|
||||
button.wp-topbar__update-pill {
|
||||
/* Reset native button chrome; tonal border comes from the
|
||||
--accent/--warn modifier classes below. */
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
a.wp-topbar__update-pill:hover,
|
||||
button.wp-topbar__update-pill:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
.wp-topbar__update-arrow {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { useUiStore } from "../uiStore";
|
||||
|
||||
describe("uiStore checkOnLaunch", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("defaults to true when unset", () => {
|
||||
const ui = useUiStore();
|
||||
expect(ui.checkOnLaunch).toBe(true);
|
||||
});
|
||||
|
||||
it("persists and reloads from localStorage", () => {
|
||||
const ui = useUiStore();
|
||||
ui.setCheckOnLaunch(false);
|
||||
expect(ui.checkOnLaunch).toBe(false);
|
||||
expect(localStorage.getItem("wp-update-check-on-launch")).toBe("false");
|
||||
// Fresh store instance reads the stored value.
|
||||
setActivePinia(createPinia());
|
||||
const ui2 = useUiStore();
|
||||
expect(ui2.checkOnLaunch).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ export type DensityMode = "comfortable" | "compact";
|
||||
const STORAGE_KEY = "wp-theme-mode";
|
||||
const STORAGE_KEY_DENSITY = "wp-density-mode";
|
||||
const STORAGE_KEY_MAX_REF_DEPTH = "wp-wildcard-max-ref-depth";
|
||||
const STORAGE_KEY_CHECK_ON_LAUNCH = "wp-update-check-on-launch";
|
||||
const FLASH_SUPPRESS_MS = 120;
|
||||
const DEFAULT_MAX_REF_DEPTH = 8;
|
||||
const MIN_MAX_REF_DEPTH = 1;
|
||||
@@ -50,6 +51,17 @@ function readStoredMaxRefDepth(): number {
|
||||
return DEFAULT_MAX_REF_DEPTH;
|
||||
}
|
||||
|
||||
function readStoredCheckOnLaunch(): boolean {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY_CHECK_ON_LAUNCH);
|
||||
if (v === "true") return true;
|
||||
if (v === "false") return false;
|
||||
} catch {
|
||||
/* localStorage unavailable */
|
||||
}
|
||||
return true; // default: check on launch
|
||||
}
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
return typeof window !== "undefined"
|
||||
&& typeof window.matchMedia === "function"
|
||||
@@ -61,6 +73,7 @@ export const useUiStore = defineStore("ui", () => {
|
||||
const density = ref<DensityMode>(readStoredDensity());
|
||||
const sidebarCollapsed = ref(false);
|
||||
const maxRefDepth = ref<number>(readStoredMaxRefDepth());
|
||||
const checkOnLaunch = ref<boolean>(readStoredCheckOnLaunch());
|
||||
|
||||
/** Resolved theme — `"auto"` collapsed to dark/light via OS preference. */
|
||||
const resolvedTheme = computed<"dark" | "light">(() =>
|
||||
@@ -131,17 +144,24 @@ export const useUiStore = defineStore("ui", () => {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
}
|
||||
|
||||
function setCheckOnLaunch(v: boolean): void {
|
||||
checkOnLaunch.value = v;
|
||||
try { localStorage.setItem(STORAGE_KEY_CHECK_ON_LAUNCH, String(v)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return {
|
||||
themeMode,
|
||||
resolvedTheme,
|
||||
density,
|
||||
sidebarCollapsed,
|
||||
maxRefDepth,
|
||||
checkOnLaunch,
|
||||
cycleTheme,
|
||||
setThemeMode,
|
||||
setDensity,
|
||||
toggleDensity,
|
||||
setMaxRefDepth,
|
||||
setCheckOnLaunch,
|
||||
initializeTheme,
|
||||
toggleSidebar,
|
||||
};
|
||||
|
||||
@@ -598,6 +598,74 @@ input, textarea, select { font: inherit; }
|
||||
font-weight: 400;
|
||||
}
|
||||
.wp-card__body { padding: 14px; }
|
||||
/* Sticky-header cards pin their header + `subheader` slot (e.g. a bulk-edit
|
||||
* toolbar) to the top of the scroll region so a long list's actions (Add
|
||||
* option, Bulk edit, the selection toolbar, …) stay reachable at the bottom.
|
||||
* The whole `.wp-card__sticky` region sticks as one so its sub-toolbar can't
|
||||
* be left behind. Card.vue publishes `--wp-card-header-h` (the region's
|
||||
* measured height) so a sticky <thead> below offsets right under it. Needs an
|
||||
* opaque bg (header/subheader are transparent). */
|
||||
.wp-card--sticky-header > .wp-card__sticky {
|
||||
position: sticky;
|
||||
/* -1px, not 0: the scroll container's top can land on a fractional device
|
||||
* pixel (measured e.g. 152.39 at dpr 1), so pinning exactly at top:0 leaves
|
||||
* a sub-pixel sliver above the opaque header through which the scrolled
|
||||
* content behind bleeds as a faint ghost line. Overlapping the scroll edge
|
||||
* by 1px (the extra px is clipped by the container's overflow and absorbed
|
||||
* by the header's 12px top padding) guarantees the bg covers that seam at
|
||||
* any DPR. The sticky <thead> below tracks the same -1px. */
|
||||
top: -1px;
|
||||
z-index: 5;
|
||||
background: var(--wp-bg-1);
|
||||
}
|
||||
/* Sticky column header inside a sticky-header card — pins right under the
|
||||
* toolbar. Chromium won't scroll-anchor a table CELL here, but sticking the
|
||||
* <thead> element does; th gets an opaque bg so rows don't bleed through.
|
||||
* Shared by the wildcard + fixed-values option tables. The -1px mirrors the
|
||||
* header's overlap above so the two stay flush (no gap for rows to bleed). */
|
||||
.wp-card--sticky-header .wp-options-table thead {
|
||||
position: sticky;
|
||||
top: calc(var(--wp-card-header-h, 53px) - 1px);
|
||||
z-index: 4;
|
||||
}
|
||||
.wp-card--sticky-header .wp-options-table thead th {
|
||||
background: var(--wp-bg-1);
|
||||
box-shadow: inset 0 -1px 0 var(--wp-border);
|
||||
}
|
||||
|
||||
/* Quick-select preset chip — shared by the import/export presets bars
|
||||
* (ExportTab + ImportPicker). Layout of the bar itself stays per-component;
|
||||
* this is just the button atom. */
|
||||
.wp-preset-btn {
|
||||
background: var(--wp-bg-3);
|
||||
border: 1px solid var(--wp-border);
|
||||
color: var(--wp-text);
|
||||
font-family: var(--wp-font);
|
||||
font-size: var(--wp-text-sm);
|
||||
padding: 4px 11px;
|
||||
border-radius: var(--wp-radius-sm);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wp-preset-btn:hover:not(:disabled) {
|
||||
background: var(--wp-bg-4);
|
||||
border-color: var(--wp-border-strong);
|
||||
}
|
||||
.wp-preset-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.wp-preset-btn .pi {
|
||||
font-size: 10px;
|
||||
color: var(--wp-text-muted);
|
||||
}
|
||||
.wp-preset-btn:focus-visible {
|
||||
outline: 2px solid var(--wp-accent-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
.wp-btn {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Minimal, safe Markdown → HTML for GitHub release notes.
|
||||
*
|
||||
* Deliberately NOT a general markdown engine — we avoid a `marked`
|
||||
* dependency to keep the manager bundle within the size gate. It supports
|
||||
* the subset GitHub release bodies actually use: ATX headings (#..###),
|
||||
* `-`/`*` bullet lists, **bold**, `inline code`, fenced ```code``` blocks,
|
||||
* and [text](url) links.
|
||||
*
|
||||
* Safety model: EVERY line is HTML-escaped FIRST, then a small set of
|
||||
* transforms re-introduce a whitelisted tag set on the already-escaped
|
||||
* text. Raw HTML in the source therefore renders as inert text; the only
|
||||
* tags in the output are the ones this function emits. The result is the
|
||||
* sole sanctioned input to a `v-html` (the `.wpc-relnotes` container).
|
||||
*/
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** Inline transforms applied to an already-escaped line. */
|
||||
function renderInline(escaped: string): string {
|
||||
// Links: [text](url). `url` is validated to http(s) only; because the
|
||||
// whole line is pre-escaped, a `"` in the source is already `"`,
|
||||
// so it cannot terminate the href attribute.
|
||||
let out = escaped.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||
(_m, text: string, url: string) =>
|
||||
`<a href="${url}" target="_blank" rel="noopener nofollow">${text}</a>`,
|
||||
);
|
||||
// Inline code (before bold so ** inside code isn't bolded).
|
||||
out = out.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
// Bold.
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the small set of HTML tags that GitHub release bodies use —
|
||||
* `<details>`/`<summary>` collapsibles and `<b>` — into markdown BEFORE the
|
||||
* escape pass, so they render instead of appearing as literal `<details>`
|
||||
* text. Everything else still falls through to escaping (rendered inert), so
|
||||
* this stays safe: it only recognises a fixed allow-list of formatting tags.
|
||||
*/
|
||||
function normalizeReleaseHtml(md: string): string {
|
||||
return md
|
||||
// <summary>…</summary> → a bold lead line (drop any inner tags).
|
||||
.replace(/<summary>([\s\S]*?)<\/summary>/gi, (_m, s: string) =>
|
||||
`**${s.replace(/<\/?[a-z][^>]*>/gi, "").trim()}**\n`)
|
||||
// Drop the <details> wrapper markers — the content flows inline.
|
||||
.replace(/<\/?details[^>]*>/gi, "")
|
||||
// Bold.
|
||||
.replace(/<\/?(?:b|strong)>/gi, "**");
|
||||
}
|
||||
|
||||
export function renderReleaseNotes(md: string): string {
|
||||
if (!md || !md.trim()) {
|
||||
return '<p class="wpc-relnotes__empty">No release notes.</p>';
|
||||
}
|
||||
const lines = normalizeReleaseHtml(md).replace(/\r\n/g, "\n").split("\n");
|
||||
const html: string[] = [];
|
||||
let inList = false;
|
||||
let inCode = false;
|
||||
const codeBuf: string[] = [];
|
||||
|
||||
const closeList = () => { if (inList) { html.push("</ul>"); inList = false; } };
|
||||
|
||||
for (const rawLine of lines) {
|
||||
// Fenced code toggles.
|
||||
if (/^```/.test(rawLine.trim())) {
|
||||
if (inCode) {
|
||||
html.push(`<pre><code>${escapeHtml(codeBuf.join("\n"))}</code></pre>`);
|
||||
codeBuf.length = 0;
|
||||
inCode = false;
|
||||
} else {
|
||||
closeList();
|
||||
inCode = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inCode) { codeBuf.push(rawLine); continue; }
|
||||
|
||||
const line = rawLine.trimEnd();
|
||||
const escaped = escapeHtml(line);
|
||||
|
||||
const heading = /^(#{1,3})\s+(.*)$/.exec(line);
|
||||
if (heading) {
|
||||
closeList();
|
||||
const level = heading[1].length;
|
||||
html.push(`<h${level}>${renderInline(escapeHtml(heading[2]))}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bullet = /^\s*[-*]\s+(.*)$/.exec(line);
|
||||
if (bullet) {
|
||||
if (!inList) { html.push("<ul>"); inList = true; }
|
||||
html.push(`<li>${renderInline(escapeHtml(bullet[1]))}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim() === "") { closeList(); continue; }
|
||||
|
||||
closeList();
|
||||
html.push(`<p>${renderInline(escaped)}</p>`);
|
||||
}
|
||||
if (inCode) { html.push(`<pre><code>${escapeHtml(codeBuf.join("\n"))}</code></pre>`); }
|
||||
closeList();
|
||||
return html.join("\n");
|
||||
}
|
||||
@@ -32,3 +32,62 @@ describe("expandInlineChoices — branch weights (engine parity)", () => {
|
||||
expect(expandInlineChoices("{x::y|x::y}")).toBe("x::y");
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandInlineChoices — multi-pick {N-M$$sep$$…} (engine parity)", () => {
|
||||
it("fixed count picks exactly N unique branches joined by sep", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const out = expandInlineChoices("{2$$, $$a|b|c}");
|
||||
const parts = out.split(", ");
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(new Set(parts).size).toBe(2); // unique (no replacement)
|
||||
for (const p of parts) expect(["a", "b", "c"]).toContain(p);
|
||||
expect(out).not.toContain("$$");
|
||||
}
|
||||
});
|
||||
|
||||
it("range count picks between min and max branches", () => {
|
||||
const counts = new Set<number>();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const out = expandInlineChoices("{1-2$$, $$a|b|c}");
|
||||
const parts = out.split(", ");
|
||||
expect(parts.length).toBeGreaterThanOrEqual(1);
|
||||
expect(parts.length).toBeLessThanOrEqual(2);
|
||||
counts.add(parts.length);
|
||||
expect(out).not.toContain("$$");
|
||||
}
|
||||
// Over 100 draws a 1-2 range should exercise both counts.
|
||||
expect(counts.has(1) && counts.has(2)).toBe(true);
|
||||
});
|
||||
|
||||
it("empty separator concatenates the picks", () => {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const out = expandInlineChoices("{2$$$$a|b}");
|
||||
expect(["ab", "ba"]).toContain(out);
|
||||
}
|
||||
});
|
||||
|
||||
it("independent (~) draws WITH replacement — repeats allowed", () => {
|
||||
let sawRepeat = false;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const out = expandInlineChoices("{2~$$, $$a|b}");
|
||||
const parts = out.split(", ");
|
||||
expect(parts).toHaveLength(2);
|
||||
for (const p of parts) expect(["a", "b"]).toContain(p);
|
||||
if (parts[0] === parts[1]) sawRepeat = true;
|
||||
}
|
||||
expect(sawRepeat).toBe(true); // replacement makes a|a / b|b possible
|
||||
});
|
||||
|
||||
it("user repro: {1-2$$, $$smirk|smug|half-closed eyes} never leaks the raw prefix", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const out = expandInlineChoices(
|
||||
"dark brown eyes, {1-2$$, $$smirk|smug|half-closed eyes}",
|
||||
);
|
||||
expect(out).not.toContain("$$");
|
||||
expect(out).not.toContain("1-2");
|
||||
expect(out.startsWith("dark brown eyes, ")).toBe(true);
|
||||
const picked = out.slice("dark brown eyes, ".length).split(", ");
|
||||
for (const p of picked) expect(["smirk", "smug", "half-closed eyes"]).toContain(p);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,11 +72,71 @@ function stripBranchWeight(branch: string): string {
|
||||
return branch.slice(i + 2);
|
||||
}
|
||||
|
||||
/** Inclusive random integer in [min, max]. */
|
||||
function randInt(min: number, max: number): number {
|
||||
return min + Math.floor(Math.random() * (max - min + 1));
|
||||
}
|
||||
|
||||
/** One weighted pick over raw `N::branch` strings → the resolved value.
|
||||
* Unweighted (no `N::` on any branch) picks uniformly. */
|
||||
function pickOneWeighted(rawBranches: string[]): string {
|
||||
const values = rawBranches.map(stripBranchWeight);
|
||||
const hasWeights = values.some((v, i) => v !== rawBranches[i]);
|
||||
if (!hasWeights) {
|
||||
return values[Math.floor(Math.random() * values.length)];
|
||||
}
|
||||
const weights = rawBranches.map(parseBranchWeight);
|
||||
const total = weights.reduce((a, w) => a + Math.max(0, w), 0);
|
||||
if (total <= 0) return values[0];
|
||||
let r = Math.random() * total;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
r -= Math.max(0, weights[i]);
|
||||
if (r <= 0) return values[i];
|
||||
}
|
||||
return values[values.length - 1];
|
||||
}
|
||||
|
||||
/** Pick `k` items without replacement, weighted, in pick order. Clamps `k`
|
||||
* to `values.length`. Mirrors the engine's `pick_k_unique`. */
|
||||
function pickKUnique(values: string[], weights: number[], k: number): string[] {
|
||||
const n = Math.min(Math.max(0, k), values.length);
|
||||
const idx = values.map((_, i) => i);
|
||||
const w = weights.slice();
|
||||
const chosen: string[] = [];
|
||||
for (let c = 0; c < n && idx.length > 0; c++) {
|
||||
const total = w.reduce((a, x) => a + Math.max(0, x), 0);
|
||||
let j = 0;
|
||||
if (total > 0) {
|
||||
let r = Math.random() * total;
|
||||
for (; j < w.length; j++) {
|
||||
r -= Math.max(0, w[j]);
|
||||
if (r <= 0) break;
|
||||
}
|
||||
if (j >= w.length) j = w.length - 1;
|
||||
}
|
||||
chosen.push(values[idx[j]]);
|
||||
idx.splice(j, 1);
|
||||
w.splice(j, 1);
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
// Multi-pick prefix inside a brace block: `N$$sep$$` or `N-M~$$sep$$`.
|
||||
// Group 1 = count (fixed `N` or range `N-M`), group 2 = optional `~`
|
||||
// independent flag (repeats allowed; absent = unique), group 3 = separator
|
||||
// (may be empty), group 4 = the branch source. Mirror of the engine's
|
||||
// `_MULTI_PREFIX_RE` in engine/syntax/tokenize.py (SP2b).
|
||||
const MULTI_PREFIX_RE = /^(\d+(?:-\d+)?)(~?)\$\$([\s\S]*?)\$\$([\s\S]*)$/;
|
||||
|
||||
/**
|
||||
* Expand inline `{a|b|c}` choices in a value string. Each occurrence is
|
||||
* resolved to one of its `|`-separated branches at random (weighted when a
|
||||
* branch carries an `N::` prefix). Nested braces are resolved innermost-first
|
||||
* via a fixpoint loop.
|
||||
* Expand inline choices in a value string:
|
||||
* - single-pick `{a|b|c}` → one `|`-branch at random (weighted via `N::`).
|
||||
* - multi-pick `{N-M$$sep$$a|b|c}` → pick N..M branches, joined by `sep`.
|
||||
* `~` after the count draws WITH replacement; default is unique. Mirrors
|
||||
* the engine's `_resolve_multi_pick` so the Test Runner preview matches
|
||||
* the actual pipeline (previously the `N-M$$sep$$` prefix was treated as
|
||||
* part of the first branch, leaking raw `1-2$$, $$smirk` into renders).
|
||||
* Nested braces resolve innermost-first via a fixpoint loop.
|
||||
*
|
||||
* Important — this function MUST NOT touch `@{...}` refs. The previous
|
||||
* implementation delegated to `resolveTokens` which tokenized `@{8hex}`
|
||||
@@ -90,7 +150,7 @@ function stripBranchWeight(branch: string): string {
|
||||
export function expandInlineChoices(text: string): string {
|
||||
if (!text) return text;
|
||||
let out = String(text);
|
||||
// Repeatedly resolve innermost `{a|b|c}` braces — `[^{}]` rules out
|
||||
// Repeatedly resolve innermost `{...}` braces — `[^{}]` rules out
|
||||
// nested braces, the `\|` in the alternation rules out single-branch
|
||||
// `{abc}` (which has no `|` to split on, intentionally preserved).
|
||||
// Loop until no replacement happens to handle nested choices.
|
||||
@@ -98,21 +158,27 @@ export function expandInlineChoices(text: string): string {
|
||||
const next = out.replace(
|
||||
/\{([^{}]*\|[^{}]*)\}/g,
|
||||
(_, content: string) => {
|
||||
const raw = content.split("|");
|
||||
const values = raw.map(stripBranchWeight);
|
||||
const hasWeights = values.some((v, i) => v !== raw[i]);
|
||||
if (!hasWeights) {
|
||||
return values[Math.floor(Math.random() * values.length)];
|
||||
const mp = content.match(MULTI_PREFIX_RE);
|
||||
if (mp) {
|
||||
const [minStr, maxStr] = mp[1].split("-");
|
||||
const cmin = Number(minStr);
|
||||
const cmax = maxStr !== undefined ? Number(maxStr) : cmin;
|
||||
const independent = mp[2] === "~";
|
||||
const sep = mp[3];
|
||||
const rawBranches = mp[4].split("|");
|
||||
// A fixed count (min==max) skips the RNG draw, matching the engine.
|
||||
const count = cmin === cmax ? cmin : randInt(cmin, cmax);
|
||||
if (count <= 0) return "";
|
||||
if (independent) {
|
||||
const picks: string[] = [];
|
||||
for (let c = 0; c < count; c++) picks.push(pickOneWeighted(rawBranches));
|
||||
return picks.join(sep);
|
||||
}
|
||||
const values = rawBranches.map(stripBranchWeight);
|
||||
const weights = rawBranches.map(parseBranchWeight);
|
||||
return pickKUnique(values, weights, Math.min(count, rawBranches.length)).join(sep);
|
||||
}
|
||||
const weights = raw.map(parseBranchWeight);
|
||||
const total = weights.reduce((a, w) => a + Math.max(0, w), 0);
|
||||
if (total <= 0) return values[0];
|
||||
let r = Math.random() * total;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
r -= Math.max(0, weights[i]);
|
||||
if (r <= 0) return values[i];
|
||||
}
|
||||
return values[values.length - 1];
|
||||
return pickOneWeighted(content.split("|"));
|
||||
},
|
||||
);
|
||||
if (next === out) break;
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import type { BundleRow, ModuleRow } from "../api/types";
|
||||
import { tokenizeRich, varBaseName, type RichToken } from "../../widgets/richTokenize";
|
||||
import { validateExpression } from "../parsing/subcatFilter";
|
||||
import { VALID_IDENTIFIER_RE } from "./slug";
|
||||
import { isValidVariableName } from "../validation/names";
|
||||
|
||||
/** A produced-var NAME must be a clean `$varname` identifier
|
||||
* (`[A-Za-z_][A-Za-z0-9_]*`). Anything else — a comma, space, or other
|
||||
@@ -30,7 +30,7 @@ import { VALID_IDENTIFIER_RE } from "./slug";
|
||||
* payload check (Python ≡ TS). Returns true when a NON-EMPTY name is
|
||||
* invalid (the empty case is reported separately as a "missing" warn). */
|
||||
function isInvalidVarName(name: string): boolean {
|
||||
return name.length > 0 && !VALID_IDENTIFIER_RE.test(name);
|
||||
return name.length > 0 && !isValidVariableName(name);
|
||||
}
|
||||
|
||||
/** Shared error message for an invalid produced-var name. */
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Canonical name validators — the SINGLE surface every UI site imports so
|
||||
* that "add", "rename", and any other name-entry point enforce identical
|
||||
* rules and cannot drift apart. (This module exists because sub-category
|
||||
* *add* validated with `validateSubcatName` while *rename* hand-rolled a
|
||||
* weaker check that missed spaces — issue #7. Centralizing removes the
|
||||
* class of bug, not just the instance.)
|
||||
*
|
||||
* Each validator returns a human-readable error message, or `null` when the
|
||||
* name is valid. There are three grammar surfaces (see the engine's
|
||||
* `engine/syntax` + `docs`):
|
||||
*
|
||||
* - **Sub-category tags** → `validateSubcatName` (whitespace / reserved
|
||||
* word / disallowed char). The rule lives in `parsing/subcatFilter.ts`
|
||||
* because it is parity-tested against the engine's shared fixtures; we
|
||||
* re-export it here so callers still import from one place.
|
||||
* - **Produced-variable / `$identifier` names** → `validateVariableName`
|
||||
* (or the boolean `isValidVariableName` for terse per-row surfaces).
|
||||
* - **Display names embeddable in an `@{uuid#name:subcat}` ref** →
|
||||
* `validateRefGrammarName` (rejects the structural + comma chars).
|
||||
*/
|
||||
import { validateSubcatName } from "../parsing/subcatFilter";
|
||||
import { VALID_IDENTIFIER_RE } from "../utils/slug";
|
||||
|
||||
export { validateSubcatName };
|
||||
|
||||
/** Reserved characters for names that flow into the `@{uuid#name:subcat}`
|
||||
* ref grammar (structural braces/colon/hash/at + the comma that separates
|
||||
* sub-categories). Mirrors `wp_api/_validators.py`. */
|
||||
const REF_GRAMMAR_FORBIDDEN_RE = /[{}:#@,]/;
|
||||
|
||||
/** Validate a display name that may be embedded in an `@{uuid#name:subcat}`
|
||||
* ref. Returns an error message or `null`. Whitespace is allowed here —
|
||||
* wildcard/module display names legitimately contain spaces; only the
|
||||
* structural characters are forbidden. */
|
||||
export function validateRefGrammarName(name: string): string | null {
|
||||
const bad = name.match(REF_GRAMMAR_FORBIDDEN_RE);
|
||||
if (bad) {
|
||||
return `Cannot contain "${bad[0]}" (reserved by the @{uuid#name:subcat} ref grammar)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when `name` is a valid produced-variable identifier (letter or
|
||||
* underscore first, then letters/digits/underscores; no spaces). Use this
|
||||
* for terse per-row surfaces that supply their own message. */
|
||||
export function isValidVariableName(name: string): boolean {
|
||||
return VALID_IDENTIFIER_RE.test(name);
|
||||
}
|
||||
|
||||
/** Validate a produced-variable / `$identifier` name. Returns an error
|
||||
* message or `null`. Empty is treated as invalid — callers that allow an
|
||||
* empty value (e.g. "use the library default") gate that before calling. */
|
||||
export function validateVariableName(name: string): string | null {
|
||||
if (isValidVariableName(name)) return null;
|
||||
return "Use letters, digits, underscores; must not start with a digit.";
|
||||
}
|
||||
@@ -32,7 +32,8 @@ import { useReturnTo } from "../composables/useReturnTo";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { useRecentStore } from "../stores/recentStore";
|
||||
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
|
||||
import { toIdentifier } from "../utils/slug";
|
||||
import { validateVariableName } from "../validation/names";
|
||||
import { buildUuidToName } from "../utils/wildcardSyntax";
|
||||
import { collectLibraryVarHints, type VarHint } from "../utils/library-suggestions";
|
||||
import { appendSnapshot, readHistory } from "../utils/history";
|
||||
@@ -373,8 +374,9 @@ async function save() {
|
||||
return;
|
||||
}
|
||||
const finalOutput = outputVar.value.trim() || toIdentifier(name.value);
|
||||
if (!VALID_IDENTIFIER_RE.test(finalOutput)) {
|
||||
outputVarError.value = "Use letters, digits, underscores; must not start with a digit.";
|
||||
const outputVarErr = validateVariableName(finalOutput);
|
||||
if (outputVarErr) {
|
||||
outputVarError.value = outputVarErr;
|
||||
toast.push({ severity: "warn", summary: "Invalid output variable" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
* Sections:
|
||||
* 1. Identity
|
||||
* 2. Source / Target wildcards
|
||||
* 3. Rule matrix (ConstraintMatrix)
|
||||
* 4. Exceptions table
|
||||
* 3. Target reach (default `target_select`) — sits above the matrix so the
|
||||
* "how many targets" decision reads before the per-pair rule grid.
|
||||
* 4. Rule matrix (ConstraintMatrix)
|
||||
* 5. Exceptions table
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import type { BreadcrumbItem } from "../components/Breadcrumb.types";
|
||||
@@ -20,6 +22,9 @@ import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import DraftBanner from "../components/DraftBanner.vue";
|
||||
import Input from "../components/ui/Input.vue";
|
||||
import Select from "../components/ui/Select.vue";
|
||||
import Checkbox from "../components/ui/Checkbox.vue";
|
||||
import BulkDeleteToolbar from "../components/BulkDeleteToolbar.vue";
|
||||
import { useBulkSelection } from "../composables/useBulkSelection";
|
||||
import RichTextPreview from "../components/RichTextPreview.vue";
|
||||
import ConstraintMatrixGrid from "../components/ConstraintMatrix.vue";
|
||||
import ConfirmDialog from "../../components/shared/ConfirmDialog.vue";
|
||||
@@ -852,6 +857,46 @@ function removeException(idx: number) {
|
||||
exceptions.value.splice(idx, 1);
|
||||
}
|
||||
|
||||
/* ── Bulk select + delete ────────────────────────────────────────────────
|
||||
* Exceptions have no persisted id, and adding a payload field is off-limits
|
||||
* (the engine reads this shape). So key selection by object identity via a
|
||||
* WeakMap-backed transient uid — stable across edits/adds, never persisted,
|
||||
* and unaffected by index shifts when a single row is removed mid-selection. */
|
||||
const exUids = new WeakMap<object, string>();
|
||||
let exUidSeq = 0;
|
||||
function exUid(ex: object): string {
|
||||
let u = exUids.get(ex);
|
||||
if (!u) {
|
||||
u = `ex_${exUidSeq++}`;
|
||||
exUids.set(ex, u);
|
||||
}
|
||||
return u;
|
||||
}
|
||||
const exBulk = useBulkSelection(() => exceptions.value.map((ex) => exUid(ex)));
|
||||
const {
|
||||
active: exBulkActive,
|
||||
count: exBulkCount,
|
||||
allSelected: exBulkAllSelected,
|
||||
someSelected: exBulkSomeSelected,
|
||||
isSelected: exBulkIsSelected,
|
||||
toggle: exBulkToggle,
|
||||
toggleAll: exBulkToggleAll,
|
||||
toggleMode: exBulkToggleMode,
|
||||
clear: exBulkClear,
|
||||
} = exBulk;
|
||||
function deleteSelectedExceptions(): void {
|
||||
const ids = new Set(exBulk.selectedIds());
|
||||
if (ids.size === 0) return;
|
||||
const removed = ids.size;
|
||||
exceptions.value = exceptions.value.filter((ex) => !ids.has(exUid(ex)));
|
||||
exBulkClear();
|
||||
toast.push({
|
||||
severity: "success",
|
||||
summary: `${removed} exception${removed === 1 ? "" : "s"} deleted`,
|
||||
life: 2500,
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve a value-string to its current option `id` on a wildcard.
|
||||
*
|
||||
* Returns `undefined` if no match (lets the exception keep source_id
|
||||
@@ -1217,63 +1262,6 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div id="editor-section-matrix">
|
||||
<Card title="Rule matrix">
|
||||
<template #actions>
|
||||
<!-- Stranded ref → the grid is a read-only snapshot, so swap the
|
||||
"click to edit" affordance for a lock pill. Reattaching a live
|
||||
wildcard (banner above) is the only way back to editing. -->
|
||||
<span
|
||||
v-if="refMissing"
|
||||
class="cn-lock-pill"
|
||||
data-test="matrix-readonly-pill"
|
||||
>
|
||||
<i class="pi pi-lock" aria-hidden="true" /> Read-only
|
||||
</span>
|
||||
<span v-else class="wp-card__hint">Click a cell to edit rule + factor</span>
|
||||
</template>
|
||||
<div
|
||||
v-if="!sourceWildcardId || !targetWildcardId"
|
||||
class="wp-empty-card"
|
||||
data-test="matrix-empty"
|
||||
>
|
||||
Pick a source and target wildcard to populate the matrix.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="sourceSubCategories.length === 0 || targetSubCategories.length === 0"
|
||||
class="wp-empty-card"
|
||||
data-test="matrix-need-subs"
|
||||
>
|
||||
<i class="pi pi-info-circle" />
|
||||
<!-- With the keys-fallback filling the axes for a stranded ref, this
|
||||
branch only fires when the reconstructed axes are ALSO empty —
|
||||
i.e. a deleted wildcard whose constraint has no saved rules. -->
|
||||
<template v-if="refMissing">
|
||||
The source or target wildcard was deleted and this constraint has no
|
||||
saved rules — reattach a live wildcard to restore the matrix.
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="sourceSubCategories.length === 0">Source wildcard needs at least one sub-category. </span>
|
||||
<span v-if="targetSubCategories.length === 0">Target wildcard needs at least one sub-category. </span>
|
||||
Add them on the wildcard editor to define rules.
|
||||
</template>
|
||||
</div>
|
||||
<ConstraintMatrixGrid
|
||||
v-else
|
||||
:rows="sourceSubCategories"
|
||||
:cols="targetSubCategories"
|
||||
:model-value="matrix"
|
||||
:source-name="sourceWildcard?.name ?? (sourceWildcardName ?? '')"
|
||||
:target-name="targetWildcard?.name ?? (targetWildcardName ?? '')"
|
||||
:source-groups="sourceGroups"
|
||||
:target-groups="targetGroups"
|
||||
:readonly="refMissing"
|
||||
data-test="matrix-grid"
|
||||
@update:model-value="onMatrixUpdate"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div id="editor-section-reach">
|
||||
<Card title="Target reach">
|
||||
<template #actions>
|
||||
@@ -1335,16 +1323,91 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div id="editor-section-matrix">
|
||||
<Card title="Rule matrix">
|
||||
<template #actions>
|
||||
<!-- Stranded ref → the grid is a read-only snapshot, so swap the
|
||||
"click to edit" affordance for a lock pill. Reattaching a live
|
||||
wildcard (banner above) is the only way back to editing. -->
|
||||
<span
|
||||
v-if="refMissing"
|
||||
class="cn-lock-pill"
|
||||
data-test="matrix-readonly-pill"
|
||||
>
|
||||
<i class="pi pi-lock" aria-hidden="true" /> Read-only
|
||||
</span>
|
||||
<span v-else class="wp-card__hint">Click a cell to edit rule + factor</span>
|
||||
</template>
|
||||
<div
|
||||
v-if="!sourceWildcardId || !targetWildcardId"
|
||||
class="wp-empty-card"
|
||||
data-test="matrix-empty"
|
||||
>
|
||||
Pick a source and target wildcard to populate the matrix.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="sourceSubCategories.length === 0 || targetSubCategories.length === 0"
|
||||
class="wp-empty-card"
|
||||
data-test="matrix-need-subs"
|
||||
>
|
||||
<i class="pi pi-info-circle" />
|
||||
<!-- With the keys-fallback filling the axes for a stranded ref, this
|
||||
branch only fires when the reconstructed axes are ALSO empty —
|
||||
i.e. a deleted wildcard whose constraint has no saved rules. -->
|
||||
<template v-if="refMissing">
|
||||
The source or target wildcard was deleted and this constraint has no
|
||||
saved rules — reattach a live wildcard to restore the matrix.
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="sourceSubCategories.length === 0">Source wildcard needs at least one sub-category. </span>
|
||||
<span v-if="targetSubCategories.length === 0">Target wildcard needs at least one sub-category. </span>
|
||||
Add them on the wildcard editor to define rules.
|
||||
</template>
|
||||
</div>
|
||||
<ConstraintMatrixGrid
|
||||
v-else
|
||||
:rows="sourceSubCategories"
|
||||
:cols="targetSubCategories"
|
||||
:model-value="matrix"
|
||||
:source-name="sourceWildcard?.name ?? (sourceWildcardName ?? '')"
|
||||
:target-name="targetWildcard?.name ?? (targetWildcardName ?? '')"
|
||||
:source-groups="sourceGroups"
|
||||
:target-groups="targetGroups"
|
||||
:readonly="refMissing"
|
||||
data-test="matrix-grid"
|
||||
@update:model-value="onMatrixUpdate"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div id="editor-section-exceptions">
|
||||
<Card :title="`Exceptions (${exceptions.length})`" :padding="false">
|
||||
<Card :title="`Exceptions (${exceptions.length})`" :padding="false" sticky-header>
|
||||
<template #actions>
|
||||
<!-- Editing requires a live wildcard — the value pickers are empty
|
||||
when the ref is missing, so authoring a new exception is hidden
|
||||
in the read-only recovery view. -->
|
||||
<Button
|
||||
v-if="!refMissing && (exceptions.length || exBulkActive)"
|
||||
size="sm"
|
||||
:variant="exBulkActive ? 'secondary' : 'ghost'"
|
||||
icon="pi-check-square"
|
||||
data-test="cn-ex-bulk-toggle"
|
||||
@click="exBulkToggleMode"
|
||||
>{{ exBulkActive ? "Done" : "Bulk edit" }}</Button>
|
||||
<Button v-if="!refMissing" size="sm" variant="primary" icon="pi-plus" data-test="add-exception" @click="addException">
|
||||
Add exception
|
||||
</Button>
|
||||
</template>
|
||||
<template v-if="!refMissing" #subheader>
|
||||
<div v-if="exBulkActive && exBulkCount > 0" class="cn-ex-bulkbar">
|
||||
<BulkDeleteToolbar
|
||||
:count="exBulkCount"
|
||||
noun="exceptions"
|
||||
@delete-selected="deleteSelectedExceptions"
|
||||
@clear="exBulkClear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!exceptions.length" class="wp-empty-card">
|
||||
<i class="pi pi-info-circle" />
|
||||
Per-pair overrides for specific option values that the matrix doesn't cover.
|
||||
@@ -1396,6 +1459,15 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
|
||||
<table v-else class="wp-table wp-options-table cn-ex-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="exBulkActive" scope="col" class="cn-ex-col-check">
|
||||
<Checkbox
|
||||
:model-value="exBulkAllSelected"
|
||||
:indeterminate="exBulkSomeSelected"
|
||||
aria-label="Select all exceptions"
|
||||
data-test="cn-ex-select-all"
|
||||
@update:model-value="exBulkToggleAll"
|
||||
/>
|
||||
</th>
|
||||
<th class="cn-ex-th-src">Source value</th>
|
||||
<th class="cn-ex-th-tgt">Target value</th>
|
||||
<th class="cn-col-mode">Mode</th>
|
||||
@@ -1408,8 +1480,25 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
|
||||
v-for="(ex, idx) in exceptions"
|
||||
:key="idx"
|
||||
class="cn-ex-row"
|
||||
:class="{ 'cn-ex-row--selected': exBulkActive && exBulkIsSelected(exUid(ex)) }"
|
||||
:style="{ '--cn-mode-var': `var(${modeMeta(ex.mode).cssVar})` }"
|
||||
>
|
||||
<td v-if="exBulkActive" class="cn-ex-col-check">
|
||||
<button
|
||||
type="button"
|
||||
class="wp-check"
|
||||
role="checkbox"
|
||||
:aria-checked="exBulkIsSelected(exUid(ex))"
|
||||
:data-checked="exBulkIsSelected(exUid(ex)) ? 'true' : 'false'"
|
||||
:aria-label="`Select exception ${idx + 1}`"
|
||||
:data-test="`cn-ex-check-${idx}`"
|
||||
@click="exBulkToggle(exUid(ex))"
|
||||
>
|
||||
<svg v-if="exBulkIsSelected(exUid(ex))" viewBox="0 0 12 12" fill="none" style="display:block">
|
||||
<path d="M3 6.2l2.2 2.2L9 4.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<Select
|
||||
:model-value="ex.source"
|
||||
@@ -1673,6 +1762,9 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
|
||||
.cn-ex-row {
|
||||
background: color-mix(in srgb, var(--cn-mode-var, transparent) 5%, transparent);
|
||||
}
|
||||
.cn-ex-col-check { width: 34px; text-align: center; }
|
||||
.cn-ex-row--selected > td { background: color-mix(in oklab, var(--wp-accent) 10%, transparent); }
|
||||
.cn-ex-bulkbar { padding: 12px 16px 14px; }
|
||||
|
||||
/* Mode chip — icon glyph + label, tinted by --cn-mode-var. Editable +
|
||||
* read-only share the base; the read-only modifier mutes it to the same
|
||||
|
||||
@@ -17,6 +17,9 @@ import Button from "../components/ui/Button.vue";
|
||||
import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import DraftBanner from "../components/DraftBanner.vue";
|
||||
import DerivationRuleCard from "../components/DerivationRuleCard.vue";
|
||||
import Checkbox from "../components/ui/Checkbox.vue";
|
||||
import BulkDeleteToolbar from "../components/BulkDeleteToolbar.vue";
|
||||
import { useBulkSelection } from "../composables/useBulkSelection";
|
||||
import ConfirmDialog from "../../components/shared/ConfirmDialog.vue";
|
||||
import { useToast } from "../composables/useToast";
|
||||
import { useUnsavedGuard } from "../composables/useUnsavedGuard";
|
||||
@@ -293,6 +296,42 @@ function updateRule(idx: number, value: DerivationRule) {
|
||||
rules.value = rules.value.map((r, i) => (i === idx ? value : r));
|
||||
}
|
||||
|
||||
// Collapse-all / Expand-all broadcast to every rule card. Bumping the nonce
|
||||
// makes each card adopt `collapsed`; cards can still be toggled individually
|
||||
// afterwards (#9).
|
||||
const collapseCommand = ref({ nonce: 0, collapsed: false });
|
||||
function collapseAllRules(collapsed: boolean): void {
|
||||
collapseCommand.value = { nonce: collapseCommand.value.nonce + 1, collapsed };
|
||||
}
|
||||
|
||||
/* ── Bulk select + delete ────────────────────────────────────────────────
|
||||
* Rules are a card list (not a table), so the checkbox rides alongside each
|
||||
* card. Rules carry a stable `id`, so selection keys off that directly. */
|
||||
const ruleBulk = useBulkSelection(() => rules.value.map((r) => r.id));
|
||||
const {
|
||||
active: ruleBulkActive,
|
||||
count: ruleBulkCount,
|
||||
allSelected: ruleBulkAllSelected,
|
||||
someSelected: ruleBulkSomeSelected,
|
||||
isSelected: ruleBulkIsSelected,
|
||||
toggle: ruleBulkToggle,
|
||||
toggleAll: ruleBulkToggleAll,
|
||||
toggleMode: ruleBulkToggleMode,
|
||||
clear: ruleBulkClear,
|
||||
} = ruleBulk;
|
||||
function deleteSelectedRules(): void {
|
||||
const ids = new Set(ruleBulk.selectedIds());
|
||||
if (ids.size === 0) return;
|
||||
const removed = ids.size;
|
||||
rules.value = rules.value.filter((r) => !ids.has(r.id));
|
||||
ruleBulkClear();
|
||||
toast.push({
|
||||
severity: "success",
|
||||
summary: `${removed} rule${removed === 1 ? "" : "s"} deleted`,
|
||||
life: 2500,
|
||||
});
|
||||
}
|
||||
|
||||
function applyRestore(entry: ModuleHistoryEntry): void {
|
||||
name.value = entry.name;
|
||||
description.value = entry.description ?? "";
|
||||
@@ -445,12 +484,58 @@ defineExpose({ rules, addRule, removeRule, applyRestore });
|
||||
@update:content-rating="(v) => (contentRating = v)"
|
||||
/>
|
||||
|
||||
<Card :title="`Rules (${rules.length})`">
|
||||
<Card :title="`Rules (${rules.length})`" sticky-header>
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="rules.length > 1"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="pi-angle-double-up"
|
||||
data-test="drv-collapse-all"
|
||||
@click="collapseAllRules(true)"
|
||||
>Collapse all</Button>
|
||||
<Button
|
||||
v-if="rules.length > 1"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="pi-angle-double-down"
|
||||
data-test="drv-expand-all"
|
||||
@click="collapseAllRules(false)"
|
||||
>Expand all</Button>
|
||||
<Button
|
||||
v-if="rules.length || ruleBulkActive"
|
||||
size="sm"
|
||||
:variant="ruleBulkActive ? 'secondary' : 'ghost'"
|
||||
icon="pi-check-square"
|
||||
data-test="drv-bulk-toggle"
|
||||
@click="ruleBulkToggleMode"
|
||||
>{{ ruleBulkActive ? "Done" : "Bulk edit" }}</Button>
|
||||
<Button size="sm" variant="primary" icon="pi-plus" data-test="add-rule" @click="addRule">
|
||||
Add rule
|
||||
</Button>
|
||||
</template>
|
||||
<template #subheader>
|
||||
<div v-if="ruleBulkActive" class="drv-bulkbar">
|
||||
<label class="drv-bulkbar__all">
|
||||
<Checkbox
|
||||
:model-value="ruleBulkAllSelected"
|
||||
:indeterminate="ruleBulkSomeSelected"
|
||||
aria-label="Select all rules"
|
||||
data-test="drv-select-all"
|
||||
@update:model-value="ruleBulkToggleAll"
|
||||
/>
|
||||
Select all
|
||||
</label>
|
||||
<BulkDeleteToolbar
|
||||
v-if="ruleBulkCount > 0"
|
||||
class="drv-bulkbar__toolbar"
|
||||
:count="ruleBulkCount"
|
||||
noun="rules"
|
||||
@delete-selected="deleteSelectedRules"
|
||||
@clear="ruleBulkClear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<p class="wp-card__hint">
|
||||
Each rule runs independently. Inside a rule, branches evaluate top-to-bottom — the first matching IF/ELIF wins; the optional ELSE only fires when no branch matched.
|
||||
</p>
|
||||
@@ -460,24 +545,46 @@ defineExpose({ rules, addRule, removeRule, applyRestore });
|
||||
</div>
|
||||
|
||||
<div v-else class="rules-stack" data-test="rules-stack">
|
||||
<DerivationRuleCard
|
||||
<div
|
||||
v-for="(rule, idx) in rules"
|
||||
:key="rule.id"
|
||||
:model-value="rule"
|
||||
:index="idx"
|
||||
:var-suggestions="varSuggestions"
|
||||
:uuid-to-name="uuidToName"
|
||||
:ref-suggestions="refSuggestions"
|
||||
:uuid-to-sub-categories="refData.uuidToSubCategories"
|
||||
:uuid-to-has-null="refData.uuidToHasNull"
|
||||
:uuid-to-options-count="refData.uuidToOptionsCount"
|
||||
:uuid-to-option-tag-sets="refData.uuidToOptionTagSets"
|
||||
:uuid-to-tag-groups="refData.uuidToTagGroups"
|
||||
:default-collapsed="rules.length > 1"
|
||||
:data-test="`rule-${idx}`"
|
||||
@update:model-value="(v) => updateRule(idx, v)"
|
||||
@remove="removeRule(idx)"
|
||||
/>
|
||||
class="rule-row"
|
||||
:class="{ 'rule-row--selected': ruleBulkActive && ruleBulkIsSelected(rule.id) }"
|
||||
>
|
||||
<button
|
||||
v-if="ruleBulkActive"
|
||||
type="button"
|
||||
class="wp-check rule-row__check"
|
||||
role="checkbox"
|
||||
:aria-checked="ruleBulkIsSelected(rule.id)"
|
||||
:data-checked="ruleBulkIsSelected(rule.id) ? 'true' : 'false'"
|
||||
:aria-label="`Select rule ${idx + 1}`"
|
||||
:data-test="`drv-check-${idx}`"
|
||||
@click="ruleBulkToggle(rule.id)"
|
||||
>
|
||||
<svg v-if="ruleBulkIsSelected(rule.id)" viewBox="0 0 12 12" fill="none" style="display:block">
|
||||
<path d="M3 6.2l2.2 2.2L9 4.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<DerivationRuleCard
|
||||
class="rule-row__card"
|
||||
:model-value="rule"
|
||||
:index="idx"
|
||||
:var-suggestions="varSuggestions"
|
||||
:uuid-to-name="uuidToName"
|
||||
:ref-suggestions="refSuggestions"
|
||||
:uuid-to-sub-categories="refData.uuidToSubCategories"
|
||||
:uuid-to-has-null="refData.uuidToHasNull"
|
||||
:uuid-to-options-count="refData.uuidToOptionsCount"
|
||||
:uuid-to-option-tag-sets="refData.uuidToOptionTagSets"
|
||||
:uuid-to-tag-groups="refData.uuidToTagGroups"
|
||||
:default-collapsed="true"
|
||||
:collapse-command="collapseCommand"
|
||||
:data-test="`rule-${idx}`"
|
||||
@update:model-value="(v) => updateRule(idx, v)"
|
||||
@remove="removeRule(idx)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<!-- CascadeConfirmDialog: shown when entity has downstream refs. -->
|
||||
@@ -518,4 +625,31 @@ defineExpose({ rules, addRule, removeRule, applyRestore });
|
||||
flex-direction: column;
|
||||
gap: var(--wp-space-6);
|
||||
}
|
||||
/* Each rule row: an optional bulk checkbox rides to the left of the card. */
|
||||
.rule-row { display: flex; align-items: flex-start; gap: 10px; }
|
||||
.rule-row__card { flex: 1 1 auto; min-width: 0; }
|
||||
.rule-row__check { margin-top: 14px; flex: 0 0 auto; }
|
||||
.rule-row--selected .rule-row__card {
|
||||
outline: 2px solid color-mix(in oklab, var(--wp-accent) 45%, transparent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--wp-radius, 8px);
|
||||
}
|
||||
/* Bulk bar in the sticky subheader: select-all + (delete/clear when any). */
|
||||
.drv-bulkbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.drv-bulkbar__all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--wp-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.drv-bulkbar__toolbar { flex: 1 1 auto; }
|
||||
</style>
|
||||
|
||||
@@ -20,6 +20,9 @@ import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import DraftBanner from "../components/DraftBanner.vue";
|
||||
import RichTextInput from "../components/RichTextInput.vue";
|
||||
import BulkAddPanel from "../components/BulkAddPanel.vue";
|
||||
import BulkDeleteToolbar from "../components/BulkDeleteToolbar.vue";
|
||||
import Checkbox from "../components/ui/Checkbox.vue";
|
||||
import { useBulkSelection } from "../composables/useBulkSelection";
|
||||
import type { ParsedFixedValue } from "../utils/bulkParse";
|
||||
import ConfirmDialog from "../../components/shared/ConfirmDialog.vue";
|
||||
import { useToast } from "../composables/useToast";
|
||||
@@ -30,7 +33,7 @@ import { useReturnTo } from "../composables/useReturnTo";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { useRecentStore } from "../stores/recentStore";
|
||||
import { VALID_IDENTIFIER_RE } from "../utils/slug";
|
||||
import { isValidVariableName } from "../validation/names";
|
||||
import { appendSnapshot, readHistory } from "../utils/history";
|
||||
import { useCascadeStore } from "../cascade/cascade-store";
|
||||
import { useCascadeApply } from "../cascade/useCascadeApply";
|
||||
@@ -224,6 +227,34 @@ function commitBulkValues(parsed: ParsedFixedValue[]): void {
|
||||
toast.push({ severity: "success", summary: `${added} added, ${updated} updated`, life: 2500 });
|
||||
}
|
||||
|
||||
/* ── Bulk select + delete ────────────────────────────────────────────────
|
||||
* Multi-select value rows to delete many at once (the bulk-ADD panel above
|
||||
* stays independent). Selection keys off each row's stable `id`. */
|
||||
const bulk = useBulkSelection(() => values.value.map((v) => v.id));
|
||||
const {
|
||||
active: bulkActive,
|
||||
count: bulkCount,
|
||||
allSelected: bulkAllSelected,
|
||||
someSelected: bulkSomeSelected,
|
||||
isSelected: bulkIsSelected,
|
||||
toggle: bulkToggle,
|
||||
toggleAll: bulkToggleAll,
|
||||
toggleMode: bulkToggleMode,
|
||||
clear: bulkClear,
|
||||
} = bulk;
|
||||
function deleteSelectedValues(): void {
|
||||
const ids = new Set(bulk.selectedIds());
|
||||
if (ids.size === 0) return;
|
||||
const removed = ids.size;
|
||||
values.value = values.value.filter((v) => !ids.has(v.id));
|
||||
bulkClear();
|
||||
toast.push({
|
||||
severity: "success",
|
||||
summary: `${removed} value${removed === 1 ? "" : "s"} deleted`,
|
||||
life: 2500,
|
||||
});
|
||||
}
|
||||
|
||||
function onVarInput(idx: number, raw: string) {
|
||||
values.value[idx].name = (raw ?? "").replace(/[^a-zA-Z0-9_]/g, "");
|
||||
}
|
||||
@@ -238,7 +269,7 @@ const rowErrors = computed<string[]>(() => {
|
||||
for (const v of values.value) {
|
||||
const n = v.name.trim();
|
||||
if (!n) { errs.push("Required"); continue; }
|
||||
if (!VALID_IDENTIFIER_RE.test(n)) { errs.push("Invalid identifier"); continue; }
|
||||
if (!isValidVariableName(n)) { errs.push("Invalid identifier"); continue; }
|
||||
if ((counts.get(n) ?? 0) > 1) { errs.push("Duplicate name"); continue; }
|
||||
errs.push("");
|
||||
}
|
||||
@@ -402,8 +433,15 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
|
||||
@update:content-rating="(v) => (contentRating = v)"
|
||||
/>
|
||||
|
||||
<Card :title="`Values (${values.length})`" :padding="false">
|
||||
<Card :title="`Values (${values.length})`" :padding="false" sticky-header>
|
||||
<template #actions>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="bulkActive ? 'secondary' : 'ghost'"
|
||||
icon="pi-check-square"
|
||||
data-test="fv-bulk-toggle"
|
||||
@click="bulkToggleMode"
|
||||
>{{ bulkActive ? "Done" : "Bulk edit" }}</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="bulkAddOpen ? 'secondary' : 'ghost'"
|
||||
@@ -415,17 +453,36 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
|
||||
Add value
|
||||
</Button>
|
||||
</template>
|
||||
<div v-if="bulkAddOpen" class="wpc-bulk-controls">
|
||||
<template #subheader>
|
||||
<div v-if="bulkAddOpen || (bulkActive && bulkCount > 0)" class="wpc-bulk-controls">
|
||||
<BulkAddPanel
|
||||
v-if="bulkAddOpen"
|
||||
mode="values"
|
||||
:existing-values="existingValueNames"
|
||||
@commit-values="commitBulkValues"
|
||||
@cancel="bulkAddOpen = false"
|
||||
/>
|
||||
<BulkDeleteToolbar
|
||||
v-if="bulkActive && bulkCount > 0"
|
||||
:count="bulkCount"
|
||||
noun="values"
|
||||
@delete-selected="deleteSelectedValues"
|
||||
@clear="bulkClear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<table class="wp-table wp-options-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="bulkActive" scope="col" class="fv-col-check">
|
||||
<Checkbox
|
||||
:model-value="bulkAllSelected"
|
||||
:indeterminate="bulkSomeSelected"
|
||||
aria-label="Select all values"
|
||||
data-test="fv-bulk-select-all"
|
||||
@update:model-value="bulkToggleAll"
|
||||
/>
|
||||
</th>
|
||||
<th class="fv-col-var">Variable</th>
|
||||
<th>Value</th>
|
||||
<th class="fv-col-trash" />
|
||||
@@ -437,7 +494,24 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
|
||||
:key="v.id"
|
||||
:data-test="`fv-row-${idx}`"
|
||||
:data-invalid="rowErrors[idx] ? 'true' : 'false'"
|
||||
:class="{ 'fv-row--selected': bulkActive && bulkIsSelected(v.id) }"
|
||||
>
|
||||
<td v-if="bulkActive" class="fv-col-check">
|
||||
<button
|
||||
type="button"
|
||||
class="wp-check"
|
||||
role="checkbox"
|
||||
:aria-checked="bulkIsSelected(v.id)"
|
||||
:data-checked="bulkIsSelected(v.id) ? 'true' : 'false'"
|
||||
:aria-label="`Select value ${idx + 1}`"
|
||||
:data-test="`fv-check-${idx}`"
|
||||
@click="bulkToggle(v.id)"
|
||||
>
|
||||
<svg v-if="bulkIsSelected(v.id)" viewBox="0 0 12 12" fill="none" style="display:block">
|
||||
<path d="M3 6.2l2.2 2.2L9 4.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<div class="wp-input-group">
|
||||
<span class="wp-input-group__addon">$</span>
|
||||
@@ -479,7 +553,7 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!values.length">
|
||||
<td colspan="3" class="opt-empty">No values yet.</td>
|
||||
<td :colspan="bulkActive ? 4 : 3" class="opt-empty">No values yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -518,6 +592,8 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
|
||||
color: var(--wp-text-muted);
|
||||
}
|
||||
.fv-col-var { width: 220px; }
|
||||
.fv-col-check { width: 34px; text-align: center; }
|
||||
.fv-row--selected > td { background: color-mix(in oklab, var(--wp-accent) 8%, transparent); }
|
||||
.wpc-bulk-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useToast } from "../composables/useToast";
|
||||
import { useResolveWarnings } from "../composables/useResolveWarnings";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useBundleStore } from "../stores/bundleStore";
|
||||
import { useTemplateStore } from "../stores/templateStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { api, ApiError } from "../api/client";
|
||||
import ExportTab from "../import-export/ExportTab.vue";
|
||||
import ImportTab from "../import-export/ImportTab.vue";
|
||||
@@ -52,6 +54,8 @@ type Mode = "export" | "import";
|
||||
const toast = useToast();
|
||||
const moduleStore = useModuleStore();
|
||||
const bundleStore = useBundleStore();
|
||||
const templateStore = useTemplateStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
|
||||
const mode = ref<Mode>("export");
|
||||
|
||||
@@ -264,6 +268,23 @@ function buildSelectedEntities(
|
||||
}
|
||||
const templateColl: Record<string, SelectedEntity["collision"]> =
|
||||
templateRows.length > 0 ? detectTemplateCollisions(templateRows, library) : {};
|
||||
// Bundles were historically excluded from collision detection (deferred to
|
||||
// a `bundle-fingerprint.ts` MOD-detection flow that was never built), so an
|
||||
// existing bundle fell through to a blind `add` decision — the server then
|
||||
// 400'd the whole commit with "id collision on add" and the bundles never
|
||||
// imported. Bundles live in the same id→fingerprint library map as modules
|
||||
// (buildLibraryMap), so route them through the SAME detector: identical →
|
||||
// silent-skip, present-but-changed / no library fingerprint → conflict /
|
||||
// exists-unknown, both of which surface in the conflict modal for the user
|
||||
// to Replace / Rename / Skip, exactly like a colliding module.
|
||||
const bundleRows: Array<FingerprintModuleRow & { id: string }> = [];
|
||||
for (const id of selection) {
|
||||
const hit = idIndex.get(id);
|
||||
if (!hit || hit.kind !== "bundle") continue;
|
||||
bundleRows.push(hit.entity as unknown as FingerprintModuleRow & { id: string });
|
||||
}
|
||||
const bundleColl: Record<string, SelectedEntity["collision"]> =
|
||||
bundleRows.length > 0 ? detectCollisions(bundleRows, library) : {};
|
||||
for (const id of selection) {
|
||||
const hit = idIndex.get(id);
|
||||
if (!hit) continue;
|
||||
@@ -272,7 +293,10 @@ function buildSelectedEntities(
|
||||
collision = templateColl[id] ?? "no-collision";
|
||||
} else if (MODULE_KINDS.has(hit.kind)) {
|
||||
collision = collisionByKind[hit.kind]?.[id] ?? "no-collision";
|
||||
} else if (hit.kind === "bundle") {
|
||||
collision = bundleColl[id] ?? "no-collision";
|
||||
} else {
|
||||
// Categories merge by name server-side — no id collision concept.
|
||||
collision = "no-collision";
|
||||
}
|
||||
result.push({ kind: hit.kind, entity: hit.entity, collision });
|
||||
@@ -650,6 +674,12 @@ async function runCommit(resolution: {
|
||||
loadLibrary(),
|
||||
moduleStore.fetchCatalog(),
|
||||
bundleStore.fetchCatalog(),
|
||||
// Templates + categories drive their own sidebar count badges; refresh
|
||||
// them too so the counts update live after an import/undo (#7) — an
|
||||
// import that adds templates or categories used to leave a stale count
|
||||
// until the next route change forced a re-fetch.
|
||||
templateStore.fetchCatalog(),
|
||||
categoryStore.fetchAll(),
|
||||
]);
|
||||
const libraryIds = new Set<string>();
|
||||
for (const m of localModules.value) libraryIds.add(m.id);
|
||||
@@ -703,6 +733,12 @@ async function undoImport(undoEntryId: string): Promise<void> {
|
||||
loadLibrary(),
|
||||
moduleStore.fetchCatalog(),
|
||||
bundleStore.fetchCatalog(),
|
||||
// Templates + categories drive their own sidebar count badges; refresh
|
||||
// them too so the counts update live after an import/undo (#7) — an
|
||||
// import that adds templates or categories used to leave a stale count
|
||||
// until the next route change forced a re-fetch.
|
||||
templateStore.fetchCatalog(),
|
||||
categoryStore.fetchAll(),
|
||||
]);
|
||||
resolveWarningsStore.clearByType("broken_ref_on_import");
|
||||
toast.push({ severity: "info", summary: "Import undone", life: 4000 });
|
||||
|
||||
@@ -8,18 +8,41 @@
|
||||
* with dot-prefix fix) and DatabaseCard (server-side SQLite info +
|
||||
* maintenance ops).
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Field from "../components/ui/Field.vue";
|
||||
import Icon from "../components/ui/Icon.vue";
|
||||
import Input from "../components/ui/Input.vue";
|
||||
import Toggle from "../components/ui/Toggle.vue";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import BrowserPrefsCard from "../components/settings/BrowserPrefsCard.vue";
|
||||
import DatabaseCard from "../components/settings/DatabaseCard.vue";
|
||||
import { useUiStore, type ThemeMode } from "../stores/uiStore";
|
||||
import { useReleaseCheck } from "../composables/useReleaseCheck";
|
||||
import { GITHUB_REPO } from "../config/links";
|
||||
|
||||
const uiStore = useUiStore();
|
||||
|
||||
const { latestVersion, hasUpdate, lastChecked, checking, checkNow } = useReleaseCheck();
|
||||
|
||||
const updateStatus = computed(() =>
|
||||
hasUpdate.value && latestVersion.value
|
||||
? `Update v${latestVersion.value} available`
|
||||
: "Up to date",
|
||||
);
|
||||
const lastCheckedLabel = computed(() => {
|
||||
if (!lastChecked.value) return "never checked";
|
||||
const then = new Date(lastChecked.value).getTime();
|
||||
if (Number.isNaN(then)) return "never checked";
|
||||
const mins = Math.round((Date.now() - then) / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins} min ago`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return `${hrs} h ago`;
|
||||
return `${Math.round(hrs / 24)} d ago`;
|
||||
});
|
||||
|
||||
const repoUrl = GITHUB_REPO;
|
||||
// Build-time injected via vite `define` (see vite.config.mts) — source
|
||||
// of truth is package.json, which semantic-release stamps on every
|
||||
@@ -67,6 +90,32 @@ function setTheme(mode: ThemeMode) {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Updates">
|
||||
<Field
|
||||
label="Check for updates on launch"
|
||||
hint="Look for a newer release when the manager opens. Turn off to check only manually."
|
||||
>
|
||||
<Toggle
|
||||
:model-value="uiStore.checkOnLaunch"
|
||||
label="Check on launch"
|
||||
data-test="settings-check-on-launch"
|
||||
@update:model-value="uiStore.setCheckOnLaunch($event)"
|
||||
/>
|
||||
</Field>
|
||||
<div class="wp-settings__update-row">
|
||||
<div>
|
||||
<p class="wp-settings__update-status" data-test="settings-update-status">{{ updateStatus }}</p>
|
||||
<p class="wp-dim wp-settings__update-sub">Last checked · {{ lastCheckedLabel }}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
:loading="checking"
|
||||
data-test="settings-check-now"
|
||||
@click="checkNow"
|
||||
>Check now</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Theme">
|
||||
<p class="wp-dim wp-settings__hint">
|
||||
Choose dark, light, or follow the OS preference.
|
||||
@@ -158,6 +207,16 @@ function setTheme(mode: ThemeMode) {
|
||||
margin: 0 0 var(--wp-space-5);
|
||||
}
|
||||
|
||||
.wp-settings__update-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--wp-space-4);
|
||||
margin-top: var(--wp-space-4);
|
||||
}
|
||||
.wp-settings__update-status { margin: 0; font-size: var(--wp-text-sm); color: var(--wp-text); font-weight: 600; }
|
||||
.wp-settings__update-sub { margin: 2px 0 0; font-size: var(--wp-text-sm); }
|
||||
|
||||
.wp-settings__radio {
|
||||
display: inline-flex;
|
||||
gap: var(--wp-space-3);
|
||||
|
||||
@@ -38,8 +38,8 @@ import { useReturnTo } from "../composables/useReturnTo";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { useRecentStore } from "../stores/recentStore";
|
||||
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
|
||||
import { validateSubcatName } from "@/manager/parsing/subcatFilter";
|
||||
import { toIdentifier } from "../utils/slug";
|
||||
import { validateSubcatName, validateRefGrammarName, isValidVariableName } from "@/manager/validation/names";
|
||||
import {
|
||||
buildWildcardRefData,
|
||||
collectLibraryWildcardRefs,
|
||||
@@ -950,6 +950,34 @@ const someSelected = computed(() =>
|
||||
selectableOptions.value.some((o) => selectedIds.value.has(o.id as string)),
|
||||
);
|
||||
|
||||
/** Sub-categories present on ≥1 selected row (union), in registry order —
|
||||
* the Remove menu's candidate list, so it only offers tags the selection
|
||||
* actually carries (removing a tag no row has would be a no-op). */
|
||||
const presentSelectedTags = computed<string[]>(() => {
|
||||
const present = new Set<string>();
|
||||
for (const o of options.value) {
|
||||
if (!isSelected(o.id)) continue;
|
||||
for (const t of o.sub_categories ?? []) present.add(t);
|
||||
}
|
||||
return subCategories.value.filter((t) => present.has(t));
|
||||
});
|
||||
/** Sub-categories already on EVERY selected row (intersection) — excluded
|
||||
* from the Apply menu since applying one there changes nothing. */
|
||||
const commonSelectedTags = computed<string[]>(() => {
|
||||
const sel = options.value.filter((o) => isSelected(o.id));
|
||||
if (sel.length === 0) return [];
|
||||
return subCategories.value.filter((t) =>
|
||||
sel.every((o) => (o.sub_categories ?? []).includes(t)),
|
||||
);
|
||||
});
|
||||
/** tag → axis hue, so the Apply/Remove menu chips read with the same colour
|
||||
* as the pills and option-row chips (shared `tagHue`). */
|
||||
const selectedTagHues = computed<Record<string, string>>(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const t of subCategories.value) m[t] = tagHue(t);
|
||||
return m;
|
||||
});
|
||||
|
||||
function toggleBulkMode(): void {
|
||||
bulkMode.value = !bulkMode.value;
|
||||
if (!bulkMode.value) {
|
||||
@@ -1156,7 +1184,7 @@ async function save() {
|
||||
// Update varBindingError synchronously so the rollup picks it up
|
||||
// in the same tick. The validation computed reads this ref.
|
||||
const finalBinding = varBinding.value.trim() || toIdentifier(name.value);
|
||||
if (varBinding.value.trim() && !VALID_IDENTIFIER_RE.test(finalBinding)) {
|
||||
if (varBinding.value.trim() && !isValidVariableName(finalBinding)) {
|
||||
varBindingError.value = "Use letters, digits, underscores; must not start with a digit.";
|
||||
} else {
|
||||
varBindingError.value = "";
|
||||
@@ -1261,17 +1289,16 @@ const validationErrors = computed<EditorFieldError[]>(() => {
|
||||
if (!name.value.trim()) {
|
||||
out.push({ field: "editor-section-identity", label: "Name", message: "Required" });
|
||||
} else {
|
||||
// Wildcard names become the `#name` segment of nested refs.
|
||||
// Forbidding the grammar-reserved chars here mirrors
|
||||
// `wp_api/_validators.py:REF_GRAMMAR_FORBIDDEN_CHARS` so the
|
||||
// server never has to reject a save the editor already accepted.
|
||||
const REF_FORBIDDEN = /[{}:#@,]/;
|
||||
const bad = name.value.match(REF_FORBIDDEN);
|
||||
if (bad) {
|
||||
// Wildcard names become the `#name` segment of nested refs. The
|
||||
// canonical rule (mirrors `wp_api/_validators.py`) lives in
|
||||
// `validation/names` so the editor rejects exactly what the rename
|
||||
// dialog + server reject.
|
||||
const refErr = validateRefGrammarName(name.value);
|
||||
if (refErr) {
|
||||
out.push({
|
||||
field: "editor-section-identity",
|
||||
label: "Name",
|
||||
message: `Cannot contain "${bad[0]}" (reserved by the @{uuid#name:subcat} ref grammar)`,
|
||||
message: refErr,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1515,7 +1542,7 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
|
||||
</div>
|
||||
|
||||
<div id="editor-section-options">
|
||||
<Card :title="`Options (${options.length})`" :padding="false">
|
||||
<Card :title="`Options (${options.length})`" :padding="false" sticky-header>
|
||||
<template #actions>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1544,6 +1571,7 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
|
||||
Add option
|
||||
</Button>
|
||||
</template>
|
||||
<template #subheader>
|
||||
<div v-if="bulkMode && (bulkAddOpen || selectedCount > 0 || bulkNote)" class="wpc-bulk-controls">
|
||||
<BulkAddPanel
|
||||
v-if="bulkAddOpen"
|
||||
@@ -1557,6 +1585,9 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
|
||||
v-if="selectedCount > 0"
|
||||
:count="selectedCount"
|
||||
:tags="subCategories"
|
||||
:common-tags="commonSelectedTags"
|
||||
:present-tags="presentSelectedTags"
|
||||
:tag-hues="selectedTagHues"
|
||||
@apply-tag="applyTagToSelected"
|
||||
@remove-tag="removeTagFromSelected"
|
||||
@set-weight="setWeightSelected"
|
||||
@@ -1565,6 +1596,7 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
|
||||
/>
|
||||
<p v-if="bulkNote" class="wpc-bulk-note" role="status">{{ bulkNote }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<table class="wp-table wp-options-table">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -64,12 +64,17 @@ export function create(node: SeedListHostNode, inputName: string) {
|
||||
Object.is,
|
||||
);
|
||||
|
||||
// Loop series params + the loop's bypassed frames (for the read-only
|
||||
// bypass mirror in the Seed List modal).
|
||||
type LoopSeries = SeriesParams & { bypassFrames: number[] };
|
||||
|
||||
// Walk the `loop_config` input to its upstream WP_ContextLoop node and
|
||||
// read its stock `seed` + `count` widgets plus `wp_context_loop_config`
|
||||
// strategy. Returns null when unwired. Reactive so the preview updates
|
||||
// strategy + bypass_frames. Returns null when unwired. Reactive so the
|
||||
// preview updates
|
||||
// whenever the wire or upstream widgets change (same poll+event model
|
||||
// as the baseSeed/count reads above).
|
||||
function readLoopSeries(): SeriesParams | null {
|
||||
function readLoopSeries(): LoopSeries | null {
|
||||
const inputs = (node as { inputs?: Array<{ name?: string; link?: number | null }> }).inputs;
|
||||
if (!inputs) return null;
|
||||
const slot = inputs.find((i) => i.name === "loop_config");
|
||||
@@ -85,13 +90,18 @@ export function create(node: SeedListHostNode, inputName: string) {
|
||||
const loopCount = Number(widgets.find((w) => w.name === "count")?.value ?? 1);
|
||||
const rawCfg = widgets.find((w) => w.name === "wp_context_loop_config")?.value;
|
||||
const loopCfg = parseContextLoopConfig(typeof rawCfg === "string" ? rawCfg : null);
|
||||
return { base: loopBase, count: loopCount, strategy: loopCfg.strategy };
|
||||
return {
|
||||
base: loopBase, count: loopCount, strategy: loopCfg.strategy,
|
||||
bypassFrames: loopCfg.bypass_frames,
|
||||
};
|
||||
}
|
||||
|
||||
function loopParamsEqual(a: SeriesParams | null, b: SeriesParams | null): boolean {
|
||||
function loopParamsEqual(a: LoopSeries | null, b: LoopSeries | null): boolean {
|
||||
if (a === null && b === null) return true;
|
||||
if (a === null || b === null) return false;
|
||||
return a.base === b.base && a.count === b.count && a.strategy === b.strategy;
|
||||
return a.base === b.base && a.count === b.count && a.strategy === b.strategy
|
||||
&& a.bypassFrames.length === b.bypassFrames.length
|
||||
&& a.bypassFrames.every((v, i) => v === b.bypassFrames[i]);
|
||||
}
|
||||
|
||||
const loopSeries = reactiveFromGraph(node, readLoopSeries, loopParamsEqual);
|
||||
@@ -147,6 +157,12 @@ export function create(node: SeedListHostNode, inputName: string) {
|
||||
override_strategy: config.value.override_strategy,
|
||||
};
|
||||
const effective = resolveSeedListPreview(localSeries, flags, loopSeries.value);
|
||||
// Mirror the loop's bypass ONLY when the count comes from the loop —
|
||||
// that's when this node's frames align 1:1 with the loop's.
|
||||
const loopBypass =
|
||||
config.value.override_count && loopSeries.value
|
||||
? loopSeries.value.bypassFrames
|
||||
: [];
|
||||
return h(SeedListWidget, {
|
||||
modelValue: config.value,
|
||||
nodeMode: nodeMode.value,
|
||||
@@ -154,6 +170,7 @@ export function create(node: SeedListHostNode, inputName: string) {
|
||||
count: effective.count,
|
||||
previewStrategy: effective.strategy,
|
||||
previousSeeds: previousSeeds.value,
|
||||
bypassedFrames: loopBypass,
|
||||
"onUpdate:modelValue": onUpdate,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ _DEFAULTS = {
|
||||
"iteration_internal": True,
|
||||
"total_internal": True,
|
||||
"seed_locks": {},
|
||||
"bypass_frames": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -35,14 +36,14 @@ _DEFAULTS = {
|
||||
{"strategy": "sequential", "override_seed": True,
|
||||
"iteration_var_name": "idx", "bypass": True,
|
||||
"iteration_internal": True, "total_internal": True,
|
||||
"seed_locks": {}},
|
||||
"seed_locks": {}, "bypass_frames": []},
|
||||
),
|
||||
(
|
||||
'{"strategy": "wat", "override_seed": true}',
|
||||
{"strategy": "hash_index", "override_seed": True,
|
||||
"iteration_var_name": "iteration", "bypass": False,
|
||||
"iteration_internal": True, "total_internal": True,
|
||||
"seed_locks": {}},
|
||||
"seed_locks": {}, "bypass_frames": []},
|
||||
),
|
||||
('{"iteration_var_name": " "}', _DEFAULTS),
|
||||
(
|
||||
@@ -194,6 +195,8 @@ def test_execute_emits_loop_config_payload_on_second_output():
|
||||
loop_config = values[1]
|
||||
assert loop_config == {
|
||||
"count": 3,
|
||||
"active_count": 3,
|
||||
"kept_indices": [0, 1, 2],
|
||||
"strategy": "sequential",
|
||||
"base_seed": 42,
|
||||
"override_seed": True,
|
||||
|
||||
+35
-29
@@ -4,8 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from engine.modules import FixedValueEntry, FixedValueModule
|
||||
from engine.pipeline import PipelineEngine
|
||||
|
||||
@@ -25,9 +23,6 @@ class TestPipelineRun:
|
||||
ctx = PipelineEngine().run([module], seed=0)
|
||||
assert ctx["style"] == "photoreal"
|
||||
|
||||
# TODO(syntax-task-15): trace writes now always include `overwrite` and
|
||||
# `status` fields; old assertion expects legacy shape without these keys.
|
||||
@pytest.mark.skip(reason="awaits handler migration in tasks 15-17: trace shape changed")
|
||||
def test_trace_records_new_write(self):
|
||||
module = FixedValueModule(
|
||||
id="m1",
|
||||
@@ -40,8 +35,17 @@ class TestPipelineRun:
|
||||
assert entry["id"] == "m1"
|
||||
assert entry["type"] == "fixed_values"
|
||||
assert entry["enabled"] is True
|
||||
assert entry["status"] == "ok"
|
||||
assert entry["error"] is None
|
||||
# Every write now carries an `overwrite` flag; a first write into a
|
||||
# fresh variable is not an overwrite.
|
||||
assert entry["writes"] == [
|
||||
{"variable": "style", "value": "photoreal", "source": "fixed_values"}
|
||||
{
|
||||
"variable": "style",
|
||||
"value": "photoreal",
|
||||
"source": "fixed_values",
|
||||
"overwrite": False,
|
||||
}
|
||||
]
|
||||
|
||||
def test_trace_records_overwrite_flag(self):
|
||||
@@ -58,18 +62,23 @@ class TestPipelineRun:
|
||||
}
|
||||
]
|
||||
|
||||
# TODO(syntax-task-15): disabled modules now emit a trace entry with
|
||||
# status="skipped_disabled"; old test expects disabled modules absent from trace.
|
||||
@pytest.mark.skip(reason="awaits handler migration in tasks 15-17: disabled trace entry added")
|
||||
def test_disabled_module_skipped(self):
|
||||
m1 = FixedValueModule(
|
||||
id="m1", enabled=False, entries=[FixedValueEntry("x", "1")]
|
||||
)
|
||||
m2 = FixedValueModule(id="m2", entries=[FixedValueEntry("y", "2")])
|
||||
ctx = PipelineEngine().run([m1, m2], seed=0)
|
||||
# A disabled module still writes nothing to ctx...
|
||||
assert "x" not in ctx
|
||||
assert ctx["y"] == "2"
|
||||
assert [e["id"] for e in ctx["__wp_trace__"]] == ["m2"]
|
||||
# ...but now leaves a trace row so the debug viewer can show it was
|
||||
# skipped (rather than silently vanishing).
|
||||
trace = ctx["__wp_trace__"]
|
||||
assert [e["id"] for e in trace] == ["m1", "m2"]
|
||||
assert trace[0]["status"] == "skipped_disabled"
|
||||
assert trace[0]["enabled"] is False
|
||||
assert trace[0]["writes"] == []
|
||||
assert trace[1]["status"] == "ok"
|
||||
|
||||
def test_inherits_upstream_ctx(self):
|
||||
upstream = {"upstream_var": "hello"}
|
||||
@@ -88,9 +97,6 @@ class TestPipelineRun:
|
||||
ids = [e["id"] for e in ctx["__wp_trace__"]]
|
||||
assert ids == ["prev", "m1"]
|
||||
|
||||
# TODO(syntax-task-15): unknown types now emit a skipped_unknown_type trace
|
||||
# entry; old test expects empty trace on unknown type.
|
||||
@pytest.mark.skip(reason="awaits handler migration in tasks 15-17: unknown types emit trace")
|
||||
def test_unknown_type_is_skipped(self, caplog):
|
||||
class Weird:
|
||||
id = "weird"
|
||||
@@ -98,7 +104,14 @@ class TestPipelineRun:
|
||||
enabled = True
|
||||
|
||||
ctx = PipelineEngine().run([Weird()], seed=0) # type: ignore[list-item]
|
||||
assert ctx["__wp_trace__"] == []
|
||||
# An unknown module type is skipped but now leaves a trace row so the
|
||||
# debug viewer surfaces the unrecognised module instead of hiding it.
|
||||
trace = ctx["__wp_trace__"]
|
||||
assert len(trace) == 1
|
||||
assert trace[0]["id"] == "weird"
|
||||
assert trace[0]["type"] == "does_not_exist"
|
||||
assert trace[0]["status"] == "skipped_unknown_type"
|
||||
assert trace[0]["writes"] == []
|
||||
assert any(
|
||||
"Unknown module type" in rec.message for rec in caplog.records
|
||||
)
|
||||
@@ -192,21 +205,14 @@ class TestPipelineRun:
|
||||
assert trace[1]["id"] == "fv1"
|
||||
assert trace[1]["seed"] == 4242
|
||||
|
||||
# TODO(syntax-task-15): HANDLERS dict removed; pipeline now delegates to
|
||||
# dispatcher.resolve_module. Stub-handler injection via HANDLERS no longer works.
|
||||
@pytest.mark.skip(reason="awaits handler migration in tasks 15-17: HANDLERS dict removed")
|
||||
def test_seed_deterministic_rng_passed_to_handlers(self):
|
||||
captured: list[random.Random] = []
|
||||
|
||||
def stub_handler(module, ctx, rng):
|
||||
captured.append(rng)
|
||||
return ctx
|
||||
|
||||
engine = PipelineEngine()
|
||||
engine.HANDLERS = {**engine.HANDLERS, "fixed_values": stub_handler}
|
||||
module = FixedValueModule(id="m1", entries=[])
|
||||
engine.run([module], seed=12345)
|
||||
assert len(captured) == 1
|
||||
rng = captured[0]
|
||||
# The pipeline no longer owns a HANDLERS dict; it delegates to
|
||||
# dispatcher.resolve_module and threads randomness by seeding
|
||||
# `ctx["__wp_rng__"]`, which handlers read off the context. Verify the
|
||||
# pipeline seeds that rng deterministically from the `seed` arg. Run
|
||||
# with no modules so nothing consumes the rng before we inspect it.
|
||||
ctx = PipelineEngine().run([], seed=12345)
|
||||
rng = ctx["__wp_rng__"]
|
||||
assert isinstance(rng, random.Random)
|
||||
other = random.Random(12345)
|
||||
assert rng.random() == other.random()
|
||||
|
||||
@@ -43,3 +43,91 @@ def test_execute_emits_empty_ui_seeds_when_override_off():
|
||||
raw = json.dumps({"strategy": "sequential", "override_seed": False})
|
||||
out = WPContextLoop.execute(seed=42, count=4, wp_context_loop_config=raw)
|
||||
assert out.ui == {"loop_seeds": [[]]}
|
||||
|
||||
|
||||
def test_parse_config_bypass_frames_clean():
|
||||
cfg = _parse_config('{"bypass_frames": [3, 1, 3, 0]}')
|
||||
# deduped + sorted, 0-based
|
||||
assert cfg["bypass_frames"] == [0, 1, 3]
|
||||
|
||||
|
||||
def test_parse_config_bypass_frames_drops_junk_keeps_out_of_range():
|
||||
# non-ints + negatives dropped; large (out-of-range at any count) KEPT
|
||||
cfg = _parse_config('{"bypass_frames": [2, -1, "x", 2.0, 999]}')
|
||||
assert cfg["bypass_frames"] == [2, 999]
|
||||
|
||||
|
||||
def test_parse_config_bypass_frames_default_and_malformed():
|
||||
assert _parse_config("{}")["bypass_frames"] == []
|
||||
assert _parse_config('{"bypass_frames": "nope"}')["bypass_frames"] == []
|
||||
assert _parse_config("not json")["bypass_frames"] == []
|
||||
|
||||
|
||||
def _run(seed, count, cfg):
|
||||
out = WPContextLoop.execute(
|
||||
seed=seed, count=count, wp_context_loop_config=json.dumps(cfg)
|
||||
)
|
||||
return out.values[0], out.values[1] # (payloads, loop_config)
|
||||
|
||||
|
||||
def test_bypass_frames_emits_kept_with_pinned_index():
|
||||
# count 5, bypass frames 1 and 3 (0-based) -> kept 0,2,4
|
||||
payloads, loop_config = _run(0, 5, {"override_seed": True, "bypass_frames": [1, 3]})
|
||||
idxs = [p.internals["__wp_loop_index__"] for p in payloads]
|
||||
assert idxs == [0, 2, 4]
|
||||
# iteration counter is the ORIGINAL 1-based number; total = configured count
|
||||
assert payloads[1].context["iteration"] == "3" # frame index 2 -> "3"
|
||||
assert all(p.context["iteration_total"] == "5" for p in payloads)
|
||||
assert loop_config["count"] == 5
|
||||
assert loop_config["active_count"] == 3
|
||||
|
||||
|
||||
def test_bypass_pins_kept_frame_seeds():
|
||||
# A kept frame's override seed is identical with and without a DIFFERENT
|
||||
# frame bypassed (pinned identity).
|
||||
full, _ = _run(0, 5, {"override_seed": True, "bypass_frames": []})
|
||||
partial, _ = _run(0, 5, {"override_seed": True, "bypass_frames": [1]})
|
||||
seed_by_idx = {
|
||||
p.internals["__wp_loop_index__"]: p.internals["__wp_seed_override__"]
|
||||
for p in full
|
||||
}
|
||||
for p in partial:
|
||||
i = p.internals["__wp_loop_index__"]
|
||||
assert p.internals["__wp_seed_override__"] == seed_by_idx[i]
|
||||
|
||||
|
||||
def test_bypass_all_floors_to_full_run():
|
||||
payloads, loop_config = _run(0, 3, {"bypass_frames": [0, 1, 2]})
|
||||
idxs = [p.internals["__wp_loop_index__"] for p in payloads]
|
||||
assert idxs == [0, 1, 2]
|
||||
assert loop_config["active_count"] == 3
|
||||
|
||||
|
||||
def test_out_of_range_bypass_is_inert():
|
||||
# frame index 9 does not exist at count 3 -> ignored, all 3 emitted
|
||||
payloads, _ = _run(0, 3, {"bypass_frames": [9]})
|
||||
assert [p.internals["__wp_loop_index__"] for p in payloads] == [0, 1, 2]
|
||||
|
||||
|
||||
def test_whole_loop_bypass_still_single_run():
|
||||
payloads, loop_config = _run(0, 8, {"bypass": True, "bypass_frames": [2, 4]})
|
||||
assert len(payloads) == 1
|
||||
assert payloads[0].internals["__wp_loop_index__"] == 0
|
||||
assert loop_config["count"] == 1
|
||||
assert loop_config["active_count"] == 1
|
||||
|
||||
|
||||
def test_bypass_frame_zero():
|
||||
# Frame 0 is bypassable — seed-hold's base pass runs internally regardless.
|
||||
payloads, _ = _run(0, 3, {"bypass_frames": [0]})
|
||||
assert [p.internals["__wp_loop_index__"] for p in payloads] == [1, 2]
|
||||
|
||||
|
||||
def test_out_of_range_bypass_reapplies_when_count_grows():
|
||||
# Index 9 is inert at count 3 but takes effect once count grows past it.
|
||||
at3, _ = _run(0, 3, {"bypass_frames": [9]})
|
||||
assert [p.internals["__wp_loop_index__"] for p in at3] == [0, 1, 2]
|
||||
at10, _ = _run(0, 10, {"bypass_frames": [9]})
|
||||
idxs = [p.internals["__wp_loop_index__"] for p in at10]
|
||||
assert 9 not in idxs
|
||||
assert idxs == [0, 1, 2, 3, 4, 5, 6, 7, 8]
|
||||
|
||||
@@ -543,3 +543,47 @@ def test_execute_output_reflects_seed_locks():
|
||||
)
|
||||
seeds = _seeds(out)
|
||||
assert seeds == [10, 77, 12]
|
||||
|
||||
|
||||
def test_execute_override_count_respects_loop_kept_indices():
|
||||
# Loop bypassed frames 1 + 3 of 5 -> kept_indices [0,2,4]. The seed list
|
||||
# must emit those 3 seeds (drawn from the full 5-seed series), not 5.
|
||||
loop_config = {
|
||||
"count": 5, "strategy": "hash_index", "base_seed": 42,
|
||||
"kept_indices": [0, 2, 4],
|
||||
}
|
||||
out = WPSeedList.execute(
|
||||
base_seed=42, count=99,
|
||||
wp_seed_list_config=_cfg(strategy="hash_index", override_count=True),
|
||||
loop_config=loop_config,
|
||||
)
|
||||
full = derive_loop_seeds(42, 5, "hash_index")
|
||||
assert _seeds(out) == [full[0], full[2], full[4]]
|
||||
|
||||
|
||||
def test_execute_kept_indices_ignored_when_override_count_off():
|
||||
# Not mirroring the loop's count -> the loop's kept_indices don't apply.
|
||||
loop_config = {
|
||||
"count": 5, "strategy": "hash_index", "base_seed": 42,
|
||||
"kept_indices": [0, 2, 4],
|
||||
}
|
||||
out = WPSeedList.execute(
|
||||
base_seed=42, count=4,
|
||||
wp_seed_list_config=_cfg(strategy="hash_index", override_count=False),
|
||||
loop_config=loop_config,
|
||||
)
|
||||
assert _seeds(out) == derive_loop_seeds(42, 4, "hash_index")
|
||||
|
||||
|
||||
def test_execute_full_kept_indices_is_noop():
|
||||
# A full kept list (no bypass) leaves the emit unchanged.
|
||||
loop_config = {
|
||||
"count": 3, "strategy": "hash_index", "base_seed": 42,
|
||||
"kept_indices": [0, 1, 2],
|
||||
}
|
||||
out = WPSeedList.execute(
|
||||
base_seed=42, count=99,
|
||||
wp_seed_list_config=_cfg(strategy="hash_index", override_count=True),
|
||||
loop_config=loop_config,
|
||||
)
|
||||
assert _seeds(out) == derive_loop_seeds(42, 3, "hash_index")
|
||||
|
||||
+66
-19
@@ -40,6 +40,7 @@ def _parse_config(raw: str) -> dict[str, object]:
|
||||
"iteration_internal": True,
|
||||
"total_internal": True,
|
||||
"seed_locks": {},
|
||||
"bypass_frames": [],
|
||||
}
|
||||
if not raw or not isinstance(raw, str):
|
||||
return defaults
|
||||
@@ -68,6 +69,21 @@ def _parse_config(raw: str) -> dict[str, object]:
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
out["seed_locks"] = locks
|
||||
frames_raw = parsed.get("bypass_frames", [])
|
||||
frames: set[int] = set()
|
||||
if isinstance(frames_raw, list):
|
||||
for x in frames_raw:
|
||||
if isinstance(x, bool): # bool is an int subclass — exclude
|
||||
continue
|
||||
if isinstance(x, int):
|
||||
n = x
|
||||
elif isinstance(x, float) and x.is_integer():
|
||||
n = int(x)
|
||||
else:
|
||||
continue
|
||||
if n >= 0:
|
||||
frames.add(n)
|
||||
out["bypass_frames"] = sorted(frames)
|
||||
return out
|
||||
|
||||
|
||||
@@ -147,27 +163,56 @@ class WPContextLoop(io.ComfyNode):
|
||||
iteration_internal = bool(cfg["iteration_internal"])
|
||||
total_internal = bool(cfg["total_internal"])
|
||||
seed_locks: dict[int, int] = cfg["seed_locks"] # type: ignore[assignment]
|
||||
bypass_frames: list[int] = cfg["bypass_frames"] # type: ignore[assignment]
|
||||
|
||||
# Resolve effective count. `bypass` collapses to single-run while
|
||||
# preserving the list-shape output contract — downstream still
|
||||
# sees an `is_list=True` socket with one element.
|
||||
effective_count = 1 if bypass else max(1, int(count))
|
||||
# Resolve which frames to emit + the seed series they draw from.
|
||||
#
|
||||
# - Whole-loop `bypass` (existing behavior) collapses to a single
|
||||
# run while preserving the list-shape output contract.
|
||||
# - Per-frame bypass (`bypass_frames`, 0-based) skips individual
|
||||
# iterations WITHOUT renumbering the kept ones: each kept frame
|
||||
# keeps its original index as `__wp_loop_index__`, so it draws
|
||||
# `derived[idx]` and its per-frame overrides still key correctly.
|
||||
# Out-of-range indices are ignored; an all-bypassed config floors
|
||||
# to a full run so the `is_output_list` socket is never empty.
|
||||
# The seed series is derived for the FULL count (not the reduced
|
||||
# one) so kept frame N always draws `derived[N]`.
|
||||
has_override = override_seed
|
||||
configured_count = max(1, int(count))
|
||||
if bypass:
|
||||
total_count = 1
|
||||
kept = [0]
|
||||
else:
|
||||
in_range_bypassed = {
|
||||
i for i in bypass_frames if 0 <= i < configured_count
|
||||
}
|
||||
kept = [i for i in range(configured_count) if i not in in_range_bypassed]
|
||||
if not kept:
|
||||
kept = list(range(configured_count))
|
||||
total_count = configured_count
|
||||
derived = (
|
||||
apply_seed_locks(
|
||||
derive_loop_seeds(int(seed), effective_count, strategy),
|
||||
derive_loop_seeds(int(seed), total_count, strategy),
|
||||
seed_locks,
|
||||
)
|
||||
if has_override else None
|
||||
)
|
||||
# Build the loop_config payload once so the same dict drives both
|
||||
# the per-iteration internals AND the side output. `count` here
|
||||
# is the EFFECTIVE count (post-bypass) so downstream sees what
|
||||
# the loop is actually running, not the raw widget value.
|
||||
# `base_seed` carries the user's `seed` input verbatim so
|
||||
# WP_SeedList can re-derive the same series locally.
|
||||
# the per-iteration internals AND the side output. `count` is the
|
||||
# configured/effective total (so WP_SeedList + the seed modal
|
||||
# re-derive the identical full series); `active_count` is how many
|
||||
# frames actually emit after per-frame bypass. `base_seed` carries
|
||||
# the user's `seed` input verbatim so WP_SeedList can re-derive the
|
||||
# same series locally.
|
||||
loop_config_payload: dict[str, object] = {
|
||||
"count": effective_count,
|
||||
"count": total_count,
|
||||
"active_count": len(kept),
|
||||
# The 0-based indices this loop actually emits (== range(count)
|
||||
# for a normal run). A wired WP_SeedList mirrors this so it emits
|
||||
# ONE seed per kept frame — otherwise it would derive the full
|
||||
# `count` seeds and the sampler would run more times than the
|
||||
# loop has prompts.
|
||||
"kept_indices": list(kept),
|
||||
"strategy": strategy,
|
||||
"base_seed": int(seed),
|
||||
"override_seed": has_override,
|
||||
@@ -186,16 +231,18 @@ class WPContextLoop(io.ComfyNode):
|
||||
internal_flags[total_var_name] = True
|
||||
|
||||
payloads = []
|
||||
for idx in range(effective_count):
|
||||
# User-facing iteration counter is 1-based — reads naturally
|
||||
# in PromptAssembler templates (`iteration 1 of 4`) and in
|
||||
# the debug viewer. The internal `__wp_loop_index__` below
|
||||
# stays 0-based because `derive_loop_seeds[idx]` indexes the
|
||||
# derived series + `effective_chain_seed` math assumes
|
||||
# zero-origin (loop_index=0 means "no XOR shift").
|
||||
for idx in kept:
|
||||
# User-facing iteration counter is the frame's ORIGINAL 1-based
|
||||
# number (bypass keeps identity — frame 5 stays "5 of 18", not
|
||||
# renumbered). The internal `__wp_loop_index__` below stays the
|
||||
# 0-based original index because `derived[idx]` indexes the
|
||||
# full series + `effective_chain_seed` math assumes zero-origin
|
||||
# (loop_index=0 means "no XOR shift"). `total` is the configured
|
||||
# count so bypassing a frame never rewrites the other frames'
|
||||
# "of N" text (which would bust their downstream caches).
|
||||
context_vars = {
|
||||
iteration_var_name: str(idx + 1),
|
||||
total_var_name: str(effective_count),
|
||||
total_var_name: str(total_count),
|
||||
}
|
||||
internals: dict[str, object] = {
|
||||
"__wp_loop_index__": idx,
|
||||
|
||||
+20
-1
@@ -247,10 +247,29 @@ class WPSeedList(io.ComfyNode):
|
||||
override_strategy=override_strategy,
|
||||
loop_config=loop_config,
|
||||
)
|
||||
seeds = apply_seed_locks(
|
||||
full_series = apply_seed_locks(
|
||||
derive_loop_seeds(resolved_base, resolved_count, resolved_strategy),
|
||||
cfg["seed_locks"],
|
||||
)
|
||||
# Mirror the loop's per-frame bypass: when the count comes FROM the
|
||||
# loop (override_count), emit only the frames the loop actually kept
|
||||
# (its `kept_indices`), drawing each kept frame's seed from the full
|
||||
# series. Without this the loop bypasses 15/18 frames (3 prompts) but
|
||||
# this node still emits 18 seeds, so the sampler runs 18 times. Locks
|
||||
# are applied over the FULL series first, so a lock on a kept frame
|
||||
# still lands and a lock on a bypassed frame is simply dropped.
|
||||
seeds = full_series
|
||||
if override_count and isinstance(loop_config, dict):
|
||||
raw_kept = loop_config.get("kept_indices")
|
||||
if isinstance(raw_kept, list):
|
||||
kept = [
|
||||
i for i in raw_kept
|
||||
if isinstance(i, int) and not isinstance(i, bool)
|
||||
and 0 <= i < len(full_series)
|
||||
]
|
||||
# Only narrow — a full/empty/malformed list leaves emit as-is.
|
||||
if kept and len(kept) < len(full_series):
|
||||
seeds = [full_series[i] for i in kept]
|
||||
# Surface the per-iteration series to the node UI so the frontend can
|
||||
# offer "lock previous" in the seed modal (captured in seed_list.ts).
|
||||
return io.NodeOutput(seeds, ui={"loop_seeds": [[int(s) for s in seeds]]})
|
||||
|
||||
Reference in New Issue
Block a user