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 | ||
|
|
f888acdcd3 | ||
|
|
41fae1e41b | ||
|
|
bf61f649fa | ||
|
|
fc9d8a85a2 | ||
|
|
6f664ce9e9 | ||
|
|
5d715fba99 | ||
|
|
e98aca30b3 | ||
|
|
df76abdbb0 | ||
|
|
8568aa2c1e | ||
|
|
aba4e8c8ac | ||
|
|
ff6825cedd | ||
|
|
0ede205530 | ||
|
|
a738618dd3 | ||
|
|
8dcebdcc55 | ||
|
|
1b3e4aa61f | ||
|
|
0281b8e321 | ||
|
|
58c5b27922 | ||
|
|
4a34c9bbdd | ||
|
|
06c380fd01 | ||
|
|
c48aafb143 | ||
|
|
ff4d8faa1f | ||
|
|
c7c867e531 | ||
|
|
2fb853fa81 | ||
|
|
e1885a3115 | ||
|
|
f38a316ccb | ||
|
|
e56fd22bc9 | ||
|
|
b33afd8797 | ||
|
|
67a09df6d6 | ||
|
|
1f164df0c5 | ||
|
|
91b5f7581a | ||
|
|
3527ada397 | ||
|
|
25388b4650 | ||
|
|
6eb4b730cb | ||
|
|
942834d16a | ||
|
|
03796c56ba | ||
|
|
65683aed7c | ||
|
|
8a9dc9efbd | ||
|
|
8f696d6eec | ||
|
|
1822297a9a | ||
|
|
21f285033d | ||
|
|
bb5e84d3b2 | ||
|
|
b292699487 | ||
|
|
39fdb6f187 | ||
|
|
3c492f1cb9 | ||
|
|
79cf3857ad | ||
|
|
e93f7ba06e | ||
|
|
4e71e043e7 | ||
|
|
7b416ad0ed | ||
|
|
ece6d49929 | ||
|
|
6dc033d57e | ||
|
|
9663636d10 | ||
|
|
8910b98a86 | ||
|
|
10dcac4100 | ||
|
|
15c1ade3ef | ||
|
|
8871a2806d | ||
|
|
072e4e7836 | ||
|
|
0f7aaabe3c | ||
|
|
f1451baf40 | ||
|
|
589f45eb74 | ||
|
|
fa73750ca0 | ||
|
|
b49a579b33 | ||
|
|
34ec18feff | ||
|
|
44d8ab9575 | ||
|
|
68910bfc18 | ||
|
|
b5262c22d5 | ||
|
|
fb19e3cef8 | ||
|
|
43d775ec8f | ||
|
|
86eb15cfa3 | ||
|
|
0f0cd47018 | ||
|
|
1ad7e8e415 | ||
|
|
5a3e834c42 | ||
|
|
cc64ca3ab8 | ||
|
|
fd2cdcbf08 | ||
|
|
48845981a1 | ||
|
|
5da64f8d78 | ||
|
|
fe0d702e75 | ||
|
|
d3e25b72ad | ||
|
|
40980093e8 | ||
|
|
538db06efc | ||
|
|
eac852fbda | ||
|
|
0bbf150ca3 | ||
|
|
c0f354cb8f | ||
|
|
b3537ad6c5 | ||
|
|
a77e60b402 | ||
|
|
c1f45a3fd2 | ||
|
|
11d205bf61 | ||
|
|
d628a0687d | ||
|
|
02cc9f766f | ||
|
|
a1d051172d | ||
|
|
5b5c635be6 | ||
|
|
ccb8392219 | ||
|
|
d8c20728cb | ||
|
|
0e9e5db486 | ||
|
|
a9516996b4 | ||
|
|
02a046321f | ||
|
|
6a5a8d318e | ||
|
|
a19e5f4500 | ||
|
|
c43bff0488 | ||
|
|
a3a3dbe2df | ||
|
|
6e8696fa85 | ||
|
|
959cfbe179 | ||
|
|
325c90818f | ||
|
|
0f5166e7d1 | ||
|
|
9c1bb35a78 | ||
|
|
a53d5266de | ||
|
|
3065e99c43 | ||
|
|
844d16e677 | ||
|
|
402fdcc453 | ||
|
|
8b99795dae | ||
|
|
7297b2ff7c | ||
|
|
feb760e2a0 | ||
|
|
33316aa494 | ||
|
|
7b2d057f34 | ||
|
|
c66c1f52fa | ||
|
|
e458e8d75a | ||
|
|
f8edcfedd5 | ||
|
|
f58d849500 | ||
|
|
b4f2d6bf5a | ||
|
|
e7e844b7e8 | ||
|
|
f1ab33c025 | ||
|
|
b1fa0ce411 | ||
|
|
0984a7f450 | ||
|
|
02ad0ebcc7 | ||
|
|
7dc9817e71 | ||
|
|
c858bef9c3 | ||
|
|
1ee8fe28ec | ||
|
|
59af070e7d | ||
|
|
223770e18c | ||
|
|
a9bde14434 | ||
|
|
0494e3c1a5 | ||
|
|
e138868abc | ||
|
|
a5f95cf172 | ||
|
|
7845d91bc5 | ||
|
|
50612dcc39 | ||
|
|
9a3b5fafb4 | ||
|
|
292a0a547c | ||
|
|
107d5f4432 | ||
|
|
e709d538af | ||
|
|
9f7ee12218 | ||
|
|
0cb57064b1 | ||
|
|
5ae0ff083f | ||
|
|
4598f68d28 | ||
|
|
d188ead525 | ||
|
|
fa68a364c7 | ||
|
|
97d3036e6a | ||
|
|
eff7a6a8ed | ||
|
|
37ca8bdd52 | ||
|
|
fd254a6aa2 | ||
|
|
c10236108c | ||
|
|
70da7e0faf | ||
|
|
fa0f2ceca1 | ||
|
|
6fe52c065f | ||
|
|
a4d19134b7 | ||
|
|
9071dfba6d | ||
|
|
690169bda3 | ||
|
|
febb9f3e70 | ||
|
|
d6094ed393 | ||
|
|
7bcfcac9ad | ||
|
|
3884ded916 | ||
|
|
9a976137f0 | ||
|
|
e288d1b31d | ||
|
|
498a4ba935 | ||
|
|
236c39ac49 | ||
|
|
4dd6d6c48e | ||
|
|
1bf1e42b49 | ||
|
|
47af97ee11 | ||
|
|
77f00ae975 | ||
|
|
07dcf313df | ||
|
|
f7369ff40e | ||
|
|
770909dec7 | ||
|
|
a75c7371f5 | ||
|
|
e897bd3eba | ||
|
|
43de217109 | ||
|
|
fd25a63bf0 | ||
|
|
1d59df9f73 | ||
|
|
96fc26a1ae | ||
|
|
67de90c37b | ||
|
|
e748064969 | ||
|
|
bb9ec6dfd6 | ||
|
|
e69ceaeb9a | ||
|
|
ec3508a0bf | ||
|
|
ab813bbca4 | ||
|
|
2a1123f549 | ||
|
|
3564938b72 | ||
|
|
0e9b5cfbbf | ||
|
|
098c069d53 | ||
|
|
b27b9f8d7c | ||
|
|
8247d739ce | ||
|
|
599c3b17f6 | ||
|
|
059517f740 | ||
|
|
73a00a3760 | ||
|
|
d826c6ba61 | ||
|
|
83149630ab | ||
|
|
826d86f70d | ||
|
|
764c82ee73 | ||
|
|
a0f9651c6b | ||
|
|
c80605ac4e | ||
|
|
3f14a6ff35 | ||
|
|
02ed686129 | ||
|
|
39022106c3 | ||
|
|
a94cd543d0 | ||
|
|
71e05b1956 | ||
|
|
1ddbe4548f | ||
|
|
b20faa0ca8 | ||
|
|
2eb0e365b7 | ||
|
|
4165b2e9f4 | ||
|
|
be1f765243 | ||
|
|
8c3c9aedc2 | ||
|
|
f68d2ece81 | ||
|
|
dd785c5ca7 | ||
|
|
8ad2d0cec6 | ||
|
|
8000e1b75c | ||
|
|
c6baeb8136 | ||
|
|
62d3768428 | ||
|
|
e78592839f | ||
|
|
33b3600bf8 | ||
|
|
ba455fb098 | ||
|
|
55d9ef19e0 | ||
|
|
0962aad413 | ||
|
|
62f2e4aafb | ||
|
|
51a526eb06 | ||
|
|
619cd5ab1e | ||
|
|
3a9a09aeb9 | ||
|
|
6097590854 | ||
|
|
b9ae37a5f5 | ||
|
|
36942a27ec | ||
|
|
ef6fadcf24 | ||
|
|
63b824e4a1 | ||
|
|
0b36ce892b | ||
|
|
94ccd7f192 | ||
|
|
c661550091 | ||
|
|
bf189b33d5 | ||
|
|
af90203f39 | ||
|
|
435cdb178b | ||
|
|
743f8b08c8 | ||
|
|
d9235c39b1 | ||
|
|
b9df102a80 | ||
|
|
2a24f9ff3a | ||
|
|
d3881c84a1 | ||
|
|
31fe070a45 | ||
|
|
7d267d0be6 | ||
|
|
f922dca356 | ||
|
|
dc05a5d176 | ||
|
|
12082228e7 | ||
|
|
bc7ea336e7 | ||
|
|
6703583db8 | ||
|
|
ace0486f9d | ||
|
|
d41be86fae | ||
|
|
8d7bda2234 | ||
|
|
d1ee97b3ec | ||
|
|
d167a1f564 | ||
|
|
64d1cb157e | ||
|
|
17a2149fb0 | ||
|
|
443db49546 | ||
|
|
8eeb0a8032 | ||
|
|
3ac0afa979 | ||
|
|
e5f54c6ba8 | ||
|
|
c5720e79bd | ||
|
|
4cda98f227 | ||
|
|
5a0e05e6bf | ||
|
|
c77781062a | ||
|
|
ff0f9dc0cd | ||
|
|
b23e29e625 | ||
|
|
54054fb172 | ||
|
|
c2d0ef5379 | ||
|
|
723adde0bb | ||
|
|
3bcafc986c | ||
|
|
a62851256b | ||
|
|
c374863c87 | ||
|
|
33e6bd0d34 | ||
|
|
11ad8ca4bc | ||
|
|
221aa9097b | ||
|
|
39529a669b | ||
|
|
13de622c6b | ||
|
|
0e1803ec12 | ||
|
|
04841aaab9 | ||
|
|
4fabb7685e | ||
|
|
ca54a3a323 | ||
|
|
c13351d21c | ||
|
|
885f7fb98a | ||
|
|
9e34d23129 | ||
|
|
4b491b8fa5 | ||
|
|
0da118e10b | ||
|
|
915e0cce0d | ||
|
|
24e83ea9b1 | ||
|
|
51f6793177 | ||
|
|
8a9ee8c85e | ||
|
|
191f880fa2 | ||
|
|
f8efcc92d7 | ||
|
|
47b153503f | ||
|
|
bbd1a37bd8 | ||
|
|
63a7cd9ae4 | ||
|
|
39c06371e3 | ||
|
|
ede4f4e0f2 | ||
|
|
99374cf76f | ||
|
|
395af63b1a | ||
|
|
87b8c6d837 | ||
|
|
73ca8a3f9a | ||
|
|
ad0f8c2cd8 | ||
|
|
19836dd2d3 | ||
|
|
07fe3ab064 | ||
|
|
320315c0ae | ||
|
|
4758c8402d | ||
|
|
810e33ebf4 | ||
|
|
c46cbbf509 | ||
|
|
97c92b97e0 | ||
|
|
9c7a110d22 | ||
|
|
ea3c628615 | ||
|
|
59c5d705bd | ||
|
|
9c0b11f7da | ||
|
|
7a5c382eb4 | ||
|
|
b85f21a01d | ||
|
|
af314d3abe | ||
|
|
9957702099 | ||
|
|
519abd399a | ||
|
|
bb82b667dd | ||
|
|
c6c42c4d9b | ||
|
|
3d92673078 | ||
|
|
a2cc1bb844 | ||
|
|
738ede31c3 | ||
|
|
b9132af764 | ||
|
|
160b729e15 | ||
|
|
a10a321e3b | ||
|
|
c0cfd134cb | ||
|
|
84cd82faa6 | ||
|
|
6f2b804af4 | ||
|
|
6f51e4a804 | ||
|
|
7d43164d26 | ||
|
|
78d1f07027 | ||
|
|
c6a1030035 | ||
|
|
843a679c25 | ||
|
|
02c83002dc | ||
|
|
efb38ff894 | ||
|
|
9a053e8bdf | ||
|
|
710205768b | ||
|
|
d23a72887b | ||
|
|
eb48d7fc60 | ||
|
|
8eb59674a8 | ||
|
|
8c760f2bef | ||
|
|
04d3795103 | ||
|
|
747a84ac16 | ||
|
|
00b0c735bb | ||
|
|
52966e69d1 | ||
|
|
c03fc070ba | ||
|
|
069527a066 | ||
|
|
a89bf7d085 | ||
|
|
885a2d06ac | ||
|
|
df7eb75e50 | ||
|
|
7cfe29b435 | ||
|
|
b2b75d4cb0 | ||
|
|
2003f38a55 | ||
|
|
f20a9e376f | ||
|
|
0909817018 | ||
|
|
dc839bfb99 | ||
|
|
5a62929f91 | ||
|
|
d39add1a44 | ||
|
|
7f125756a3 | ||
|
|
4abe679525 | ||
|
|
ecdbf6c7f5 | ||
|
|
2b5f2b7af7 | ||
|
|
7e12626248 |
+13
@@ -12,6 +12,12 @@ node_modules/
|
||||
pnpm-debug.log
|
||||
.pnpm-store/
|
||||
|
||||
# Vite per-contributor env overrides (e.g. VITE_WPC_API_URL pointing
|
||||
# at a dev community host). Never commit — leaks dev URLs into the
|
||||
# tree and makes `pnpm build` produce divergent bundles per checkout.
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Build outputs
|
||||
js/
|
||||
web/
|
||||
@@ -46,9 +52,16 @@ 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
|
||||
|
||||
# Throwaway design / scratch artifacts (mockups, screenshots, demos)
|
||||
/_temp/
|
||||
|
||||
# DB location control — per-contributor sidecar config + root-location DB dir
|
||||
db-config.json
|
||||
/db/
|
||||
|
||||
+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-
|
||||
|
||||
+544
@@ -1,3 +1,547 @@
|
||||
## 🎉 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)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* fix(derivation-modal): absolute max-height for the rules scroll (revert flex) ([5d715fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5d715fb))
|
||||
* fix(derivation-modal): pin rule cards so the rule list actually scrolls ([fc9d8a8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fc9d8a8))
|
||||
* fix(derivation-modal): scroll the rule LIST, not the whole modal ([6f664ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f664ce))
|
||||
* fix(derivation-modal): scroll the rules region, pin header + footer ([e98aca3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e98aca3))
|
||||
* fix(engine): derivation hold freezes each branch roll, not the branch choice ([df76abd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/df76abd))
|
||||
* fix(engine): hold the resolved VALUE across iterations, not just the seed ([58c5b27](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/58c5b27))
|
||||
* fix(injector): keep custom pin labels with the socket on drag-reorder ([41fae1e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/41fae1e))
|
||||
* fix(injector): persist custom pin labels durably across collapse + reload ([8568aa2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8568aa2))
|
||||
* fix(injector): reconstruct collapsed-wire labels from data, not the dead stash ([0ede205](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0ede205))
|
||||
* fix(injector): repair collapse-wire labels corrupted across a reload ([0281b8e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0281b8e))
|
||||
* fix(injector): right-align the type pill on label-less ref/slot rows ([bf61f64](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bf61f64))
|
||||
* docs(spa): describe hold as a value freeze (constrained picks + nested refs) ([1b3e4aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1b3e4aa))
|
||||
* docs(spa): document per-iteration overrides, seed scope + lock-previous ([4a34c9b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4a34c9b))
|
||||
* docs(spa): mention the right-click Hold toggle shortcut ([a738618](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a738618))
|
||||
* docs(spa): note derivation supports Hold across run ([aba4e8c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/aba4e8c))
|
||||
* feat(context): quick Hold toggle in the module row context menu ([8dcebdc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8dcebdc))
|
||||
* feat(engine): derivation honors seed_scope=hold (freeze frame-0 output) ([ff6825c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff6825c))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.8.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>
|
||||
|
||||
|
||||
* fix(context): add hover states to the per-kind edit modal footer buttons ([b292699](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b292699))
|
||||
* fix(context): capture the loop's per-frame seeds so the lock pins the previous roll ([b33afd8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b33afd8))
|
||||
* fix(context): clear redundant frame lock-null override when the base unlocks ([f38a316](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f38a316))
|
||||
* fix(context): coerce disabled_frames to int set and guard frame toggle ([65683ae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/65683ae))
|
||||
* fix(context): modal lock button pins the rolled seed via injected resolver ([e56fd22](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e56fd22))
|
||||
* fix(context): name the disabled toggles in the per-frame runtime hint + wrap loop frame chips ([2fb853f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2fb853f))
|
||||
* fix(context): per-frame enable overrides base enabled (frame_enabled) ([6eb4b73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6eb4b73))
|
||||
* fix(context): per-frame seed lock captures that frame's own rolled seed ([25388b4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/25388b4))
|
||||
* fix(context): per-frame seed lock computes the frame's real seed from the loop ([1f164df](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1f164df))
|
||||
* fix(context): restrict per-frame overrides to an allowlist of meaningful fields ([bb5e84d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb5e84d))
|
||||
* fix(context): resync editor draft on frame revert + restyle the banner for readability ([39fdb6f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/39fdb6f))
|
||||
* fix(context): scope the wrapping grid to the frame chips only ([c48aafb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c48aafb))
|
||||
* fix(context): stack the frame banner above the editor card instead of beside it ([3c492f1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3c492f1))
|
||||
* fix(context): sweep redundant frame lock-null when base unlocks via the modal ([e1885a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e1885a3))
|
||||
* fix(engine): apply iteration_overrides for generic-object modules ([942834d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/942834d))
|
||||
* fix(engine): include disabled_frames in the generic-object snapshot keys ([03796c5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/03796c5))
|
||||
* fix(engine): stop double-shifting the per-iteration override seed ([3527ada](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3527ada))
|
||||
* fix(extension): read loop seed and count as numbers for per-frame seed lock ([67a09df](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/67a09df))
|
||||
* feat(context-loop): add edit-frame selector chip row ([ece6d49](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ece6d49))
|
||||
* feat(context-loop): add session frame-cursor singleton ([6dc033d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6dc033d))
|
||||
* feat(context): add hold-across-run toggle to the runtime sections ([9663636](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9663636))
|
||||
* feat(context): add lock-previous-seed to the iteration-seed modals ([c7c867e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c7c867e))
|
||||
* feat(context): add pure frame-override helpers ([7b416ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7b416ad))
|
||||
* feat(context): disable run-level controls while editing a frame ([1822297](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1822297))
|
||||
* feat(context): frame banner with revert-to-base in the module editor ([79cf385](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/79cf385))
|
||||
* feat(context): held and per-frame override row badges ([e93f7ba](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e93f7ba))
|
||||
* feat(context): make the row quick-lock act on the active frame ([21f2850](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/21f2850))
|
||||
* feat(context): per-frame enable/disable via disabled_frames ([8a9dc9e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8a9dc9e))
|
||||
* feat(context): redirect edits into iteration_overrides when a frame is active ([4e71e04](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4e71e04))
|
||||
* feat(engine): apply per-frame iteration_overrides onto module instance ([10dcac4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/10dcac4))
|
||||
* feat(engine): combine honors hold seed_scope ([8871a28](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8871a28))
|
||||
* feat(engine): fixed_values honors hold seed_scope ([15c1ade](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/15c1ade))
|
||||
* feat(engine): thread a hold seed and loop index into the pipeline run ([0f7aaab](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f7aaab))
|
||||
* feat(engine): wildcard honors hold seed_scope, locked_seed still wins ([072e4e7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/072e4e7))
|
||||
* feat(manager): snapshot source/target tag_groups into published constraints ([ff4d8fa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff4d8fa))
|
||||
* feat(types): add seed_scope and iteration_overrides to module config ([8910b98](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8910b98))
|
||||
* revert(context): drop per-frame seed capture, superseded by double-shift fix ([91b5f75](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/91b5f75))
|
||||
* refactor(context): unify frame-hint markup and add false-case coverage ([8f696d6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8f696d6))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.7.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>
|
||||
|
||||
|
||||
* feat(engine): add apply_seed_locks for per-iteration seed overrides ([5da64f8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5da64f8))
|
||||
* feat(loop): apply per-iteration seed locks in the context loop node ([4884598](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4884598))
|
||||
* feat(loop): per-iteration seeds button + modal; shrink iteration-var buttons ([86eb15c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/86eb15c))
|
||||
* feat(seedlist): add paste-to-replace locks from copied text ([fa73750](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa73750))
|
||||
* feat(seedlist): alt-click copy to grab only the locked seeds ([589f45e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/589f45e))
|
||||
* feat(seedlist): apply per-iteration seed locks to the output series ([fd2cdcb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd2cdcb))
|
||||
* feat(seedlist): flash a copied indicator on the modal copy button ([34ec18f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/34ec18f))
|
||||
* feat(seedlist): per-iteration seeds button + modal ([43d775e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/43d775e))
|
||||
* feat(seedlist): preview effective base/count/strategy from the wired loop ([68910bf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/68910bf))
|
||||
* feat(seedlist): show out-of-range locks dimmed but still editable ([b49a579](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b49a579))
|
||||
* feat(shared): add per-iteration view/set/lock row (SeedLockRow) ([1ad7e8e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1ad7e8e))
|
||||
* feat(shared): ts mirror of loop seed derivation with py-ts parity corpus ([cc64ca3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/cc64ca3))
|
||||
* feat(shared): view/set/lock per-iteration seeds modal ([0f0cd47](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f0cd47))
|
||||
* feat(widgets): add seed_locks field to loop + seed-list configs ([5a3e834](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a3e834))
|
||||
* feat(widgets): feed stock base-seed + count into the seed modal ([fb19e3c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fb19e3c))
|
||||
* feat(widgets): seed-list override hint + capitalize loop bypass label ([44d8ab9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/44d8ab9))
|
||||
* test(widgets): restore parse-recovery coverage dropped alongside seed_locks tests ([b5262c2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b5262c2))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.6.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>
|
||||
|
||||
|
||||
* fix(api): keep duplicate children when creating or updating bundles ([c1f45a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c1f45a3))
|
||||
* fix(constraint): drop matrix scroll-container padding that leaked scrolled cells ([d3e25b7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d3e25b7))
|
||||
* fix(constraint): stop scrolled cells bleeding through the frozen matrix headers ([4098009](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4098009))
|
||||
* fix(manager): decimal option probabilities and distinct sub-category group colors ([11d205b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/11d205b))
|
||||
* feat(constraint): group the canvas matrix axes like the SPA ([538db06](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/538db06))
|
||||
* feat(manager): author and save bundles directly in the manager ([a77e60b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a77e60b))
|
||||
* feat(manager): group constraint matrix axes with header chips and column bands ([c0f354c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c0f354c))
|
||||
* feat(manager): label the matrix ungrouped axis run as "uncategorized" ([eac852f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eac852f))
|
||||
* feat(manager): show uuid + option meta in reattach pickers to tell duplicates apart ([0bbf150](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0bbf150))
|
||||
* feat(manager): type-to-filter all dropdowns ([b3537ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b3537ad))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.5.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>
|
||||
|
||||
|
||||
* feat(manager): one-time library repair for non-hex module/bundle ids ([02cc9f7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02cc9f7))
|
||||
* fix(import): normalize non-hex module/bundle ids on import + download ([a1d0511](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a1d0511))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.4.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 #4 from DumiFlex/feat/inline-weights-pool-ui-debug ([ccb8392](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ccb8392)), closes [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4)
|
||||
* fix(engine): apply N:: branch weights in simple inline {a|b|c} choices ([a951699](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a951699))
|
||||
* fix(extension): wildcard pool MOD dot, subtitle counts, library-aware refs ([d8c2072](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d8c2072))
|
||||
* feat(engine): emit a constraint source-missing debug warning ([0e9e5db](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e9e5db))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.3.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 #3 from DumiFlex/fix/instance-modal-scroll ([6a5a8d3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6a5a8d3)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* Merge remote-tracking branch 'origin/main' into fix/instance-modal-scroll ([a19e5f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a19e5f4))
|
||||
* fix(extension): scroll instance edit modals with pinned header + footer ([c43bff0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c43bff0))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.3.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 #2 from DumiFlex/feat/manager-bulk-editor ([6e8696f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6e8696f)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2)
|
||||
* feat(manager): add bulk editor for wildcard options and fixed values ([959cfbe](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/959cfbe))
|
||||
* feat(widgets): shield clipboard shortcuts on editable widget inputs ([325c908](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/325c908))
|
||||
* fix(manager): recover from rich-text editor crash and persist drafts on unload ([0f5166e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f5166e))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.2.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 #1 from DumiFlex/feat/community-embed ([a53d526](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a53d526)), closes [#1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/1)
|
||||
* test: lock cascade-restore end-to-end constraint repoint (#2) ([67de90c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/67de90c)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2)
|
||||
* test(cascade): edge-case qa for subcat rename/delete (collapse, null, multi-surface) ([54054fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/54054fb))
|
||||
* test(constraint): double-cast findComponent to VueWrapper in cached-names test ([c102361](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c102361))
|
||||
* test(engine): lock constraint source-pick fallback semantics ([690169b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/690169b))
|
||||
* test(sp2a): lock multi-pick debug render + var.K cascade/validation ([d1ee97b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d1ee97b))
|
||||
* test(spa): lock accessor-aware var autocomplete match ([8d7bda2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8d7bda2))
|
||||
* test(syntax): move subcat_filter tests into tests/syntax for the coverage gate ([3065e99](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3065e99))
|
||||
* test(validators): engine-parity harness to stop validator drift ([51f6793](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/51f6793))
|
||||
* test(wp_api): lock comma rejection on variable names server-side ([94ccd7f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/94ccd7f))
|
||||
* docs: document schema_version machine + column-add procedure ([ede4f4e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ede4f4e))
|
||||
* docs: flag pick-bucket collision + deprecate dead remapBundleUuids ([96fc26a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/96fc26a))
|
||||
* docs: update constraint conventions to the SP3 reach model ([8314963](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8314963))
|
||||
* docs(constraint): document target reach selectors and axis reattach ([844d16e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/844d16e))
|
||||
* docs(derivation): document nested refs in action values ([402fdcc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/402fdcc))
|
||||
* docs(syntax): document multi-pick, nested-in-inline, $var.K, and the surface gate ([7297b2f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7297b2f))
|
||||
* docs(wildcard): document multi-pick, sub-categories, and nested refs ([8b99795](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8b99795))
|
||||
* feat: pick_min/pick_max/pick_separator wildcard instance fields ([17a2149](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/17a2149))
|
||||
* feat(api): modules/hashes returns type + fingerprint per id ([885f7fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/885f7fb))
|
||||
* feat(bundles): add extractBundleChildren fresh-id materialization helper ([223770e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/223770e))
|
||||
* feat(bundles): extract bundle children to library with ref remap ([59af070](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/59af070))
|
||||
* feat(bundles): manual per-child reattach with community download ([f58d849](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f58d849))
|
||||
* feat(bundles): move extract action to children header, used-by beside title ([f1ab33c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f1ab33c))
|
||||
* feat(bundles): relink bundle to extracted modules + gate extract on dangling children ([b1fa0ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b1fa0ce))
|
||||
* feat(canvas): @{} chips + autocomplete in derivation instance modal ([ab813bb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ab813bb))
|
||||
* feat(cascade): expression-aware subcat rename/delete (engine + ts) ([11ad8ca](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/11ad8ca)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name)
|
||||
* feat(cascade): per-entity wildcard-delete cleanup via cleanup_ids ([9a97613](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9a97613))
|
||||
* feat(cascade): reconcileSubcatExpr token reconciler for remap preview ([fd25a63](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd25a63))
|
||||
* feat(cascade): rewriteBrokenRef pure rewriter for broken-ref remap ([1d59df9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1d59df9))
|
||||
* feat(collision): add type-conflict state + classifyOne core ([9e34d23](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9e34d23))
|
||||
* feat(community): add public post detail + download fetch helpers ([c858bef](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c858bef))
|
||||
* feat(community): add transitive dependency closure walker ([1ee8fe2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1ee8fe2))
|
||||
* feat(community): orchestrate transitive dep download for dangling refs ([7dc9817](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7dc9817))
|
||||
* feat(community): route Publish through embed instead of new tab ([59c5d70](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/59c5d70))
|
||||
* feat(community): surface underlying import error in EmbedLoadError ([6f51e4a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f51e4a))
|
||||
* feat(community): track install origin + update-available indicator ([9957702](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9957702))
|
||||
* feat(community): update-action dialog with in-place + as-new + dismiss ([af314d3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/af314d3))
|
||||
* feat(conflicts): surface broken nested @{} refs at module row ([fd254a6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd254a6))
|
||||
* feat(constraint): backfill cached wildcard name from live catalog on save ([d188ead](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d188ead))
|
||||
* feat(constraint): cache source/target wildcard names for broken-ref banner ([70da7e0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/70da7e0))
|
||||
* feat(constraint): canvas modal detects dangling source/target and reattaches via walkremap ([77f00ae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/77f00ae))
|
||||
* feat(constraint): canvas parity for stranded read-only matrix + colored exception modes ([5ae0ff0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5ae0ff0))
|
||||
* feat(constraint): download missing deps from community + reattach ([02ad0eb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02ad0eb))
|
||||
* feat(constraint): list derivation-nested occurrences in reach pick-list + orphan scan ([098c069](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/098c069))
|
||||
* feat(constraint): lock stranded wildcard fields + color-code exception modes ([4598f68](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4598f68))
|
||||
* feat(constraint): reattach section for dangling source/target with blast-radius warning ([f7369ff](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f7369ff))
|
||||
* feat(constraint): recognise derivations as @{} carriers (branch-key option ids) ([8247d73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8247d73))
|
||||
* feat(constraint): show stranded constraint matrix + exceptions read-only in spa editor ([fa68a36](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa68a36))
|
||||
* feat(constraint): spa editor reattach control mirrors the canvas modal ([1bf1e42](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1bf1e42))
|
||||
* feat(context): type-aware drift/missing + id-clash state via classifyOne ([c13351d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c13351d))
|
||||
* feat(engine): bulk lazy runner — eager iteration of lazy_migrate_row ([73ca8a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/73ca8a3))
|
||||
* feat(engine): capture constraint's own bundle_origin on registered meta ([febb9f3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/febb9f3))
|
||||
* feat(engine): constraint reach selector (first/next/all) + multi-constraint combine; retire one-shot ([be1f765](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/be1f765))
|
||||
* feat(engine): content_rating column on library rows (migration 015) ([395af63](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/395af63))
|
||||
* feat(engine): independent (allow-repeats) multi-pick for the wildcard pool ([33b3600](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/33b3600))
|
||||
* feat(engine): lazy_migrate_row — the ONE migration runtime ([07fe3ab](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/07fe3ab))
|
||||
* feat(engine): list-backed variables + var.K accessor ([8eeb0a8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8eeb0a8))
|
||||
* feat(engine): migrate library v1->v2 (multi-tag) + bump schema version ([39529a6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/39529a6))
|
||||
* feat(engine): migration 014 - payload schema_version + verbatim-local mirror ([c46cbbf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c46cbbf))
|
||||
* feat(engine): multi-pick prefix gains N-M range + ~ independent flag ([0b36ce8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0b36ce8))
|
||||
* feat(engine): multi-select wildcard binds a list variable ([443db49](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/443db49))
|
||||
* feat(engine): multi-tag options + boolean sub-category filters (v2) ([13de622](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/13de622))
|
||||
* feat(engine): per-pick recording + apply constraints via combine_constraint_factor ([f68d2ec](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f68d2ec))
|
||||
* feat(engine): pure combine_constraint_factor + shared constraint corpus ([dd785c5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/dd785c5))
|
||||
* feat(engine): range count + independent mode + lone-ref pool multi-pick ([63b824e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/63b824e))
|
||||
* feat(engine): record per-origin source pick view in __wp_picks__ ([d6094ed](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d6094ed))
|
||||
* feat(engine): resolve @{} refs on the derivation surface ([059517f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/059517f))
|
||||
* feat(engine): stamp derivation carrier (uid, branch_key) for nested reach ([599c3b1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/599c3b1))
|
||||
* feat(engine): thread bundle_origin into resolve ctx per module ([7bcfcac](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7bcfcac))
|
||||
* feat(engine): thread carrier uid + option id for nested-pick constraint reach ([4165b2e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4165b2e))
|
||||
* feat(engine): tokenize var.K list accessor (py+ts parity) ([3ac0afa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3ac0afa))
|
||||
* feat(engine): validate + register constraint target_select; instance override field ([8c3c9ae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8c3c9ae))
|
||||
* feat(import-export): install decision tree per spec §4 Flow 3 ([19836dd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/19836dd))
|
||||
* feat(import-export): parseTolerantAsCurrentShape — strip newer fields ([4758c84](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4758c84))
|
||||
* feat(import-export): publish stamps schema_version + producer_engine_version ([ad0f8c2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ad0f8c2))
|
||||
* feat(import): apply walkremap follow-through over committed entities on rename ([236c39a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/236c39a))
|
||||
* feat(import): surface cross-kind id clashes as CLASH; never replace ([4fabb76](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4fabb76))
|
||||
* feat(import): walkremap follow-through helper for renamed friend ids ([4dd6d6c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4dd6d6c))
|
||||
* feat(install): add buildReattachRemap slug-bridge helper ([7845d91](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7845d91))
|
||||
* feat(install): add reattach type surface (deps, slug, count) ([e138868](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e138868))
|
||||
* feat(install): auto-reattach inner-bundle references on bundle install ([b4f2d6b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b4f2d6b))
|
||||
* feat(install): auto-reattach references via slug-bridge on install ([0494e3c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0494e3c))
|
||||
* feat(install): flag + backstop cross-kind id clashes (never replace) ([ca54a3a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ca54a3a))
|
||||
* feat(install): host bridge forwards post deps + library slug ([a9bde14](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a9bde14))
|
||||
* feat(install): pre-commit collision detection + resolver callback ([519abd3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/519abd3))
|
||||
* feat(manager): add Publish to community button on Export tab ([c6c42c4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c6c42c4))
|
||||
* feat(manager): editor footers gain Upload + Copy buttons ([9c0b11f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9c0b11f))
|
||||
* feat(manager): embed Wildcard Pipeline Community via runtime bundle ([7d43164](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7d43164))
|
||||
* feat(manager): enforce identifier grammar on variable names ([c661550](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c661550))
|
||||
* feat(manager): expose window.__wpcRuntime host bridge for community installs ([a10a321](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a10a321))
|
||||
* feat(manager): mirror v1->v2 migration + bump client schema version ([221aa90](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/221aa90))
|
||||
* feat(manager): move editor Publish + Copy from footer to header ([ea3c628](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ea3c628))
|
||||
* feat(manager): nsfw content filter on every list view + all items ([4b491b8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4b491b8))
|
||||
* feat(manager): nsfw toggle in editors + pill on library rows ([99374cf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/99374cf))
|
||||
* feat(manager): per-row community upload + copy + update diag ([7a5c382](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7a5c382))
|
||||
* feat(manager): real 404 page + global error boundary ([c0cfd13](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c0cfd13))
|
||||
* feat(manager): remaprefpopup for healing broken nested chips ([e897bd3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e897bd3))
|
||||
* feat(manager): thread module-id so remap clears broken-ref warnings in all #3 hosts ([770909d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/770909d)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* feat(manager): wire @remap in richtextinput to heal the open field ([a75c737](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a75c737))
|
||||
* feat(pool): multi-select pick-count control + null disable in multi ([d167a1f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d167a1f))
|
||||
* feat(publish): add listReferencedUuids dependency detector ([e709d53](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e709d53))
|
||||
* feat(publish): auto-detect + inject module dependencies into publish hash ([107d5f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/107d5f4))
|
||||
* feat(publish): guided unmet-dependency checklist before publish ([292a0a5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/292a0a5))
|
||||
* feat(publish): surface bundle children external refs as dependencies ([33316aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/33316aa))
|
||||
* feat(publish): surface inner-bundle dependencies in guided publish ([e7e844b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e7e844b))
|
||||
* feat(publish): verify published deps + reclassify stale ones to publish-first ([feb760e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/feb760e))
|
||||
* feat(publish): wire inner-bundle dependency publish button to navigate ([f8edcfe](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f8edcfe))
|
||||
* feat(publish): writeback community origin to local row after publish ([bbd1a37](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bbd1a37))
|
||||
* feat(refchip): distinct remap emit for unresolved broken chips ([43de217](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/43de217))
|
||||
* feat(spa): "Allow repeats" toggle for the wildcard pool multi-pick ([e785928](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e785928))
|
||||
* feat(spa): author @{} nested refs in derivation action values ([3564938](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3564938))
|
||||
* feat(spa): canvas wildcard editor decomposes blocks into inner chips (sp2b) ([0962aad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0962aad))
|
||||
* feat(spa): decompose brace blocks into scaffolding + inner chips, render v2 ([ef6fadc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ef6fadc))
|
||||
* feat(spa): render var.K as a single chip + accessor-aware autocomplete ([64d1cb1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/64d1cb1))
|
||||
* feat(spa): resolve inline blocks in the combine preview (deterministic) ([62f2e4a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/62f2e4a))
|
||||
* feat(spa): stamp sp2b schema_version on publish when range/independent used ([36942a2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/36942a2))
|
||||
* feat(syntax): boolean sub-category filter parser, matcher + ts parity ([0e1803e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e1803e))
|
||||
* feat(ts): badge reframe — reach suffix, contributor cluster + solid collapse chip, before name ([02ed686](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02ed686))
|
||||
* feat(ts): combineConstraintFactor mirror + shared corpus parity ([2eb0e36](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2eb0e36))
|
||||
* feat(ts): constraint reach selector — segmented modes + cross-node pick checklist ([c80605a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c80605a))
|
||||
* feat(ts): debug rows for constraint never_applied + partial_reach ([764c82e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/764c82e))
|
||||
* feat(ts): library-default target_select in the manager constraint editor ([a0f9651](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a0f9651))
|
||||
* feat(ts): pairings mark-all reach + contributors (drop exclusive claim) ([3902210](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3902210))
|
||||
* feat(ts): per-selector constraint orphan scan (drop one-shot claim-order) ([3f14a6f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3f14a6f))
|
||||
* feat(ts): per-WP_Context node_label, tagged through the cross-node walker + editable header chip ([a94cd54](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a94cd54))
|
||||
* feat(ts): preview resolver applies combine + reach (engine parity) ([b20faa0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b20faa0))
|
||||
* feat(ts): stamp schema_version 4 when constraint target_select is non-default ([826d86f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/826d86f))
|
||||
* feat(ts): transitive nested-target carrier walk (cap + cycle) with route chain ([1ddbe45](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1ddbe45))
|
||||
* feat(ui): nested-ref filter popover + chip + in-graph pool editor ([33e6bd0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/33e6bd0))
|
||||
* feat(ui): wildcard editor sub-category groups + per-option multi-tag ([c374863](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c374863))
|
||||
* feat(validators): v1 strict + tolerant validators per subtype + bundle ([810e33e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/810e33e)), closes [#1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/1)
|
||||
* fix: refresh token field + show update pill outside #name slot ([9c7a110](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9c7a110)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name) [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name) [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name)
|
||||
* fix(assembler): render list-valued vars + .K accessor in node preview ([dc05a5d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/dc05a5d))
|
||||
* fix(bundle): remap constraint source/target to restored uuids on cascade push ([bb9ec6d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb9ec6d))
|
||||
* fix(bundles): reconcile restored inner-bundle fingerprint to clear false drift ([e458e8d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e458e8d))
|
||||
* fix(bundles): repoint cascade-restored standalone library refs (phase 1 pass 2) ([fa0f2ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa0f2ce))
|
||||
* fix(bundles): repoint workflow module refs on cascade restore, not just ids ([6fe52c0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6fe52c0))
|
||||
* fix(bundles): stamp payload_hash on cascade-restored bundle children so re-insert shows no drift ([eff7a6a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eff7a6a))
|
||||
* fix(bundles): sync restored module payload_hash on cascade so canvas shows no drift ([37ca8bd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/37ca8bd))
|
||||
* fix(canvas): picker z-index + teleport theme + inline ↪#N badge in derivation summary ([ec3508a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ec3508a)), closes [#N](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/N) [#N](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/N)
|
||||
* fix(cascade): allow whole-entity delete for fixed_values/combine/derivation/constraint ([738ede3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/738ede3))
|
||||
* fix(cascade): discover v2 boolean sub-category filter refs ([4cda98f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4cda98f))
|
||||
* fix(cascade): keep bundles frozen on wildcard rename (no auto-mutate) ([e748064](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e748064)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name)
|
||||
* fix(cascade): stamp deleted wildcard name onto dependent constraints for broken-ref banner ([97d3036](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/97d3036))
|
||||
* fix(cascade): wildcard-delete leaves constraints, opt-in nested-ref strip ([e288d1b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e288d1b))
|
||||
* fix(combine): filter multi-pick representative to engine pool + honor .K in template preview ([b9df102](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b9df102))
|
||||
* fix(community): preserve bundles/library/deleted views in host nav ([f8efcc9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f8efcc9))
|
||||
* fix(community): sync embed theme with the manager toggle live ([04841aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/04841aa))
|
||||
* fix(community): tokenStore refresh hits /auth/token/refresh not /auth/refresh ([a2cc1bb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a2cc1bb))
|
||||
* fix(constraint): derive real dropped-cell count for reattach preview ([47af97e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/47af97e))
|
||||
* fix(constraint): float cached-name match in reattach dropdown (auto-suggest) ([07dcf31](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/07dcf31)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name) [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* fix(constraint): keep @{} carrier regex strict-hex (engine/dep-graph parity) ([b27b9f8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b27b9f8))
|
||||
* fix(constraint): origin-stamp downloaded deps so they get the community badge ([0984a7f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0984a7f))
|
||||
* fix(context): constraint-reach QA, node codenames, bundle-aware toolbar ([73a00a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/73a00a3))
|
||||
* fix(engine): bind constraint to its own bundle copy's source pick ([9071dfb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9071dfb))
|
||||
* fix(engine): fold list-valued vars in derivation + converter reads ([d41be86](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d41be86))
|
||||
* fix(engine): guard junk pick counts + warn on multi-pick constraint source ([ace0486](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ace0486))
|
||||
* fix(engine): slugify nested-ref tags in v1->v2 migrate + sp1 e2e smoke ([a628512](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a628512))
|
||||
* fix(graph): honor .K accessor + multi-pick representative in static preview ([f922dca](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f922dca))
|
||||
* fix(import-export): refresh also reloads the export tab listing ([7b2d057](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7b2d057))
|
||||
* fix(install): drop normalizeEntity now that community speaks engine-row shape ([b9132af](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b9132af))
|
||||
* fix(install): lift meta.name to top-level name in install pipeline ([160b729](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/160b729))
|
||||
* fix(library): validate all-items bundles against loaded catalog not empty store ([c66c1f5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c66c1f5))
|
||||
* fix(manager): add Delete button to WildcardEditor ([3d92673](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3d92673))
|
||||
* fix(manager): anchor remap popup to the chip using its real height ([498a4ba](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/498a4ba))
|
||||
* fix(manager): bundle-editor constraint names + list keyboard nav reachability ([0da118e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0da118e)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2) [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* fix(manager): community pill lifecycle, list keyboard nav, dashboard intro ([24e83ea](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/24e83ea)), closes [4/#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6) [#5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/5) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7) [#8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/8) [#9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/9) [#10](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/10)
|
||||
* fix(manager): community routes share layoutKey to stop embed re-mount ([6f2b804](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f2b804))
|
||||
* fix(manager): confirm before destructive actions ([bb82b66](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb82b66))
|
||||
* fix(manager): dashboard tabs left + settings restart confirm ([b85f21a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b85f21a)), closes [#actions](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/actions)
|
||||
* fix(manager): dismiss subcat menus on outside click + flip near edge + drag-to-regroup ([723adde](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/723adde))
|
||||
* fix(manager): keep editor header Publish + Copy grouped ([97c92b9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/97c92b9))
|
||||
* fix(manager): never chipify on backspace/delete in rich-text editor ([af90203](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/af90203))
|
||||
* fix(manager): parseRoute handles :rest(.*)* array params ([84cd82f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/84cd82f))
|
||||
* fix(manager): re-anchor remap popup on resize + cap height to viewport ([3884ded](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3884ded))
|
||||
* fix(manager): render unsettled $var/@ref as plain text in editor ([bf189b3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bf189b3))
|
||||
* fix(manager): row actions/focus follow hover+keyboard, not a sticky state ([915e0cc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/915e0cc))
|
||||
* fix(pool): custom pick steppers + min<=max clamp + null-aware enabled/probability ([2a24f9f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2a24f9f))
|
||||
* fix(pool): strike + uncheck null row in multi-pick; align pick stepper styling ([7d267d0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7d267d0))
|
||||
* fix(pool): surface multi-pick count in pool summary + instance subtitle ([31fe070](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/31fe070))
|
||||
* fix(preview): serialize list-valued vars in resolve endpoint ([6703583](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6703583))
|
||||
* fix(publish): bypass vue-router hash encoder ([63a7cd9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/63a7cd9))
|
||||
* fix(publish): correct fixed_values shape + strip history sidecar ([191f880](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/191f880))
|
||||
* fix(publish): forward content_rating + tags + origin slug to embed ([39c0637](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/39c0637))
|
||||
* fix(publish): forward schema_version stamp into the community publish hash ([9a3b5fa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9a3b5fa))
|
||||
* fix(publish): normalize bundle children to module-row shape ([47b1535](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/47b1535))
|
||||
* fix(publish): stamp constraint source/target wildcard names so shares self-describe ([50612dc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/50612dc))
|
||||
* fix(resolver): carry structured list-vars through static resolve so $mood.0 indexes ([435cdb1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/435cdb1))
|
||||
* fix(schema): accept natively-supported future versions on install (max-known, not chain head) ([9f7ee12](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9f7ee12))
|
||||
* fix(schema): advertise max-known schema version to publish gate, not chain head ([0cb5706](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0cb5706))
|
||||
* fix(snapshot): include SP2a pick fields in instance baseline + snapshot type ([d3881c8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d3881c8))
|
||||
* fix(spa): $var autocomplete fires after the $$ multi-pick delimiter ([3a9a09a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3a9a09a))
|
||||
* fix(spa): block native delete eating the ZWSP pad at field boundaries ([2a1123f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2a1123f))
|
||||
* fix(spa): chip whitespace-padded block arms + recolour blocks on edit ([51a526e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/51a526e)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* fix(spa): colour rich-text syntax on the ComfyUI canvas ([ba455fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ba455fb))
|
||||
* fix(spa): detect @{uuid} refs nested inside brace/multi blocks ([6097590](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6097590))
|
||||
* fix(spa): drop leftover background box behind multi-pick blocks ([b9ae37a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b9ae37a))
|
||||
* fix(spa): keep autocomplete open across a $var.K accessor (no Enter newline split) ([d9235c3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d9235c3))
|
||||
* fix(spa): keep brace-block scaffolding colour through atom-direct edits ([619cd5a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/619cd5a)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* fix(spa): resolve \.K to base name in derivation validation + conflict scan ([bc7ea33](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bc7ea33))
|
||||
* fix(spa): show rich-text placeholder ghost (zwsp pad defeated :empty) ([e69ceae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e69ceae))
|
||||
* fix(spa): show the !null ban mark on canvas inner-block ref chips ([55d9ef1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/55d9ef1))
|
||||
* fix(spa): single-char backspace/delete on raw-typed $var (atomic-delete only committed chips) ([743f8b0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/743f8b0))
|
||||
* fix(spa): stable $var.K chip round-trip in rich-text editor ([1208222](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1208222))
|
||||
* fix(ts): pick reach stores bare _uid; partial_reach severity info + stale-comment cleanup ([d826c6b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d826c6b))
|
||||
* fix(ts): thread cross-node chain to the constraint modal (chain-modules prop) ([71e05b1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/71e05b1))
|
||||
* fix(ui): anchor ref-filter popover snug to its chip + move delete button far-left ([ff0f9dc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff0f9dc))
|
||||
* fix(ui): clamp ref-filter popover on-screen, show match count, link null toggle to exclude_null ([b23e29e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b23e29e))
|
||||
* fix(ui): peel !null in nested-ref filter chips on canvas + preview ([c5720e7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c5720e7))
|
||||
* fix(ui): render embed/canvas checkboxes via a scoped WpCheck component ([8000e1b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8000e1b))
|
||||
* fix(ui): shrink ref-filter action buttons by overriding the fixed .wp-btn height ([c777810](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c777810))
|
||||
* fix(ui): stack the pool allow-repeats + exclude-null toggles ([8ad2d0c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8ad2d0c))
|
||||
* fix(ui): style wildcard editor sub-category groups + option-tag picker ([3bcafc9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3bcafc9))
|
||||
* fix(ui): subcategory polish across spa + graph (colours, exclude-null, palette, popover) ([c2d0ef5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c2d0ef5)), closes [1/#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2) [#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6) [#8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/8) [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4) [#5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/5) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7) [9/#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6)
|
||||
* fix(validation): parse v2 boolean sub-category ref filters ([5a0e05e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a0e05e))
|
||||
* fix(validators): match real engine shapes + widget-native bundle children ([8a9ee8c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8a9ee8c))
|
||||
* refactor(bundles): delete dead remapBundleUuids, keep walkRemap ([a4d1913](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a4d1913))
|
||||
* refactor(constraint): centralise derivation branch-key format + py/ts corpus parity ([0e9b5cf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e9b5cf))
|
||||
* refactor(engine): extract pick_k_unique from multi-pick resolver ([e5f54c6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e5f54c6))
|
||||
* refactor(import-export): per-row migratePayload contract per spec §2 ([320315c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/320315c))
|
||||
* refactor(install): applyCollisionDecisions returns rename map ([a5f95cf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a5f95cf))
|
||||
* refactor(manager): harmonize module-list row pills ([87b8c6d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/87b8c6d))
|
||||
* refactor(ui): convert remaining native checkboxes to the shared wp-check look ([c6baeb8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c6baeb8))
|
||||
* refactor(ui): make .wp-check the one checkbox style + restyle to wildcard-option look ([62d3768](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/62d3768))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.1.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>
|
||||
|
||||
|
||||
* feat(api): add GET /wp/api/database/info ([dc839bf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/dc839bf))
|
||||
* feat(api): add POST /wp/api/database/maintenance ([0909817](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0909817))
|
||||
* feat(api): expose db config get/put endpoints with pending-move ([04d3795](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/04d3795))
|
||||
* feat(api): run pending db move before migration on startup ([00b0c73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/00b0c73))
|
||||
* feat(engine): add analyze maintenance op ([d39add1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d39add1))
|
||||
* feat(engine): add gather_info DB introspection ([2b5f2b7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2b5f2b7))
|
||||
* feat(engine): add integrity_check maintenance op ([7f12575](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7f12575))
|
||||
* feat(engine): add run_migrations maintenance op ([5a62929](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a62929))
|
||||
* feat(engine): add sidecar db-config.json reader/writer ([069527a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/069527a))
|
||||
* feat(engine): add user/global/root location path helpers ([747a84a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/747a84a))
|
||||
* feat(engine): add vacuum maintenance op ([4abe679](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4abe679))
|
||||
* feat(engine): execute pending db moves at plugin load ([52966e6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/52966e6))
|
||||
* feat(engine): expose db path resolution source ([7e12626](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7e12626))
|
||||
* feat(engine): honor sidecar preference and add root db location ([c03fc07](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c03fc07))
|
||||
* feat(manager): add always-visible restart comfyui row to maintenance section ([c6a1030](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c6a1030))
|
||||
* feat(manager): add database API client + types ([f20a9e3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f20a9e3))
|
||||
* feat(manager): add database card with info and maintenance ([df7eb75](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/df7eb75))
|
||||
* feat(manager): add databaseStore Pinia store ([2003f38](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2003f38))
|
||||
* feat(manager): add db config types and api client methods ([8c760f2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8c760f2))
|
||||
* feat(manager): add location selector with copy or move flow ([eb48d7f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eb48d7f))
|
||||
* feat(manager): add maintenance op three-phase modal ([7cfe29b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7cfe29b))
|
||||
* feat(manager): add restart comfyui button via manager reboot endpoint ([02c8300](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02c8300))
|
||||
* feat(manager): browser preferences card with full prefix coverage ([b2b75d4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b2b75d4))
|
||||
* feat(manager): detect server restart and prompt stale page reload ([7102057](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7102057))
|
||||
* feat(manager): extend database store with config and pending move actions ([8eb5967](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8eb5967))
|
||||
* feat(manager): fade routes on tab switch with reduced-motion fallback ([9a053e8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9a053e8))
|
||||
* feat(manager): heartbeat poll every 30s so stale banner appears without tab switch ([efb38ff](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/efb38ff))
|
||||
* feat(manager): replace storage card with browser prefs and database cards ([885a2d0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/885a2d0))
|
||||
* fix(manager): flex-based row layout for database info, wrap long paths ([a89bf7d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a89bf7d))
|
||||
* fix(manager): probe manager via get /manager/version instead of head reboot ([843a679](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/843a679))
|
||||
* chore: ignore db-config.json sidecar and root db dir ([d23a728](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d23a728))
|
||||
* chore(tests): fix fresh_db fixture return type annotation ([ecdbf6c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ecdbf6c))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.0.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)
|
||||
|
||||
@@ -44,8 +44,8 @@ ruff check . # Python lint
|
||||
- **Toast singleton** — one Vue app mounted to body-level div renders all toasts pushed via `src/components/shared/toast-store.ts`. Every Context node import same store so toasts surface from anywhere. Position mirror ComfyUI native PrimeVue toast anchor (rect-relative to `.graph-canvas-container`).
|
||||
- **Subgraph traversal** — `collectUpstreamVariables` / `collectUpstreamValues` / `findDownstreamAssemblers` cross subgraph boundaries via `-10` SubgraphInputNode + `-20` SubgraphOutputNode sentinel ids. Walkers expect `app.graph` (root); per-call `buildSubgraphParents` map handle step-out. Use `walkAllNodes(rootGraph)` for graph-wide scans (e.g. pre-run validation).
|
||||
- **Subgraph badges** — `extension/subgraph-badge.ts` attach `LGraphBadge` (via `window.LGraphBadge`) to any SubgraphNode containing WP nodes. Badge surface worst inner conflict severity with human label (e.g. `missing $foo`, `2 conflicts`). Wire via `nodeCreated` + `loadedGraphNode` hooks — `beforeRegisterNodeDef` does NOT fire for SubgraphNodes.
|
||||
- **Conflict scanner** — advisory only; never block execution. Runtime last-write-wins. Rule types: `shadows_upstream` (info), `duplicate_variable` (warning), `missing_template_variable` (warning), `constraint_source_missing` / `constraint_target_missing` (warning — uuid not in catalog), `constraint_orphan_source` (warning — no source instance UPSTREAM), `constraint_orphan_target` (warning — no available target instance DOWNSTREAM, count-aware: N constraints targeting same wildcard need N downstream instances). Engine emits parallel runtime warning `constraint_never_applied` (warning) when a constraint's target never came up.
|
||||
- **Constraint first-instance** — each constraint module is a one-shot. Fires on the FIRST target wildcard instance encountered downstream at runtime (including via nested `@{uuid}` refs), then is consumed. Engine tracks consumption in `ctx["__wp_consumed_constraints__"]` (set keyed by the owning constraint module's id). To affect multiple target instances, author multiple constraint modules. Position in chain controls which target each constraint claims — no Local/Global / Stack/Override scope modes.
|
||||
- **Conflict scanner** — advisory only; never block execution. Runtime last-write-wins. Rule types: `shadows_upstream` (info), `duplicate_variable` (warning), `missing_template_variable` (warning), `constraint_source_missing` / `constraint_target_missing` (warning — uuid not in catalog), `constraint_orphan_source` (warning — no source instance UPSTREAM), `constraint_orphan_target` (warning — the constraint's reach selector covers ZERO reachable downstream instances; per-selector, NOT count-aware — overlapping constraints may freely share an instance). Engine emits runtime warnings `constraint_never_applied` (the reach matched nothing at runtime) and `constraint_partial_reach` (a `next N` / `pick` reach matched fewer instances than requested).
|
||||
- **Constraint reach (SP3)** — each constraint carries a `target_select` reach selector: `{mode: "first"|"next"|"all"|"pick", count?, picks?}`, default `all`. Every constraint whose reach covers a firing downstream target instance applies, COMBINED — per-pick × per-tag × per-constraint multiply, with `exclude` (factor 0) absorbing. No more one-shot/consumed-set: the engine counts per-constraint downstream hits in `ctx["__wp_constraint_hits__"]`. Reach is downstream-relative (counts from the constraint's own chain position) and cross-node + transitive (resolves `@{uuid}` at any depth). `pick` selects specific instances by `_uid` (direct top-level) or carrier `_uid` + option id (nested). The re-weight math is the pure `engine/modules/_constraint_math.py::combine_constraint_factor`, mirrored in TS at `src/manager/utils/constraint-math.ts` via the shared `tests/fixtures/constraint-corpus.json`. Published payloads using a non-default `target_select` stamp `schema_version = 4` (additive; community catalog v4).
|
||||
- **Bundle-size gate** — entry `main.js` must stay ≤ 30 KB gzipped; total `js/` ≤ 250 KB. Pre-commit run `pnpm build:extension && pnpm size`. Raising budget require explicit PR discussion.
|
||||
- **Coverage gate** — vitest v8 coverage with thresholds (lines/statements 50%, functions 40%, branches 65%). Anchored to current baseline; ratchet up alongside new tests.
|
||||
- **Caching** — `WP_Context` + `WP_Debug` set `not_idempotent=True` so seed cycling always re-executes.
|
||||
@@ -65,6 +65,87 @@ ruff check . # Python lint
|
||||
- **Reactivity** `extension/reactive.ts:reactiveFromGraph` — wires `onConnectionsChange` chain + 400ms polling fallback + `afterConfigureGraph` re-sync so widgets recompute on graph edits + workflow loads without flashing stale state.
|
||||
- **Adding new widget**: (1) write SFC under `src/components/<name>/`, (2) add `src/widgets/<name>.ts` exposing `create(node, inputName)` that return `createDomWidgetHost(...)`, (3) wire in `src/main.ts` — either as `getCustomWidgets` entry (for inputs declared with matching widget-type) or via `beforeRegisterNodeDef → onNodeCreated` (for free-floating helpers). Use `import("./widgets/<name>")` so stay out of entry chunk + size gate keep holding.
|
||||
|
||||
## Schema versioning + lazy migration machine
|
||||
|
||||
Spec: `D:\Desktop\Wildcard-Pipeline-Community\docs\superpowers\specs\2026-06-05-schema-versioning-migration-design.md` (cross-repo lock; read before bumping).
|
||||
|
||||
**Three axes** every payload + library row carries:
|
||||
|
||||
1. **`schema_version`** (per row, engine column added in migration 014): which payload shape this row's `payload` is in. Single version per payload root — bundles' children inherit the bundle's `schema_version`.
|
||||
2. **`producer_engine_version`** (per published community version, server-side only): diagnostic. Never load-bearing.
|
||||
3. **`version_number`** (per community post, server-side): the user-facing 1/2/3 publish revision. Unrelated to schema_version.
|
||||
|
||||
**Engine row columns** (migration 014):
|
||||
- `schema_version INTEGER NOT NULL DEFAULT 1` — current shape this row targets.
|
||||
- `original_payload_json TEXT` — verbatim local mirror of the payload as it arrived from community (or import). Lazy migrator reads from here, never from the live `payload` field. Lets us re-project from the original if a migrator chain gets fixed retroactively.
|
||||
- `tolerant_drift_status TEXT` — `none` | `tolerant` | `strict`. Set by parseTolerantAsCurrentShape during install.
|
||||
- `schema_migrated_at TEXT` — wall-clock stamp of last lazy migration step.
|
||||
|
||||
**The machine**:
|
||||
|
||||
- **Validator registry** (`src/manager/import-export/validators/`): one Zod validator per known `schema_version`. Strict at `≤ CURRENT_SCHEMA_VERSION`; future versions go through `parseTolerantAsCurrentShape` (additive-strip).
|
||||
- **`parseTolerantAsCurrentShape(raw, currentVersion)`** (`src/manager/import-export/tolerant-parse.ts`): strips fields the current shape doesn't know, returns the projection that matches `currentVersion`. Used at install when source `schema_version > CURRENT`.
|
||||
- **`migratePayload(payload, from, to)`** (`src/manager/import-export/migration-machine.ts`): forward-only chain. Each step is a registered function in the migrators map. `from > to` is illegal — no downgrades.
|
||||
- **`lazy_migrate_row(row, currentVersion, migrators, validators)`** (`engine/db/lazy_migrate.py`): reads `original_payload_json` (or `payload` if absent), walks the migrator chain to `currentVersion`, validates, writes back. Bulk-at-boot runner is eager iteration of this routine.
|
||||
- **Install decision tree** (`src/manager/import-export/install.ts`): branches on `source_schema_version` vs `CURRENT_SCHEMA_VERSION`:
|
||||
- `=`: strict validate + insert.
|
||||
- `<`: migratePayload then strict validate + insert.
|
||||
- `>` and tolerant-only diff: parseTolerantAsCurrentShape, mark `tolerant_drift_status='tolerant'`, insert.
|
||||
- `>` and breaking-future diff (per catalog `is_breaking_from_previous` AND-fold across the interval): refuse install with a clear error.
|
||||
|
||||
**Server probe**: `engine-export-wrap.ts:ENGINE_SCHEMA_VERSION` is the sister's pinned CURRENT — it ships in the sister bundle, not fetched from server. The host bridge install path reads it from `window.__wpcRuntime.schemaVersion`. Server-first deploy ordering (see community CLAUDE.md) means the community catalog always reflects shapes ≥ sister's CURRENT.
|
||||
|
||||
### Bumping `schema_version` (the proper way)
|
||||
|
||||
When a payload shape change lands (not a row-column change — those don't bump schema):
|
||||
|
||||
1. **Add the new shape's validator.** New file under `src/manager/import-export/validators/v<N>.ts` exporting a Zod schema. Register in `validators/index.ts`.
|
||||
2. **Add the migrator step.** In `src/manager/import-export/migration-machine.ts`, register `migrators[N-1 → N]: (payload) => ...`. Forward-only. The function is pure — no side effects.
|
||||
3. **Add fixtures.** `src/manager/__tests__/import-export/fixtures/v<N>/` — at minimum: a `valid-strict.json`, a `from-v<N-1>-migrated.json` round-trip case, and one `breaking-shape.json` if the bump is breaking.
|
||||
4. **Bump the constant.** `CURRENT_SCHEMA_VERSION` lives in the validator registry. Bump it to `N`. Also bump `ENGINE_SCHEMA_VERSION` in `src/manager/components/engine-export-wrap.ts`.
|
||||
5. **Write the migration test.** `tests/engine/db/test_lazy_migrate.py` should cover v<N-1>→v<N> round-trip + the breaking-future path if applicable.
|
||||
6. **Run `pytest tests/engine/ -q && pnpm test` until green.** Sister pre-commit hook (~2 min) also runs lint + typecheck + build + size; expect that wait.
|
||||
7. **Coordinate the community-side catalog row.** Community web's `schema_catalog` table gets the matching `(version, is_breaking_from_previous, notes)` row + a deploy that lands BEFORE sister's PR merges. See community CLAUDE.md "Bumping the schema" for the server side.
|
||||
8. **Sister deploys after server.** Once catalog HEAD ≥ sister's CURRENT, sister can safely refuse breaking-future shapes.
|
||||
|
||||
### Validator ↔ engine parity (DON'T skip)
|
||||
|
||||
The community TS validators in `src/validators/` are a hand-authored
|
||||
re-implementation of the engine's payload shapes, and re-implementations
|
||||
drift — fixed_values shipped for weeks as `entries/{variable_name}` when
|
||||
the engine has always used `values/{id,name,value}`. The guard against
|
||||
this:
|
||||
|
||||
- `scripts/dump_engine_shapes.py` builds one sample payload per subtype,
|
||||
validates each against that subtype's **own engine `Handler.validate_payload`**
|
||||
(the authority — raises if not engine-valid), and writes the engine-row
|
||||
shape to `src/validators/fixtures/engine-parity/*.json`.
|
||||
- `src/validators/__tests__/engine-parity.test.ts` asserts the **strict**
|
||||
community validator accepts every one of those fixtures.
|
||||
|
||||
**Any time you change an engine module payload shape** (a `*_handler.py`
|
||||
`validate_payload`, or what the editor stores), run
|
||||
`python scripts/dump_engine_shapes.py` and commit the regenerated
|
||||
fixtures. If the sample no longer validates against the handler, the
|
||||
script fails — update the sample. If the TS validator then rejects the
|
||||
new shape, the parity test fails — update the validator. Either failure
|
||||
means the two have diverged; fix it before publish breaks for a user.
|
||||
|
||||
### Row-level column changes (no schema_version bump needed)
|
||||
|
||||
The most recent example is **migration 015 (`content_rating` column)**. Adding a column to `modules` or `bundles` is just an Alembic-style SQLite migration — no payload-shape change, no validator update, no migrator chain. Procedure:
|
||||
|
||||
1. **New file** `engine/db/migrations_sql/<NNN>_<name>.py` mirroring 013/015 shape: `_has_column` gate + `with conn: conn.execute("ALTER TABLE … ADD COLUMN …")` for both `modules` and `bundles` if the field applies to both.
|
||||
2. **Update `_row_to_module` / `_row_to_bundle`** in `engine/db/repositories.py` — wrap the new column read in `try/except (IndexError, KeyError)` so pre-migration fixtures don't blow up.
|
||||
3. **Update `ModuleRepository.create/update` + `BundleRepository.create/update`** to accept the new field as a kwarg.
|
||||
4. **Update `_insert_module` / `_insert_bundle` (+ `_update_module` / `_update_bundle` if needed)** in `engine/importer.py` to pass the field through from the entity dict. Use `COALESCE(?, col)` on the UPDATE side when you want missing-in-content to preserve existing value (NOT overwrite to NULL).
|
||||
5. **Update `wp_api/modules.py` + `wp_api/bundles.py`**: add the field to `_UPDATABLE_FIELDS` + plumb into the create handlers.
|
||||
6. **TS types**: extend `ModuleRow` / `BundleRow` + `*CreateInput` / `*UpdateInput` in `src/manager/api/types.ts`.
|
||||
7. **Bump the head-migration test**: `tests/engine/db/test_migrations.py:test_migrate_records_version` asserts the head version — update to `<NNN>`. Same for the expected-columns set tests.
|
||||
8. **Write a migration test** at `tests/engine/db/test_migration_<NNN>.py` — column exists, default backfill correct, idempotent rerun, accepts the new values.
|
||||
|
||||
If the column also flows through the host-bridge install seam (community origin metadata), extend `InstallOrigin` in `src/manager/import-export/install.ts` AND the embed's `HostInstallOpts.origin` in `web/src/embed/EmbedDetail.vue` (community web repo). Map community-side naming → engine-side naming at the seam if they differ (e.g. `'sfw'` → `'safe'`).
|
||||
|
||||
## Anti-patterns (inherited from reference project `ComfyUI-Wildcard-Pipeline`)
|
||||
|
||||
- Never use V1 `NODE_CLASS_MAPPINGS` / `EXTENSION_WEB_DIRS`.
|
||||
|
||||
+253
-85
@@ -37,6 +37,9 @@ import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from engine.db.repositories import BundleRepository, ModuleRepository
|
||||
from engine.syntax.subcat_filter import ParseError as _ParseError
|
||||
from engine.syntax.subcat_filter import parse as _parse_subcat
|
||||
from engine.syntax.subcat_filter import reads_as as _reads_as
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
@@ -59,24 +62,112 @@ def _rewrite_var_in_string(s: str, old_name: str, new_name: str) -> str:
|
||||
return pattern.sub(f"${new_name}", s)
|
||||
|
||||
|
||||
def _map_tags(ast: dict[str, Any] | None, fn) -> dict[str, Any] | None:
|
||||
"""Apply *fn* to each tag leaf of a parsed sub-category expression;
|
||||
drop tags *fn* maps to ``None`` and collapse the now-childless
|
||||
operator (a one-child and/or becomes that child). Returns ``None``
|
||||
when the whole expression empties. Mirror of TS `mapTags`
|
||||
(src/manager/cascade/subcatExprCascade.ts)."""
|
||||
if ast is None:
|
||||
return None
|
||||
if "tag" in ast:
|
||||
v = fn(ast["tag"])
|
||||
return None if v is None else {"tag": v}
|
||||
if ast["op"] == "not":
|
||||
x = _map_tags(ast["x"], fn)
|
||||
return {"op": "not", "x": x} if x is not None else None
|
||||
kids = [k for k in (_map_tags(c, fn) for c in ast["kids"]) if k is not None]
|
||||
if not kids:
|
||||
return None
|
||||
return kids[0] if len(kids) == 1 else {"op": ast["op"], "kids": kids}
|
||||
|
||||
|
||||
def _expr_tags(ast: dict[str, Any] | None) -> set[str]:
|
||||
out: set[str] = set()
|
||||
|
||||
def walk(n: dict[str, Any] | None) -> None:
|
||||
if n is None:
|
||||
return
|
||||
if "tag" in n:
|
||||
out.add(n["tag"])
|
||||
return
|
||||
if n["op"] == "not":
|
||||
walk(n["x"])
|
||||
return
|
||||
for k in n["kids"]:
|
||||
walk(k)
|
||||
|
||||
walk(ast)
|
||||
return out
|
||||
|
||||
|
||||
def collect_tags(expr: str | None) -> set[str]:
|
||||
"""Distinct sub-category tags referenced by a filter *expr* string.
|
||||
|
||||
Mirror of TS ``collectTags`` (src/manager/cascade/subcatExprCascade.ts).
|
||||
Empty or malformed expressions yield the empty set — the caller simply
|
||||
won't link the ref; ``validate_expression`` surfaces real syntax errors
|
||||
elsewhere. Used by the discovery scan so a rename/delete of any one tag
|
||||
finds every ref whose boolean filter mentions it (negated tags count).
|
||||
"""
|
||||
if not expr or not expr.strip():
|
||||
return set()
|
||||
try:
|
||||
ast = _parse_subcat(expr)
|
||||
except _ParseError:
|
||||
return set()
|
||||
return _expr_tags(ast)
|
||||
|
||||
|
||||
def _ref_expr_pattern(wildcard_id: str) -> re.Pattern[str]:
|
||||
# 4-segment ref to a specific wildcard: groups = (#name, :expr, !null).
|
||||
return re.compile(
|
||||
r"@\{" + re.escape(wildcard_id)
|
||||
+ r"(?:#([^#:}@{!]*))?(?::([^}!]*))?(?:!([^}]*))?\}"
|
||||
)
|
||||
|
||||
|
||||
def _rewrite_ref_expr(s: str, wildcard_id: str, target: str, fn) -> str:
|
||||
"""Rewrite the boolean ``:expr`` of every ``@{wildcard_id...}`` ref
|
||||
whose expression contains *target*, mapping its tags via *fn*
|
||||
(rename, or remove + collapse). Refs that don't mention *target* are
|
||||
left verbatim — no cosmetic re-serialization. ``#name`` + ``!null``
|
||||
are preserved; an expression that empties drops the ``:expr``
|
||||
segment, collapsing ``@{uuid:cold}`` to a bare ``@{uuid}``."""
|
||||
pattern = _ref_expr_pattern(wildcard_id)
|
||||
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
name, expr, null = m.group(1), m.group(2), m.group(3)
|
||||
if expr is None or not expr.strip():
|
||||
return m.group(0)
|
||||
ast = _parse_subcat(expr)
|
||||
if target not in _expr_tags(ast):
|
||||
return m.group(0)
|
||||
new_expr = _reads_as(_map_tags(ast, fn))
|
||||
out = "@{" + wildcard_id + (f"#{name}" if name else "")
|
||||
if new_expr:
|
||||
out += ":" + new_expr
|
||||
if null == "null":
|
||||
out += "!null"
|
||||
return out + "}"
|
||||
|
||||
return pattern.sub(repl, s)
|
||||
|
||||
|
||||
def _rewrite_subcat_ref_in_string(
|
||||
s: str,
|
||||
wildcard_id: str,
|
||||
old_subcat: str,
|
||||
new_subcat: str,
|
||||
) -> str:
|
||||
"""Replace ``@{wildcard_id[#name]:old_subcat}`` →
|
||||
``@{wildcard_id[#name]:new_subcat}``. Preserves any optional
|
||||
``#name`` segment so a subcat rename doesn't strip the wildcard's
|
||||
cached display name from refs."""
|
||||
pattern = re.compile(
|
||||
r"@\{" + re.escape(wildcard_id)
|
||||
+ r"(#[^#:}@{]*)?:" + re.escape(old_subcat) + r"\}"
|
||||
"""Rename ``old_subcat`` -> ``new_subcat`` inside every
|
||||
``@{wildcard_id...}`` ref's boolean expression (e.g.
|
||||
``@{u:warm or cold}`` -> ``@{u:warm or chilly}``). Structure,
|
||||
``#name``, and ``!null`` are preserved."""
|
||||
return _rewrite_ref_expr(
|
||||
s, wildcard_id, old_subcat,
|
||||
lambda t: new_subcat if t == old_subcat else t,
|
||||
)
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
name_seg = m.group(1) or ""
|
||||
return f"@{{{wildcard_id}{name_seg}:{new_subcat}}}"
|
||||
return pattern.sub(repl, s)
|
||||
|
||||
|
||||
def _strip_subcat_ref_in_string(
|
||||
@@ -84,13 +175,38 @@ def _strip_subcat_ref_in_string(
|
||||
wildcard_id: str,
|
||||
subcat_name: str,
|
||||
) -> str:
|
||||
"""Remove ``@{wildcard_id[#name]:subcat_name}`` occurrences from
|
||||
*s*. Matches with or without the optional ``#name`` segment."""
|
||||
pattern = re.compile(
|
||||
r"@\{" + re.escape(wildcard_id)
|
||||
+ r"(?:#[^#:}@{]*)?:" + re.escape(subcat_name) + r"\}"
|
||||
"""Remove ``subcat_name`` from every ``@{wildcard_id...}`` ref's
|
||||
expression, collapsing operators (``@{u:warm or cold}`` ->
|
||||
``@{u:warm}``); a ref whose expression empties collapses to the bare
|
||||
``@{wildcard_id}`` (the nested insertion survives, only its filter
|
||||
is dropped)."""
|
||||
return _rewrite_ref_expr(
|
||||
s, wildcard_id, subcat_name,
|
||||
lambda t: None if t == subcat_name else t,
|
||||
)
|
||||
return pattern.sub("", s)
|
||||
|
||||
|
||||
def _strip_whole_ref_in_string(s: str, wildcard_id: str) -> str:
|
||||
"""Remove every whole ``@{wildcard_id...}`` token from *s*.
|
||||
|
||||
Unlike ``_strip_subcat_ref_in_string`` (which only drops the
|
||||
``:expr`` filter, collapsing to a bare ``@{uuid}``), this deletes the
|
||||
entire nested-insertion token — used by ``fix_wildcard_delete`` when
|
||||
the referenced wildcard itself is gone, so the insertion can't
|
||||
resolve at all. Surrounding text is preserved; whitespace left at the
|
||||
seam is collapsed (doubled spaces -> single) and the result trimmed,
|
||||
so ``"warm @{u} glow"`` -> ``"warm glow"`` rather than
|
||||
``"warm glow"``.
|
||||
|
||||
Reuses the shared 4-segment ref pattern (``_ref_expr_pattern``) so the
|
||||
``#name`` / ``:expr`` / ``!null`` variants of the token all match.
|
||||
"""
|
||||
pattern = _ref_expr_pattern(wildcard_id)
|
||||
stripped = pattern.sub("", s)
|
||||
if stripped == s:
|
||||
return s
|
||||
# Collapse whitespace left at the seam, then trim edges.
|
||||
return re.sub(r"[ \t]{2,}", " ", stripped).strip()
|
||||
|
||||
|
||||
def _rewrite_ref_name_in_string(
|
||||
@@ -124,21 +240,40 @@ def _rewrite_ref_name_in_string(
|
||||
def fix_wildcard_delete(
|
||||
conn: sqlite3.Connection,
|
||||
wildcard_id: str,
|
||||
cleanup_ids: list[str],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Strip all references to *wildcard_id* from constraints.
|
||||
"""Opt-in strip of nested ``@{wildcard_id...}`` refs to a deleted wildcard.
|
||||
|
||||
* Constraints whose source or target is *wildcard_id* are deleted —
|
||||
they reference the wildcard by id only, with no fallback shape.
|
||||
Constraints are never DELETED here. They reference the wildcard by id
|
||||
only and are healable via the reattach UI — the cascade leaves them
|
||||
broken so the user can re-point them later, rather than deleting them
|
||||
(the old behavior, removed). The ONE constraint mutation that does run
|
||||
is a display-only name stamp: the deleted wildcard's name is cached onto
|
||||
every dependent constraint's ``source_wildcard_name`` /
|
||||
``target_wildcard_name`` (the last moment the live name is known), so the
|
||||
broken-reference banner shows "(was …)" instead of a bare uuid. The
|
||||
id/matrix/exceptions stay untouched. The stamp is in `touched` (undo) but
|
||||
not in `diff` (it's not a ref removal). It runs regardless of cleanup_ids.
|
||||
|
||||
Bundles are intentionally NOT touched. Bundle children are full
|
||||
frozen snapshots: the wildcard's payload, options, and meta were
|
||||
deep-copied into ``bundle.children[i]`` at insert time, so the
|
||||
bundle keeps working even when the source library row is deleted.
|
||||
Removing the snapshot would destroy data the user explicitly
|
||||
captured for offline / portable use. The MISSING badge on the
|
||||
referenced workflow rows still fires (drift-store sees the id is
|
||||
gone) — that's the honest signal; stripping the snapshot would be
|
||||
over-eager housekeeping.
|
||||
Nested ``@{wildcard_id...}`` refs (the deleted wildcard inserted
|
||||
inside ANOTHER wildcard's option value, or a derivation action's
|
||||
string value) are stripped ONLY for the specific entity ids the
|
||||
caller lists in *cleanup_ids*. Every entity not in *cleanup_ids* is
|
||||
left untouched — its broken ref survives and is healed later via a
|
||||
remap. An empty/absent *cleanup_ids* therefore strips nothing: only
|
||||
the wildcard row itself is dropped (by the orchestrator), and all
|
||||
constraints + nested refs survive.
|
||||
|
||||
For each entity in *cleanup_ids*:
|
||||
* ``wildcard`` — every ``@{wildcard_id...}`` token is removed from
|
||||
each ``options[].value`` string (whole token, surrounding text
|
||||
preserved).
|
||||
* ``derivation`` — same strip applied to every string value inside
|
||||
``rules[].branches[].actions[]`` (mirrors ``_scan_wildcard_delete``).
|
||||
|
||||
Bundles are intentionally NOT touched (unchanged): bundle children
|
||||
are full frozen snapshots, so the bundle keeps resolving with its
|
||||
captured payload even when the source library row is deleted.
|
||||
|
||||
The fixer does NOT delete the wildcard row itself; the orchestrator
|
||||
owns that mutation.
|
||||
@@ -148,16 +283,78 @@ def fix_wildcard_delete(
|
||||
|
||||
mod_repo = ModuleRepository(conn)
|
||||
|
||||
# --- Constraints --------------------------------------------------------
|
||||
# Stamp the about-to-be-deleted wildcard's name onto every dependent
|
||||
# constraint's source/target name cache, BEFORE the orchestrator drops the
|
||||
# row. This is the last moment the live name is known; without it a
|
||||
# constraint broken by the deletion shows only a bare uuid in the reattach
|
||||
# banner ("Source wildcard 1e9c0e2c is not in your library"). Display-only:
|
||||
# the id/matrix/exceptions are untouched and the constraint is never deleted
|
||||
# (still healable via reattach). Recorded in `touched` for undo but NOT
|
||||
# emitted as a `diff` entry — it's not a ref removal, so it must not inflate
|
||||
# affected_count. Runs regardless of cleanup_ids (the empty-cleanup
|
||||
# early-return is below).
|
||||
target = mod_repo.get(wildcard_id)
|
||||
wc_name = target.get("name") if target else None
|
||||
if wc_name:
|
||||
for c in mod_repo.list():
|
||||
if c["type"] != "constraint":
|
||||
continue
|
||||
cp = c.get("payload") or {}
|
||||
new_cp = copy.deepcopy(cp)
|
||||
stamped = False
|
||||
if cp.get("source_wildcard_id") == wildcard_id:
|
||||
if cp.get("source_wildcard_name") != wc_name:
|
||||
new_cp["source_wildcard_name"] = wc_name
|
||||
stamped = True
|
||||
if cp.get("target_wildcard_id") == wildcard_id:
|
||||
if cp.get("target_wildcard_name") != wc_name:
|
||||
new_cp["target_wildcard_name"] = wc_name
|
||||
stamped = True
|
||||
if stamped:
|
||||
touched.append(_deepcopy_row(c))
|
||||
mod_repo.update(c["id"], payload=new_cp)
|
||||
|
||||
cleanup = set(cleanup_ids)
|
||||
if not cleanup:
|
||||
return touched, diff
|
||||
|
||||
for m in mod_repo.list():
|
||||
if m["type"] != "constraint":
|
||||
if m["id"] not in cleanup:
|
||||
continue
|
||||
t = m["type"]
|
||||
p = m.get("payload") or {}
|
||||
if (p.get("source_wildcard_id") == wildcard_id
|
||||
or p.get("target_wildcard_id") == wildcard_id):
|
||||
new_payload = copy.deepcopy(p)
|
||||
changed = False
|
||||
|
||||
if t == "wildcard":
|
||||
for opt in new_payload.get("options") or []:
|
||||
v = opt.get("value")
|
||||
if isinstance(v, str):
|
||||
new_v = _strip_whole_ref_in_string(v, wildcard_id)
|
||||
if new_v != v:
|
||||
opt["value"] = new_v
|
||||
changed = True
|
||||
|
||||
elif t == "derivation":
|
||||
for rule in new_payload.get("rules") or []:
|
||||
for branch in rule.get("branches") or []:
|
||||
for action in branch.get("actions") or []:
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
for k, v in list(action.items()):
|
||||
if isinstance(v, str):
|
||||
new_v = _strip_whole_ref_in_string(v, wildcard_id)
|
||||
if new_v != v:
|
||||
action[k] = new_v
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
touched.append(_deepcopy_row(m))
|
||||
mod_repo.delete(m["id"])
|
||||
diff.append({"entity_id": m["id"], "removed": True})
|
||||
mod_repo.update(m["id"], payload=new_payload)
|
||||
diff.append({
|
||||
"entity_id": m["id"],
|
||||
"remove_ref": {"kind": "wildcard", "wildcard_id": wildcard_id},
|
||||
})
|
||||
|
||||
return touched, diff
|
||||
|
||||
@@ -193,11 +390,12 @@ def fix_subcat_delete(
|
||||
s for s in declared if s != subcat_name
|
||||
]
|
||||
changed = True
|
||||
# Per-option singular assignment — null out options pointing at
|
||||
# the deleted subcat (preserves the option, releases the link)
|
||||
# Per-option membership — drop the deleted subcat from each
|
||||
# option's sub_categories list (preserves the option).
|
||||
for opt in new_payload.get("options") or []:
|
||||
if opt.get("sub_category") == subcat_name:
|
||||
opt["sub_category"] = None
|
||||
subs = opt.get("sub_categories")
|
||||
if isinstance(subs, list) and subcat_name in subs:
|
||||
opt["sub_categories"] = [s for s in subs if s != subcat_name]
|
||||
changed = True
|
||||
|
||||
elif t == "constraint":
|
||||
@@ -270,10 +468,13 @@ def fix_subcat_rename(
|
||||
new_name if s == old_name else s for s in declared
|
||||
]
|
||||
changed = True
|
||||
# Per-option singular assignment (`opt.sub_category`, not plural)
|
||||
# Per-option membership list (`opt.sub_categories`)
|
||||
for opt in new_payload.get("options") or []:
|
||||
if opt.get("sub_category") == old_name:
|
||||
opt["sub_category"] = new_name
|
||||
subs = opt.get("sub_categories")
|
||||
if isinstance(subs, list) and old_name in subs:
|
||||
opt["sub_categories"] = [
|
||||
new_name if s == old_name else s for s in subs
|
||||
]
|
||||
changed = True
|
||||
|
||||
elif t == "constraint":
|
||||
@@ -335,9 +536,12 @@ def fix_wildcard_rename_name(
|
||||
Combine templates, fixed-values, and constraint payloads don't hold
|
||||
``@{}`` text refs (constraints reference by id only via
|
||||
``source_wildcard_id`` / ``target_wildcard_id``), so they're
|
||||
skipped. Bundle children carry frozen module snapshots whose option
|
||||
values may contain refs; we walk those too so a save propagates
|
||||
into bundle copies.
|
||||
skipped. Bundle children are frozen point-in-time snapshots and are
|
||||
intentionally NOT rewritten (mirrors ``fix_wildcard_delete``): a rename
|
||||
leaves the bundle's cached ``#name`` / refs stale until the user
|
||||
explicitly refreshes the bundle. Otherwise editing ANY module would
|
||||
silently mutate every bundle holding a snapshot of it — a bundle must
|
||||
stay a fixed snapshot, not auto-track its source modules.
|
||||
|
||||
Skips the renamed wildcard itself — its own name lives on the row,
|
||||
not in its own option values.
|
||||
@@ -346,7 +550,6 @@ def fix_wildcard_rename_name(
|
||||
diff: list[dict[str, Any]] = []
|
||||
|
||||
mod_repo = ModuleRepository(conn)
|
||||
bundle_repo = BundleRepository(conn)
|
||||
|
||||
for m in mod_repo.list():
|
||||
if m["id"] == wildcard_id:
|
||||
@@ -390,45 +593,10 @@ def fix_wildcard_rename_name(
|
||||
},
|
||||
})
|
||||
|
||||
# Bundle child snapshots — walk frozen module copies and rewrite
|
||||
# ref strings in their option values. Children may reference the
|
||||
# renamed wildcard via @{uuid} in their stored snapshots.
|
||||
for b in bundle_repo.list():
|
||||
children = b.get("children") or []
|
||||
new_children = copy.deepcopy(children)
|
||||
bundle_changed = False
|
||||
for ch in new_children:
|
||||
if not isinstance(ch, dict):
|
||||
continue
|
||||
if ch.get("type") != "wildcard":
|
||||
continue
|
||||
if ch.get("id") == wildcard_id:
|
||||
# The renamed wildcard's own snapshot in this bundle —
|
||||
# update the cached `name` field instead of refs.
|
||||
if ch.get("name") != new_name:
|
||||
ch["name"] = new_name
|
||||
bundle_changed = True
|
||||
continue
|
||||
payload = ch.get("payload") or {}
|
||||
for opt in payload.get("options") or []:
|
||||
v = opt.get("value")
|
||||
if isinstance(v, str):
|
||||
new_v = _rewrite_ref_name_in_string(v, wildcard_id, new_name)
|
||||
if new_v != v:
|
||||
opt["value"] = new_v
|
||||
bundle_changed = True
|
||||
if bundle_changed:
|
||||
touched.append(_deepcopy_row(b))
|
||||
bundle_repo.update(b["id"], children=new_children)
|
||||
diff.append({
|
||||
"entity_id": b["id"],
|
||||
"rename_ref": {
|
||||
"kind": "wildcard_name",
|
||||
"wildcard_id": wildcard_id,
|
||||
"new": new_name,
|
||||
},
|
||||
})
|
||||
|
||||
# Bundle children are frozen snapshots — intentionally NOT rewritten
|
||||
# here (see docstring). They drift; the explicit bundle-refresh flow
|
||||
# re-snapshots them on demand, so a module edit never auto-mutates a
|
||||
# bundle.
|
||||
return touched, diff
|
||||
|
||||
|
||||
|
||||
@@ -53,9 +53,7 @@ def _rename_subcat_target_only(
|
||||
"""Rename a subcat inside the source wildcard ONLY (no cascade to refs).
|
||||
|
||||
Touches both the top-level `payload.sub_categories` declared list and
|
||||
each option's singular `sub_category` field. The plural-array shape
|
||||
that the prior version operated on does not exist in real data — see
|
||||
`engine/modules/wildcard_handler.py:206` for the actual schema.
|
||||
each option's `sub_categories` membership list (SP1 multi-tag shape).
|
||||
"""
|
||||
mod = ModuleRepository(conn)
|
||||
wc = mod.get(wildcard_id)
|
||||
@@ -71,8 +69,11 @@ def _rename_subcat_target_only(
|
||||
new_options = []
|
||||
for opt in payload.get("options") or []:
|
||||
new_opt = dict(opt)
|
||||
if new_opt.get("sub_category") == old_name:
|
||||
new_opt["sub_category"] = new_name
|
||||
subs = new_opt.get("sub_categories")
|
||||
if isinstance(subs, list) and old_name in subs:
|
||||
new_opt["sub_categories"] = [
|
||||
new_name if s == old_name else s for s in subs
|
||||
]
|
||||
new_options.append(new_opt)
|
||||
payload["options"] = new_options
|
||||
|
||||
@@ -181,7 +182,12 @@ def apply_cascade(conn: sqlite3.Connection, req: dict[str, Any]) -> dict[str, An
|
||||
key = (kind, action)
|
||||
|
||||
if key == ("wildcard", "delete"):
|
||||
touched_before, diff = fix_wildcard_delete(conn, target_id)
|
||||
# Opt-in nested-ref cleanup: only entities the caller
|
||||
# lists get their @{wildcard_id...} token stripped.
|
||||
# Constraints are never deleted (healed via reattach UI);
|
||||
# an empty list strips nothing.
|
||||
cleanup_ids = req.get("cleanup_ids") or []
|
||||
touched_before, diff = fix_wildcard_delete(conn, target_id, cleanup_ids)
|
||||
elif key == ("subcategory", "delete"):
|
||||
touched_before, diff = fix_subcat_delete(
|
||||
conn, target_id, extra.get("subcat_name", "")
|
||||
@@ -202,16 +208,46 @@ def apply_cascade(conn: sqlite3.Connection, req: dict[str, Any]) -> dict[str, An
|
||||
touched_before, diff = fix_option_delete(
|
||||
conn, extra.get("wildcard_id", ""), target_id,
|
||||
)
|
||||
elif action == "delete" and kind in (
|
||||
"fixed_values",
|
||||
"combine",
|
||||
"derivation",
|
||||
"constraint",
|
||||
):
|
||||
# These module types are leaves from a reverse-ref
|
||||
# perspective: nothing in another row's payload points
|
||||
# at them by id the way `option.id` is referenced from
|
||||
# constraint matrices or `wildcard.id` is referenced
|
||||
# from constraint source/target. A whole-entity delete
|
||||
# is just a row drop + undo snapshot — no fix step,
|
||||
# no diff entries.
|
||||
#
|
||||
# Caveat: combine.output_var IS referenced by name
|
||||
# from downstream payloads, but the cascade pipeline
|
||||
# treats that as a "rename" concern (already wired as
|
||||
# combine_output_var/rename above). Deleting a combine
|
||||
# leaves any consumer referencing the output_var with
|
||||
# an unresolved variable, surfaced separately by the
|
||||
# integrity/broken-refs scan.
|
||||
touched_before, diff = [], []
|
||||
else:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"unsupported (kind, action) pair: ({kind}, {action})",
|
||||
}
|
||||
|
||||
# Delete target entity for whole-entity delete ops.
|
||||
# Subcategory deletes are NOT in this set — the fixer already
|
||||
# mutated the source wildcard's payload; there is no separate row.
|
||||
if action == "delete" and kind in ("wildcard", "category", "bundle"):
|
||||
# Delete target entity for whole-entity delete ops. Module
|
||||
# subtypes route through ModuleRepository via _delete_target
|
||||
# (the else-branch of its dispatch).
|
||||
if action == "delete" and kind in (
|
||||
"wildcard",
|
||||
"category",
|
||||
"bundle",
|
||||
"fixed_values",
|
||||
"combine",
|
||||
"derivation",
|
||||
"constraint",
|
||||
):
|
||||
target_snapshot = _delete_target(conn, kind, target_id)
|
||||
touched_before.append(target_snapshot)
|
||||
diff.append({"entity_id": target_id, "removed": True})
|
||||
|
||||
+21
-10
@@ -17,14 +17,18 @@ import re
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from engine.cascade.fixers import collect_tags
|
||||
from engine.db.repositories import ModuleRepository
|
||||
|
||||
# ``@{8hex}``, ``@{8hex#name}``, ``@{8hex:subcat}``, or
|
||||
# ``@{8hex#name:subcat}`` — uuid + optional cached display name +
|
||||
# optional subcat filter. Mirrors `engine/syntax/tokenize.py:_REF_RE`
|
||||
# and the TS twin in `src/manager/cascade/dep-graph.ts`. Name segment
|
||||
# is informational; this scanner only ever reads the captured uuid.
|
||||
_REF_REGEX = re.compile(r"@\{([0-9a-f]{8})(?:#([^#:}@{]*))?(?::([^}]*))?\}")
|
||||
# 4-segment ref: ``@{8hex [#name] [:expr] [!null]}`` — uuid + optional
|
||||
# cached display name + optional boolean sub-category filter expression
|
||||
# + optional exclude-null marker. Mirrors `engine/syntax/tokenize.py:_REF_RE`
|
||||
# and the TS twin in `src/manager/cascade/dep-graph.ts`. findall groups:
|
||||
# (uuid, name, expr, null-marker). This scanner reads the uuid; the
|
||||
# subcat scan also reads the raw expr (see `_scan_subcat`).
|
||||
_REF_REGEX = re.compile(
|
||||
r"@\{([0-9a-f]{8})(?:#([^#:}@{!]*))?(?::([^}!]*))?(?:!([^}]*))?\}"
|
||||
)
|
||||
# ``$varname`` — leading-letter identifier
|
||||
_VAR_REGEX = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
|
||||
|
||||
@@ -66,7 +70,7 @@ def _scan_wildcard_delete(conn: sqlite3.Connection, wildcard_id: str) -> list[di
|
||||
v = opt.get("value")
|
||||
if not isinstance(v, str):
|
||||
continue
|
||||
if any(uid == wildcard_id for uid, _name, _sub in _REF_REGEX.findall(v)):
|
||||
if any(m.group(1) == wildcard_id for m in _REF_REGEX.finditer(v)):
|
||||
out.append(_ref_entry("wildcard", m, f"options[{idx}].value"))
|
||||
break
|
||||
continue
|
||||
@@ -85,7 +89,7 @@ def _scan_wildcard_delete(conn: sqlite3.Connection, wildcard_id: str) -> list[di
|
||||
continue
|
||||
for v in action.values():
|
||||
if isinstance(v, str) and any(
|
||||
uid == wildcard_id for uid, _name, _sub in _REF_REGEX.findall(v)
|
||||
m.group(1) == wildcard_id for m in _REF_REGEX.finditer(v)
|
||||
):
|
||||
out.append(_ref_entry(
|
||||
"derivation", m,
|
||||
@@ -149,8 +153,15 @@ def _scan_subcat(
|
||||
v = opt.get("value")
|
||||
if not isinstance(v, str):
|
||||
continue
|
||||
for uid, _name, sub in _REF_REGEX.findall(v):
|
||||
if uid == wildcard_id and sub == subcat_name:
|
||||
# SP1: `sub` is the raw `:expr` segment (the 4-segment regex
|
||||
# already split `!null` into the null group). A boolean filter
|
||||
# like `warm or cold` references multiple tags, so parse it and
|
||||
# test tag membership — the same set the fixer rewrites
|
||||
# (engine/cascade/fixers.py). Exact-string equality would miss
|
||||
# every multi-tag / negated / parenthesized filter and make the
|
||||
# preview undercount what the apply actually changes.
|
||||
for uid, _name, sub, _null in _REF_REGEX.findall(v):
|
||||
if uid == wildcard_id and subcat_name in collect_tags(sub):
|
||||
out.append(_ref_entry("wildcard", m, f"options[{idx}].value"))
|
||||
break
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ Each function:
|
||||
import re
|
||||
from typing import Protocol
|
||||
|
||||
from engine.syntax.types import deref_var_value, split_var_accessor
|
||||
|
||||
|
||||
class _CtxLike(Protocol):
|
||||
context: dict[str, object]
|
||||
@@ -80,7 +82,6 @@ def lookup_var(context: _CtxLike, name: str) -> str:
|
||||
bare = (name or "").lstrip("$").strip()
|
||||
if not bare:
|
||||
return ""
|
||||
value = context.context.get(bare) if context and getattr(context, "context", None) else None
|
||||
if value is None:
|
||||
return ""
|
||||
return value if isinstance(value, str) else str(value)
|
||||
base, index = split_var_accessor(bare)
|
||||
raw = context.context.get(base) if context and getattr(context, "context", None) else None
|
||||
return deref_var_value(raw, index)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Sidecar config for DB location preference + pending move state.
|
||||
|
||||
Lives at ``<plugin>/db-config.json`` so the resolver can read it BEFORE
|
||||
opening any DB connection (chicken-and-egg: the preference about where
|
||||
the DB lives can't live inside the DB itself).
|
||||
|
||||
Atomic writes: write-to-temp-then-rename so a crash mid-write can't
|
||||
corrupt the sidecar.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
Preference = Literal["user", "global", "root"]
|
||||
MoveMode = Literal["copy", "move"]
|
||||
|
||||
# Plugin root: this file is at <plugin>/engine/db/config.py — go up three.
|
||||
_PLUGIN_ROOT = Path(__file__).resolve().parents[2]
|
||||
SIDECAR_PATH = _PLUGIN_ROOT / "db-config.json"
|
||||
|
||||
_VALID_PREFERENCES = {"user", "global", "root"}
|
||||
_VALID_MODES = {"copy", "move"}
|
||||
|
||||
|
||||
class PendingMove(TypedDict, total=False):
|
||||
from_: str # NB: "from" is a Python keyword; we map this on read/write
|
||||
to: str
|
||||
mode: MoveMode
|
||||
|
||||
|
||||
class DbConfig(TypedDict, total=False):
|
||||
preference: Preference
|
||||
pending_move: dict[str, str] # {"from": "...", "to": "...", "mode": "..."}
|
||||
|
||||
|
||||
def _read_raw(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_sidecar(path: Path | None) -> Path:
|
||||
"""Resolve the effective sidecar path.
|
||||
|
||||
Reads ``SIDECAR_PATH`` from this module dynamically when ``path`` is
|
||||
``None`` so tests can ``monkeypatch.setattr("engine.db.config.SIDECAR_PATH", ...)``
|
||||
and have all three of ``load``/``save``/``clear_pending_move`` honor
|
||||
the override. Using a default argument like ``path = SIDECAR_PATH``
|
||||
captures the value at function-definition time, which breaks
|
||||
monkeypatching the module attribute."""
|
||||
return path if path is not None else SIDECAR_PATH
|
||||
|
||||
|
||||
def load(path: Path | None = None) -> DbConfig:
|
||||
"""Return the sidecar config or {} if absent / unreadable / invalid."""
|
||||
raw = _read_raw(_resolve_sidecar(path))
|
||||
out: DbConfig = {}
|
||||
pref = raw.get("preference")
|
||||
if isinstance(pref, str) and pref in _VALID_PREFERENCES:
|
||||
out["preference"] = pref # type: ignore[typeddict-item]
|
||||
pm = raw.get("pending_move")
|
||||
if isinstance(pm, dict):
|
||||
mode = pm.get("mode")
|
||||
src = pm.get("from")
|
||||
dst = pm.get("to")
|
||||
if (isinstance(mode, str) and mode in _VALID_MODES
|
||||
and isinstance(src, str) and isinstance(dst, str)):
|
||||
out["pending_move"] = {"from": src, "to": dst, "mode": mode}
|
||||
return out
|
||||
|
||||
|
||||
def save(config: DbConfig, path: Path | None = None) -> None:
|
||||
"""Atomically write the sidecar (write-temp + rename)."""
|
||||
path = _resolve_sidecar(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Strip any unknown keys; only persist the validated shape.
|
||||
out: dict[str, Any] = {}
|
||||
if "preference" in config:
|
||||
out["preference"] = config["preference"]
|
||||
pm = config.get("pending_move")
|
||||
if isinstance(pm, dict) and {"from", "to", "mode"} <= pm.keys():
|
||||
out["pending_move"] = {"from": pm["from"], "to": pm["to"], "mode": pm["mode"]}
|
||||
fd, tmp_str = tempfile.mkstemp(prefix=".db-config-", dir=str(path.parent))
|
||||
tmp_path = Path(tmp_str)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(out, f, indent=2)
|
||||
os.replace(tmp_path, path)
|
||||
except OSError:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def clear_pending_move(path: Path | None = None) -> None:
|
||||
"""Remove just the pending_move field; preserve preference."""
|
||||
path = _resolve_sidecar(path)
|
||||
cfg = load(path)
|
||||
if "pending_move" in cfg:
|
||||
cfg.pop("pending_move", None)
|
||||
save(cfg, path)
|
||||
|
||||
|
||||
def plugin_root() -> Path:
|
||||
"""Expose the plugin root so the resolver can compute the `root` location."""
|
||||
return _PLUGIN_ROOT
|
||||
+105
-35
@@ -2,22 +2,28 @@
|
||||
|
||||
DB path resolution order (highest priority first):
|
||||
|
||||
1. ``WP_DB_PATH`` env var — explicit override, used as-is.
|
||||
1. ``WP_DB_PATH`` env var — explicit override, used as-is. Source ``"WP_DB_PATH"``.
|
||||
2. ``COMFYUI_USER_DIR`` env var — places the DB under
|
||||
``$COMFYUI_USER_DIR/wildcard-pipeline.db``.
|
||||
3. ComfyUI's user directory, detected at runtime. The detector tries
|
||||
``folder_paths.get_user_directory()`` first (canonical inside the
|
||||
ComfyUI process), then walks up from ``__file__`` to find the
|
||||
ComfyUI root and appends ``user/``. This is the default for any
|
||||
user running the plugin inside a normal ComfyUI install.
|
||||
4. Legacy path ``~/.comfyui/wildcard-pipeline.db`` — kept as a
|
||||
backward-compat fallback. If a DB already exists at the legacy
|
||||
path AND the detected ComfyUI user dir has none, the resolver
|
||||
returns the legacy path so existing installs don't silently
|
||||
start with an empty DB. Run with ``WP_DB_PATH`` (or copy the
|
||||
file) to migrate to the new location.
|
||||
``$COMFYUI_USER_DIR/wildcard-pipeline.db``. Source ``"COMFYUI_USER_DIR"``.
|
||||
3. Sidecar ``preference`` field at ``<plugin>/db-config.json`` (loaded
|
||||
indirectly through ``_load_sidecar`` so tests can stub it):
|
||||
- ``"user"`` → ComfyUI user dir + ``wildcard-pipeline.db``,
|
||||
source ``"user"``. Falls through if the user dir can't be detected.
|
||||
- ``"global"`` → ``~/.comfyui/wildcard-pipeline.db``, source ``"global"``.
|
||||
- ``"root"`` → ``<plugin>/db/wildcard-pipeline.db``, source ``"root"``.
|
||||
4. Default — ComfyUI user dir + ``wildcard-pipeline.db``, source ``"user"``.
|
||||
Falls through if detection fails.
|
||||
5. Last-ditch fallback — ``~/.comfyui/wildcard-pipeline.db``, source
|
||||
``"global"`` (the design folds the old ``"legacy"`` label into
|
||||
``"global"`` since the path is identical and the UI exposes a Global
|
||||
radio for it).
|
||||
|
||||
Tests can short-circuit the entire chain by setting ``WP_DB_PATH``.
|
||||
The pre-feature back-compat clause that preferred legacy when the new
|
||||
path had no DB has been removed — the design intentionally surprises
|
||||
users with an empty new DB so they migrate explicitly via Settings.
|
||||
|
||||
Tests can short-circuit the entire chain by setting ``WP_DB_PATH`` or by
|
||||
stubbing ``_load_sidecar`` to return a chosen preference.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -68,39 +74,103 @@ def _comfyui_user_dir_from_path() -> Path | None:
|
||||
|
||||
|
||||
def _legacy_home_path() -> Path:
|
||||
"""Return ``~/.comfyui/wildcard-pipeline.db``.
|
||||
|
||||
Still named ``_legacy_*`` for historical reasons but now serves as
|
||||
both the explicit ``global`` preference target and the last-ditch
|
||||
fallback when no ComfyUI user dir can be detected. Both report
|
||||
source ``"global"``."""
|
||||
return Path.home() / ".comfyui" / DB_FILENAME
|
||||
|
||||
|
||||
def resolve_db_path() -> Path:
|
||||
# 1. Explicit override.
|
||||
def _plugin_root_db_path() -> Path:
|
||||
"""Return ``<plugin>/db/wildcard-pipeline.db`` — the ``root``
|
||||
preference target. Lives inside the plugin install dir so it
|
||||
survives ComfyUI reinstalls; vulnerable to plugin git pulls that
|
||||
wipe untracked files (called out in the UI as "not recommended")."""
|
||||
from engine.db.config import plugin_root
|
||||
|
||||
return plugin_root() / "db" / DB_FILENAME
|
||||
|
||||
|
||||
def _load_sidecar() -> dict:
|
||||
"""Indirection over ``engine.db.config.load`` so tests can stub the
|
||||
sidecar read without touching the real ``<plugin>/db-config.json``
|
||||
file."""
|
||||
from engine.db.config import load
|
||||
|
||||
return dict(load())
|
||||
|
||||
|
||||
def user_location_path() -> Path | None:
|
||||
"""Return the ``user`` preference target path, or ``None`` if the
|
||||
ComfyUI user dir can't be detected.
|
||||
|
||||
Same detector chain that ``resolve_db_path_with_source`` uses for the
|
||||
``user`` branch. Exposed for the SPA Settings API so the frontend can
|
||||
list all three potential locations regardless of which one is
|
||||
currently active (or whether one is detectable at all)."""
|
||||
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
|
||||
return (comfy_user / DB_FILENAME) if comfy_user else None
|
||||
|
||||
|
||||
def global_location_path() -> Path:
|
||||
"""Return the ``global`` preference target — ``~/.comfyui/wildcard-pipeline.db``.
|
||||
|
||||
Always available regardless of host detection (just ``Path.home()``)."""
|
||||
return _legacy_home_path()
|
||||
|
||||
|
||||
def root_location_path() -> Path:
|
||||
"""Return the ``root`` preference target — ``<plugin>/db/wildcard-pipeline.db``."""
|
||||
return _plugin_root_db_path()
|
||||
|
||||
|
||||
def resolve_db_path_with_source() -> tuple[Path, str]:
|
||||
"""Like ``resolve_db_path`` but also reports which resolver rule won.
|
||||
|
||||
Source values:
|
||||
- ``"WP_DB_PATH"`` — explicit env override
|
||||
- ``"COMFYUI_USER_DIR"`` — env user-dir
|
||||
- ``"user"`` — ComfyUI user dir (sidecar preference or default)
|
||||
- ``"global"`` — ~/.comfyui/wildcard-pipeline.db
|
||||
- ``"root"`` — <plugin>/db/wildcard-pipeline.db
|
||||
"""
|
||||
override = os.environ.get("WP_DB_PATH")
|
||||
if override:
|
||||
return Path(override)
|
||||
return Path(override), "WP_DB_PATH"
|
||||
|
||||
# 2. COMFYUI_USER_DIR env var. Honour even when the directory
|
||||
# doesn't exist yet — get_connection() will mkdir before opening.
|
||||
user_dir_env = os.environ.get("COMFYUI_USER_DIR")
|
||||
if user_dir_env:
|
||||
return Path(user_dir_env) / DB_FILENAME
|
||||
return Path(user_dir_env) / DB_FILENAME, "COMFYUI_USER_DIR"
|
||||
|
||||
# 3. ComfyUI user dir via API or path traversal. Both detectors
|
||||
# return None if they can't find a usable directory; we fall
|
||||
# through to the legacy path in that case.
|
||||
sidecar = _load_sidecar()
|
||||
preference = sidecar.get("preference")
|
||||
if preference == "global":
|
||||
return _legacy_home_path(), "global"
|
||||
if preference == "root":
|
||||
return _plugin_root_db_path(), "root"
|
||||
if preference == "user":
|
||||
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
|
||||
if comfy_user is not None:
|
||||
return comfy_user / DB_FILENAME, "user"
|
||||
# Detection failed — fall through to the default chain rather
|
||||
# than crash. Last-ditch fallback below will catch it.
|
||||
|
||||
# Default — same as explicit "user" preference.
|
||||
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
|
||||
if comfy_user is not None:
|
||||
new_path = comfy_user / DB_FILENAME
|
||||
# 4. Backward-compat: if the new ComfyUI-user location has no
|
||||
# DB yet but the legacy ~/.comfyui location does, prefer the
|
||||
# legacy file so existing installs keep their data without a
|
||||
# silent reset. Users can copy the file across (or set
|
||||
# WP_DB_PATH) when they're ready to migrate.
|
||||
legacy = _legacy_home_path()
|
||||
if not new_path.exists() and legacy.exists():
|
||||
return legacy
|
||||
return new_path
|
||||
return comfy_user / DB_FILENAME, "user"
|
||||
|
||||
# 5. Standalone / no-ComfyUI fallback.
|
||||
return _legacy_home_path()
|
||||
# Last-ditch fallback. The design folds the old "legacy" label into
|
||||
# "global" because the path is identical and the UI has no separate
|
||||
# "legacy" radio.
|
||||
return _legacy_home_path(), "global"
|
||||
|
||||
|
||||
def resolve_db_path() -> Path:
|
||||
"""Path-only convenience wrapper — preserved for all existing callers."""
|
||||
return resolve_db_path_with_source()[0]
|
||||
|
||||
|
||||
def get_connection(path: Path | None = None) -> sqlite3.Connection:
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Read-only DB introspection + safe maintenance operations.
|
||||
|
||||
Pure Python — no ComfyUI imports, no aiohttp. Each public function takes
|
||||
a ``sqlite3.Connection`` and returns a dict. Expected-failure modes
|
||||
(locked DB, integrity errors) are reported via ``ok: False`` payloads,
|
||||
not exceptions, so the API layer can pass results through verbatim.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from engine.db.migrations import _discover, current_version
|
||||
|
||||
# Map module-row "type" column values to the human/UI-facing names used
|
||||
# in the counts response. Kept in one place so future kinds get a single
|
||||
# insertion point.
|
||||
_KIND_TYPES: dict[str, str] = {
|
||||
"wildcards": "wildcard",
|
||||
"fixed_values": "fixed_values",
|
||||
"combines": "combine",
|
||||
"derivations": "derivation",
|
||||
"constraints": "constraint",
|
||||
}
|
||||
|
||||
# Maps the UI-facing count key to the real SQLite table name. Some
|
||||
# tables have a `module_` prefix in storage but show up unprefixed in
|
||||
# the response (e.g. `module_categories` -> "categories") to keep the
|
||||
# settings card readable.
|
||||
_TABLE_COUNTS: dict[str, str] = {
|
||||
"bundles": "bundles",
|
||||
"templates": "templates",
|
||||
"categories": "module_categories",
|
||||
}
|
||||
|
||||
|
||||
def _iso(ts: float) -> str:
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _count_modules_by_type(conn: sqlite3.Connection, type_value: str) -> int:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM modules WHERE type = ?", (type_value,)
|
||||
).fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
def _count_table(conn: sqlite3.Connection, table: str) -> int:
|
||||
try:
|
||||
row = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
# Table might not exist on very old DBs that haven't migrated yet.
|
||||
return 0
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
def _pragma_int(conn: sqlite3.Connection, name: str) -> int:
|
||||
row = conn.execute(f"PRAGMA {name}").fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
def _pragma_str(conn: sqlite3.Connection, name: str) -> str:
|
||||
row = conn.execute(f"PRAGMA {name}").fetchone()
|
||||
return str(row[0]) if row else ""
|
||||
|
||||
|
||||
def _migration_names() -> dict[int, str]:
|
||||
"""Discover known migration files and map version -> stem name.
|
||||
|
||||
The migrations table only stores ``version`` and ``applied_at``;
|
||||
the human-readable name lives in the filename
|
||||
(``012_templates.sql`` -> ``012_templates``). Walking the same
|
||||
discovery routine the migrator uses keeps this in sync.
|
||||
"""
|
||||
return {version: path.stem for version, path in _discover()}
|
||||
|
||||
|
||||
def _applied_migrations(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
"""Return all rows from the migrations table joined with the
|
||||
on-disk migration name. Empty list if the table doesn't exist
|
||||
(pre-migration DB)."""
|
||||
table = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='migrations';"
|
||||
).fetchone()
|
||||
if not table:
|
||||
return []
|
||||
rows = conn.execute(
|
||||
"SELECT version, applied_at FROM migrations ORDER BY version ASC"
|
||||
).fetchall()
|
||||
names = _migration_names()
|
||||
return [
|
||||
{
|
||||
"version": int(r[0]),
|
||||
"name": names.get(int(r[0]), f"migration_{int(r[0]):03d}"),
|
||||
"applied_at": str(r[1]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def gather_info(
|
||||
conn: sqlite3.Connection, db_path: Path, *, source: str = "unknown"
|
||||
) -> dict[str, Any]:
|
||||
"""Return a read-only snapshot of the DB suitable for the SPA card.
|
||||
|
||||
``source`` is passed through verbatim. The resolver in
|
||||
``engine.db.connection`` produces one of:
|
||||
``"WP_DB_PATH" | "COMFYUI_USER_DIR" | "user" | "global" | "root"``.
|
||||
``"unknown"`` is reserved for callers that don't have a real source
|
||||
(e.g. engine unit tests that build a connection directly)."""
|
||||
counts: dict[str, int] = {}
|
||||
for ui_name, type_value in _KIND_TYPES.items():
|
||||
counts[ui_name] = _count_modules_by_type(conn, type_value)
|
||||
for ui_name, table_name in _TABLE_COUNTS.items():
|
||||
counts[ui_name] = _count_table(conn, table_name)
|
||||
|
||||
try:
|
||||
stat = db_path.stat()
|
||||
size_bytes = stat.st_size
|
||||
mtime_iso = _iso(stat.st_mtime)
|
||||
except OSError:
|
||||
size_bytes = 0
|
||||
mtime_iso = ""
|
||||
|
||||
return {
|
||||
"path": str(db_path),
|
||||
"source": source,
|
||||
"size_bytes": size_bytes,
|
||||
"mtime_iso": mtime_iso,
|
||||
"counts": counts,
|
||||
"migration": {
|
||||
"current_version": current_version(conn),
|
||||
"applied": _applied_migrations(conn),
|
||||
},
|
||||
"pragma": {
|
||||
"journal_mode": _pragma_str(conn, "journal_mode"),
|
||||
"foreign_keys": _pragma_int(conn, "foreign_keys"),
|
||||
"page_size": _pragma_int(conn, "page_size"),
|
||||
"page_count": _pragma_int(conn, "page_count"),
|
||||
"freelist_count": _pragma_int(conn, "freelist_count"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _db_filename(conn: sqlite3.Connection) -> str | None:
|
||||
"""Return the on-disk filename for the main DB attached to this
|
||||
connection, or None if it's an in-memory / un-attached connection.
|
||||
|
||||
Uses ``PRAGMA database_list`` which returns one row per attached
|
||||
DB; the ``main`` one is what we care about. The third column is the
|
||||
absolute file path (empty string for ``:memory:``)."""
|
||||
try:
|
||||
for row in conn.execute("PRAGMA database_list"):
|
||||
# row order: seq, name, file
|
||||
if row[1] == "main":
|
||||
fname = row[2]
|
||||
return fname if fname else None
|
||||
except sqlite3.OperationalError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _file_size(conn: sqlite3.Connection) -> int:
|
||||
"""Return current DB file size from the filesystem. Falls back to
|
||||
page_size * page_count (logical size) when the connection is
|
||||
in-memory or the file can't be stat'd."""
|
||||
fname = _db_filename(conn)
|
||||
if fname:
|
||||
try:
|
||||
return Path(fname).stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
page_size = _pragma_int(conn, "page_size")
|
||||
page_count = _pragma_int(conn, "page_count")
|
||||
return page_size * page_count
|
||||
|
||||
|
||||
def vacuum(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Run VACUUM. Returns {ok, op, duration_ms, bytes_reclaimed}.
|
||||
|
||||
bytes_reclaimed is the on-disk size delta of the main DB file. In
|
||||
WAL mode pending writes live in the ``-wal`` sidecar until a
|
||||
checkpoint flushes them into the main DB, so we issue a TRUNCATE
|
||||
checkpoint BOTH before VACUUM (so ``size_before`` reflects the real
|
||||
occupied size including the freelist we're about to reclaim) and
|
||||
after VACUUM (so ``size_after`` reflects the post-vacuum shrinkage
|
||||
immediately rather than waiting for the next automatic checkpoint).
|
||||
"""
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
# Pre-checkpoint: flush any pending WAL writes into the main DB
|
||||
# file so size_before captures the true on-disk footprint.
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
size_before = _file_size(conn)
|
||||
conn.execute("VACUUM")
|
||||
conn.commit()
|
||||
# Post-checkpoint: VACUUM itself writes via the WAL; flush again
|
||||
# so the shrinkage is reflected on the main DB file right now.
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
except sqlite3.OperationalError as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"op": "vacuum",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"error": str(e),
|
||||
}
|
||||
size_after = _file_size(conn)
|
||||
return {
|
||||
"ok": True,
|
||||
"op": "vacuum",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"bytes_reclaimed": max(0, size_before - size_after),
|
||||
}
|
||||
|
||||
|
||||
def integrity_check(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Run PRAGMA integrity_check. ``ok=True`` iff output is exactly ['ok']."""
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
rows = conn.execute("PRAGMA integrity_check").fetchall()
|
||||
except sqlite3.OperationalError as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"op": "integrity",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"output": [],
|
||||
"error": str(e),
|
||||
}
|
||||
output = [str(r[0]) for r in rows]
|
||||
is_ok = output == ["ok"]
|
||||
return {
|
||||
"ok": is_ok,
|
||||
"op": "integrity",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"output": output,
|
||||
}
|
||||
|
||||
|
||||
def analyze(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Run ANALYZE to refresh the query planner's statistics tables."""
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
conn.execute("ANALYZE")
|
||||
conn.commit()
|
||||
except sqlite3.OperationalError as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"op": "analyze",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"error": str(e),
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"op": "analyze",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
}
|
||||
|
||||
|
||||
def run_migrations(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Force-run pending migrations. Returns the list of versions applied
|
||||
(empty when the DB was already up to date)."""
|
||||
from engine.db.migrations import migrate # local import to keep top-level clean
|
||||
|
||||
before = current_version(conn)
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
migrate(conn)
|
||||
except Exception as e: # noqa: BLE001 — surface to UI as a structured failure
|
||||
return {
|
||||
"ok": False,
|
||||
"op": "migrate",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"error": str(e),
|
||||
"applied": [],
|
||||
}
|
||||
after = current_version(conn)
|
||||
applied = list(range(before + 1, after + 1)) if after > before else []
|
||||
return {
|
||||
"ok": True,
|
||||
"op": "migrate",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"applied": applied,
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Lazy-read routine — the ONE migration runtime.
|
||||
|
||||
See community-web spec
|
||||
D:\\Desktop\\Wildcard-Pipeline-Community\\docs\\superpowers\\specs\\
|
||||
2026-06-05-schema-versioning-migration-design.md §4 Flow 5. Every row
|
||||
read (loadModule / loadBundle) goes through this function; the engine
|
||||
startup bulk runner also calls it for each stale row.
|
||||
|
||||
Six branches:
|
||||
1. No-op (row at CURRENT, original NULL or original.sv ≤ CURRENT).
|
||||
2. NULL-drop optimization (row.sv == CURRENT, original.sv == CURRENT).
|
||||
3. Recovery exact catch-up (original.sv ≤ CURRENT, strict-validate
|
||||
succeeds): promote original into payload, NULL original column.
|
||||
4. Still-drifted re-project (original.sv > CURRENT): tolerantly strip.
|
||||
5. Deferred strict-validate failure: mark row 'broken'.
|
||||
6. Standard forward chain (row.sv < CURRENT, no recovery applicable).
|
||||
|
||||
CRITICAL bug-fix from spec §4: the write-back gate is
|
||||
'base_v < current OR base IS original', NOT just 'base_v < current'.
|
||||
Otherwise the recovery exact-catch-up case (base_v == current AND base
|
||||
IS original) silently no-ops and the row stays drifted.
|
||||
|
||||
The three dependency-injected dicts:
|
||||
migrators[(kind, v)] → (payload, ctx) -> next_payload
|
||||
validators[(kind, v, subtype)] → payload -> bool
|
||||
tolerant_strip[(kind, target_v, sub)] → payload -> stripped_payload
|
||||
|
||||
Subtype lookup: for modules, derived from payload["type"]; for bundles, None.
|
||||
original_payload_json is INERT — only the initial tolerant install + this
|
||||
NULL-drop touch it. schema_migrated_at is SEPARATE from updated_at
|
||||
(structural migration is not a user edit).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from engine._utils import now_iso
|
||||
|
||||
Migrators = dict[tuple[str, int], Callable[[dict[str, Any], dict], dict]]
|
||||
Validators = dict[tuple[str, int, str | None], Callable[[dict[str, Any]], bool]]
|
||||
TolerantStrip = dict[tuple[str, int, str | None], Callable[[dict[str, Any]], dict]]
|
||||
|
||||
|
||||
def lazy_migrate_row(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
kind: str,
|
||||
row_id: str,
|
||||
current_version: int,
|
||||
migrators: Migrators,
|
||||
validators: Validators,
|
||||
tolerant_strip: TolerantStrip | None = None,
|
||||
) -> None:
|
||||
"""Apply lazy migration to a single row.
|
||||
|
||||
Idempotent: if row is already at CURRENT and original is NULL or
|
||||
matches CURRENT, returns without writing (or fires NULL-drop only).
|
||||
"""
|
||||
table = "modules" if kind == "module" else "bundles"
|
||||
row = conn.execute(f"SELECT * FROM {table} WHERE id=?", (row_id,)).fetchone()
|
||||
if row is None:
|
||||
return
|
||||
|
||||
sv = row["schema_version"]
|
||||
original_raw = row["original_payload_json"]
|
||||
original = json.loads(original_raw) if original_raw else None
|
||||
# Narrow original_v to int so the rest of the function's `original is not None`
|
||||
# checks correctly imply an int. A malformed original (no schema_version, or
|
||||
# non-int) is demoted to the no-original branch — the row's payload column is
|
||||
# the only thing we trust.
|
||||
original_v: int | None = None
|
||||
if original is not None:
|
||||
raw_v = original.get("schema_version")
|
||||
if isinstance(raw_v, int):
|
||||
original_v = raw_v
|
||||
else:
|
||||
original = None
|
||||
payload = json.loads(row["payload"])
|
||||
subtype = payload.get("type") if kind == "module" else None
|
||||
|
||||
# Branch 1: no-op or NULL-drop
|
||||
if sv == current_version and (
|
||||
original is None or (original_v is not None and original_v <= current_version)
|
||||
):
|
||||
if original is not None and original_v == sv:
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET original_payload_json=NULL, "
|
||||
f"tolerant_drift_status='none' WHERE id=?",
|
||||
(row_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
|
||||
# Decide base
|
||||
base_is_original = False
|
||||
base_v: int
|
||||
if original is not None and original_v is not None and original_v > sv:
|
||||
if original_v <= current_version:
|
||||
# Recovery candidate — deferred strict-validate FIRST
|
||||
strict = validators.get((kind, original_v, subtype))
|
||||
if strict is not None and not strict(original):
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET tolerant_drift_status='broken', "
|
||||
f"schema_migrated_at=? WHERE id=?",
|
||||
(now_iso(), row_id),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
base = original
|
||||
base_v = original_v
|
||||
base_is_original = True
|
||||
else:
|
||||
# Still drifted; tolerantly strip original down to current
|
||||
strip = (tolerant_strip or {}).get((kind, current_version, subtype))
|
||||
if strip is None:
|
||||
return
|
||||
projected = strip(original)
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET payload=?, schema_version=?, "
|
||||
f"tolerant_drift_status='pending', schema_migrated_at=? WHERE id=?",
|
||||
(
|
||||
json.dumps(projected),
|
||||
current_version,
|
||||
now_iso(),
|
||||
row_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
else:
|
||||
base = payload
|
||||
base_v = sv
|
||||
|
||||
# Write-back gate (THE BUG FIX): write when base_v < current OR base IS original.
|
||||
# The recovery exact-catch-up case has zero chain steps (base_v == current_version)
|
||||
# but still needs the write to promote original into payload + NULL the original
|
||||
# column. Gating on 'base_v < current_version' alone silently no-ops the recovery.
|
||||
needs_write = (base_v < current_version) or base_is_original
|
||||
if not needs_write:
|
||||
return
|
||||
|
||||
new_payload = base
|
||||
for v in range(base_v, current_version):
|
||||
migrator = migrators.get((kind, v))
|
||||
if migrator is None:
|
||||
return
|
||||
new_payload = migrator(new_payload, {})
|
||||
|
||||
promote_from_original = base_is_original
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET payload=?, schema_version=?, schema_migrated_at=?, "
|
||||
f"original_payload_json=?, tolerant_drift_status=? WHERE id=?",
|
||||
(
|
||||
json.dumps(new_payload),
|
||||
current_version,
|
||||
now_iso(),
|
||||
None if promote_from_original else row["original_payload_json"],
|
||||
"none" if promote_from_original else row["tolerant_drift_status"],
|
||||
row_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Migration 013 — community install origin columns.
|
||||
|
||||
Adds ``community_post_slug`` and ``community_version_number`` to both
|
||||
``modules`` and ``bundles``. Stamped at install time by the host-bridge
|
||||
``install()`` path (via the embed's ``origin`` opt) so the SPA can:
|
||||
|
||||
- Render an "installed from community" pill on origin-stamped rows.
|
||||
- Run an update-availability check that compares
|
||||
``community_version_number`` against the post's
|
||||
``latest_version_number`` in the community API.
|
||||
|
||||
Both columns are nullable on purpose — locally-authored rows (Create
|
||||
flow, Import-from-file, starter set) leave them NULL forever.
|
||||
|
||||
Idempotent: each ALTER is gated on ``PRAGMA table_info`` so re-running
|
||||
the migration against a DB that already has the columns is a no-op.
|
||||
The migration runner's existing version table guards against re-runs
|
||||
in production; this guard exists for tests that explicitly rewind the
|
||||
``migrations`` table to exercise the migration on a populated DB
|
||||
(see ``tests/engine/db/test_migration_010.py``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
cur = conn.execute(f"PRAGMA table_info({table});")
|
||||
return any(row[1] == column for row in cur.fetchall())
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
with conn:
|
||||
if not _has_column(conn, "modules", "community_post_slug"):
|
||||
conn.execute("ALTER TABLE modules ADD COLUMN community_post_slug TEXT;")
|
||||
if not _has_column(conn, "modules", "community_version_number"):
|
||||
conn.execute("ALTER TABLE modules ADD COLUMN community_version_number INTEGER;")
|
||||
if not _has_column(conn, "bundles", "community_post_slug"):
|
||||
conn.execute("ALTER TABLE bundles ADD COLUMN community_post_slug TEXT;")
|
||||
if not _has_column(conn, "bundles", "community_version_number"):
|
||||
conn.execute("ALTER TABLE bundles ADD COLUMN community_version_number INTEGER;")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_modules_community_slug "
|
||||
"ON modules(community_post_slug);"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_bundles_community_slug "
|
||||
"ON bundles(community_post_slug);"
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Migration 014: payload schema_version + verbatim-local mirror columns.
|
||||
|
||||
See the community-web design doc at
|
||||
docs/superpowers/specs/2026-06-05-schema-versioning-migration-design.md
|
||||
(in the community-web repo) sections 4 + 5 for the design. The columns:
|
||||
|
||||
- schema_version (INTEGER NOT NULL DEFAULT 1): the payload's shape
|
||||
version. ``1`` is the implicit baseline — every row that existed
|
||||
before this migration was authored against shape v1, so the DEFAULT
|
||||
doubles as the backfill.
|
||||
- original_payload_json (TEXT, nullable): verbatim future-shaped
|
||||
producer bytes preserved through tolerant installs. NULL means no
|
||||
preservation was needed (row was installed at-or-below CURRENT, i.e.
|
||||
no future-shape tolerance was applied).
|
||||
- tolerant_drift_status (TEXT NOT NULL DEFAULT 'none'): enum tracking
|
||||
the drift state of the row's payload vs the catalog. ``'none'`` = no
|
||||
drift; ``'pending'`` = drifted (tolerant-installed from a future
|
||||
shape and not yet reconciled); ``'broken'`` = deferred strict-validate
|
||||
failed.
|
||||
- schema_migrated_at (TEXT, nullable): timestamp of the last structural
|
||||
migration on the row. Intentionally SEPARATE from ``updated_at`` —
|
||||
structural migration is the engine moving the payload between shape
|
||||
versions, not a user edit, so it must not dirty user-edit state.
|
||||
|
||||
Backfill: every pre-existing row stamps ``schema_version=1`` and
|
||||
``tolerant_drift_status='none'`` implicitly via the DEFAULT on the
|
||||
NOT NULL columns — SQLite materializes the default into existing rows
|
||||
when ADD COLUMN runs.
|
||||
|
||||
Idempotent: each ALTER is gated on ``PRAGMA table_info`` so re-running
|
||||
the migration against a DB that already has the columns is a no-op.
|
||||
The migration runner's existing version table guards against re-runs
|
||||
in production; this guard exists for tests that explicitly rewind the
|
||||
``migrations`` table to exercise the migration on a populated DB
|
||||
(see ``tests/engine/db/test_migration_010.py``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from engine.db.migrations_sql._014_helpers import run_bulk_lazy
|
||||
|
||||
|
||||
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
cur = conn.execute(f"PRAGMA table_info({table});")
|
||||
return any(row[1] == column for row in cur.fetchall())
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
with conn:
|
||||
# modules
|
||||
if not _has_column(conn, "modules", "schema_version"):
|
||||
conn.execute(
|
||||
"ALTER TABLE modules ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;"
|
||||
)
|
||||
if not _has_column(conn, "modules", "original_payload_json"):
|
||||
conn.execute("ALTER TABLE modules ADD COLUMN original_payload_json TEXT;")
|
||||
if not _has_column(conn, "modules", "tolerant_drift_status"):
|
||||
conn.execute(
|
||||
"ALTER TABLE modules ADD COLUMN tolerant_drift_status "
|
||||
"TEXT NOT NULL DEFAULT 'none';"
|
||||
)
|
||||
if not _has_column(conn, "modules", "schema_migrated_at"):
|
||||
conn.execute("ALTER TABLE modules ADD COLUMN schema_migrated_at TEXT;")
|
||||
|
||||
# bundles
|
||||
if not _has_column(conn, "bundles", "schema_version"):
|
||||
conn.execute(
|
||||
"ALTER TABLE bundles ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;"
|
||||
)
|
||||
if not _has_column(conn, "bundles", "original_payload_json"):
|
||||
conn.execute("ALTER TABLE bundles ADD COLUMN original_payload_json TEXT;")
|
||||
if not _has_column(conn, "bundles", "tolerant_drift_status"):
|
||||
conn.execute(
|
||||
"ALTER TABLE bundles ADD COLUMN tolerant_drift_status "
|
||||
"TEXT NOT NULL DEFAULT 'none';"
|
||||
)
|
||||
if not _has_column(conn, "bundles", "schema_migrated_at"):
|
||||
conn.execute("ALTER TABLE bundles ADD COLUMN schema_migrated_at TEXT;")
|
||||
|
||||
# Bulk run: at the time 014 ships, CURRENT_SCHEMA_VERSION is still 1, so
|
||||
# every existing row is already at CURRENT and the bulk is a no-op pass.
|
||||
# We invoke it anyway to exercise + validate the wiring on first run.
|
||||
# FUTURE: when a bump migration 015 ships, it ALSO calls run_bulk_lazy
|
||||
# with the new CURRENT and the bump's migrators.
|
||||
run_bulk_lazy(conn, current_version=1, migrators={}, validators={}, tolerant_strip={})
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Migration 015 -- content_rating column on library rows.
|
||||
|
||||
Adds ``content_rating`` (TEXT, default 'safe') to both ``modules`` and
|
||||
``bundles``. Stamped by:
|
||||
|
||||
- The IdentityCard's NSFW toggle in every editor (user-controlled
|
||||
flag the author sets on their own row).
|
||||
- The community install bridge when ``opts.origin.content_rating`` is
|
||||
supplied -- NSFW community posts auto-stamp the row at install
|
||||
time, so the SPA can render the ``18+`` pill without an extra
|
||||
fetch.
|
||||
|
||||
Default 'safe' means rows that pre-date this migration (every
|
||||
locally-authored row prior to v2.3) end up marked safe. Toggle to
|
||||
'nsfw' if needed. No CHECK constraint at the DB level (SQLite ALTER
|
||||
TABLE does not support adding CHECK); validation happens at the
|
||||
application layer (``set_content_rating``).
|
||||
|
||||
Idempotent: each ALTER is gated on ``PRAGMA table_info``. The
|
||||
migration runner's version table is the primary re-run guard; the
|
||||
column check exists for test fixtures that rewind ``migrations``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
cur = conn.execute(f"PRAGMA table_info({table});")
|
||||
return any(row[1] == column for row in cur.fetchall())
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
with conn:
|
||||
if not _has_column(conn, "modules", "content_rating"):
|
||||
conn.execute(
|
||||
"ALTER TABLE modules ADD COLUMN content_rating TEXT "
|
||||
"NOT NULL DEFAULT 'safe';"
|
||||
)
|
||||
if not _has_column(conn, "bundles", "content_rating"):
|
||||
conn.execute(
|
||||
"ALTER TABLE bundles ADD COLUMN content_rating TEXT "
|
||||
"NOT NULL DEFAULT 'safe';"
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Migration 016: wildcard multi-sub-category v2 (SP1).
|
||||
|
||||
Marches every stale row from schema_version 1 -> 2 by eager iteration of
|
||||
the lazy-read routine (see `_014_helpers.run_bulk_lazy`), using the SP1
|
||||
per-row migrators. The transform:
|
||||
|
||||
* option `sub_category: "x"` -> `sub_categories: ["x"]`;
|
||||
* sub-category names slugified (whitespace / grammar chars -> `_`),
|
||||
reserved words suffixed, de-duped, cascaded to options + groups;
|
||||
* nested `@{uuid:a,b,null}` refs -> `@{uuid:a or b!null}`.
|
||||
|
||||
See engine/migrations/v1_to_v2.py for the shared transform + the envelope
|
||||
mirror used by import/export, and CLAUDE.md "Schema versioning".
|
||||
|
||||
Idempotent: rows already at schema_version 2 are not stale, so a re-run
|
||||
(or a run on a fresh DB with no v1 rows) is a no-op. validators /
|
||||
tolerant_strip are empty — the forward 1->2 chain needs neither (there is
|
||||
no future-shaped `original_payload_json` to recover at this bump).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from engine.db.migrations_sql._014_helpers import run_bulk_lazy
|
||||
from engine.migrations.v1_to_v2 import (
|
||||
migrate_bundle_v1_to_v2,
|
||||
migrate_module_v1_to_v2,
|
||||
)
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
run_bulk_lazy(
|
||||
conn,
|
||||
current_version=2,
|
||||
migrators={
|
||||
("module", 1): migrate_module_v1_to_v2,
|
||||
("bundle", 1): migrate_bundle_v1_to_v2,
|
||||
},
|
||||
validators={},
|
||||
tolerant_strip={},
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Helpers for migration 014 — the bulk runner used at startup + at
|
||||
future payload-bump migrations.
|
||||
|
||||
See community-web spec
|
||||
D:\\Desktop\\Wildcard-Pipeline-Community\\docs\\superpowers\\specs\\
|
||||
2026-06-05-schema-versioning-migration-design.md §4 Flow 4: bulk is
|
||||
EAGER ITERATION of the lazy-read routine, NOT a parallel forward-chain
|
||||
implementation. This eliminates the transient-wrong window where bulk
|
||||
could write fabricated defaults over recoverable data preserved in
|
||||
original_payload_json.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from engine.db.lazy_migrate import lazy_migrate_row
|
||||
|
||||
STALE_PREDICATE = (
|
||||
"schema_version < ? "
|
||||
"OR (original_payload_json IS NOT NULL AND "
|
||||
" json_extract(original_payload_json, '$.schema_version') > schema_version)"
|
||||
)
|
||||
|
||||
|
||||
def run_bulk_lazy(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
current_version: int,
|
||||
migrators,
|
||||
validators,
|
||||
tolerant_strip,
|
||||
) -> int:
|
||||
"""Eager iteration of lazy_migrate_row over every stale row.
|
||||
|
||||
The stale predicate catches BOTH 'sv < current' AND 'tolerantly-
|
||||
installed row whose original now outranks the projection'. The
|
||||
second clause handles rows where the extension has caught up after
|
||||
a previous tolerant install.
|
||||
|
||||
Returns count of rows that failed to migrate (caller surfaces a
|
||||
dashboard banner when > 0).
|
||||
"""
|
||||
failed = 0
|
||||
for table, kind in [("modules", "module"), ("bundles", "bundle")]:
|
||||
ids = [
|
||||
row["id"]
|
||||
for row in conn.execute(
|
||||
f"SELECT id FROM {table} WHERE {STALE_PREDICATE}",
|
||||
(current_version,),
|
||||
).fetchall()
|
||||
]
|
||||
for row_id in ids:
|
||||
try:
|
||||
lazy_migrate_row(
|
||||
conn,
|
||||
kind=kind,
|
||||
row_id=row_id,
|
||||
current_version=current_version,
|
||||
migrators=migrators,
|
||||
validators=validators,
|
||||
tolerant_strip=tolerant_strip,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — defensive sweep
|
||||
failed += 1
|
||||
continue
|
||||
# After lazy_migrate_row: if the row's schema_version is still
|
||||
# below current_version, count as failed.
|
||||
after = conn.execute(
|
||||
f"SELECT schema_version FROM {table} WHERE id=?", (row_id,)
|
||||
).fetchone()
|
||||
if after and after["schema_version"] < current_version:
|
||||
failed += 1
|
||||
return failed
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Execute deferred DB file moves at plugin load.
|
||||
|
||||
The SPA Settings UI lets users change the DB location. The actual file
|
||||
operation runs here, on next ComfyUI start, BEFORE any DB connection
|
||||
opens. Errors are logged and never crash the host.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from engine.db.config import clear_pending_move, load
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def execute_pending_move() -> None:
|
||||
"""If the sidecar has a pending_move, run it and clear the entry.
|
||||
|
||||
Idempotent if the move already happened (source missing, dest present).
|
||||
Failures (source missing without dest, permission errors) are logged;
|
||||
sidecar is left intact so the UI can show the pending state on next
|
||||
page load.
|
||||
"""
|
||||
cfg = load()
|
||||
pm = cfg.get("pending_move")
|
||||
if not pm:
|
||||
return
|
||||
src = Path(pm["from"])
|
||||
dst = Path(pm["to"])
|
||||
mode = pm["mode"]
|
||||
|
||||
# Resume: previous run completed the move but failed to clear sidecar.
|
||||
if not src.exists() and dst.exists():
|
||||
logger.info("wp_db_pending_move: already-done (src missing, dst present), clearing")
|
||||
clear_pending_move()
|
||||
return
|
||||
|
||||
if not src.exists():
|
||||
logger.error("wp_db_pending_move: source %s missing, skipping", src)
|
||||
return
|
||||
|
||||
try:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
if mode == "copy":
|
||||
shutil.copy2(src, dst)
|
||||
else: # move
|
||||
shutil.move(str(src), str(dst))
|
||||
except OSError as e:
|
||||
logger.exception("wp_db_pending_move: %s failed: %s", mode, e)
|
||||
return
|
||||
|
||||
clear_pending_move()
|
||||
logger.info("wp_db_pending_move: %s %s -> %s OK", mode, src, dst)
|
||||
+131
-15
@@ -19,6 +19,21 @@ from engine.modules.snapshot import payload_hash
|
||||
_VALID_TYPES: frozenset[str] = frozenset({
|
||||
"wildcard", "fixed_values", "combine", "derivation", "constraint",
|
||||
})
|
||||
|
||||
# Free-text adult-content flag on every library row (migration 015).
|
||||
# Stamped by the IdentityCard's NSFW toggle or auto-stamped at install
|
||||
# time when a community post carried `content_rating='nsfw'`. No DB-level
|
||||
# CHECK constraint (SQLite ALTER limit); validate at the repository.
|
||||
_VALID_CONTENT_RATINGS: frozenset[str] = frozenset({"safe", "nsfw"})
|
||||
|
||||
|
||||
def _validate_content_rating(rating: str) -> str:
|
||||
if rating not in _VALID_CONTENT_RATINGS:
|
||||
raise ValueError(
|
||||
f"content_rating must be one of {sorted(_VALID_CONTENT_RATINGS)}, "
|
||||
f"got {rating!r}"
|
||||
)
|
||||
return rating
|
||||
# Module ids are 8-hex short uuids — same shape the tokenizer's
|
||||
# `@{8hex}` ref token captures and the engine catalog keys by. Slug
|
||||
# prefixes (e.g. `wc_outfit_a1b2c3d4`) were removed in migration 004
|
||||
@@ -55,6 +70,24 @@ class ModuleNotFound(LookupError):
|
||||
|
||||
def _row_to_module(row: sqlite3.Row) -> dict[str, Any]:
|
||||
payload = json.loads(row["payload"])
|
||||
# Community origin columns (migration 013) — NULL for any row that
|
||||
# didn't come from a community install. Surfaced verbatim so the
|
||||
# SPA can render an "installed from community" badge + drive
|
||||
# update-available checks.
|
||||
try:
|
||||
community_post_slug = row["community_post_slug"]
|
||||
community_version_number = row["community_version_number"]
|
||||
except (IndexError, KeyError):
|
||||
# Defensive: a connection opened against a DB that hasn't run
|
||||
# migration 013 yet (e.g. mid-rollout, test fixtures) won't
|
||||
# have these columns. Treat absence as NULL.
|
||||
community_post_slug = None
|
||||
community_version_number = None
|
||||
try:
|
||||
content_rating = row["content_rating"]
|
||||
except (IndexError, KeyError):
|
||||
# Pre-015 fixture: column absent, treat as 'safe'.
|
||||
content_rating = "safe"
|
||||
return {
|
||||
"id": row["id"],
|
||||
"type": row["type"],
|
||||
@@ -69,6 +102,9 @@ def _row_to_module(row: sqlite3.Row) -> dict[str, Any]:
|
||||
"version": row["version"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"community_post_slug": community_post_slug,
|
||||
"community_version_number": community_version_number,
|
||||
"content_rating": content_rating,
|
||||
}
|
||||
|
||||
|
||||
@@ -125,13 +161,14 @@ class ModuleRepository:
|
||||
payload: dict[str, Any],
|
||||
is_favorite: bool = False,
|
||||
id: str | None = None,
|
||||
content_rating: str = "safe",
|
||||
) -> dict[str, Any]:
|
||||
"""Insert a new module row.
|
||||
|
||||
``id`` is normally generated; pass an explicit id to import
|
||||
a workflow-resident snapshot back into the library at the
|
||||
SAME uuid (so a freshly-saved row immediately matches the
|
||||
workflow's existing references — no broken `@{uuid}` refs).
|
||||
workflow's existing references -- no broken `@{uuid}` refs).
|
||||
Validates the supplied id is 8-hex to prevent the workflow
|
||||
from injecting arbitrary primary-key shapes.
|
||||
"""
|
||||
@@ -139,6 +176,7 @@ class ModuleRepository:
|
||||
raise ValueError(
|
||||
f"unknown module type {type!r}; expected one of {sorted(_VALID_TYPES)}"
|
||||
)
|
||||
_validate_content_rating(content_rating)
|
||||
if type == "wildcard":
|
||||
payload = self._backfill_option_ids(payload)
|
||||
if id is None:
|
||||
@@ -163,12 +201,13 @@ class ModuleRepository:
|
||||
self._conn.execute(
|
||||
"INSERT INTO modules("
|
||||
"id, type, name, description, category_id, tags, "
|
||||
"is_favorite, payload, snapshot_fingerprint, version, created_at, updated_at"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?);",
|
||||
"is_favorite, payload, snapshot_fingerprint, version, "
|
||||
"created_at, updated_at, content_rating"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?);",
|
||||
(
|
||||
mid, type, name, description, category_id,
|
||||
json.dumps(tags), int(is_favorite),
|
||||
json.dumps(payload), fp, now, now,
|
||||
json.dumps(payload), fp, now, now, content_rating,
|
||||
),
|
||||
)
|
||||
return self.get(mid)
|
||||
@@ -226,6 +265,7 @@ class ModuleRepository:
|
||||
tags: list[str] | _Unset = _UNSET,
|
||||
payload: dict[str, Any] | _Unset = _UNSET,
|
||||
is_favorite: bool | _Unset = _UNSET,
|
||||
content_rating: str | _Unset = _UNSET,
|
||||
) -> dict[str, Any]:
|
||||
existing = self.get(module_id)
|
||||
# Backfill missing option ids when caller writes a fresh wildcard payload.
|
||||
@@ -235,6 +275,8 @@ class ModuleRepository:
|
||||
and isinstance(payload, dict)
|
||||
):
|
||||
payload = self._backfill_option_ids(payload)
|
||||
if not isinstance(content_rating, _Unset):
|
||||
_validate_content_rating(content_rating)
|
||||
new = {
|
||||
"name": existing["name"] if isinstance(name, _Unset) else name,
|
||||
"description": (
|
||||
@@ -248,6 +290,11 @@ class ModuleRepository:
|
||||
"is_favorite": (
|
||||
existing["is_favorite"] if isinstance(is_favorite, _Unset) else is_favorite
|
||||
),
|
||||
"content_rating": (
|
||||
existing["content_rating"]
|
||||
if isinstance(content_rating, _Unset)
|
||||
else content_rating
|
||||
),
|
||||
}
|
||||
new_payload_hash = payload_hash(new["payload"])
|
||||
new_fp = module_fingerprint({
|
||||
@@ -263,16 +310,44 @@ class ModuleRepository:
|
||||
"UPDATE modules SET "
|
||||
"name = ?, description = ?, category_id = ?, tags = ?, "
|
||||
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
|
||||
"version = version + 1, updated_at = ? "
|
||||
"version = version + 1, updated_at = ?, content_rating = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
new["name"], new["description"], new["category_id"],
|
||||
json.dumps(new["tags"]), int(new["is_favorite"]),
|
||||
json.dumps(new["payload"]), new_fp, now, module_id,
|
||||
json.dumps(new["payload"]), new_fp, now,
|
||||
new["content_rating"], module_id,
|
||||
),
|
||||
)
|
||||
return self.get(module_id)
|
||||
|
||||
def set_community_origin(
|
||||
self, module_id: str, *, post_slug: str, version_number: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Stamp community origin on an existing row WITHOUT a version
|
||||
bump or fingerprint change.
|
||||
|
||||
Used by the publish-writeback path (host bridge
|
||||
``markPublished``): after a row is published to community, the
|
||||
local row gets linked to the resulting post so the SPA renders
|
||||
the community pill, the update-available checker tracks it, and
|
||||
a future re-publish detects "this is my own post" (version bump
|
||||
vs fork). Origin metadata is NOT runtime-affecting, so unlike
|
||||
``update`` this neither bumps ``version`` nor recomputes the
|
||||
snapshot fingerprint -- a published row stays byte-identical to
|
||||
what consumers already installed.
|
||||
"""
|
||||
with self._conn:
|
||||
cur = self._conn.execute(
|
||||
"UPDATE modules SET "
|
||||
"community_post_slug = ?, community_version_number = ? "
|
||||
"WHERE id = ?;",
|
||||
(post_slug, version_number, module_id),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise ModuleNotFound(module_id)
|
||||
return self.get(module_id)
|
||||
|
||||
def delete(self, module_id: str) -> None:
|
||||
with self._conn:
|
||||
cur = self._conn.execute("DELETE FROM modules WHERE id = ?;", (module_id,))
|
||||
@@ -481,6 +556,16 @@ def _row_to_bundle(row: sqlite3.Row) -> dict[str, Any]:
|
||||
intentionally frozen — the saved hash is the source of truth for
|
||||
insert-time `inserted_at_hash` capture + library-drift detection.
|
||||
"""
|
||||
try:
|
||||
community_post_slug = row["community_post_slug"]
|
||||
community_version_number = row["community_version_number"]
|
||||
except (IndexError, KeyError):
|
||||
community_post_slug = None
|
||||
community_version_number = None
|
||||
try:
|
||||
content_rating = row["content_rating"]
|
||||
except (IndexError, KeyError):
|
||||
content_rating = "safe"
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
@@ -494,6 +579,9 @@ def _row_to_bundle(row: sqlite3.Row) -> dict[str, Any]:
|
||||
"version": row["version"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"community_post_slug": community_post_slug,
|
||||
"community_version_number": community_version_number,
|
||||
"content_rating": content_rating,
|
||||
}
|
||||
|
||||
|
||||
@@ -529,7 +617,9 @@ class BundleRepository:
|
||||
children: list[dict[str, Any]] | None = None,
|
||||
is_favorite: bool = False,
|
||||
id: str | None = None,
|
||||
content_rating: str = "safe",
|
||||
) -> dict[str, Any]:
|
||||
_validate_content_rating(content_rating)
|
||||
if id is None:
|
||||
bid = self._gen_id()
|
||||
else:
|
||||
@@ -541,7 +631,7 @@ class BundleRepository:
|
||||
)
|
||||
bid = id
|
||||
children_blob = list(children) if children else []
|
||||
# `payload_hash` is computed off the children blob — the only
|
||||
# `payload_hash` is computed off the children blob -- the only
|
||||
# field whose change should signal "library has a newer
|
||||
# version" to inserted instances.
|
||||
ph = payload_hash({"children": children_blob})
|
||||
@@ -551,12 +641,12 @@ class BundleRepository:
|
||||
"INSERT INTO bundles("
|
||||
"id, name, description, color, category_id, tags, "
|
||||
"is_favorite, children, payload_hash, version, "
|
||||
"created_at, updated_at"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?);",
|
||||
"created_at, updated_at, content_rating"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?);",
|
||||
(
|
||||
bid, name, description, color, category_id,
|
||||
json.dumps(tags or []), int(is_favorite),
|
||||
json.dumps(children_blob), ph, now, now,
|
||||
json.dumps(children_blob), ph, now, now, content_rating,
|
||||
),
|
||||
)
|
||||
return self.get(bid)
|
||||
@@ -580,8 +670,11 @@ class BundleRepository:
|
||||
tags: list[str] | _Unset = _UNSET,
|
||||
children: list[dict[str, Any]] | _Unset = _UNSET,
|
||||
is_favorite: bool | _Unset = _UNSET,
|
||||
content_rating: str | _Unset = _UNSET,
|
||||
) -> dict[str, Any]:
|
||||
existing = self.get(bundle_id)
|
||||
if not isinstance(content_rating, _Unset):
|
||||
_validate_content_rating(content_rating)
|
||||
new = {
|
||||
"name": existing["name"] if isinstance(name, _Unset) else name,
|
||||
"description": (
|
||||
@@ -598,11 +691,16 @@ class BundleRepository:
|
||||
"is_favorite": (
|
||||
existing["is_favorite"] if isinstance(is_favorite, _Unset) else is_favorite
|
||||
),
|
||||
"content_rating": (
|
||||
existing["content_rating"]
|
||||
if isinstance(content_rating, _Unset)
|
||||
else content_rating
|
||||
),
|
||||
}
|
||||
# Recompute payload_hash whenever children change. Other field
|
||||
# edits (rename, recolor, retag) don't affect runtime behavior
|
||||
# so they don't bump the hash — inserted instances stay clean
|
||||
# on a pure-cosmetic library update.
|
||||
# edits (rename, recolor, retag, NSFW toggle) don't affect runtime
|
||||
# behavior so they don't bump the hash -- inserted instances stay
|
||||
# clean on a pure-cosmetic library update.
|
||||
ph = (
|
||||
existing["payload_hash"]
|
||||
if isinstance(children, _Unset)
|
||||
@@ -614,17 +712,35 @@ class BundleRepository:
|
||||
"UPDATE bundles SET "
|
||||
"name = ?, description = ?, color = ?, category_id = ?, "
|
||||
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
|
||||
"version = version + 1, updated_at = ? "
|
||||
"version = version + 1, updated_at = ?, content_rating = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
new["name"], new["description"], new["color"],
|
||||
new["category_id"], json.dumps(new["tags"]),
|
||||
int(new["is_favorite"]),
|
||||
json.dumps(new["children"]), ph, now, bundle_id,
|
||||
json.dumps(new["children"]), ph, now,
|
||||
new["content_rating"], bundle_id,
|
||||
),
|
||||
)
|
||||
return self.get(bundle_id)
|
||||
|
||||
def set_community_origin(
|
||||
self, bundle_id: str, *, post_slug: str, version_number: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Stamp community origin on a bundle row WITHOUT a version bump
|
||||
or payload-hash change. See ModuleRepository.set_community_origin
|
||||
for the rationale -- origin metadata is not runtime-affecting."""
|
||||
with self._conn:
|
||||
cur = self._conn.execute(
|
||||
"UPDATE bundles SET "
|
||||
"community_post_slug = ?, community_version_number = ? "
|
||||
"WHERE id = ?;",
|
||||
(post_slug, version_number, bundle_id),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise BundleNotFound(bundle_id)
|
||||
return self.get(bundle_id)
|
||||
|
||||
def delete(self, bundle_id: str) -> None:
|
||||
with self._conn:
|
||||
cur = self._conn.execute(
|
||||
|
||||
+119
-35
@@ -154,8 +154,10 @@ def _insert_module(
|
||||
"INSERT INTO modules("
|
||||
"id, type, name, description, category_id, tags, "
|
||||
"is_favorite, payload, snapshot_fingerprint, version, "
|
||||
"created_at, updated_at"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
"created_at, updated_at, "
|
||||
"community_post_slug, community_version_number, "
|
||||
"content_rating"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
(
|
||||
mid, kind, entity["name"], entity.get("description", ""),
|
||||
entity.get("category_id"),
|
||||
@@ -166,6 +168,17 @@ def _insert_module(
|
||||
entity.get("version", 1),
|
||||
entity.get("created_at", now),
|
||||
entity.get("updated_at", now),
|
||||
# Community origin -- only set when the install bridge stamps
|
||||
# them on the entity (see manager/main.ts snapshotLibrary +
|
||||
# the embed's install call). Local Create/Import-from-file
|
||||
# paths leave these NULL so the row stays "locally authored".
|
||||
entity.get("community_post_slug"),
|
||||
entity.get("community_version_number"),
|
||||
# content_rating defaults to 'safe' when the entity carries
|
||||
# no value -- migration 015 stamps the same default. Community
|
||||
# install passes 'nsfw' through via the entity when the post
|
||||
# carries content_rating='nsfw'.
|
||||
entity.get("content_rating", "safe"),
|
||||
),
|
||||
)
|
||||
return mid
|
||||
@@ -193,8 +206,10 @@ def _insert_bundle(
|
||||
"INSERT INTO bundles("
|
||||
"id, name, description, color, category_id, tags, "
|
||||
"is_favorite, children, payload_hash, version, "
|
||||
"created_at, updated_at"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
"created_at, updated_at, "
|
||||
"community_post_slug, community_version_number, "
|
||||
"content_rating"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
(
|
||||
bid, entity["name"], entity.get("description", ""),
|
||||
entity.get("color"),
|
||||
@@ -206,6 +221,11 @@ def _insert_bundle(
|
||||
entity.get("version", 1),
|
||||
entity.get("created_at", now),
|
||||
entity.get("updated_at", now),
|
||||
# Community origin -- see _insert_module comment above.
|
||||
entity.get("community_post_slug"),
|
||||
entity.get("community_version_number"),
|
||||
# content_rating -- see _insert_module comment above.
|
||||
entity.get("content_rating", "safe"),
|
||||
),
|
||||
)
|
||||
return bid
|
||||
@@ -219,50 +239,114 @@ def _update_module(
|
||||
Preserves `mid` and the row's `type` (the kind dispatch already
|
||||
routed us here so type is implied). Bumps `version` and stamps
|
||||
`updated_at` like `ModuleRepository.update`.
|
||||
|
||||
Community origin (migration 013) is rewritten ONLY when the new
|
||||
content explicitly carries it — a community Update-in-place
|
||||
install stamps both fields on the entity, while a vanilla Import
|
||||
tab replace leaves them absent and the row keeps its prior
|
||||
community_* values (or NULL for locally-authored rows).
|
||||
"""
|
||||
_require_entity_fields(kind, "replace", content, ("name",))
|
||||
now = now_iso()
|
||||
fp = _module_fp_for_entity({**content, "type": kind})
|
||||
conn.execute(
|
||||
"UPDATE modules SET "
|
||||
"name = ?, description = ?, category_id = ?, tags = ?, "
|
||||
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
|
||||
"version = version + 1, updated_at = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(content.get("payload") or {}),
|
||||
fp, now, mid,
|
||||
),
|
||||
)
|
||||
if "community_post_slug" in content or "community_version_number" in content:
|
||||
# `content_rating` uses COALESCE so missing key preserves the
|
||||
# existing value -- community-update installs that only bump
|
||||
# the version don't accidentally wipe a user-set NSFW flag.
|
||||
conn.execute(
|
||||
"UPDATE modules SET "
|
||||
"name = ?, description = ?, category_id = ?, tags = ?, "
|
||||
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
|
||||
"version = version + 1, updated_at = ?, "
|
||||
"community_post_slug = ?, community_version_number = ?, "
|
||||
"content_rating = COALESCE(?, content_rating) "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(content.get("payload") or {}),
|
||||
fp, now,
|
||||
content.get("community_post_slug"),
|
||||
content.get("community_version_number"),
|
||||
content.get("content_rating"),
|
||||
mid,
|
||||
),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE modules SET "
|
||||
"name = ?, description = ?, category_id = ?, tags = ?, "
|
||||
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
|
||||
"version = version + 1, updated_at = ?, "
|
||||
"content_rating = COALESCE(?, content_rating) "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(content.get("payload") or {}),
|
||||
fp, now,
|
||||
content.get("content_rating"),
|
||||
mid,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _update_bundle(
|
||||
conn: sqlite3.Connection, bid: str, content: dict[str, Any],
|
||||
) -> None:
|
||||
"""UPDATE the bundles row identified by `bid` to mirror `content`."""
|
||||
"""UPDATE the bundles row identified by `bid` to mirror `content`.
|
||||
|
||||
See _update_module for the community_* rewrite semantics.
|
||||
"""
|
||||
_require_entity_fields("bundle", "replace", content, ("name",))
|
||||
children_blob = list(content.get("children") or [])
|
||||
ph = payload_hash({"children": children_blob})
|
||||
now = now_iso()
|
||||
conn.execute(
|
||||
"UPDATE bundles SET "
|
||||
"name = ?, description = ?, color = ?, category_id = ?, "
|
||||
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
|
||||
"version = version + 1, updated_at = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("color"),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(children_blob), ph, now, bid,
|
||||
),
|
||||
)
|
||||
if "community_post_slug" in content or "community_version_number" in content:
|
||||
conn.execute(
|
||||
"UPDATE bundles SET "
|
||||
"name = ?, description = ?, color = ?, category_id = ?, "
|
||||
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
|
||||
"version = version + 1, updated_at = ?, "
|
||||
"community_post_slug = ?, community_version_number = ?, "
|
||||
"content_rating = COALESCE(?, content_rating) "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("color"),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(children_blob), ph, now,
|
||||
content.get("community_post_slug"),
|
||||
content.get("community_version_number"),
|
||||
content.get("content_rating"),
|
||||
bid,
|
||||
),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE bundles SET "
|
||||
"name = ?, description = ?, color = ?, category_id = ?, "
|
||||
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
|
||||
"version = version + 1, updated_at = ?, "
|
||||
"content_rating = COALESCE(?, content_rating) "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("color"),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(children_blob), ph, now,
|
||||
content.get("content_rating"),
|
||||
bid,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _module_exists(conn: sqlite3.Connection, mid: str) -> bool:
|
||||
|
||||
@@ -10,11 +10,26 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from engine.migrations.v0_to_v1 import migrate_v0_to_v1
|
||||
from engine.migrations.v1_to_v2 import migrate_v1_to_v2
|
||||
|
||||
CURRENT_SCHEMA_VERSION = 1
|
||||
CURRENT_SCHEMA_VERSION = 2
|
||||
|
||||
# Highest schema version this runtime can natively READ + WRITE — distinct
|
||||
# from CURRENT_SCHEMA_VERSION (the migration-chain head, which stays 2 because
|
||||
# v2->v3/v3->v4 are no-ops). v3 is the SP2b text-grammar bump (shape-identical
|
||||
# to v2) and v4 is the additive `target_select` constraint reach; both are
|
||||
# handled natively, so a payload at CURRENT < v <= MAX_KNOWN installs AS-IS
|
||||
# with no migration. Mirror of TS `MAX_KNOWN_SCHEMA_VERSION` in
|
||||
# `src/manager/import-export/migrations.ts`.
|
||||
#
|
||||
# MAINTENANCE CONTRACT: bump this whenever the TS `schemaVersionForPayload()`
|
||||
# learns to stamp a new (higher) version — otherwise this commit-side
|
||||
# re-validate will reject the very shapes the runtime just learned to produce.
|
||||
MAX_KNOWN_SCHEMA_VERSION = 4
|
||||
|
||||
_CHAIN = {
|
||||
0: migrate_v0_to_v1,
|
||||
1: migrate_v1_to_v2,
|
||||
}
|
||||
|
||||
|
||||
@@ -32,10 +47,16 @@ def migrate_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
version = payload.get("schema_version")
|
||||
if not isinstance(version, int):
|
||||
return {"ok": False, "reason": "payload missing schema_version field"}
|
||||
if version > CURRENT_SCHEMA_VERSION:
|
||||
# Reject threshold reads MAX_KNOWN (highest natively-supported version),
|
||||
# NOT CURRENT (the migration-chain head). A payload at
|
||||
# CURRENT < v <= MAX_KNOWN is shape-compatible and handled natively, so it
|
||||
# passes through AS-IS — the while-loop below is bound by CURRENT, so there
|
||||
# is nothing to migrate and schema_version is preserved. Only > MAX_KNOWN
|
||||
# rejects. Mirror of TS `migrateImportEnvelope`.
|
||||
if version > MAX_KNOWN_SCHEMA_VERSION:
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": f"future schema version {version} (current: {CURRENT_SCHEMA_VERSION})",
|
||||
"reason": f"future schema version {version} (max known: {MAX_KNOWN_SCHEMA_VERSION})",
|
||||
}
|
||||
current = {
|
||||
"schema_version": version,
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""v1 -> v2 payload migration (SP1 multi-tag + boolean sub-category filter).
|
||||
|
||||
Transforms, all idempotent:
|
||||
* option `sub_category: "x"` -> `sub_categories: ["x"]` (None/absent -> []);
|
||||
* registry + option + tag_group sub-category names slugified to a single
|
||||
safe token (whitespace / boolean-grammar / ref chars -> `_`), reserved
|
||||
words suffixed (`or` -> `or_1`), de-duped — cascaded so an option/group
|
||||
membership tracks its renamed registry entry;
|
||||
* instance `category_filter: [...]` -> a boolean expression string
|
||||
(`" or ".join`) + a separate `exclude_null` flag (the old `"null"`
|
||||
list-entry);
|
||||
* nested `@{uuid#name:a,b,null}` refs -> `@{uuid#name:a or b!null}`
|
||||
(comma list -> `or`, `null` keyword -> trailing `!null` marker).
|
||||
|
||||
Two entry shapes share one core (`_deep_migrate`):
|
||||
* `migrate_v1_to_v2(envelope)` — the import/export multi-row envelope
|
||||
(`wildcards[]`/`bundles[]`/...); registered in the version `_CHAIN`.
|
||||
* `migrate_module_v1_to_v2` / `migrate_bundle_v1_to_v2` — per-row payload
|
||||
migrators for the lazy/bulk DB path (`engine/db/lazy_migrate.py`); the
|
||||
row's `payload` column is the inner module payload (`{options, ...}`,
|
||||
no `type` field — wildcard-ness is detected structurally).
|
||||
|
||||
Mirror: src/manager/import-export/migrations.ts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from engine.syntax.subcat_filter import RESERVED
|
||||
|
||||
# Chars a v2 sub-category name may not contain (mirrors subcat_filter
|
||||
# validate_subcat_name): whitespace + the boolean-grammar + ref delimiters.
|
||||
_BAD = re.compile(r"[\s()!,#:}@{$]+")
|
||||
# Broad ref matcher (v1 OR v2 form) so v1 comma/null lists get rewritten
|
||||
# while v2 refs pass through untouched. Groups: uuid, `#name` seg, `:tail`.
|
||||
_REF_RE = re.compile(r"@\{([0-9a-f]{8})(#[^:}]*)?(:[^}]*)?\}")
|
||||
|
||||
|
||||
def _slug(name: str) -> str:
|
||||
return _BAD.sub("_", name).strip("_") or "_"
|
||||
|
||||
|
||||
def _name_map(registry: list[str]) -> dict[str, str]:
|
||||
"""old sub-category name -> sanitized, reserved-safe, unique name."""
|
||||
out: dict[str, str] = {}
|
||||
used: set[str] = set()
|
||||
for raw in registry:
|
||||
s = _slug(raw)
|
||||
if s.lower() in RESERVED:
|
||||
s = f"{s}_1"
|
||||
base, n = s, 1
|
||||
while s in used:
|
||||
n += 1
|
||||
s = f"{base}_{n}"
|
||||
used.add(s)
|
||||
out[raw] = s
|
||||
return out
|
||||
|
||||
|
||||
def _rewrite_ref(m: re.Match[str]) -> str:
|
||||
uuid = m.group(1)
|
||||
name_seg = m.group(2) or ""
|
||||
tail = m.group(3)
|
||||
if tail is None:
|
||||
return m.group(0) # bare `@{uuid}` / `@{uuid#name}` — nothing to do
|
||||
lst = tail[1:] # strip the leading ':'
|
||||
has_comma = "," in lst
|
||||
parts = [x.strip() for x in lst.split(",")]
|
||||
has_null = "null" in parts
|
||||
if not has_comma and not has_null:
|
||||
# Single tag OR an already-v2 boolean expression — leave as-is so
|
||||
# the rewrite is idempotent and never corrupts a v2 ref.
|
||||
return m.group(0)
|
||||
# Slug each tag so a cross-wildcard ref tracks the target's renamed
|
||||
# registry entry (deterministic — same _slug the target applies).
|
||||
tags = [_slug(p) for p in parts if p and p != "null"]
|
||||
expr = " or ".join(tags)
|
||||
out = "@{" + uuid + name_seg
|
||||
if expr:
|
||||
out += ":" + expr
|
||||
if has_null:
|
||||
out += "!null"
|
||||
return out + "}"
|
||||
|
||||
|
||||
def _rewrite_refs(s: str) -> str:
|
||||
return _REF_RE.sub(_rewrite_ref, s)
|
||||
|
||||
|
||||
def _is_wildcard_payload(p: Any) -> bool:
|
||||
"""A wildcard payload is the only shape carrying a sub-category model:
|
||||
an `options` list plus either a `sub_categories` registry or an option
|
||||
bearing the legacy singular `sub_category` field."""
|
||||
if not isinstance(p, dict) or not isinstance(p.get("options"), list):
|
||||
return False
|
||||
if "sub_categories" in p:
|
||||
return True
|
||||
return any(isinstance(o, dict) and "sub_category" in o for o in p["options"])
|
||||
|
||||
|
||||
def _apply_wildcard_payload(
|
||||
p: dict[str, Any], nmap: dict[str, str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
reg = [s for s in p.get("sub_categories", []) if isinstance(s, str)]
|
||||
if nmap is None:
|
||||
nmap = _name_map(reg)
|
||||
out = {**p, "sub_categories": [nmap[s] for s in reg]}
|
||||
new_opts: list[Any] = []
|
||||
for o in p.get("options", []):
|
||||
if not isinstance(o, dict):
|
||||
new_opts.append(o)
|
||||
continue
|
||||
o = dict(o)
|
||||
if isinstance(o.get("sub_categories"), list): # already-v2 / idempotent
|
||||
o["sub_categories"] = [nmap.get(t, _slug(t)) for t in o["sub_categories"]]
|
||||
else:
|
||||
sc = o.pop("sub_category", None)
|
||||
o["sub_categories"] = (
|
||||
[nmap.get(sc, _slug(sc))] if isinstance(sc, str) and sc else []
|
||||
)
|
||||
if isinstance(o.get("value"), str):
|
||||
o["value"] = _rewrite_refs(o["value"])
|
||||
new_opts.append(o)
|
||||
out["options"] = new_opts
|
||||
if isinstance(p.get("tag_groups"), dict):
|
||||
out["tag_groups"] = {
|
||||
g: [nmap.get(m, _slug(m)) for m in mem if isinstance(m, str)]
|
||||
for g, mem in p["tag_groups"].items()
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _migrate_instance(inst: dict[str, Any], nmap: dict[str, str]) -> dict[str, Any]:
|
||||
cf = inst.get("category_filter")
|
||||
if not isinstance(cf, list):
|
||||
return inst
|
||||
out = dict(inst)
|
||||
has_null = "null" in cf
|
||||
tags = [nmap.get(c, _slug(c)) for c in cf if isinstance(c, str) and c and c != "null"]
|
||||
out["category_filter"] = " or ".join(tags)
|
||||
out["exclude_null"] = bool(out.get("exclude_null")) or has_null
|
||||
return out
|
||||
|
||||
|
||||
def _deep_migrate(obj: Any) -> Any:
|
||||
"""Recursive transform shared by the per-row module/bundle migrators:
|
||||
migrate any wildcard-shaped dict in place, rewrite `@{}` refs in every
|
||||
string, recurse into containers. Idempotent."""
|
||||
if isinstance(obj, dict):
|
||||
if _is_wildcard_payload(obj):
|
||||
return _apply_wildcard_payload(obj)
|
||||
return {k: _deep_migrate(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_deep_migrate(x) for x in obj]
|
||||
if isinstance(obj, str):
|
||||
return _rewrite_refs(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def migrate_module_v1_to_v2(payload: dict[str, Any], ctx: Any = None) -> dict[str, Any]:
|
||||
"""Per-row module-payload migrator for the lazy/bulk DB path."""
|
||||
return _deep_migrate(payload)
|
||||
|
||||
|
||||
def migrate_bundle_v1_to_v2(payload: dict[str, Any], ctx: Any = None) -> dict[str, Any]:
|
||||
"""Per-row bundle-payload migrator — recurses into frozen child snapshots."""
|
||||
return _deep_migrate(payload)
|
||||
|
||||
|
||||
def _migrate_wildcard_entry(w: dict[str, Any]) -> dict[str, Any]:
|
||||
p = w.get("payload")
|
||||
nmap: dict[str, str] = {}
|
||||
out = dict(w)
|
||||
if isinstance(p, dict):
|
||||
reg = [s for s in p.get("sub_categories", []) if isinstance(s, str)]
|
||||
nmap = _name_map(reg)
|
||||
out["payload"] = _apply_wildcard_payload(p, nmap)
|
||||
inst = w.get("instance")
|
||||
if isinstance(inst, dict):
|
||||
out["instance"] = _migrate_instance(inst, nmap)
|
||||
return out
|
||||
|
||||
|
||||
def migrate_v1_to_v2(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Envelope migrator (mirrors the TS chain step). Maps each wildcard
|
||||
entry structurally (+ its instance), deep-migrates the other entity
|
||||
arrays (ref rewrite + any nested wildcard snapshots), bumps to v2."""
|
||||
out = dict(payload)
|
||||
out["wildcards"] = [
|
||||
_migrate_wildcard_entry(w) if isinstance(w, dict) else w
|
||||
for w in payload.get("wildcards", [])
|
||||
]
|
||||
for key in ("bundles", "fixed_values", "combines", "derivations",
|
||||
"constraints", "categories", "templates"):
|
||||
if isinstance(payload.get(key), list):
|
||||
out[key] = [_deep_migrate(e) for e in payload[key]]
|
||||
out["schema_version"] = 2
|
||||
return out
|
||||
+57
-18
@@ -31,15 +31,30 @@ class _RuntimeResolveContext:
|
||||
# bucket is empty/None.
|
||||
_constraints: list[dict[str, Any]] = field(default_factory=list)
|
||||
_picks: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
# First-instance consume bookkeeping (2026-05-24 spec). Threaded
|
||||
# by reference so mutations inside apply_constraints_for_target
|
||||
# propagate back to the pipeline's ctx bucket and to subsequent
|
||||
# target-instance resolves later in the same chain.
|
||||
_consumed: set[str] = field(default_factory=set)
|
||||
# SP3 reach-selector hit counter (per-constraint, keyed by
|
||||
# `__constraint_module_id__`). Threaded by reference so increments
|
||||
# inside apply_constraints_for_target on the nested-ref path share
|
||||
# one counter with the chain-level direct path + the pipeline's ctx
|
||||
# bucket — first/next coverage spans both surfaces.
|
||||
_hits: dict[str, int] = field(default_factory=dict)
|
||||
# SP3 nested-pick carrier identity for the CURRENT resolve frame:
|
||||
# the wildcard whose chosen option value textually contains the
|
||||
# `@{T}` ref being resolved, plus that option's id. MUTABLE +
|
||||
# save/restored by the resolver around each nested-option recursion
|
||||
# (mirroring the rng swap) so `_resolve_ref` can tell
|
||||
# apply_constraints_for_target which (carrier_uid, option_id) is
|
||||
# firing target T — the only thing a `pick` selector's `nested`
|
||||
# occurrence matches on. None outside any carrier option.
|
||||
_carrier_uid: str | None = None
|
||||
_carrier_option_id: str | None = None
|
||||
|
||||
def get_var(self, name: str) -> str | None:
|
||||
# SP2a: return the raw stored value (may be a ListVar from a
|
||||
# multi-select wildcard); the resolver formats it. Typed str | None
|
||||
# for the Protocol — a ListVar flows through at runtime via the
|
||||
# Any-valued vars dict, and the resolver narrows on isinstance.
|
||||
if name in self._vars:
|
||||
return str(self._vars[name])
|
||||
return self._vars[name]
|
||||
return None
|
||||
|
||||
def get_module(self, uuid: str) -> dict[str, Any] | None:
|
||||
@@ -71,13 +86,37 @@ class _RuntimeResolveContext:
|
||||
source wildcard's pick when reweighting the target."""
|
||||
return self._picks
|
||||
|
||||
def get_consumed_constraints(self) -> set[str]:
|
||||
"""``ctx['__wp_consumed_constraints__']`` — set of constraint
|
||||
module ids that have already fired against their first
|
||||
downstream target instance. Threaded by reference so the
|
||||
nested-ref resolver's mutation is observed by the pipeline + by
|
||||
subsequent target-instance resolves later in the chain."""
|
||||
return self._consumed
|
||||
def get_constraint_hits(self) -> dict[str, int]:
|
||||
"""``ctx['__wp_constraint_hits__']`` — per-constraint firing
|
||||
count keyed by module id. Threaded by reference so the
|
||||
nested-ref resolver's increments are observed by the pipeline +
|
||||
by subsequent target-instance resolves later in the chain
|
||||
(first/next coverage spans direct + nested encounters)."""
|
||||
return self._hits
|
||||
|
||||
def set_carrier(self, carrier_uid: str | None, option_id: str | None) -> None:
|
||||
"""Set the CURRENT carrier identity (the wildcard whose chosen
|
||||
option value contains the `@{T}` ref about to resolve, + that
|
||||
option's id). The resolver save/restores this around each nested
|
||||
option recursion (mirroring the rng swap)."""
|
||||
self._carrier_uid = carrier_uid
|
||||
self._carrier_option_id = option_id
|
||||
|
||||
def get_carrier(self) -> tuple[str | None, str | None]:
|
||||
"""Return the current ``(carrier_uid, option_id)`` so the
|
||||
resolver can save it before a recursion and restore it after."""
|
||||
return self._carrier_uid, self._carrier_option_id
|
||||
|
||||
def get_carrier_ctx(self) -> dict[str, Any] | None:
|
||||
"""The current carrier as the dict shape
|
||||
``apply_constraints_for_target`` /
|
||||
``_constraints._occurrence_matches`` expect, or ``None`` when no
|
||||
carrier is set (outside any carrier option — e.g. a top-level
|
||||
ref). A `pick` selector's `nested` entry matches on these two
|
||||
keys; every other selector mode ignores them."""
|
||||
if self._carrier_uid is None and self._carrier_option_id is None:
|
||||
return None
|
||||
return {"carrier_uid": self._carrier_uid, "option_id": self._carrier_option_id}
|
||||
|
||||
|
||||
def build_resolve_ctx(
|
||||
@@ -93,7 +132,7 @@ def build_resolve_ctx(
|
||||
"""
|
||||
constraints = ctx.get("__wp_constraints__")
|
||||
picks = ctx.get("__wp_picks__")
|
||||
consumed = ctx.setdefault("__wp_consumed_constraints__", set())
|
||||
hits = ctx.setdefault("__wp_constraint_hits__", {})
|
||||
return _RuntimeResolveContext( # type: ignore[return-value]
|
||||
rng=ctx["__wp_rng__"],
|
||||
max_ref_depth=int(ctx.get("__wp_max_ref_depth__", 8)),
|
||||
@@ -110,10 +149,10 @@ def build_resolve_ctx(
|
||||
# support) — defaults handle that path.
|
||||
_constraints=constraints if isinstance(constraints, list) else [],
|
||||
_picks=picks if isinstance(picks, dict) else {},
|
||||
# First-instance consume set — passed by reference so the
|
||||
# nested-ref resolver's mutation sticks in ctx for subsequent
|
||||
# target-instance resolves later in the chain.
|
||||
_consumed=consumed if isinstance(consumed, set) else set(),
|
||||
# SP3 hit counter — passed by reference so the nested-ref
|
||||
# resolver's increments stick in ctx + share one counter with
|
||||
# the direct path for first/next coverage.
|
||||
_hits=hits if isinstance(hits, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Pure constraint re-weight math (SP3). No engine/ctx imports — testable bare.
|
||||
|
||||
One multiply operator at three levels: across an option's matching matrix
|
||||
cells (multi-tag), across source picks (multi-pick), and — by the caller —
|
||||
across constraints. `exclude` (factor 0) is the absorbing element.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
EXCLUDE = object() # sentinel: option drops out of the pool
|
||||
|
||||
def _apply_rule(rule: dict[str, Any]):
|
||||
mode = rule.get("mode")
|
||||
if mode == "exclude":
|
||||
return EXCLUDE
|
||||
if mode in ("boost", "reduce"):
|
||||
try:
|
||||
return max(0.0, float(rule.get("factor", 1.0)))
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
return 1.0 # allow / unknown -> no weight change
|
||||
|
||||
def combine_constraint_factor(source_picks, option, matrix, exceptions):
|
||||
matrix = matrix or {}
|
||||
exc_by_pair: dict[tuple[str, str], dict] = {}
|
||||
for e in (exceptions or []):
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
s = e.get("source_value")
|
||||
if s is None:
|
||||
s = e.get("source")
|
||||
t = e.get("target_value")
|
||||
if t is None:
|
||||
t = e.get("target")
|
||||
if isinstance(s, str) and isinstance(t, str):
|
||||
exc_by_pair[(s, t)] = e
|
||||
opt_value = str(option.get("value", ""))
|
||||
opt_tags = option.get("tags") or []
|
||||
factor = 1.0
|
||||
for pick in (source_picks or []):
|
||||
p_value = str(pick.get("value", ""))
|
||||
p_tags = pick.get("tags") or []
|
||||
exc = exc_by_pair.get((p_value, opt_value))
|
||||
if exc is not None:
|
||||
r = _apply_rule(exc)
|
||||
if r is EXCLUDE:
|
||||
return EXCLUDE
|
||||
factor *= r
|
||||
continue
|
||||
for s in p_tags:
|
||||
row = matrix.get(s)
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
for t in opt_tags:
|
||||
rule = row.get(t)
|
||||
if isinstance(rule, dict):
|
||||
r = _apply_rule(rule)
|
||||
if r is EXCLUDE:
|
||||
return EXCLUDE
|
||||
factor *= r
|
||||
return factor
|
||||
+131
-116
@@ -17,89 +17,10 @@ handler) or narrow ResolveContext-style accessors (ref resolver).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from engine.modules.wildcard_handler import _apply_constraint_to_options
|
||||
|
||||
# Mirror of `engine.syntax.tokenize._REF_RE` (kept local to avoid
|
||||
# importing a private symbol across the module boundary). Captures the
|
||||
# 8-hex uuid; the optional `#name` / `:subcat` segments are skipped.
|
||||
_REF_RE = re.compile(r"@\{([0-9a-f]{8})(?:#[^#:}@{]*)?(?::[^}]*)?\}")
|
||||
|
||||
|
||||
def claim_carrier_constraints(
|
||||
options: list[dict[str, Any]],
|
||||
constraints: list[dict[str, Any]] | None,
|
||||
picks: dict[str, dict[str, Any]] | None,
|
||||
consumed: set[str] | None,
|
||||
) -> None:
|
||||
"""First-instance positional-claim failsafe (2026-05-26).
|
||||
|
||||
After a wildcard rolls + resolves its chosen option, any *unconsumed*
|
||||
constraint whose target this wildcard CARRIES — i.e. has an
|
||||
``@{target}`` ref in ANY of its options, not just the chosen one — is
|
||||
consumed-as-skipped, provided the constraint's source has already
|
||||
been picked.
|
||||
|
||||
Why: the pair badge assigns a constraint to the FIRST downstream
|
||||
wildcard that could host its target (direct instance OR nested-ref
|
||||
carrier). Without this failsafe the runtime only consumed a
|
||||
constraint when the target *actually* resolved, so a carrier whose
|
||||
chosen option happened to omit the ref let the constraint "spill"
|
||||
onto a later target instance — contradicting the badge and
|
||||
surprising the user. Claiming here binds the constraint to the
|
||||
carrier positionally: if the carrier's roll skips the ref, the
|
||||
constraint is spent (no spill) and no never_applied warning fires
|
||||
for it (the carrier did roll — it just didn't use the ref).
|
||||
|
||||
No-op cases:
|
||||
- chosen option DID resolve the ref → the nested-resolve path
|
||||
already consumed the constraint; the ``cid in consumed`` guard
|
||||
skips it here.
|
||||
- source not yet picked → don't claim (matches the direct-target
|
||||
apply path, which also defers + warns rather than consuming an
|
||||
un-appliable constraint). Lets it potentially fire once the
|
||||
source is available later in the chain.
|
||||
"""
|
||||
if not constraints or not isinstance(constraints, list):
|
||||
return
|
||||
if consumed is None:
|
||||
return
|
||||
if not isinstance(picks, dict):
|
||||
picks = {}
|
||||
carried: set[str] = set()
|
||||
for opt in options:
|
||||
val = opt.get("value")
|
||||
if isinstance(val, str):
|
||||
for m in _REF_RE.finditer(val):
|
||||
carried.add(m.group(1))
|
||||
if not carried:
|
||||
return
|
||||
# Claim at most ONE constraint per carried target — the first
|
||||
# unconsumed one in registration order. A carrier hosts a single
|
||||
# nested resolution per target, so it consumes a single constraint
|
||||
# for it (matching `apply_constraints_for_target`, which also fires
|
||||
# one per call). Claiming ALL would let a carrier swallow an entire
|
||||
# family of same-target constraints, starving a later direct target
|
||||
# instance that the pair badge assigns to constraint #2.
|
||||
claimed_targets: set[str] = set()
|
||||
for c in constraints:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
tgt = c.get("target_wildcard_id")
|
||||
if tgt not in carried or tgt in claimed_targets:
|
||||
continue
|
||||
cid = c.get("__constraint_module_id__")
|
||||
if cid is None or cid in consumed:
|
||||
continue
|
||||
src_id = c.get("source_wildcard_id")
|
||||
src_pick = picks.get(src_id) if isinstance(src_id, str) else None
|
||||
if not isinstance(src_pick, dict):
|
||||
continue
|
||||
consumed.add(cid)
|
||||
claimed_targets.add(tgt)
|
||||
|
||||
|
||||
def _push_constraint_warning(
|
||||
warnings: list[dict[str, Any]],
|
||||
@@ -126,6 +47,80 @@ def _push_constraint_warning(
|
||||
})
|
||||
|
||||
|
||||
def _occurrence_matches(pick_entries, firing_uid, carrier_ctx) -> bool:
|
||||
"""True when an explicit ``pick`` reach selector covers THIS firing
|
||||
target occurrence.
|
||||
|
||||
Two occurrence kinds:
|
||||
- ``direct`` — a top-level wildcard instance. Matched by its
|
||||
per-instance uid (``firing_uid``). ``carrier_ctx`` is ``None``
|
||||
for these (the wildcard handler fires them directly).
|
||||
- ``nested`` — a ``@{target}`` ref resolved inside a carrier
|
||||
wildcard's chosen option. Matched by (carrier_uid, option_id).
|
||||
``carrier_ctx`` carries those, threaded by the nested-resolve
|
||||
path (``engine.syntax.resolve``). The direct-firing wildcard
|
||||
handler passes ``carrier_ctx=None`` (it fires top-level
|
||||
instances, not nested refs), so the two kinds never cross-match.
|
||||
"""
|
||||
for p in pick_entries:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
if carrier_ctx is None:
|
||||
if p.get("kind") == "direct" and p.get("uid") == firing_uid:
|
||||
return True
|
||||
else:
|
||||
if (
|
||||
p.get("kind") == "nested"
|
||||
and p.get("carrier_uid") == carrier_ctx.get("carrier_uid")
|
||||
and p.get("option_id") == carrier_ctx.get("option_id")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _select_source_pick(
|
||||
picks: dict[str, dict[str, Any]],
|
||||
src_id: Any,
|
||||
constraint: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Resolve the source pick a constraint should re-weight against.
|
||||
|
||||
Selection rule (task_5200c1fc — source-instance binding):
|
||||
1. Look up the top-level entry `picks[src_id]` (today's bucket, the
|
||||
last-writer-wins survivor when two instances share a library uuid).
|
||||
2. If the constraint carries a `bundle_origin` AND that entry has a
|
||||
`by_origin[origin]` view, return that view — the source instance in
|
||||
the SAME bundle copy as this constraint.
|
||||
3. Otherwise return the top-level entry as-is = TODAY'S behavior,
|
||||
exactly. Covers: a no-bundle constraint, a constraint inside a
|
||||
bundle whose source is OUTSIDE the bundle, and every legacy
|
||||
workflow. This exact-fallback is what makes the change safe.
|
||||
|
||||
DOCUMENTED LIMITATION (task_5200c1fc): the same library wildcard added
|
||||
MANUALLY twice OUTSIDE any bundle shares no `bundle_origin`, so both
|
||||
manual instances land in the same top-level bucket and the constraint
|
||||
falls back to last-writer-wins. The constraint payload genuinely does
|
||||
not encode WHICH manual instance the user meant, so the design declines
|
||||
to guess rather than guessing wrong. This is a principled residual, not
|
||||
a gap; an explicit stored `source_uid` binding (Option 2 in the design)
|
||||
can be layered on later with precedence explicit > bundle-scope >
|
||||
fallback, foreclosing nothing.
|
||||
"""
|
||||
if not isinstance(src_id, str):
|
||||
return None
|
||||
top = picks.get(src_id)
|
||||
if not isinstance(top, dict):
|
||||
return None
|
||||
origin = constraint.get("__constraint_bundle_origin__")
|
||||
if origin:
|
||||
by_origin = top.get("by_origin")
|
||||
if isinstance(by_origin, dict):
|
||||
scoped = by_origin.get(origin)
|
||||
if isinstance(scoped, dict):
|
||||
return scoped
|
||||
return top
|
||||
|
||||
|
||||
def apply_constraints_for_target(
|
||||
options: list[dict[str, Any]],
|
||||
target_uuid: str,
|
||||
@@ -133,54 +128,60 @@ def apply_constraints_for_target(
|
||||
picks: dict[str, dict[str, Any]] | None,
|
||||
warnings: list[dict[str, Any]],
|
||||
*,
|
||||
consumed: set[str] | None = None,
|
||||
consumed=None,
|
||||
hits=None,
|
||||
firing_uid=None,
|
||||
carrier_ctx=None,
|
||||
) -> tuple[list[dict[str, Any]], bool]:
|
||||
"""Apply the FIRST unconsumed constraint targeting ``target_uuid``.
|
||||
"""Apply EVERY constraint whose reach selector covers this firing
|
||||
target instance, combined sequentially.
|
||||
|
||||
Returns ``(adjusted_options, any_applied)``. ``any_applied`` is True
|
||||
iff a constraint actually fired — callers use it to decide whether
|
||||
to run the excludes-all check.
|
||||
iff at least one constraint actually fired — callers use it to
|
||||
decide whether to run the excludes-all check.
|
||||
|
||||
**One-shot semantic (2026-05-24 first-instance redesign).** Each
|
||||
constraint module is a single ammo round: it fires on the first
|
||||
downstream target wildcard instance, gets marked consumed via its
|
||||
``__constraint_module_id__`` key, and is skipped on subsequent
|
||||
target instances. A second target instance later in the chain
|
||||
claims the NEXT unconsumed constraint, not the same one again.
|
||||
**Reach selector (SP3).** Each constraint carries
|
||||
``target_select = {mode, count?, picks?}`` (default ``{mode: "all"}``).
|
||||
On every firing target instance the per-constraint hit counter
|
||||
``hits[cid]`` increments; the selector then decides coverage:
|
||||
- ``all`` — covers every instance (re-weights every downstream
|
||||
target occurrence).
|
||||
- ``first`` — covers only the 1st (``n == 1``).
|
||||
- ``next`` — covers the first ``count`` (``n <= count``).
|
||||
- ``pick`` — covers the explicitly-listed occurrences
|
||||
(``_occurrence_matches``).
|
||||
Multiple covering constraints stack: their factors multiply in
|
||||
registration order and ``exclude`` is absorbing. This replaces the
|
||||
pre-SP3 one-shot / consumed-set model.
|
||||
|
||||
Constraints are walked in registration (chain) order so the first
|
||||
unconsumed match wins. After applying, the function **breaks** out
|
||||
of the loop — exactly one constraint application per call.
|
||||
Reach is downstream-relative by construction: a constraint only
|
||||
appears in ``ctx["__wp_constraints__"]`` after its own module ran,
|
||||
so target occurrences upstream of it never see it.
|
||||
|
||||
``consumed`` is the ctx-resident ``__wp_consumed_constraints__``
|
||||
set; the caller is expected to thread it from ctx. ``None`` is
|
||||
accepted only by legacy test paths that pre-date the consume model.
|
||||
``hits`` is the ctx-resident ``__wp_constraint_hits__`` dict; the
|
||||
caller threads it from ctx so first/next counters persist across the
|
||||
run (direct + nested encounters share one counter). ``consumed`` is
|
||||
accepted but ignored — kept so legacy callers don't break.
|
||||
|
||||
Each constraint warns + skips when its source wildcard hasn't been
|
||||
picked yet — caller code is responsible for ordering source
|
||||
wildcards before the target's roll. The warning gives the user a
|
||||
signal in WP_Debug instead of a silent no-op.
|
||||
|
||||
See docs/superpowers/specs/2026-05-24-constraint-first-instance-design.md.
|
||||
"""
|
||||
if not constraints or not isinstance(constraints, list):
|
||||
return options, False
|
||||
if not isinstance(picks, dict):
|
||||
picks = {}
|
||||
if consumed is None:
|
||||
consumed = set()
|
||||
if hits is None:
|
||||
hits = {}
|
||||
|
||||
any_applied = False
|
||||
for c in constraints:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
if c.get("target_wildcard_id") != target_uuid:
|
||||
if not isinstance(c, dict) or c.get("target_wildcard_id") != target_uuid:
|
||||
continue
|
||||
cid = c.get("__constraint_module_id__")
|
||||
if cid is not None and cid in consumed:
|
||||
continue
|
||||
src_id = c.get("source_wildcard_id")
|
||||
src_pick = picks.get(src_id) if isinstance(src_id, str) else None
|
||||
src_pick = _select_source_pick(picks, src_id, c)
|
||||
if not isinstance(src_pick, dict):
|
||||
_push_constraint_warning(
|
||||
warnings,
|
||||
@@ -195,14 +196,33 @@ def apply_constraints_for_target(
|
||||
},
|
||||
)
|
||||
continue
|
||||
adjustment_warnings: list[dict[str, Any]] = []
|
||||
options = _apply_constraint_to_options(
|
||||
options, c, src_pick, adjustment_warnings,
|
||||
)
|
||||
if cid is not None:
|
||||
consumed.add(cid)
|
||||
hits[cid] = hits.get(cid, 0) + 1
|
||||
n = hits.get(cid, 1)
|
||||
sel = c.get("target_select") or {"mode": "all"}
|
||||
mode = sel.get("mode", "all")
|
||||
if mode == "first":
|
||||
covered = n == 1
|
||||
elif mode == "next":
|
||||
try:
|
||||
cnt = int(sel.get("count", 1))
|
||||
except (TypeError, ValueError):
|
||||
cnt = 1
|
||||
covered = n <= cnt
|
||||
elif mode == "pick":
|
||||
covered = _occurrence_matches(
|
||||
sel.get("picks") or [], firing_uid, carrier_ctx
|
||||
)
|
||||
else:
|
||||
covered = True
|
||||
if not covered:
|
||||
continue
|
||||
adjust_warnings: list[dict[str, Any]] = []
|
||||
options = _apply_constraint_to_options(
|
||||
options, c, src_pick, adjust_warnings,
|
||||
)
|
||||
any_applied = True
|
||||
for w in adjustment_warnings:
|
||||
for w in adjust_warnings:
|
||||
_push_constraint_warning(
|
||||
warnings,
|
||||
type_=w.get("type", "unknown_constraint_mode"),
|
||||
@@ -213,11 +233,6 @@ def apply_constraints_for_target(
|
||||
"target_wildcard_id": target_uuid,
|
||||
},
|
||||
)
|
||||
# Key change for first-instance semantic: one constraint per
|
||||
# target-instance call. Subsequent target instances re-enter
|
||||
# this function and pick up the NEXT unconsumed constraint.
|
||||
break
|
||||
|
||||
return options, any_applied
|
||||
|
||||
|
||||
|
||||
@@ -101,7 +101,16 @@ class CombineHandler(ModuleHandler):
|
||||
# - chain seed otherwise → re-rolls each queue
|
||||
# Both feed derive_module_rng(seed, output_var) so locking with
|
||||
# locked_seed = chain_seed reproduces the unlocked roll exactly.
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
if instance.get("seed_scope") == "hold":
|
||||
# Held: reuse the frame-0 resolved value verbatim (from the
|
||||
# pipeline's base pass) so a held combine's alternation stays
|
||||
# frozen across the whole run, nested refs included.
|
||||
_hb = ctx.get("__wp_hold_base_ctx__")
|
||||
if isinstance(_hb, dict) and output_var in _hb:
|
||||
return {output_var: _hb[output_var]}
|
||||
chain_seed = int(ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0)
|
||||
else:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
if isinstance(locked_seed, (int, float)):
|
||||
effective_seed = int(locked_seed)
|
||||
|
||||
@@ -24,6 +24,55 @@ from engine.modules.dispatcher import ModuleHandler
|
||||
|
||||
_VALID_MODES = {"allow", "exclude", "boost", "reduce"}
|
||||
|
||||
# SP3 reach selector — which downstream target instance(s) a constraint
|
||||
# reaches. Shape only here; reach *behaviour* is applied by a later task.
|
||||
_REACH_MODES = {"first", "next", "all", "pick"}
|
||||
|
||||
|
||||
def _validate_target_select(ts):
|
||||
"""Validate the optional ``target_select`` reach selector.
|
||||
|
||||
Shape: ``{mode: "first"|"next"|"all"|"pick", count?, picks?}``. ``None``
|
||||
(absent) is allowed — the resolver defaults it to ``{mode: "all"}``.
|
||||
"""
|
||||
if ts is None:
|
||||
return
|
||||
if not isinstance(ts, dict):
|
||||
raise ValueError("constraint payload.target_select must be an object")
|
||||
mode = ts.get("mode", "all")
|
||||
if mode not in _REACH_MODES:
|
||||
raise ValueError(
|
||||
f"constraint payload.target_select.mode must be one of {sorted(_REACH_MODES)}"
|
||||
)
|
||||
if mode == "next":
|
||||
count = ts.get("count")
|
||||
if not isinstance(count, int) or isinstance(count, bool) or count < 1:
|
||||
raise ValueError(
|
||||
"constraint payload.target_select.count must be an int >= 1 for mode 'next'"
|
||||
)
|
||||
if mode == "pick":
|
||||
picks = ts.get("picks")
|
||||
if not isinstance(picks, list):
|
||||
raise ValueError(
|
||||
"constraint payload.target_select.picks must be a list for mode 'pick'"
|
||||
)
|
||||
for i, p in enumerate(picks):
|
||||
if not isinstance(p, dict) or p.get("kind") not in ("direct", "nested"):
|
||||
raise ValueError(
|
||||
f"constraint payload.target_select.picks[{i}].kind must be 'direct' or 'nested'"
|
||||
)
|
||||
if p["kind"] == "direct" and not isinstance(p.get("uid"), str):
|
||||
raise ValueError(
|
||||
f"constraint payload.target_select.picks[{i}].uid must be a string"
|
||||
)
|
||||
if p["kind"] == "nested" and not (
|
||||
isinstance(p.get("carrier_uid"), str) and isinstance(p.get("option_id"), str)
|
||||
):
|
||||
raise ValueError(
|
||||
f"constraint payload.target_select.picks[{i}] nested "
|
||||
"needs carrier_uid + option_id"
|
||||
)
|
||||
|
||||
|
||||
def _ctx_set_constraint(ctx: Any, meta: dict[str, Any]) -> None:
|
||||
"""Append ``meta`` to ``ctx['__wp_constraints__']`` (best-effort).
|
||||
@@ -185,6 +234,7 @@ class ConstraintHandler(ModuleHandler):
|
||||
raise ValueError(
|
||||
f"constraint payload.exceptions[{i}].factor must not be negative"
|
||||
)
|
||||
_validate_target_select(payload.get("target_select"))
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
@@ -208,6 +258,16 @@ class ConstraintHandler(ModuleHandler):
|
||||
exception_factor_overrides = instance.get("exception_factor_overrides") or {}
|
||||
extra_exceptions = instance.get("extra_exceptions") or []
|
||||
|
||||
# SP3 reach selector — instance override wins over the payload's
|
||||
# value, falling back to the {mode:"all"} default. Shape was
|
||||
# already validated in validate_payload (payload side); the
|
||||
# instance side is validated when the instance is loaded.
|
||||
ts = (
|
||||
instance.get("target_select")
|
||||
or payload.get("target_select")
|
||||
or {"mode": "all"}
|
||||
)
|
||||
|
||||
# ── Matrix: filter then override ─────────────────────────────
|
||||
raw_matrix = payload.get("matrix", {})
|
||||
matrix: dict[str, Any] = {}
|
||||
@@ -353,20 +413,22 @@ class ConstraintHandler(ModuleHandler):
|
||||
exceptions.append(dict(exc))
|
||||
|
||||
# Carry the owning constraint module's id into the registered
|
||||
# bucket entry so `apply_constraints_for_target` can mark it
|
||||
# consumed once its first downstream target instance fires
|
||||
# (one-shot semantic per 2026-05-24 first-instance spec).
|
||||
# bucket entry as `__constraint_module_id__` (the `cid`). Under the
|
||||
# SP3 reach model it identifies the constraint for two things:
|
||||
# (1) the per-constraint hit counter `__wp_constraint_hits__[cid]`
|
||||
# that drives first / next / pick coverage in
|
||||
# `apply_constraints_for_target`, and (2) the dedup key + ref of
|
||||
# the never-applied / partial-reach finalisation warnings.
|
||||
#
|
||||
# Key on the PER-INSTANCE uid (`__wp_current_module_uid__`), not
|
||||
# the library uuid (`__wp_current_module_id__`): two instances of
|
||||
# the same library constraint entry must be INDEPENDENT one-shots
|
||||
# the same library constraint entry must reach INDEPENDENTLY
|
||||
# (CLAUDE.md — "author multiple constraint modules" to affect
|
||||
# multiple target instances). Pre-2026-05-26 both keyed on the
|
||||
# shared library uuid, so claiming/consuming one silently spent
|
||||
# the other — a carrier could swallow the whole family and a
|
||||
# downstream direct target then rolled unconstrained. The pipeline
|
||||
# falls back to the library id when `_uid` is absent (legacy /
|
||||
# hand-built test modules), preserving the old behaviour there.
|
||||
# shared library uuid, so the two instances' counters collided —
|
||||
# one instance's encounters advanced the other's first/next
|
||||
# selector. The pipeline falls back to the library id when `_uid`
|
||||
# is absent (legacy / hand-built test modules).
|
||||
module_id = None
|
||||
try:
|
||||
if hasattr(ctx, "get"):
|
||||
@@ -376,27 +438,33 @@ class ConstraintHandler(ModuleHandler):
|
||||
)
|
||||
except Exception:
|
||||
module_id = None
|
||||
# The per-instance `_uid` (12+ chars) keys the consumed-set so two
|
||||
# canvas instances of the same library constraint stay independent
|
||||
# one-shots. The LIBRARY id (8-hex) is also stashed because the
|
||||
# never-applied warning text needs it to render the chip — the SPA
|
||||
# / Debug viewer's chip resolver looks up library uuids in the
|
||||
# module catalog, and a per-instance `_uid` would never resolve
|
||||
# (silently rendering as raw `@{…}` text in the warning).
|
||||
# The per-instance `_uid` (12+ chars) keys the per-constraint hit
|
||||
# counter so two canvas instances of the same library constraint
|
||||
# reach independently. The LIBRARY id (8-hex) is also stashed
|
||||
# because the never-applied / partial-reach warning text needs it
|
||||
# to render the chip — the SPA / Debug viewer's chip resolver looks
|
||||
# up library uuids in the module catalog, and a per-instance `_uid`
|
||||
# would never resolve (silently rendering as raw `@{…}` text in the
|
||||
# warning).
|
||||
library_id = None
|
||||
library_name = None
|
||||
bundle_origin = None
|
||||
try:
|
||||
if hasattr(ctx, "get"):
|
||||
library_id = ctx.get("__wp_current_module_id__")
|
||||
library_name = ctx.get("__wp_current_module_name__")
|
||||
bundle_origin = ctx.get("__wp_current_module_bundle_origin__")
|
||||
except Exception:
|
||||
library_id = None
|
||||
library_name = None
|
||||
bundle_origin = None
|
||||
meta = {
|
||||
"source_wildcard_id": payload["source_wildcard_id"],
|
||||
"target_wildcard_id": payload["target_wildcard_id"],
|
||||
"matrix": matrix,
|
||||
"exceptions": exceptions,
|
||||
# SP3 reach selector — recorded for the (later) apply pass.
|
||||
"target_select": ts,
|
||||
"__constraint_module_id__": module_id,
|
||||
"__constraint_library_id__": library_id,
|
||||
# Cached display name (e.g. "Starter pairing"). The never-applied
|
||||
@@ -407,6 +475,15 @@ class ConstraintHandler(ModuleHandler):
|
||||
# endpoint would 404 there, leaving the chip stuck on the raw
|
||||
# uuid otherwise.
|
||||
"__constraint_library_name__": library_name,
|
||||
# Per-insertion bundle discriminator (task_5200c1fc). When this
|
||||
# constraint instance lives inside a bundle copy, it shares one
|
||||
# `bundle_origin` with the source wildcard instance in the SAME
|
||||
# copy. `apply_constraints_for_target` uses it to read that
|
||||
# copy's source pick (`by_origin[origin]`) rather than the
|
||||
# last-writer survivor. Falsy (None) for a top-level / manual /
|
||||
# legacy constraint — then apply falls back to today's
|
||||
# last-writer-wins lookup, unchanged.
|
||||
"__constraint_bundle_origin__": bundle_origin,
|
||||
}
|
||||
_ctx_set_constraint(ctx, meta)
|
||||
return {}
|
||||
|
||||
@@ -19,6 +19,7 @@ from engine.modules import build_resolve_ctx
|
||||
from engine.modules._seed import derive_module_rng
|
||||
from engine.modules.dispatcher import ModuleHandler
|
||||
from engine.syntax import resolve_text
|
||||
from engine.syntax.types import deref_var_value, split_var_accessor
|
||||
|
||||
_VALID_OPS = {
|
||||
"equals", "not_equals", "contains", "matches",
|
||||
@@ -33,25 +34,34 @@ _VALID_OPS = {
|
||||
_VALID_MODES = {"replace", "append", "prepend"}
|
||||
|
||||
|
||||
def _ctx_get(ctx: Any, name: str) -> str:
|
||||
def _ctx_get_raw(ctx: Any, name: str) -> Any:
|
||||
"""Read the RAW stored value for `name` (may be a ListVar) through the
|
||||
callable-getter -> dict-lookup indirection. Missing / error -> None."""
|
||||
if ctx is None:
|
||||
return ""
|
||||
return None
|
||||
getter = getattr(ctx, "get", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
value = getter(name, "")
|
||||
return getter(name, None)
|
||||
except TypeError:
|
||||
try:
|
||||
value = getter(name)
|
||||
return getter(name)
|
||||
except Exception:
|
||||
return ""
|
||||
return "" if value is None else str(value)
|
||||
return None
|
||||
try:
|
||||
if name in ctx: # type: ignore[operator]
|
||||
return str(ctx[name]) # type: ignore[index]
|
||||
return ctx[name] # type: ignore[index]
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _ctx_get(ctx: Any, name: str) -> str:
|
||||
"""Read `name` as a string, honoring a `.K` list accessor and folding a
|
||||
ListVar to its joined value (SP2a): `$mood.0` splits to base `mood` +
|
||||
index 0. The fold/accessor contract lives in deref_var_value."""
|
||||
base, index = split_var_accessor(name)
|
||||
return deref_var_value(_ctx_get_raw(ctx, base), index)
|
||||
|
||||
|
||||
def _ctx_has(ctx: Any, name: str) -> bool:
|
||||
@@ -66,6 +76,7 @@ def _ctx_has(ctx: Any, name: str) -> bool:
|
||||
"""
|
||||
if ctx is None:
|
||||
return False
|
||||
name = split_var_accessor(name)[0] # SP2a: presence checks the base var
|
||||
# Engine ctx is a dict in tests + at runtime; check `__contains__`
|
||||
# before falling back to a getter probe so we get the cheap path.
|
||||
try:
|
||||
@@ -91,6 +102,7 @@ def _ctx_has(ctx: Any, name: str) -> bool:
|
||||
def _ctx_set(ctx: Any, name: str, value: str) -> None:
|
||||
if ctx is None:
|
||||
return
|
||||
name = split_var_accessor(name)[0] # SP2a: writing $x.K targets the base var
|
||||
setter = getattr(ctx, "set", None)
|
||||
if callable(setter):
|
||||
setter(name, value)
|
||||
@@ -132,17 +144,48 @@ def _match_condition(condition: dict[str, Any], ctx: Any) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def branch_carrier_key(rule_id: str, branch: int | str) -> str:
|
||||
"""Carrier key for a derivation branch occurrence -- the `option_id` the
|
||||
SP3 nested-occurrence model matches a constraint `pick` against. `branch`
|
||||
is the 0-based branch index (0 = IF) or the literal ``"else"``.
|
||||
|
||||
MUST stay byte-identical to the TS twin ``branchKey`` in
|
||||
``src/extension/constraint-pairs.ts``; both are corpus-locked against
|
||||
``tests/fixtures/constraint-corpus.json`` ``branch_key_cases``.
|
||||
"""
|
||||
return f"{rule_id}:{branch}"
|
||||
|
||||
|
||||
def _apply_action(
|
||||
action: dict[str, Any],
|
||||
ctx: Any,
|
||||
resolve_ctx: Any,
|
||||
carrier_key: str | None = None,
|
||||
) -> tuple[str, str] | None:
|
||||
target = action.get("target_var", "")
|
||||
if not target:
|
||||
return None
|
||||
mode = action.get("mode", "replace")
|
||||
raw_value = str(action.get("value", ""))
|
||||
new_value = resolve_text(raw_value, resolve_ctx)
|
||||
# Stamp this derivation instance as the CARRIER of any `@{}` in the action
|
||||
# value, keyed by the branch key (`${rule_id}:${bi}` / `${rule_id}:else`) —
|
||||
# the `option_id` the SP3 nested-occurrence model matches a `pick` against.
|
||||
# Save/restore around the resolve, mirroring the resolver's own carrier
|
||||
# save/restore for nested recursion.
|
||||
set_carrier = getattr(resolve_ctx, "set_carrier", None)
|
||||
get_carrier = getattr(resolve_ctx, "get_carrier", None)
|
||||
if carrier_key is not None and callable(set_carrier) and callable(get_carrier):
|
||||
module_uid = None
|
||||
if isinstance(ctx, dict):
|
||||
module_uid = ctx.get("__wp_current_module_uid__") or ctx.get("__wp_current_module_id__")
|
||||
prev = get_carrier()
|
||||
set_carrier(module_uid, carrier_key)
|
||||
try:
|
||||
new_value = resolve_text(raw_value, resolve_ctx)
|
||||
finally:
|
||||
set_carrier(*prev)
|
||||
else:
|
||||
new_value = resolve_text(raw_value, resolve_ctx)
|
||||
if mode == "replace":
|
||||
result = new_value
|
||||
elif mode == "append":
|
||||
@@ -252,8 +295,30 @@ class DerivationHandler(ModuleHandler):
|
||||
# action.value across runs; without lock the chain seed
|
||||
# propagates per-queue. Falls back to the host ctx's RNG when
|
||||
# the ctx wasn't built with `__wp_rng__` (legacy callers).
|
||||
# seed_scope == "hold" freezes the DICE per branch, NOT which branch
|
||||
# fires. The conditions still pick a branch live each iteration, so a
|
||||
# varying input can switch IF→ELIF and we use THAT branch — but every
|
||||
# branch's inline {a|b} / @{} roll is frozen to its frame-0 value via
|
||||
# the constant hold seed, keyed PER BRANCH (see _branch_resolve_ctx).
|
||||
# So each branch's value is identical every iteration it fires and
|
||||
# equals the value it WOULD have had at frame 0, independent of which
|
||||
# other branches fired. (Contrast wildcard/combine/fixed_values hold,
|
||||
# which freeze the whole resolved VALUE because a constraint can
|
||||
# reshape their pool; a derivation branch's pool is fixed, so a frozen
|
||||
# seed alone is enough — and freezing the branch SELECTION would be
|
||||
# wrong: the user wants the chosen branch to follow the inputs.)
|
||||
_hold = False
|
||||
effective_seed = 0
|
||||
module_id = "derivation"
|
||||
ctx_local: dict[str, Any] = {}
|
||||
if isinstance(ctx, dict) and "__wp_rng__" in ctx:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
_hold = instance.get("seed_scope") == "hold"
|
||||
if _hold:
|
||||
chain_seed = int(
|
||||
ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0
|
||||
)
|
||||
else:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
effective_seed = (
|
||||
int(locked_seed) if isinstance(locked_seed, (int, float))
|
||||
@@ -266,6 +331,19 @@ class DerivationHandler(ModuleHandler):
|
||||
else:
|
||||
resolve_ctx = build_resolve_ctx(ctx, surface="derivation")
|
||||
|
||||
def _branch_resolve_ctx(branch_key: str) -> Any:
|
||||
# Non-hold (or no rng): the shared rng stream — existing
|
||||
# behaviour, branch values draw in fire order. Hold: an
|
||||
# INDEPENDENT rng per branch (keyed by rule:branch) so its value
|
||||
# is frozen to its frame-0 roll regardless of which other
|
||||
# branches drew before it in the shared stream.
|
||||
if not _hold:
|
||||
return resolve_ctx
|
||||
rng_b = derive_module_rng(effective_seed, f"{module_id}::{branch_key}")
|
||||
return build_resolve_ctx(
|
||||
{**ctx_local, "__wp_rng__": rng_b}, surface="derivation"
|
||||
)
|
||||
|
||||
# Tier-D instance overrides (2026-05-10 cycle).
|
||||
disabled_rule_ids = set(instance.get("disabled_rule_ids") or [])
|
||||
disabled_branch_keys = set(instance.get("disabled_branch_keys") or [])
|
||||
@@ -296,7 +374,7 @@ class DerivationHandler(ModuleHandler):
|
||||
# Branch-level disable — `r1:1` etc. IF (bi=0) ignored
|
||||
# even when listed because disabling IF == disabling rule
|
||||
# (the per-rule toggle handles that case cleanly).
|
||||
if bi != 0 and f"{rule_id}:{bi}" in disabled_branch_keys:
|
||||
if bi != 0 and branch_carrier_key(rule_id, bi) in disabled_branch_keys:
|
||||
continue
|
||||
|
||||
# Condition-value override per branch index.
|
||||
@@ -320,7 +398,11 @@ class DerivationHandler(ModuleHandler):
|
||||
if isinstance(action_override, str):
|
||||
action = {**action, "value": action_override}
|
||||
|
||||
pair = _apply_action(action, ctx, resolve_ctx)
|
||||
pair = _apply_action(
|
||||
action, ctx,
|
||||
_branch_resolve_ctx(branch_carrier_key(rule_id, bi)),
|
||||
carrier_key=branch_carrier_key(rule_id, bi),
|
||||
)
|
||||
if pair is not None:
|
||||
out[pair[0]] = pair[1]
|
||||
applied = True
|
||||
@@ -328,7 +410,7 @@ class DerivationHandler(ModuleHandler):
|
||||
|
||||
if not applied:
|
||||
# ELSE skip when listed in disabled_branch_keys.
|
||||
if f"{rule_id}:else" in disabled_branch_keys:
|
||||
if branch_carrier_key(rule_id, "else") in disabled_branch_keys:
|
||||
continue
|
||||
else_clause = rule.get("else")
|
||||
if isinstance(else_clause, dict):
|
||||
@@ -341,7 +423,11 @@ class DerivationHandler(ModuleHandler):
|
||||
)
|
||||
if isinstance(action_override, str):
|
||||
action = {**action, "value": action_override}
|
||||
pair = _apply_action(action, ctx, resolve_ctx)
|
||||
pair = _apply_action(
|
||||
action, ctx,
|
||||
_branch_resolve_ctx(branch_carrier_key(rule_id, "else")),
|
||||
carrier_key=branch_carrier_key(rule_id, "else"),
|
||||
)
|
||||
if pair is not None:
|
||||
out[pair[0]] = pair[1]
|
||||
return out
|
||||
|
||||
@@ -150,7 +150,20 @@ class FixedValuesHandler(ModuleHandler):
|
||||
return out
|
||||
|
||||
# Effective seed selection (mirrors WildcardHandler + CombineHandler):
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
if instance.get("seed_scope") == "hold":
|
||||
# Held: reuse each name's frame-0 resolved value verbatim (from the
|
||||
# pipeline's base pass) so a held fixed-values alternation stays
|
||||
# frozen across the whole run.
|
||||
_hb = ctx.get("__wp_hold_base_ctx__")
|
||||
if isinstance(_hb, dict):
|
||||
_names = [n for n in ((v.get("name") or "").strip() for v in values) if n]
|
||||
if _names and all(n in _hb for n in _names):
|
||||
return {n: _hb[n] for n in _names}
|
||||
chain_seed = int(
|
||||
ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0
|
||||
)
|
||||
else:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
if isinstance(locked_seed, (int, float)):
|
||||
effective_seed = int(locked_seed)
|
||||
|
||||
@@ -70,6 +70,15 @@ def _fresh_instance() -> dict[str, Any]:
|
||||
"enabled_options": None,
|
||||
"category_filter": None,
|
||||
"option_weights": None,
|
||||
# SP2a multi-select + the null toggle — part of the pool-shaping
|
||||
# override set, so a frozen snapshot carries them too (all None =
|
||||
# "no override"). Keeps this dict honest with the "every field" claim
|
||||
# above + the instance schema (engine/schemas/instance_schemas.py).
|
||||
"exclude_null": None,
|
||||
"pick_min": None,
|
||||
"pick_max": None,
|
||||
"pick_separator": None,
|
||||
"pick_independent": None, # SP2c: multi-pick with replacement when true
|
||||
# Pick mode — `None`/missing → `random` (legacy behavior:
|
||||
# weighted RNG over enabled options). `pinned` short-circuits
|
||||
# the RNG and always picks `pinned_option_id`. `subcategory`
|
||||
|
||||
+438
-183
@@ -24,25 +24,40 @@ from engine.modules import build_resolve_ctx
|
||||
from engine.modules._seed import derive_module_rng as _derive_module_rng
|
||||
from engine.modules.dispatcher import ModuleHandler
|
||||
from engine.syntax import resolve_text
|
||||
from engine.syntax.resolve import pick_k_unique
|
||||
from engine.syntax.subcat_filter import (
|
||||
matches as _subcat_matches,
|
||||
)
|
||||
from engine.syntax.subcat_filter import (
|
||||
parse as _parse_subcat,
|
||||
)
|
||||
from engine.syntax.subcat_filter import (
|
||||
validate_subcat_name,
|
||||
)
|
||||
from engine.syntax.types import ListVar
|
||||
|
||||
_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
||||
_MAX_IDENT_LEN = 64
|
||||
|
||||
# Characters reserved by the nested-ref grammar `@{uuid[#name][:subcat,subcat...]}`.
|
||||
# Forbidden in:
|
||||
# - wildcard `meta.name` / library `name` (the `#name` segment)
|
||||
# - wildcard `payload.sub_categories[*]` (the `:subcat` segment)
|
||||
# Comma is in here because subcat lists are comma-separated; the other
|
||||
# chars are structural delimiters of the ref grammar. Mirror in
|
||||
# `src/manager/utils/validateName.ts` so the editor + API agree.
|
||||
_REF_FORBIDDEN_CHARS = frozenset("{}:#@,")
|
||||
|
||||
# `_derive_module_rng` lifted to engine/modules/_seed.py so combine +
|
||||
# fixed_values handlers share the same helper (Phase: combine v2 +
|
||||
# syntax parity cycle). The private alias preserves the existing call
|
||||
# site at line ~376 unchanged.
|
||||
|
||||
|
||||
def _coerce_pick_int(value: Any, default: int) -> int:
|
||||
"""SP2a: coerce a pick-count field (`pick_min`/`pick_max`) to int, falling
|
||||
back to `default` on junk. The engine is reachable by hand-built and legacy
|
||||
instances, so a non-numeric value must degrade to single-pick rather than
|
||||
raise out of resolve() — mirrors the locked_seed coercion guard."""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _pick_weighted(options: list[dict[str, Any]], rng) -> dict[str, Any] | None:
|
||||
"""Pick one option weighted by `weight`. Uses rng (no module-global random)."""
|
||||
if not options:
|
||||
@@ -66,121 +81,289 @@ def _apply_constraint_to_options(
|
||||
adjustment_warnings: list[dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Adjust option weights according to a single constraint, given the
|
||||
already-picked source option.
|
||||
already-picked source option(s).
|
||||
|
||||
Two layers, in order:
|
||||
1. Matrix lookup keyed by `(source.sub_category, target.sub_category)`.
|
||||
Bulk rule expressing "long hair allows positive moods" etc.
|
||||
2. Exception lookup keyed by the literal `(source.value, target.value)`
|
||||
pair. Overrides the matrix cell when present — narrower rule wins.
|
||||
SP3: weight adjustment is delegated to
|
||||
:func:`engine.modules._constraint_math.combine_constraint_factor`, which
|
||||
multiplies one factor per matching matrix cell (an option on N source-tag
|
||||
rows multiplies N times — true multi-tag) across every source pick (true
|
||||
multi-pick). The exception layer (literal `(source_value, target_value)`
|
||||
pair) still overrides the matrix for that pick, and `exclude` is absorbing
|
||||
(factor 0 → weight 0, option drops out).
|
||||
|
||||
Modes:
|
||||
- ``allow`` → no change to weight (factor honoured but typically 1).
|
||||
- ``exclude`` → weight set to 0 (option drops out of the pool).
|
||||
- ``boost`` → multiply weight by factor (expect factor > 1).
|
||||
- ``reduce`` → multiply weight by factor (expect factor < 1).
|
||||
`source_pick["picks"]` is the per-pick list (`[{value, tags}]`) recorded by
|
||||
`_record_pick` / `_record_pick_multi`. Legacy callers that hand-build a
|
||||
source dict without it fall back to a single synthetic pick from the
|
||||
top-level `value` + `sub_categories`.
|
||||
|
||||
Options not covered by either matrix or exception keep their
|
||||
original weight. Returns shallow-copied list — the input options
|
||||
array is never mutated, so the underlying snapshot stays clean
|
||||
across runs.
|
||||
Returns a shallow-copied list — the input options array is never mutated,
|
||||
so the underlying snapshot stays clean across runs.
|
||||
|
||||
`adjustment_warnings` (optional) collects editor-footgun warnings the
|
||||
caller forwards to the chain warning list:
|
||||
- ``constraint_factor_ignored_on_allow`` — a `mode: allow` cell carries a
|
||||
non-1 factor (user expects weight×factor, engine ignores it).
|
||||
- ``unknown_constraint_mode`` — a typo'd mode (`exlcude`/`alllow`) the
|
||||
combine fn treats as a no-op.
|
||||
These are diagnostic only; they never change the computed weight.
|
||||
"""
|
||||
from engine.modules._constraint_math import EXCLUDE, combine_constraint_factor
|
||||
|
||||
matrix = constraint.get("matrix") or {}
|
||||
exceptions = constraint.get("exceptions") or []
|
||||
src_value = str(source_pick.get("value", ""))
|
||||
src_sub = source_pick.get("sub_category")
|
||||
|
||||
# Index exceptions by (source_value, target_value) for O(1) lookup.
|
||||
# Empty string is a valid key — it's the null-option marker (a
|
||||
# wildcard option with `is_null: True` has `value: ""`). So
|
||||
# `{source: "rain", target: ""}` excludes the null option when the
|
||||
# source rolls "rain". The validator allows empty strings on these
|
||||
# fields for the same reason; only fully-missing keys (None on both
|
||||
# legacy + tier-2 names) are rejected upstream.
|
||||
exc_by_pair: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for exc in exceptions:
|
||||
if not isinstance(exc, dict):
|
||||
continue
|
||||
s = exc.get("source")
|
||||
t = exc.get("target")
|
||||
if isinstance(s, str) and isinstance(t, str):
|
||||
exc_by_pair[(s, t)] = exc
|
||||
# Per-pick source list (SP3). Fall back to a single synthetic pick for
|
||||
# legacy callers that pass only the top-level value + sub_categories.
|
||||
picks = source_pick.get("picks")
|
||||
if not isinstance(picks, list):
|
||||
picks = [{
|
||||
"value": source_pick.get("value", ""),
|
||||
"tags": list(source_pick.get("sub_categories") or []),
|
||||
}]
|
||||
|
||||
matrix_row = matrix.get(src_sub) if src_sub else None
|
||||
# Pre-scan for diagnostic warnings only. The combine fn intentionally
|
||||
# treats `allow` and unknown modes as factor 1.0 (no weight change), so it
|
||||
# can't surface them — mirror its (pick × option) rule selection here,
|
||||
# exception-pair winning over matrix cell, and warn where the selected rule
|
||||
# is an ignored-factor `allow` or an unrecognised mode.
|
||||
if adjustment_warnings is not None:
|
||||
_scan_constraint_warnings(
|
||||
picks, options, matrix, exceptions, adjustment_warnings,
|
||||
)
|
||||
|
||||
adjusted: list[dict[str, Any]] = []
|
||||
for opt in options:
|
||||
opt_value = str(opt.get("value", ""))
|
||||
opt_sub = opt.get("sub_category")
|
||||
option = {"value": opt.get("value", ""), "tags": opt.get("sub_categories") or []}
|
||||
f = combine_constraint_factor(picks, option, matrix, exceptions)
|
||||
weight = float(opt.get("weight", 1))
|
||||
|
||||
# Exception layer (specific value pair) wins over matrix layer.
|
||||
rule = exc_by_pair.get((src_value, opt_value))
|
||||
if rule is None and isinstance(matrix_row, dict) and opt_sub is not None:
|
||||
rule = matrix_row.get(opt_sub)
|
||||
|
||||
if isinstance(rule, dict):
|
||||
mode = rule.get("mode")
|
||||
try:
|
||||
factor = float(rule.get("factor", 1.0))
|
||||
except (TypeError, ValueError):
|
||||
factor = 1.0
|
||||
if mode == "exclude":
|
||||
weight = 0.0
|
||||
elif mode == "boost" or mode == "reduce":
|
||||
weight = max(0.0, weight * factor)
|
||||
elif mode == "allow":
|
||||
# `allow` is a no-op weight-wise; surface a warning when
|
||||
# the SPA stored a non-1 factor on it because that's
|
||||
# almost certainly a user expecting weight*factor and
|
||||
# silently getting full weight.
|
||||
if factor != 1.0:
|
||||
out_warns = adjustment_warnings if adjustment_warnings is not None else None
|
||||
if out_warns is not None:
|
||||
out_warns.append({
|
||||
"type": "constraint_factor_ignored_on_allow",
|
||||
"factor": factor,
|
||||
"src_value": src_value,
|
||||
"opt_value": opt_value,
|
||||
})
|
||||
else:
|
||||
# Unknown mode — typo like "exlcude" / "alllow". Silent
|
||||
# no-op was the worst-of-both: weight unchanged + zero
|
||||
# signal that the rule was malformed. Bubble it up so
|
||||
# the caller can emit a `unknown_constraint_mode` warning.
|
||||
out_warns = adjustment_warnings if adjustment_warnings is not None else None
|
||||
if out_warns is not None:
|
||||
out_warns.append({
|
||||
"type": "unknown_constraint_mode",
|
||||
"mode": mode,
|
||||
"src_value": src_value,
|
||||
"opt_value": opt_value,
|
||||
})
|
||||
|
||||
weight = 0.0 if f is EXCLUDE else max(0.0, weight * float(f))
|
||||
adjusted.append({**opt, "weight": weight})
|
||||
return adjusted
|
||||
|
||||
|
||||
_KNOWN_CONSTRAINT_MODES = frozenset({"allow", "exclude", "boost", "reduce"})
|
||||
|
||||
|
||||
def _exc_pair_index(exceptions: list[Any]) -> dict[tuple[str, str], dict[str, Any]]:
|
||||
"""Index exceptions by (source_value, target_value), mirroring the
|
||||
combine fn's key fallback (tier-2 `source_value`/`target_value` names,
|
||||
then legacy `source`/`target`). Empty string is a valid key — it's the
|
||||
null-option marker (`is_null` options carry `value: ""`)."""
|
||||
out: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for exc in exceptions or []:
|
||||
if not isinstance(exc, dict):
|
||||
continue
|
||||
s = exc.get("source_value")
|
||||
if s is None:
|
||||
s = exc.get("source")
|
||||
t = exc.get("target_value")
|
||||
if t is None:
|
||||
t = exc.get("target")
|
||||
if isinstance(s, str) and isinstance(t, str):
|
||||
out[(s, t)] = exc
|
||||
return out
|
||||
|
||||
|
||||
def _scan_constraint_warnings(
|
||||
picks: list[dict[str, Any]],
|
||||
options: list[dict[str, Any]],
|
||||
matrix: dict[str, Any],
|
||||
exceptions: list[Any],
|
||||
adjustment_warnings: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Append `constraint_factor_ignored_on_allow` / `unknown_constraint_mode`
|
||||
diagnostics for the rules that fire (per pick × option). Selection order
|
||||
matches `combine_constraint_factor`: an exception on the literal value pair
|
||||
wins over a matrix cell, and only the matched rule is inspected."""
|
||||
exc_by_pair = _exc_pair_index(exceptions)
|
||||
for pick in picks:
|
||||
p_value = str(pick.get("value", ""))
|
||||
p_tags = pick.get("tags") or []
|
||||
for opt in options:
|
||||
opt_value = str(opt.get("value", ""))
|
||||
opt_tags = opt.get("sub_categories") or []
|
||||
|
||||
exc = exc_by_pair.get((p_value, opt_value))
|
||||
if exc is not None:
|
||||
_warn_for_rule(exc, p_value, opt_value, adjustment_warnings)
|
||||
continue
|
||||
|
||||
for s in p_tags:
|
||||
row = matrix.get(s)
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
for t in opt_tags:
|
||||
rule = row.get(t)
|
||||
if isinstance(rule, dict):
|
||||
_warn_for_rule(rule, p_value, opt_value, adjustment_warnings)
|
||||
|
||||
|
||||
def _warn_for_rule(
|
||||
rule: dict[str, Any],
|
||||
src_value: str,
|
||||
opt_value: str,
|
||||
adjustment_warnings: list[dict[str, Any]],
|
||||
) -> None:
|
||||
mode = rule.get("mode")
|
||||
if mode == "allow":
|
||||
# `allow` is a weight no-op; a non-1 factor is almost certainly a
|
||||
# user expecting weight×factor and silently getting full weight.
|
||||
try:
|
||||
factor = float(rule.get("factor", 1.0))
|
||||
except (TypeError, ValueError):
|
||||
factor = 1.0
|
||||
if factor != 1.0:
|
||||
adjustment_warnings.append({
|
||||
"type": "constraint_factor_ignored_on_allow",
|
||||
"factor": factor,
|
||||
"src_value": src_value,
|
||||
"opt_value": opt_value,
|
||||
})
|
||||
elif mode not in _KNOWN_CONSTRAINT_MODES:
|
||||
# Typo like "exlcude" / "alllow". The combine fn no-ops it; bubble up
|
||||
# so the caller can surface `unknown_constraint_mode`.
|
||||
adjustment_warnings.append({
|
||||
"type": "unknown_constraint_mode",
|
||||
"mode": mode,
|
||||
"src_value": src_value,
|
||||
"opt_value": opt_value,
|
||||
})
|
||||
|
||||
|
||||
def _file_pick_by_origin(
|
||||
ctx: Any,
|
||||
entry: dict[str, Any],
|
||||
bucket: dict[str, Any],
|
||||
module_id: Any,
|
||||
) -> None:
|
||||
"""Attach `entry` under `bucket[module_id]["by_origin"][origin]` when the
|
||||
active module carries a `bundle_origin` (stamped by pipeline.py). This is
|
||||
the per-instance source-pick view a constraint in the same bundle copy
|
||||
reads (task_5200c1fc); the top-level `bucket[module_id]` stays the
|
||||
last-writer-wins fallback. No-op when no origin is stamped, so a
|
||||
top-level / manual / legacy module's pick entry is byte-for-byte today's
|
||||
shape. The `by_origin` map is carried FORWARD across re-records of the
|
||||
same module_id so two origins writing the same library uuid each keep
|
||||
their own view (the 2nd record must not drop the 1st origin's bucket).
|
||||
"""
|
||||
origin = ctx.get("__wp_current_module_bundle_origin__") if ctx is not None else None
|
||||
if not origin:
|
||||
return
|
||||
prev = bucket.get(module_id)
|
||||
by_origin = {}
|
||||
if isinstance(prev, dict) and isinstance(prev.get("by_origin"), dict):
|
||||
by_origin = dict(prev["by_origin"])
|
||||
by_origin[origin] = {k: v for k, v in entry.items() if k != "by_origin"}
|
||||
entry["by_origin"] = by_origin
|
||||
|
||||
|
||||
def _record_pick(ctx: Any, chosen: dict[str, Any]) -> None:
|
||||
"""Stash the picked option dict in `ctx["__wp_picks__"][module_id]` so a
|
||||
downstream constraint-aware wildcard can look up its source's value +
|
||||
sub_category. Keyed by the active module id (set by pipeline.py).
|
||||
sub_categories. Keyed by the active module id (set by pipeline.py).
|
||||
"""
|
||||
# KNOWN COLLISION (task_5200c1fc): module_id is the LIBRARY uuid
|
||||
# (pipeline.py stamps __wp_current_module_id__ = library id). Two
|
||||
# instances of one library wildcard (bundle inserted twice, or the same
|
||||
# wildcard added twice) share this bucket key — the 2nd pick clobbers the
|
||||
# 1st, and every constraint sourcing that uuid reads the survivor. Fix =
|
||||
# key on the per-instance _uid + give constraints a source-INSTANCE bind.
|
||||
# Do NOT "fix" by regenerating ids on insert: that severs drift/refresh-
|
||||
# by-id lineage (see the remapBundleUuids dead-code note + task_5200c1fc).
|
||||
module_id = ctx.get("__wp_current_module_id__") if ctx is not None else None
|
||||
if not module_id:
|
||||
return
|
||||
bucket = ctx.setdefault("__wp_picks__", {})
|
||||
if isinstance(bucket, dict):
|
||||
bucket[module_id] = {
|
||||
entry = {
|
||||
"value": chosen.get("value"),
|
||||
"sub_category": chosen.get("sub_category"),
|
||||
"sub_categories": chosen.get("sub_categories", []),
|
||||
"id": chosen.get("id"),
|
||||
# SP3: per-pick list the combine fn reads. A single pick is a
|
||||
# one-element list so the multi-tag/multi-pick applier has a
|
||||
# uniform shape regardless of single- vs multi-select source.
|
||||
"picks": [{
|
||||
"value": chosen.get("value", ""),
|
||||
"tags": list(chosen.get("sub_categories") or []),
|
||||
}],
|
||||
}
|
||||
# Additive per-instance view (task_5200c1fc). When this wildcard
|
||||
# carries a `bundle_origin`, ALSO file the pick under
|
||||
# `by_origin[origin]` so a constraint in the SAME bundle copy can
|
||||
# read its own source instance instead of the last-writer survivor
|
||||
# at the top-level key. The top-level entry above is left as the
|
||||
# fallback bucket (unchanged shape) — anything reading it is
|
||||
# unaffected. Omitted entirely when no origin is stamped.
|
||||
_file_pick_by_origin(ctx, entry, bucket, module_id)
|
||||
bucket[module_id] = entry
|
||||
|
||||
|
||||
def _record_pick_multi(ctx: Any, chosen_list: list[dict[str, Any]], sep: str) -> None:
|
||||
"""Multi-select counterpart to `_record_pick` (SP2a). Stash the joined
|
||||
value + the individual picked values + the union of their sub-categories,
|
||||
so the debug Picks view can render the list. Single-pick records (via
|
||||
`_record_pick`) keep their original shape."""
|
||||
# Same library-uuid bucket collision as `_record_pick` (see task_5200c1fc).
|
||||
module_id = ctx.get("__wp_current_module_id__") if ctx is not None else None
|
||||
if not module_id:
|
||||
return
|
||||
bucket = ctx.setdefault("__wp_picks__", {})
|
||||
if not isinstance(bucket, dict):
|
||||
return
|
||||
values = [str(c.get("value", "")) for c in chosen_list]
|
||||
union: list[str] = []
|
||||
for c in chosen_list:
|
||||
for t in (c.get("sub_categories") or []):
|
||||
if t not in union:
|
||||
union.append(t)
|
||||
# SP3: per-pick list (value + that pick's own tags, NOT the union) so the
|
||||
# combine fn can apply the matrix per pick. The union above stays for the
|
||||
# debug Picks view; `picks` is what constraint application reads.
|
||||
per_pick = [
|
||||
{"value": str(c.get("value", "")), "tags": list(c.get("sub_categories") or [])}
|
||||
for c in chosen_list
|
||||
]
|
||||
entry = {
|
||||
"value": sep.join(values),
|
||||
"values": values,
|
||||
"sub_categories": union,
|
||||
"id": chosen_list[0].get("id") if chosen_list else None,
|
||||
"picks": per_pick,
|
||||
}
|
||||
# Same per-instance view as `_record_pick` (task_5200c1fc).
|
||||
_file_pick_by_origin(ctx, entry, bucket, module_id)
|
||||
bucket[module_id] = entry
|
||||
|
||||
|
||||
class WildcardHandler(ModuleHandler):
|
||||
type_id = "wildcard"
|
||||
|
||||
@staticmethod
|
||||
def _apply_pool_filter(
|
||||
options: list[dict[str, Any]],
|
||||
expr: str,
|
||||
*,
|
||||
exclude_null: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Narrow an option pool by a boolean sub-category expression.
|
||||
|
||||
`expr` is parsed via the shared subcat_filter matcher and tested
|
||||
against each non-null option's tag set (`sub_categories`). Empty
|
||||
/ whitespace `expr` ⇒ no tag filtering. The null option
|
||||
(`is_null`) is governed solely by `exclude_null`; the expression
|
||||
never applies to it (spec §3.4).
|
||||
"""
|
||||
ast = _parse_subcat(expr) if isinstance(expr, str) else None
|
||||
out: list[dict[str, Any]] = []
|
||||
for o in options:
|
||||
if o.get("is_null"):
|
||||
if not exclude_null:
|
||||
out.append(o)
|
||||
continue
|
||||
if _subcat_matches(ast, set(o.get("sub_categories") or [])):
|
||||
out.append(o)
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def validate_payload(cls, payload: dict[str, Any]) -> None:
|
||||
if not isinstance(payload, dict):
|
||||
@@ -188,6 +371,56 @@ class WildcardHandler(ModuleHandler):
|
||||
options = payload.get("options")
|
||||
if not isinstance(options, list):
|
||||
raise ValueError("wildcard payload.options must be a list")
|
||||
|
||||
# Sub-category registry — the allowed tag names. Validated first
|
||||
# so each option's membership + tag_groups can be checked against
|
||||
# it. `validate_subcat_name` enforces the §3.6 name rules (single
|
||||
# token; no whitespace / boolean-grammar / ref chars; not a
|
||||
# reserved word incl. `null`).
|
||||
registry_list = payload.get("sub_categories", [])
|
||||
if not isinstance(registry_list, list):
|
||||
raise ValueError("wildcard payload.sub_categories must be a list")
|
||||
registry: set[str] = set()
|
||||
for i, sc in enumerate(registry_list):
|
||||
if not isinstance(sc, str):
|
||||
raise ValueError(
|
||||
f"wildcard payload.sub_categories[{i}] must be a string"
|
||||
)
|
||||
name_err = validate_subcat_name(sc)
|
||||
if name_err:
|
||||
raise ValueError(
|
||||
f"wildcard payload.sub_categories[{i}]: {name_err}"
|
||||
)
|
||||
registry.add(sc)
|
||||
|
||||
# Optional UI-only grouping of registry tags into named axes. The
|
||||
# engine ignores it at resolve time; validate the shape, that
|
||||
# every member is a registry tag, and that a tag lives in at most
|
||||
# one group.
|
||||
tag_groups = payload.get("tag_groups")
|
||||
if tag_groups is not None:
|
||||
if not isinstance(tag_groups, dict):
|
||||
raise ValueError("wildcard payload.tag_groups must be an object")
|
||||
seen_grouped: set[str] = set()
|
||||
for gname, members in tag_groups.items():
|
||||
if not isinstance(gname, str) or not isinstance(members, list):
|
||||
raise ValueError(
|
||||
"wildcard payload.tag_groups entries must be "
|
||||
"name -> string[]"
|
||||
)
|
||||
for mem in members:
|
||||
if mem not in registry:
|
||||
raise ValueError(
|
||||
f"wildcard payload.tag_groups[{gname!r}]: {mem!r} "
|
||||
f"not in sub_categories registry"
|
||||
)
|
||||
if mem in seen_grouped:
|
||||
raise ValueError(
|
||||
f"wildcard payload.tag_groups: {mem!r} appears in "
|
||||
f"more than one group"
|
||||
)
|
||||
seen_grouped.add(mem)
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
null_count = 0
|
||||
for i, opt in enumerate(options):
|
||||
@@ -220,12 +453,12 @@ class WildcardHandler(ModuleHandler):
|
||||
f"wildcard payload.options[{i}] null option "
|
||||
f"{opt_id!r} must have empty value (got {value!r})"
|
||||
)
|
||||
sub_null = opt.get("sub_category")
|
||||
if sub_null is not None and sub_null != "":
|
||||
null_subs = opt.get("sub_categories", [])
|
||||
if null_subs:
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}] null option "
|
||||
f"{opt_id!r} must have no sub_category (got "
|
||||
f"{sub_null!r})"
|
||||
f"{opt_id!r} must have no sub_categories (got "
|
||||
f"{null_subs!r})"
|
||||
)
|
||||
else:
|
||||
if not isinstance(value, str) or value == "":
|
||||
@@ -242,11 +475,22 @@ class WildcardHandler(ModuleHandler):
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}].weight must not be negative"
|
||||
)
|
||||
sub_cat = opt.get("sub_category")
|
||||
if sub_cat is not None and not isinstance(sub_cat, str):
|
||||
subs = opt.get("sub_categories", [])
|
||||
if not isinstance(subs, list):
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}].sub_category must be a string"
|
||||
f"wildcard payload.options[{i}].sub_categories must be a list"
|
||||
)
|
||||
for s in subs:
|
||||
if not isinstance(s, str):
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}].sub_categories must be "
|
||||
f"strings"
|
||||
)
|
||||
if s not in registry:
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}].sub_categories: {s!r} "
|
||||
f"not in sub_categories registry"
|
||||
)
|
||||
if null_count > 1:
|
||||
raise ValueError(
|
||||
f"wildcard payload may have at most one null option "
|
||||
@@ -277,35 +521,6 @@ class WildcardHandler(ModuleHandler):
|
||||
raise ValueError(
|
||||
f"wildcard payload.var_binding {binding!r} is not a valid identifier"
|
||||
)
|
||||
sub_categories = payload.get("sub_categories", [])
|
||||
if not isinstance(sub_categories, list):
|
||||
raise ValueError("wildcard payload.sub_categories must be a list")
|
||||
for i, sc in enumerate(sub_categories):
|
||||
if not isinstance(sc, str):
|
||||
raise ValueError(
|
||||
f"wildcard payload.sub_categories[{i}] must be a string"
|
||||
)
|
||||
# `null` is a reserved keyword in the nested-ref filter
|
||||
# syntax — `@{uuid:warm,null}` opts the null option into
|
||||
# the pool. Forbid sub_category names colliding with it.
|
||||
if sc == "null":
|
||||
raise ValueError(
|
||||
f"wildcard payload.sub_categories[{i}] 'null' is reserved "
|
||||
f"(used by the nested ref filter syntax)"
|
||||
)
|
||||
# Sub-category name must not contain characters used by the
|
||||
# nested-ref grammar: `,` (subcat list separator), `:`
|
||||
# (subcat segment opener), `}` (ref terminator), `#` (name
|
||||
# segment opener), `@` / `{` (ref-prefix collision risk).
|
||||
# Editor + API guard against this so the regex never sees
|
||||
# ambiguous input.
|
||||
bad = set(sc) & _REF_FORBIDDEN_CHARS
|
||||
if bad:
|
||||
raise ValueError(
|
||||
f"wildcard payload.sub_categories[{i}] {sc!r} contains "
|
||||
f"forbidden characters {sorted(bad)} "
|
||||
f"(would break the @{{uuid:subcat}} syntax)"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
@@ -342,21 +557,23 @@ class WildcardHandler(ModuleHandler):
|
||||
return {binding: resolve_text(value, resolve_ctx)}
|
||||
# else: pinned target is missing — fall through to random.
|
||||
|
||||
# `category_filter` narrows the option pool to entries whose
|
||||
# `sub_category` is in the chosen list. Empty list / None = no
|
||||
# filter (all sub-categories eligible). Options with a `None`
|
||||
# / missing sub_category are excluded by an explicit filter
|
||||
# (they're "unsorted" and the user opted into a curated set) —
|
||||
# EXCEPT the null option (`is_null: True`), which is an
|
||||
# orthogonal "no-output" slot and survives every category
|
||||
# filter. See docs/superpowers/specs/2026-05-24-null-wildcard-option-design.md.
|
||||
# `category_filter` narrows the option pool to entries whose tag
|
||||
# set (`sub_categories`) satisfies a boolean expression
|
||||
# (`and`/`or`/`not`/parens; comma=or). Empty / missing = no tag
|
||||
# filter. `exclude_null` is a separate flag: when true the null
|
||||
# option is dropped, otherwise it is an orthogonal "no-output"
|
||||
# slot that survives every filter (the expression never applies
|
||||
# to it). Untagged non-null options bypass the expression unless
|
||||
# a bare tag term excludes them (spec §3.4). See the shared
|
||||
# matcher in engine/syntax/subcat_filter.py.
|
||||
category_filter = instance.get("category_filter")
|
||||
if isinstance(category_filter, list) and category_filter:
|
||||
allowed_cats = set(category_filter)
|
||||
options = [
|
||||
o for o in options
|
||||
if o.get("is_null") or o.get("sub_category") in allowed_cats
|
||||
]
|
||||
exclude_null = bool(instance.get("exclude_null", False))
|
||||
if (isinstance(category_filter, str) and category_filter.strip()) or exclude_null:
|
||||
options = cls._apply_pool_filter(
|
||||
options,
|
||||
category_filter if isinstance(category_filter, str) else "",
|
||||
exclude_null=exclude_null,
|
||||
)
|
||||
|
||||
enabled = instance.get("enabled_options")
|
||||
if enabled is not None:
|
||||
@@ -400,16 +617,22 @@ class WildcardHandler(ModuleHandler):
|
||||
if my_id:
|
||||
constraints = ctx.get("__wp_constraints__") if ctx is not None else None
|
||||
picks = ctx.get("__wp_picks__") if ctx is not None else None
|
||||
# First-instance one-shot: pass the ctx-resident consumed
|
||||
# set so the apply_constraints helper marks fired
|
||||
# constraints + skips them on subsequent target instances.
|
||||
consumed = (
|
||||
ctx.setdefault("__wp_consumed_constraints__", set())
|
||||
# SP3 reach selector: thread the ctx-resident per-constraint
|
||||
# hit counter so each covering constraint re-weights this
|
||||
# firing target instance (no more one-shot / consumed-set).
|
||||
# `firing_uid` identifies THIS direct top-level instance for
|
||||
# the `pick` selector's occurrence match.
|
||||
hits = (
|
||||
ctx.setdefault("__wp_constraint_hits__", {})
|
||||
if isinstance(ctx, dict) else {}
|
||||
)
|
||||
firing_uid = (
|
||||
ctx.get("__wp_current_module_uid__")
|
||||
if isinstance(ctx, dict) else None
|
||||
)
|
||||
options, any_constraint_applied = apply_constraints_for_target(
|
||||
options, my_id, constraints, picks, ctx["__wp_warnings__"],
|
||||
consumed=consumed,
|
||||
hits=hits, firing_uid=firing_uid,
|
||||
)
|
||||
if any_constraint_applied:
|
||||
warn_excludes_all(options, my_id or "", ctx["__wp_warnings__"])
|
||||
@@ -422,7 +645,17 @@ class WildcardHandler(ModuleHandler):
|
||||
# unlocked pick exactly. This is the contract the lock UX
|
||||
# relies on: the user sees a roll, locks it, captures the
|
||||
# current chain seed, future runs reproduce the same roll.
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
if instance.get("seed_scope") == "hold":
|
||||
# Held: reuse the frame-0 fully-resolved value verbatim (from the
|
||||
# pipeline's base pass). Skips the pick AND nested-ref resolution,
|
||||
# so a constrained or @{}-bearing wildcard stays frozen across the
|
||||
# whole run — "green jeans" on frame 1 stays "green jeans".
|
||||
_hb = ctx.get("__wp_hold_base_ctx__")
|
||||
if isinstance(_hb, dict) and binding in _hb:
|
||||
return {binding: _hb[binding]}
|
||||
chain_seed = int(ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0)
|
||||
else:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
if locked_seed is not None:
|
||||
try:
|
||||
@@ -434,6 +667,48 @@ class WildcardHandler(ModuleHandler):
|
||||
effective_seed = chain_seed
|
||||
rng = _derive_module_rng(effective_seed, binding)
|
||||
|
||||
# SP2a multi-select: a count range other than (1, 1) picks N options
|
||||
# without replacement and binds a ListVar. The null option is never in
|
||||
# the multi-pick pool (exclude_null is single-pick-only); "maybe
|
||||
# nothing" is min=0. (1, 1) falls through to the single-pick path
|
||||
# below unchanged — same RNG draws, so existing locked seeds reproduce.
|
||||
raw_min = _coerce_pick_int(instance.get("pick_min"), 1)
|
||||
raw_max = _coerce_pick_int(instance.get("pick_max"), raw_min)
|
||||
lo = max(0, min(raw_min, raw_max))
|
||||
hi = max(lo, raw_max)
|
||||
if not (lo == 1 and hi == 1):
|
||||
pool = [o for o in options if not o.get("is_null")]
|
||||
weights = [max(0.0, float(o.get("weight", 1))) for o in pool]
|
||||
pool_n = len(pool)
|
||||
independent = bool(instance.get("pick_independent", False))
|
||||
if independent:
|
||||
# SP2c: independent multi-pick draws WITH replacement (repeats
|
||||
# allowed), mirroring the inline `~` flag — so the count is the
|
||||
# requested range and is NOT clamped to the pool size.
|
||||
count = lo if lo == hi else lo + int(rng.random() * (hi - lo + 1))
|
||||
picks = [
|
||||
p for p in (_pick_weighted(pool, rng) for _ in range(count))
|
||||
if p is not None
|
||||
]
|
||||
else:
|
||||
lo_c, hi_c = min(lo, pool_n), min(hi, pool_n)
|
||||
count = lo_c if lo_c == hi_c else lo_c + int(rng.random() * (hi_c - lo_c + 1))
|
||||
picks = pick_k_unique(pool, weights, count, rng)
|
||||
sep = instance.get("pick_separator")
|
||||
if not isinstance(sep, str):
|
||||
sep = ", "
|
||||
items: list[str] = []
|
||||
saved_rng_multi = ctx.get("__wp_rng__")
|
||||
ctx["__wp_rng__"] = rng
|
||||
try:
|
||||
multi_ctx = build_resolve_ctx(ctx, surface="wildcard")
|
||||
for opt in picks:
|
||||
items.append(resolve_text(str(opt.get("value", "")), multi_ctx))
|
||||
finally:
|
||||
ctx["__wp_rng__"] = saved_rng_multi
|
||||
_record_pick_multi(ctx, picks, sep)
|
||||
return {binding: ListVar(items, sep)}
|
||||
|
||||
chosen = _pick_weighted(options, rng)
|
||||
if chosen is None:
|
||||
return {binding: ""}
|
||||
@@ -444,39 +719,9 @@ class WildcardHandler(ModuleHandler):
|
||||
# constraint exception keys on the literal empty pick value).
|
||||
_record_pick(ctx, chosen)
|
||||
|
||||
# First-instance positional-claim failsafe. This wildcard may
|
||||
# CARRY a constraint's target via an `@{target}` ref in one of
|
||||
# its options. The pair badge already assigned the constraint to
|
||||
# this carrier, so once the carrier ROLLS — regardless of which
|
||||
# option won, including the `null` / empty-value option — it
|
||||
# claims the constraint so it doesn't spill onto a later target
|
||||
# instance. `claim_carrier_constraints` is a no-op when the
|
||||
# chosen option DID resolve the ref (the nested-resolve path
|
||||
# below already consumed it) or the source isn't picked yet.
|
||||
#
|
||||
# CRITICAL: must fire on EVERY roll path, including the
|
||||
# empty-value early return — a backdrop that rolls its `null`
|
||||
# option still "rolled" and must still claim its carried
|
||||
# constraints. The 2026-05-26 bug was calling this only after
|
||||
# resolve_text, so a null/empty pick skipped the claim and the
|
||||
# constraint either spilled or surfaced a misleading
|
||||
# never_applied warning.
|
||||
def _claim_carried() -> None:
|
||||
if ctx is None:
|
||||
return
|
||||
from engine.modules._constraints import claim_carrier_constraints
|
||||
claim_carrier_constraints(
|
||||
options,
|
||||
ctx.get("__wp_constraints__"),
|
||||
ctx.get("__wp_picks__"),
|
||||
ctx.get("__wp_consumed_constraints__"),
|
||||
)
|
||||
|
||||
value = str(chosen.get("value", ""))
|
||||
if not value:
|
||||
# Empty / null pick — no ref to resolve, but the carrier
|
||||
# still rolled, so claim before returning.
|
||||
_claim_carried()
|
||||
# Empty / null pick — no ref to resolve.
|
||||
return {binding: ""}
|
||||
|
||||
# Swap `ctx['__wp_rng__']` to the per-module rng for the
|
||||
@@ -495,12 +740,22 @@ class WildcardHandler(ModuleHandler):
|
||||
ctx["__wp_rng__"] = rng
|
||||
try:
|
||||
resolve_ctx = build_resolve_ctx(ctx, surface="wildcard")
|
||||
# SP3 nested-pick reach: THIS wildcard is the carrier of any
|
||||
# `@{T}` ref inside `value`. Stamp (its per-instance uid, the
|
||||
# chosen option's id) on the resolve frame so a constraint on
|
||||
# T whose `pick` selector lists this (carrier_uid, option_id)
|
||||
# nested occurrence matches. A `pick` keyed on a DEEPER
|
||||
# carrier won't match this top-level uid — that's by design
|
||||
# (only one-hop carriers are individually pickable). Set on
|
||||
# the freshly-built ctx (carrier defaults None); no restore
|
||||
# needed — resolve_ctx is scoped to this resolve_text call.
|
||||
set_carrier = getattr(resolve_ctx, "set_carrier", None)
|
||||
if callable(set_carrier):
|
||||
set_carrier(
|
||||
ctx.get("__wp_current_module_uid__"), chosen.get("id"),
|
||||
)
|
||||
resolved = resolve_text(value, resolve_ctx)
|
||||
finally:
|
||||
ctx["__wp_rng__"] = saved_rng
|
||||
|
||||
# Claim AFTER resolve_text so a chosen option that DID carry the
|
||||
# ref gets consumed-with-effect by the nested-resolve path
|
||||
# first; the claim then no-ops on it.
|
||||
_claim_carried()
|
||||
return {binding: resolved}
|
||||
|
||||
+267
-42
@@ -20,11 +20,14 @@ from engine.modules.snapshot import coerce_legacy_module
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# `@{uuid}` ref matcher — mirror of engine.syntax.tokenize._REF_RE.
|
||||
# Used by the never_applied warning classifier to tell "target has a
|
||||
# carrier/instance somewhere in the chain" apart from "target uuid is
|
||||
# nowhere to be found".
|
||||
_PIPELINE_REF_RE = re.compile(r"@\{([0-9a-f]{8})(?:#[^#:}@{]*)?(?::[^}]*)?\}")
|
||||
# `@{uuid}` ref matcher — mirror of engine.syntax.tokenize._REF_RE
|
||||
# (4-segment: uuid + optional #name / :expr / !null; only the uuid is
|
||||
# captured here). Used by the never_applied warning classifier to tell
|
||||
# "target has a carrier/instance somewhere in the chain" apart from
|
||||
# "target uuid is nowhere to be found".
|
||||
_PIPELINE_REF_RE = re.compile(
|
||||
r"@\{([0-9a-f]{8})(?:#[^#:}@{!]*)?(?::[^}!]*)?(?:![^}]*)?\}"
|
||||
)
|
||||
|
||||
|
||||
def _module_field(module: Any, key: str) -> Any:
|
||||
@@ -233,6 +236,18 @@ def _extract_static_meta(
|
||||
return meta
|
||||
|
||||
|
||||
def _module_seed_scope(module: object) -> str | None:
|
||||
"""Read a module's base ``instance.seed_scope`` ("hold"/"vary"/None).
|
||||
Works for both dict and object module shapes. seed_scope is a base-only
|
||||
setting (disabled in per-frame edit mode), so the base instance is
|
||||
authoritative."""
|
||||
inst = module.get("instance") if isinstance(module, dict) else getattr(module, "instance", None)
|
||||
if isinstance(inst, dict):
|
||||
s = inst.get("seed_scope")
|
||||
return s if isinstance(s, str) else None
|
||||
return None
|
||||
|
||||
|
||||
class PipelineEngine:
|
||||
"""Runs an ordered list of modules against a context dict."""
|
||||
|
||||
@@ -241,18 +256,50 @@ class PipelineEngine:
|
||||
modules: list[Module],
|
||||
ctx: Context | None = None,
|
||||
seed: int = 0,
|
||||
hold_seed: int | None = None,
|
||||
loop_index: int = 0,
|
||||
) -> Context:
|
||||
"""Execute modules top-to-bottom, mutating ctx, returning ctx."""
|
||||
ctx = {} if ctx is None else ctx
|
||||
ctx["__wp_node_seed__"] = seed
|
||||
ctx["__wp_node_seed_hold__"] = hold_seed if hold_seed is not None else seed
|
||||
ctx["__wp_loop_index__"] = loop_index
|
||||
ctx["__wp_rng__"] = random.Random(seed)
|
||||
ctx.setdefault("__wp_warnings__", [])
|
||||
ctx.setdefault("__wp_trace__", [])
|
||||
ctx.setdefault("__wp_internal_flags__", {})
|
||||
# Tracks constraint module ids that have already fired against
|
||||
# their first downstream target instance. See
|
||||
# docs/superpowers/specs/2026-05-24-constraint-first-instance-design.md.
|
||||
ctx.setdefault("__wp_consumed_constraints__", set())
|
||||
# SP3 reach selector: per-constraint firing count keyed by
|
||||
# `__constraint_module_id__`. Drives first/next/all/pick
|
||||
# coverage in apply_constraints_for_target + the never_applied /
|
||||
# partial_reach finalisation below. Replaces the pre-SP3
|
||||
# one-shot consumed-set.
|
||||
ctx.setdefault("__wp_constraint_hits__", {})
|
||||
|
||||
# "Hold the value" base pass. A module on seed_scope=hold must resolve
|
||||
# to its frame-0 (loop_index=0) value on EVERY iteration — INCLUDING
|
||||
# nested @{} refs, which resolve_text bakes into the stored value.
|
||||
# Re-resolving each iteration (the old seed-swap) failed for
|
||||
# constrained wildcards (the constraint reshapes the pool per
|
||||
# iteration) and for nested refs. So when a loop is driving
|
||||
# (loop_index != 0) and any module holds, resolve the chain ONCE at
|
||||
# loop_index=0 with the constant hold seed; held handlers copy their
|
||||
# resolved value from this base context verbatim. The deepcopy keeps
|
||||
# the throwaway pass's warnings/trace/constraint-state off the real
|
||||
# ctx. loop_index=0 here means the recursive run skips its own base
|
||||
# pass — no infinite recursion.
|
||||
_any_hold = any(_module_seed_scope(m) == "hold" for m in modules)
|
||||
if loop_index != 0 and _any_hold:
|
||||
import copy as _copy
|
||||
|
||||
_hb = int(ctx.get("__wp_node_seed_hold__", 0) or 0)
|
||||
base_ctx = PipelineEngine().run(
|
||||
modules,
|
||||
ctx=_copy.deepcopy(ctx),
|
||||
seed=_hb,
|
||||
hold_seed=_hb,
|
||||
loop_index=0,
|
||||
)
|
||||
ctx["__wp_hold_base_ctx__"] = base_ctx
|
||||
|
||||
for index, module in enumerate(modules):
|
||||
# Normalise id/type/enabled reads to work for both dicts and objects.
|
||||
@@ -275,25 +322,68 @@ class PipelineEngine:
|
||||
# imported from someone else's machine), letting WP_Debug
|
||||
# render a readable chip instead of a raw uuid.
|
||||
_module_name = module.get("name", "") or ""
|
||||
# Per-insertion bundle discriminator. Two instances of one
|
||||
# library wildcard (a bundle inserted twice) carry DISTINCT
|
||||
# `bundle_origin` values (insert.ts stamps each child with
|
||||
# its immediate BundleInstance._uid). Threaded into ctx so a
|
||||
# constraint can bind to the source pick from its OWN bundle
|
||||
# copy (task_5200c1fc). Empty string when the module has no
|
||||
# bundle origin (top-level / manually-added / legacy).
|
||||
_module_bundle_origin = module.get("bundle_origin", "") or ""
|
||||
else:
|
||||
_module_id = getattr(module, "id", "")
|
||||
_module_uid = getattr(module, "_uid", "") or ""
|
||||
_module_type_raw = getattr(module, "type", None) or ""
|
||||
_module_enabled = getattr(module, "enabled", True)
|
||||
_module_name = getattr(module, "name", "") or ""
|
||||
_module_bundle_origin = getattr(module, "bundle_origin", "") or ""
|
||||
|
||||
if not _module_enabled:
|
||||
# Disabled modules don't run — but the trace row still
|
||||
# surfaces what the module would have written, so the
|
||||
# debug viewer can label it `$varname (disabled)`
|
||||
# instead of falling back to an opaque short-uuid.
|
||||
_k = int(ctx.get("__wp_loop_index__", 0))
|
||||
# Per-frame enable override. `frame_enabled[str(k)]` overrides the
|
||||
# base `enabled` flag for frame k in EITHER direction: a base-off
|
||||
# module can be turned ON for select frames, a base-on module OFF
|
||||
# for others. effective = frame_enabled.get(k, base_enabled).
|
||||
# Legacy `disabled_frames` (a one-way blocklist) is still honoured —
|
||||
# each listed frame reads as frame_enabled[k]=False.
|
||||
_frame_enabled_map = (
|
||||
module.get("frame_enabled")
|
||||
if isinstance(module, dict)
|
||||
else getattr(module, "frame_enabled", None)
|
||||
)
|
||||
_legacy_disabled = (
|
||||
module.get("disabled_frames")
|
||||
if isinstance(module, dict)
|
||||
else getattr(module, "disabled_frames", None)
|
||||
)
|
||||
_frame_override: bool | None = None
|
||||
if isinstance(_frame_enabled_map, dict) and str(_k) in _frame_enabled_map:
|
||||
_frame_override = bool(_frame_enabled_map[str(_k)])
|
||||
elif isinstance(_legacy_disabled, (list, tuple)) and _k in {
|
||||
int(f)
|
||||
for f in _legacy_disabled
|
||||
if isinstance(f, (int, float)) and not isinstance(f, bool)
|
||||
}:
|
||||
_frame_override = False
|
||||
_effective_enabled = (
|
||||
_frame_override if _frame_override is not None else bool(_module_enabled)
|
||||
)
|
||||
|
||||
if not _effective_enabled:
|
||||
# Module is suppressed this frame. Distinguish a per-frame
|
||||
# disable (a frame override forced it off) from a global
|
||||
# disable (base off, no frame override) so the debug viewer
|
||||
# can label `skipped_frame` vs `skipped_disabled`. Either way
|
||||
# the trace row still surfaces what it WOULD have written so
|
||||
# the debug viewer can label `$varname (disabled)` rather than
|
||||
# falling back to an opaque short-uuid.
|
||||
_frame_forced_off = _frame_override is False
|
||||
meta = _extract_static_meta(module)
|
||||
ctx["__wp_trace__"].append({
|
||||
"id": _module_id,
|
||||
"_uid": _module_uid,
|
||||
"type": _module_type_raw,
|
||||
"enabled": False,
|
||||
"status": "skipped_disabled",
|
||||
"enabled": bool(_module_enabled) if _frame_forced_off else False,
|
||||
"status": "skipped_frame" if _frame_forced_off else "skipped_disabled",
|
||||
"writes": [],
|
||||
"error": None,
|
||||
**meta,
|
||||
@@ -311,17 +401,38 @@ class PipelineEngine:
|
||||
else:
|
||||
# Generic object: pull known snapshot keys via getattr so
|
||||
# class-level attributes (common in tests) are visible.
|
||||
# `iteration_overrides` is load-bearing — the per-frame
|
||||
# override patch below reads it back off `raw`. `disabled_frames`
|
||||
# is listed for snapshot honesty only (the skip gate already
|
||||
# read it module-direct above).
|
||||
raw = {
|
||||
k: getattr(module, k)
|
||||
for k in (
|
||||
"id", "type", "enabled", "payload", "entries",
|
||||
"meta", "library_id", "library_snapshot_at",
|
||||
"library_version_at_snapshot", "name", "category_id",
|
||||
"instance",
|
||||
"instance", "iteration_overrides",
|
||||
"disabled_frames", "frame_enabled",
|
||||
)
|
||||
if hasattr(module, k)
|
||||
}
|
||||
snapshot = coerce_legacy_module(raw)
|
||||
# Per-frame iteration override: patch snapshot["instance"]
|
||||
# for the current loop index BEFORE the handler runs.
|
||||
# iteration_overrides is a dict keyed by stringified index;
|
||||
# the matching partial dict is shallow-merged into instance.
|
||||
_ov = (
|
||||
raw.get("iteration_overrides")
|
||||
if isinstance(raw, dict)
|
||||
else getattr(module, "iteration_overrides", None)
|
||||
)
|
||||
if isinstance(_ov, dict):
|
||||
_frame_key = str(ctx.get("__wp_loop_index__", 0))
|
||||
_patch = _ov.get(_frame_key)
|
||||
if isinstance(_patch, dict) and isinstance(
|
||||
snapshot.get("instance"), dict
|
||||
):
|
||||
snapshot["instance"] = {**snapshot["instance"], **_patch}
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to coerce module %r at index %s: %s", module, index, e
|
||||
@@ -351,15 +462,24 @@ class PipelineEngine:
|
||||
# wildcard handler matches THIS against a constraint's
|
||||
# `target_wildcard_id` (which is a library uuid).
|
||||
# - `__wp_current_module_uid__` = per-instance uid (`_uid`).
|
||||
# The constraint handler keys its consumed-set entry on
|
||||
# THIS so two instances of the same library constraint
|
||||
# entry are independent one-shots (per CLAUDE.md: "author
|
||||
# multiple constraint modules" to affect multiple
|
||||
# targets). Falls back to the library id when `_uid` is
|
||||
# absent (legacy / hand-built test modules).
|
||||
# This is the BARE per-instance uid (no node prefix). The
|
||||
# constraint handler keys its `__constraint_module_id__`
|
||||
# (the SP3 per-constraint hit counter) on THIS so two
|
||||
# instances of the same library constraint entry reach
|
||||
# INDEPENDENTLY (per CLAUDE.md: "author multiple constraint
|
||||
# modules" to affect multiple targets). It's ALSO the
|
||||
# `firing_uid` a direct `pick` selector matches against —
|
||||
# so a persisted `pick` must carry the bare `_uid`, not the
|
||||
# UI's badge rowKey (`${nodeId}#${_uid}`). Falls back to the
|
||||
# library id when `_uid` is absent (legacy / test modules).
|
||||
ctx["__wp_current_module_id__"] = _module_id
|
||||
ctx["__wp_current_module_uid__"] = _module_uid or _module_id
|
||||
ctx["__wp_current_module_name__"] = _module_name
|
||||
# See the `_module_bundle_origin` derivation above — exposed to
|
||||
# handlers (wildcard_handler records it on the pick; constraint_
|
||||
# handler captures it on the registered meta) so source-instance
|
||||
# binding can match a constraint to its own bundle copy's pick.
|
||||
ctx["__wp_current_module_bundle_origin__"] = _module_bundle_origin
|
||||
meta = _extract_static_meta(module)
|
||||
try:
|
||||
bindings = resolve_module(snapshot, ctx)
|
||||
@@ -466,20 +586,22 @@ class PipelineEngine:
|
||||
ctx.pop("__wp_current_module_id__", None)
|
||||
ctx.pop("__wp_current_module_uid__", None)
|
||||
ctx.pop("__wp_current_module_name__", None)
|
||||
ctx.pop("__wp_current_module_bundle_origin__", None)
|
||||
|
||||
# First-instance one-shot semantic: emit a soft (info) warning
|
||||
# for every registered constraint whose target instance never
|
||||
# came up during the chain. Surfaces in WP_Debug so the user
|
||||
# sees "you put a constraint here but nothing downstream took
|
||||
# it" — the chain still succeeds.
|
||||
# See docs/superpowers/specs/2026-05-24-constraint-first-instance-design.md.
|
||||
# SP3 reach selector finalisation: emit `constraint_never_applied`
|
||||
# for every registered constraint whose selector covered ZERO
|
||||
# firing target instances (hit count 0). Surfaces in WP_Debug so
|
||||
# the user sees "you put a constraint here but nothing downstream
|
||||
# took it" — the chain still succeeds. The `partial_reach` pass
|
||||
# below covers constraints that DID fire but reached fewer
|
||||
# instances than a `next N` / `pick` selector asked for.
|
||||
#
|
||||
# Dedup by cid — sibling instances of the same library entry
|
||||
# share `__constraint_module_id__`, so they show up as two
|
||||
# entries in the bucket. Without the dedup the user sees the
|
||||
# same warning twice for the same logical constraint (2026-05-26).
|
||||
constraints_bucket = ctx.get("__wp_constraints__") or []
|
||||
consumed_set = ctx.get("__wp_consumed_constraints__") or set()
|
||||
hits = ctx.get("__wp_constraint_hits__") or {}
|
||||
catalog = ctx.get("__wp_catalog__")
|
||||
warned_cids: set[str] = set()
|
||||
if isinstance(constraints_bucket, list):
|
||||
@@ -487,7 +609,7 @@ class PipelineEngine:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
cid = c.get("__constraint_module_id__")
|
||||
if not cid or cid in consumed_set:
|
||||
if not cid or hits.get(cid, 0) != 0:
|
||||
continue
|
||||
if cid in warned_cids:
|
||||
continue
|
||||
@@ -523,24 +645,22 @@ class PipelineEngine:
|
||||
cid_suffix = _build_ref_name_suffix(c.get("__constraint_library_name__"))
|
||||
tgt_suffix = _build_ref_name_suffix(target_name)
|
||||
# Classify the cause so the message is actionable.
|
||||
# With the carrier-claim failsafe in place, a carrier
|
||||
# that rolls ANY option claims its constraint — so an
|
||||
# unconsumed constraint whose target IS present in the
|
||||
# chain means the target instance/carrier ran BEFORE
|
||||
# this constraint registered (ordering), or sits in a
|
||||
# Reach is downstream-relative: a constraint only applies
|
||||
# to target occurrences that resolve AFTER it registers.
|
||||
# So a never-applied constraint whose target IS present in
|
||||
# the chain means every occurrence ran BEFORE this
|
||||
# constraint registered (ordering), or sits in a
|
||||
# branch/Context this run didn't reach. Absent target =
|
||||
# a wrong/stale uuid.
|
||||
present = _target_present_in_chain(
|
||||
target_uuid, modules, catalog,
|
||||
)
|
||||
if present:
|
||||
# Target IS in the chain. With the carrier-claim
|
||||
# failsafe, any carrier/instance that ROLLS after the
|
||||
# constraint registers claims it — so reaching this
|
||||
# branch means the target's only appearance(s) ran
|
||||
# Target IS in the chain but the selector covered no
|
||||
# firing occurrence — every appearance resolved
|
||||
# BEFORE this constraint registered (e.g. a source
|
||||
# wildcard that nests its own @{target}), or live in
|
||||
# a branch / Context this run didn't execute, or are
|
||||
# wildcard that nests its own @{target}), or lives in
|
||||
# a branch / Context this run didn't execute, or is
|
||||
# disabled. Deliberately NOT prescribing "move the
|
||||
# constraint up" — that's wrong when the target's
|
||||
# carrier is also the constraint's source (the source
|
||||
@@ -590,5 +710,110 @@ class PipelineEngine:
|
||||
# otherwise render as plain text).
|
||||
"message": message,
|
||||
})
|
||||
# Source-side companion warning. The `never_applied` message
|
||||
# above is target-focused; when the constraint's SOURCE
|
||||
# wildcard is ALSO absent from the chain, surface that
|
||||
# distinctly so the Debug viewer shows BOTH ends (mirrors the
|
||||
# editor's separate "source missing" / "target missing" row
|
||||
# badges). A constraint registers by its own module position,
|
||||
# so it can sit in the bucket with neither end present.
|
||||
source_uuid = c.get("source_wildcard_id", "")
|
||||
if source_uuid and not _target_present_in_chain(
|
||||
source_uuid, modules, catalog,
|
||||
):
|
||||
src_row = (
|
||||
catalog.get(source_uuid) if isinstance(catalog, dict) else None
|
||||
)
|
||||
source_name = (
|
||||
(src_row.get("name", "") or "")
|
||||
if isinstance(src_row, dict) else ""
|
||||
)
|
||||
src_suffix = _build_ref_name_suffix(source_name)
|
||||
warnings_bucket = ctx.setdefault("__wp_warnings__", [])
|
||||
if isinstance(warnings_bucket, list):
|
||||
warnings_bucket.append({
|
||||
"type": "constraint_source_missing",
|
||||
"severity": "warn",
|
||||
"module_id": cid,
|
||||
"source_field": "",
|
||||
"position": 0,
|
||||
"token_index": None,
|
||||
"detail": {
|
||||
"constraint_id": cid,
|
||||
"source_wildcard_id": source_uuid,
|
||||
},
|
||||
"message": (
|
||||
f"constraint @{{{library_cid}{cid_suffix}}} did not apply — "
|
||||
f"its source @{{{source_uuid}{src_suffix}}} wildcard isn't in "
|
||||
f"this chain, so it never registers a source pick."
|
||||
),
|
||||
})
|
||||
|
||||
# SP3 partial-reach finalisation: a constraint that DID fire but
|
||||
# whose `next N` / `pick` selector matched FEWER occurrences than
|
||||
# requested. Distinct from never_applied (which fired zero times)
|
||||
# — this is "the selector asked for more than the chain offered
|
||||
# downstream". `all` / `first` can't be partial. Deduped by cid,
|
||||
# same as never_applied.
|
||||
partial_warned: set[str] = set()
|
||||
if isinstance(constraints_bucket, list):
|
||||
for c in constraints_bucket:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
cid = c.get("__constraint_module_id__")
|
||||
seen = hits.get(cid, 0) if cid else 0
|
||||
if not cid or seen == 0 or cid in partial_warned:
|
||||
continue
|
||||
sel = c.get("target_select") or {"mode": "all"}
|
||||
mode = sel.get("mode", "all")
|
||||
if mode == "next":
|
||||
try:
|
||||
want = int(sel.get("count", 1))
|
||||
except (TypeError, ValueError):
|
||||
want = 1
|
||||
elif mode == "pick":
|
||||
want = len(sel.get("picks") or [])
|
||||
else:
|
||||
continue
|
||||
# LIMITATION (holistic-review L1): `seen` is the per-cid
|
||||
# TOTAL target-encounter count (`hits[cid]`, bumped on
|
||||
# every firing in apply_constraints_for_target), NOT the
|
||||
# count of picks that actually MATCHED. So a `pick`
|
||||
# partial signal is imprecise: it compares encounters to
|
||||
# `len(picks)` rather than matched-vs-listed. A precise
|
||||
# count would need a separate ctx-threaded matched-pick
|
||||
# counter (new bookkeeping across the _constraints apply
|
||||
# boundary) — deferred to avoid over-plumbing. `next` is
|
||||
# exact (it covers the first N encounters by construction).
|
||||
if seen >= want:
|
||||
continue
|
||||
partial_warned.add(cid)
|
||||
warnings_bucket = ctx.setdefault("__wp_warnings__", [])
|
||||
if isinstance(warnings_bucket, list):
|
||||
warnings_bucket.append({
|
||||
# Spec: partial reach is INFO, not warn — the
|
||||
# selector simply found fewer downstream targets
|
||||
# than requested this run (not an error condition).
|
||||
# `constraint_never_applied` stays `warn` (a
|
||||
# constraint that fired zero times is worth a louder
|
||||
# signal). DebugViewer maps `info` → blue/accent.
|
||||
"type": "constraint_partial_reach",
|
||||
"severity": "info",
|
||||
"module_id": cid,
|
||||
"source_field": "",
|
||||
"position": 0,
|
||||
"token_index": None,
|
||||
"detail": {
|
||||
"constraint_id": cid,
|
||||
"target_wildcard_id": c.get("target_wildcard_id"),
|
||||
"requested": want,
|
||||
"reached": seen,
|
||||
},
|
||||
"message": (
|
||||
f"constraint reach selector ({mode}) asked for "
|
||||
f"{want} target instance(s) but only {seen} "
|
||||
f"resolved downstream this run."
|
||||
),
|
||||
})
|
||||
|
||||
return ctx
|
||||
|
||||
@@ -14,6 +14,9 @@ from typing import Any, Literal
|
||||
InstanceFieldType = Literal[
|
||||
"string", "list[string]", "dict[string,number]", "dict[string,dict]",
|
||||
"dict[string,string]",
|
||||
# Generic object — used by SP3 `target_select`, whose interior shape is
|
||||
# validated by the constraint handler rather than this type-only schema.
|
||||
"dict",
|
||||
"boolean", "number", "list[dict]",
|
||||
]
|
||||
|
||||
@@ -22,9 +25,32 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
|
||||
"variable_binding": "string",
|
||||
"enabled_options": "list[string]",
|
||||
"option_weights": "dict[string,number]",
|
||||
"category_filter": "list[string]",
|
||||
# `category_filter` is a boolean sub-category expression string
|
||||
# (SP1). The separate `exclude_null` instance flag is declared
|
||||
# here + in the TS `_shell.ts` twin together in the SP1 UI chunk
|
||||
# (keeps the parity guard green); the engine resolver already
|
||||
# reads it directly.
|
||||
"category_filter": "string",
|
||||
"exclude_null": "boolean",
|
||||
"locked_seed": "number",
|
||||
# Iteration seed scope: "hold" pins RNG to the run base seed (same
|
||||
# across loop iterations, re-rolls per run); "vary" (default/absent)
|
||||
# rides the per-iteration seed. `locked_seed` takes priority when set.
|
||||
"seed_scope": "string",
|
||||
"internal": "boolean",
|
||||
# SP2a multi-select: per-instance count range (pick_min..pick_max; a
|
||||
# range other than 1..1 resolves N options without replacement into a
|
||||
# list-valued variable) + the separator used to join it for a bare
|
||||
# `$var`. Instance-local: NOT part of a standalone module's published
|
||||
# payload — but bundle children freeze the whole instance as a
|
||||
# snapshot, so a published bundle DOES carry them (intentional
|
||||
# frozen-snapshot fidelity).
|
||||
"pick_min": "number",
|
||||
"pick_max": "number",
|
||||
"pick_separator": "string",
|
||||
# SP2c: multi-pick draws WITH replacement (repeats allowed) when true,
|
||||
# without (unique) when false/absent. Mirrors the inline `~` flag.
|
||||
"pick_independent": "boolean",
|
||||
# `mode` and `pinned_option_id` removed in v2 — resolve mode is
|
||||
# implicit in pool state. Engine handler still reads them when
|
||||
# present in legacy snapshots; the schema validator now flags
|
||||
@@ -37,6 +63,8 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
|
||||
# resolution gives fixed_values seed-lock parity with wildcard
|
||||
# + combine. Locked seed pins the alternation outcome.
|
||||
"locked_seed": "number",
|
||||
# Iteration seed scope: hold/vary control (parity with wildcard).
|
||||
"seed_scope": "string",
|
||||
},
|
||||
"combine": {
|
||||
# v2 single-pane modal exposes a richer override surface:
|
||||
@@ -47,6 +75,8 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
|
||||
"template_override": "string",
|
||||
"variable_binding": "string",
|
||||
"locked_seed": "number",
|
||||
# Iteration seed scope: hold/vary control (parity with wildcard).
|
||||
"seed_scope": "string",
|
||||
"internal": "boolean",
|
||||
},
|
||||
"derivation": {
|
||||
@@ -86,6 +116,9 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
|
||||
"exception_mode_overrides": "dict[string,string]",
|
||||
"exception_factor_overrides": "dict[string,number]",
|
||||
"extra_exceptions": "list[dict]",
|
||||
# SP3 reach selector override. Interior shape (mode/count/picks) is
|
||||
# validated by ConstraintHandler; here it is type-only ("is a dict").
|
||||
"target_select": "dict",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -101,6 +134,8 @@ def _matches_type(value: Any, spec: InstanceFieldType) -> bool:
|
||||
return isinstance(value, list) and all(isinstance(x, str) for x in value)
|
||||
if spec == "list[dict]":
|
||||
return isinstance(value, list) and all(isinstance(x, dict) for x in value)
|
||||
if spec == "dict":
|
||||
return isinstance(value, dict)
|
||||
if spec == "dict[string,number]":
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
@@ -140,4 +175,17 @@ def validate_instance(kind: str, instance: dict[str, Any]) -> list[str]:
|
||||
warnings.append(
|
||||
f"{kind}.{field} type mismatch: expected {spec}, got {type(value).__name__}"
|
||||
)
|
||||
# SP2a advisory soft-warns for the pick range (not type errors).
|
||||
if kind == "wildcard":
|
||||
pmin = instance.get("pick_min")
|
||||
pmax = instance.get("pick_max")
|
||||
nums = (int, float)
|
||||
if (isinstance(pmin, nums) and not isinstance(pmin, bool)
|
||||
and isinstance(pmax, nums) and not isinstance(pmax, bool)):
|
||||
if pmin == 0 and pmax == 0:
|
||||
warnings.append("wildcard pick range 0..0 — always empty (resolves to nothing)")
|
||||
elif pmax < pmin:
|
||||
warnings.append(
|
||||
f"wildcard.pick_max ({pmax}) is below pick_min ({pmin}) — clamped to pick_max"
|
||||
)
|
||||
return warnings
|
||||
|
||||
+23
-3
@@ -41,6 +41,19 @@ def derive_loop_seeds(base: int, count: int, strategy: str) -> list[int]:
|
||||
raise ValueError(f"unknown loop seed strategy: {strategy!r}")
|
||||
|
||||
|
||||
def apply_seed_locks(derived: list[int], locks: dict[int, int]) -> list[int]:
|
||||
"""Overlay per-index locked seeds on a derived series.
|
||||
|
||||
`locks` maps a 0-based iteration index to a pinned seed. Locked
|
||||
indices replace the derived value (masked to MAX_SAFE_SEED);
|
||||
out-of-range indices are ignored. Pure — no ComfyUI imports.
|
||||
"""
|
||||
return [
|
||||
(locks[i] & MAX_SAFE_SEED) if i in locks else seed
|
||||
for i, seed in enumerate(derived)
|
||||
]
|
||||
|
||||
|
||||
def effective_chain_seed(
|
||||
*,
|
||||
widget_seed: int,
|
||||
@@ -53,8 +66,10 @@ def effective_chain_seed(
|
||||
1. ``seed_override`` (from ContextLoop) replaces ``widget_seed`` as
|
||||
base when present.
|
||||
2. Otherwise ``widget_seed`` is the base.
|
||||
3. ``loop_index`` is mixed via XOR with a stable hash. ``loop_index=0``
|
||||
→ no-op (backwards-compat for chains without ContextLoop).
|
||||
3. ``loop_index`` is mixed via XOR with a stable hash ONLY when there is
|
||||
no override (to vary a constant ``widget_seed`` per iteration). An
|
||||
override is already per-iteration (the ContextLoop derived series) so
|
||||
it is used as-is. ``loop_index=0`` → no-op.
|
||||
|
||||
Locked-module precedence lives in the per-module handlers, NOT here.
|
||||
Locked modules read ``instance.locked_seed`` directly and ignore the
|
||||
@@ -64,7 +79,12 @@ def effective_chain_seed(
|
||||
matches the visual shape of ComfyUI's frontend-randomised seeds.
|
||||
"""
|
||||
base = seed_override if seed_override is not None else widget_seed
|
||||
if loop_index == 0:
|
||||
# An override IS the loop's already-per-iteration derived seed → use it
|
||||
# verbatim. The loop_index XOR exists only to vary a CONSTANT widget seed
|
||||
# (the override-OFF path); re-mixing an override that already differs per
|
||||
# iteration is a double-shift that made the actual seed diverge from the
|
||||
# derived series the preview + per-iteration seed-modal locks show.
|
||||
if loop_index == 0 or seed_override is not None:
|
||||
return base & MAX_SAFE_SEED
|
||||
digest = hashlib.sha256(f"loop:{loop_index}".encode()).digest()
|
||||
shift = int.from_bytes(digest[:8], "big")
|
||||
|
||||
+248
-145
@@ -8,6 +8,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from engine.syntax.subcat_filter import matches as _subcat_matches
|
||||
from engine.syntax.subcat_filter import parse as _parse_subcat
|
||||
from engine.syntax.tokenize import tokenize_text
|
||||
from engine.syntax.types import (
|
||||
CycleDetectedError,
|
||||
@@ -17,6 +19,7 @@ from engine.syntax.types import (
|
||||
Token,
|
||||
TokenKind,
|
||||
UnknownRefError,
|
||||
deref_var_value,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -114,7 +117,12 @@ def _resolve_var(tok: Token, ctx: ResolveContext) -> str:
|
||||
message=f"Unknown variable ${name}",
|
||||
)
|
||||
return ""
|
||||
return str(value)
|
||||
# SP2a list accessor: `$name` joins a ListVar with its separator;
|
||||
# `$name.K` indexes (0-based, out-of-range -> ""). A plain string behaves
|
||||
# as a 1-element list so `$str.0` == `$str` and `$str.1` == "". The
|
||||
# accessor + ListVar-fold contract lives in deref_var_value
|
||||
# (engine/syntax/types.py); the derivation + converter reads share it.
|
||||
return deref_var_value(value, tok.meta.get("index"))
|
||||
|
||||
|
||||
def _push_warning(ctx: ResolveContext, **fields) -> None:
|
||||
@@ -161,8 +169,19 @@ def _resolve_inline_pick(
|
||||
branches: list[str] = tok.meta.get("branches", [])
|
||||
if not branches:
|
||||
return ""
|
||||
chosen_idx = ctx.rng.randrange(len(branches))
|
||||
chosen = branches[chosen_idx]
|
||||
# Per-branch weights (`N::value`), same micro-syntax the multi-pick block
|
||||
# honors. Strip the prefix off every branch so it never leaks into output;
|
||||
# only switch to weighted selection when a branch actually carries a weight,
|
||||
# so unweighted `{a|b|c}` keeps its byte-for-byte seed sequence (legacy
|
||||
# randrange path — changing the draw would shift every existing seed).
|
||||
values = [_strip_branch_weight(b) for b in branches]
|
||||
has_weights = any(v != b for v, b in zip(values, branches, strict=True))
|
||||
if has_weights:
|
||||
weights = [_parse_branch_weight(b) for b in branches]
|
||||
chosen_idx = _weighted_pick_index(weights, ctx.rng)
|
||||
else:
|
||||
chosen_idx = ctx.rng.randrange(len(branches))
|
||||
chosen = values[chosen_idx]
|
||||
if not chosen:
|
||||
return ""
|
||||
nested_tokens = tokenize_text(chosen)
|
||||
@@ -209,53 +228,120 @@ def _weighted_pick_index(weights: list[float], rng) -> int:
|
||||
return len(weights) - 1
|
||||
|
||||
|
||||
def pick_k_unique(items, weights, k, rng):
|
||||
"""Pick ``k`` items from ``items`` without replacement, weighted by the
|
||||
parallel ``weights`` list, returned in pick order. ``k`` is clamped to
|
||||
``len(items)``; ``k <= 0`` -> ``[]``.
|
||||
|
||||
Shared by the inline ``{N$$sep$$}`` block (``_resolve_multi_pick``) and by
|
||||
multi-select wildcards (``wildcard_handler``). Draws from ``rng`` in the
|
||||
same order the inline block always has, so seed behavior is unchanged.
|
||||
"""
|
||||
n = min(max(0, k), len(items))
|
||||
available_idx = list(range(len(items)))
|
||||
available_w = list(weights)
|
||||
chosen = []
|
||||
for _ in range(n):
|
||||
if not available_idx:
|
||||
break
|
||||
j = _weighted_pick_index(available_w, rng)
|
||||
chosen.append(items[available_idx.pop(j)])
|
||||
available_w.pop(j)
|
||||
return chosen
|
||||
|
||||
|
||||
def _resolve_multi_pick(
|
||||
tok: Token,
|
||||
ctx: ResolveContext,
|
||||
depth: int,
|
||||
visited: tuple[str, ...],
|
||||
) -> str:
|
||||
"""Pick `count` branches without replacement, weighted, joined by `sep`.
|
||||
"""Resolve a multi-pick block, joined by `sep`.
|
||||
|
||||
Each chosen branch is recursively resolved.
|
||||
SP2b: `count` is a range `[min, max]` (a fixed `{N$$}` has min==max), and
|
||||
`independent` (the `~` flag) toggles unique-without-replacement (default)
|
||||
vs N draws WITH replacement. A lone nested-ref body in unique mode draws N
|
||||
DISTINCT options from the ref's filtered pool (a per-site SP2a). Each chosen
|
||||
value is recursively resolved.
|
||||
"""
|
||||
# Seedless assembler surface — see _resolve_inline_pick. Render verbatim
|
||||
# rather than freezing a deterministic seed-0 multi-pick.
|
||||
if ctx.surface == "assembler":
|
||||
return tok.raw
|
||||
count: int = tok.meta.get("count", 0)
|
||||
cmin: int = tok.meta.get("min", tok.meta.get("count", 0))
|
||||
cmax: int = tok.meta.get("max", tok.meta.get("count", 0))
|
||||
independent: bool = bool(tok.meta.get("independent", False))
|
||||
sep: str = tok.meta.get("sep", "")
|
||||
branches: list[str] = list(tok.meta.get("branches", []))
|
||||
|
||||
# Range count: a fixed N (min==max) does NOT draw from rng, so a `{N$$}`
|
||||
# reproduces the pre-SP2b seed sequence byte-for-byte. A true range draws.
|
||||
count = cmin if cmin == cmax else ctx.rng.randint(cmin, cmax)
|
||||
if count <= 0:
|
||||
return ""
|
||||
|
||||
# Clamp count to available branches (without-replacement constraint)
|
||||
# Lone nested-ref body, unique mode, count>=2, wildcard surface → pick N
|
||||
# DISTINCT options from the ref's filtered pool (per-site SP2a multi-select).
|
||||
# count==1 stays on the legacy branch path below so its seed is unchanged.
|
||||
if not independent and count >= 2 and len(branches) == 1 and ctx.surface == "wildcard":
|
||||
bt = tokenize_text(branches[0])
|
||||
if len(bt) == 1 and bt[0].kind == TokenKind.REF:
|
||||
ref_tok = bt[0]
|
||||
ref_uuid = ref_tok.meta.get("uuid", "")
|
||||
pool = ref_option_pool(ref_tok, ctx)
|
||||
if not pool:
|
||||
return ""
|
||||
values = [str(o.get("value", "")) for o in pool]
|
||||
weights = [float(o.get("weight", 1) or 1) for o in pool]
|
||||
chosen = pick_k_unique(values, weights, count, ctx.rng)
|
||||
# The multi-picked ref is the carrier of any `@{X}` inside the
|
||||
# values it resolves. We pick on VALUES (not option dicts) so a
|
||||
# specific option id isn't tracked here — set carrier to
|
||||
# (ref_uuid, None) for the recursion. option_id None can't match
|
||||
# a real `pick` entry, so a deeper `@{X}` falls back to
|
||||
# positional first/next/all (design: multi-pick children aren't
|
||||
# individually pickable). Restore the enclosing carrier after.
|
||||
get_carrier = getattr(ctx, "get_carrier", None)
|
||||
set_carrier = getattr(ctx, "set_carrier", None)
|
||||
saved_carrier = (
|
||||
get_carrier() if callable(get_carrier) else None
|
||||
)
|
||||
if callable(set_carrier):
|
||||
set_carrier(ref_uuid, None)
|
||||
try:
|
||||
return sep.join(
|
||||
_resolve_tokens(
|
||||
tokenize_text(v), ctx, depth=depth + 1,
|
||||
visited=visited + (ref_uuid,),
|
||||
) if v else ""
|
||||
for v in chosen
|
||||
)
|
||||
finally:
|
||||
if callable(set_carrier) and saved_carrier is not None:
|
||||
set_carrier(*saved_carrier)
|
||||
|
||||
weights = [_parse_branch_weight(b) for b in branches]
|
||||
branch_values = [_strip_branch_weight(b) for b in branches]
|
||||
resolved: list[str] = []
|
||||
|
||||
if independent:
|
||||
# N draws WITH replacement over the branches; each resolved
|
||||
# independently (a lone ref re-rolls every time → variety from nesting).
|
||||
for _ in range(count):
|
||||
j = _weighted_pick_index(weights, ctx.rng)
|
||||
v = branch_values[j]
|
||||
resolved.append(
|
||||
_resolve_tokens(tokenize_text(v), ctx, depth=depth, visited=visited)
|
||||
if v else ""
|
||||
)
|
||||
return sep.join(resolved)
|
||||
|
||||
# Unique over branches (legacy path, now range-aware): N distinct branches.
|
||||
n_picks = min(count, len(branches))
|
||||
if n_picks == 0:
|
||||
return ""
|
||||
|
||||
# Weighted without-replacement: branch weights default to 1; if a branch
|
||||
# uses the `N::value` form (a feature of the inline-pick weight micro-syntax),
|
||||
# the weight is parsed from the prefix. For simplicity, this implementation
|
||||
# treats all branches as weight=1 unless they begin with `<int>::`.
|
||||
weights = [_parse_branch_weight(b) for b in branches]
|
||||
branch_values = [_strip_branch_weight(b) for b in branches]
|
||||
|
||||
chosen_indices: list[int] = []
|
||||
available_indices = list(range(len(branches)))
|
||||
available_weights = list(weights)
|
||||
|
||||
for _ in range(n_picks):
|
||||
if not available_indices:
|
||||
break
|
||||
idx = _weighted_pick_index(available_weights, ctx.rng)
|
||||
chosen_indices.append(available_indices.pop(idx))
|
||||
available_weights.pop(idx)
|
||||
|
||||
resolved: list[str] = []
|
||||
for idx in chosen_indices:
|
||||
branch_text = branch_values[idx]
|
||||
chosen_values = pick_k_unique(branch_values, weights, n_picks, ctx.rng)
|
||||
for branch_text in chosen_values:
|
||||
if not branch_text:
|
||||
resolved.append("")
|
||||
continue
|
||||
@@ -265,6 +351,111 @@ def _resolve_multi_pick(
|
||||
return sep.join(resolved)
|
||||
|
||||
|
||||
def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
|
||||
"""Filtered + constrained option pool a ``@{uuid[#name][:expr][!null]}`` ref
|
||||
resolves against — catalog lookup, the ``:expr``/``!null`` sub-category
|
||||
filter, and chain constraints. Shared by ``_resolve_ref`` (single weighted
|
||||
pick) and the SP2b lone-ref multi-pick path.
|
||||
|
||||
Returns ``[]`` (pushing the same ``unknown_ref`` /
|
||||
``ref_subcategory_empty_pool`` warnings, and raising ``UnknownRefError`` in
|
||||
strict mode for an unknown ref) when there is nothing to pick. Does NOT
|
||||
apply the depth / cycle / surface guards — those stay in ``_resolve_ref``
|
||||
and the multi-pick caller.
|
||||
"""
|
||||
uuid = tok.meta.get("uuid", "")
|
||||
module = ctx.get_module(uuid)
|
||||
if module is None:
|
||||
if ctx.strict:
|
||||
raise UnknownRefError(uuid)
|
||||
cached_name = tok.meta.get("name") if hasattr(tok, "meta") else None
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="unknown_ref",
|
||||
severity="warn",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={"uuid": uuid, "name": cached_name},
|
||||
message=f"Unknown wildcard ref @{{{uuid}{'#' + cached_name if cached_name else ''}}}",
|
||||
)
|
||||
return []
|
||||
|
||||
payload_dict = module.get("payload")
|
||||
if isinstance(payload_dict, dict):
|
||||
options = list(payload_dict.get("options", []))
|
||||
else:
|
||||
options = list(module.get("options", []))
|
||||
|
||||
filter_expr = tok.meta.get("filter_expr")
|
||||
exclude_null = bool(tok.meta.get("exclude_null"))
|
||||
if (isinstance(filter_expr, str) and filter_expr.strip()) or exclude_null:
|
||||
ast = _parse_subcat(filter_expr) if isinstance(filter_expr, str) else None
|
||||
options = [
|
||||
o for o in options
|
||||
if isinstance(o, dict)
|
||||
and (
|
||||
(o.get("is_null") and not exclude_null)
|
||||
or (
|
||||
not o.get("is_null")
|
||||
and _subcat_matches(ast, set(o.get("sub_categories") or []))
|
||||
)
|
||||
)
|
||||
]
|
||||
if not options:
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="ref_subcategory_empty_pool",
|
||||
severity="warn",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={
|
||||
"uuid": uuid,
|
||||
"filter_expr": filter_expr,
|
||||
"exclude_null": exclude_null,
|
||||
},
|
||||
message=(
|
||||
f"@{{{uuid}:{filter_expr or ''}"
|
||||
f"{'!null' if exclude_null else ''}}} matched no options"
|
||||
),
|
||||
)
|
||||
return []
|
||||
|
||||
get_constraints = getattr(ctx, "get_constraints", None)
|
||||
get_picks = getattr(ctx, "get_picks", None)
|
||||
if callable(get_constraints) and callable(get_picks):
|
||||
constraints = get_constraints()
|
||||
if constraints:
|
||||
from engine.modules._constraints import (
|
||||
apply_constraints_for_target,
|
||||
warn_excludes_all,
|
||||
)
|
||||
# SP3: share the ctx-resident hit counter so a nested-ref
|
||||
# target occurrence counts toward the same first/next
|
||||
# coverage as direct top-level instances. `carrier_ctx` is
|
||||
# the identity of the carrier wildcard whose chosen option
|
||||
# value textually contains THIS `@{uuid}` ref — set on the
|
||||
# resolve frame by the enclosing wildcard handler (top-level
|
||||
# carrier) or by `_resolve_ref` (deeper carrier). A `pick`
|
||||
# selector's `nested` occurrence matches on it; first / next
|
||||
# / all ignore it. None when no carrier is set (a ref at the
|
||||
# surface root) — then `pick` simply won't cover.
|
||||
get_hits = getattr(ctx, "get_constraint_hits", None)
|
||||
hits = get_hits() if callable(get_hits) else {}
|
||||
get_carrier_ctx = getattr(ctx, "get_carrier_ctx", None)
|
||||
carrier_ctx = get_carrier_ctx() if callable(get_carrier_ctx) else None
|
||||
options, any_applied = apply_constraints_for_target(
|
||||
options, uuid, constraints, get_picks(), ctx.warnings,
|
||||
hits=hits, firing_uid=None, carrier_ctx=carrier_ctx,
|
||||
)
|
||||
if any_applied:
|
||||
warn_excludes_all(options, uuid, ctx.warnings)
|
||||
return options
|
||||
|
||||
|
||||
def _resolve_ref(
|
||||
tok: Token,
|
||||
ctx: ResolveContext,
|
||||
@@ -312,8 +503,10 @@ def _resolve_ref(
|
||||
)
|
||||
return ""
|
||||
|
||||
# Surface check
|
||||
if ctx.surface != "wildcard":
|
||||
# Surface check — wildcard options AND derivation action values may host
|
||||
# `@{}` refs (both run resolve_text inside a seeded Context with the chain
|
||||
# constraint bucket threaded). combine + assembler stay gated.
|
||||
if ctx.surface not in ("wildcard", "derivation"):
|
||||
if ctx.strict:
|
||||
raise RefOutOfSurfaceError(uuid, ctx.surface)
|
||||
# Try to resolve module name for the warning detail (best-effort)
|
||||
@@ -332,121 +525,13 @@ def _resolve_ref(
|
||||
)
|
||||
return ""
|
||||
|
||||
# Catalog lookup
|
||||
module = ctx.get_module(uuid)
|
||||
if module is None:
|
||||
if ctx.strict:
|
||||
raise UnknownRefError(uuid)
|
||||
# Surface the cached `#name` from the ref token if the original
|
||||
# syntax carried one (`@{uuid#name}`). Lets WP_Debug + RichTextPreview
|
||||
# render a friendly label even when the target wildcard has been
|
||||
# deleted from the library — same fallback chain RichTextPreview
|
||||
# uses for unresolved chips elsewhere.
|
||||
cached_name = tok.meta.get("name") if hasattr(tok, "meta") else None
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="unknown_ref",
|
||||
severity="warn",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={"uuid": uuid, "name": cached_name},
|
||||
message=f"Unknown wildcard ref @{{{uuid}{'#' + cached_name if cached_name else ''}}}",
|
||||
)
|
||||
# Catalog lookup + `:expr`/`!null` filter + chain constraints, factored
|
||||
# into ref_option_pool (shared with the SP2b lone-ref multi-pick path).
|
||||
# The strict-unknown raise + the unknown / empty-pool warnings live there.
|
||||
options = ref_option_pool(tok, ctx)
|
||||
if not options:
|
||||
return ""
|
||||
|
||||
# Pick weighted option.
|
||||
# Support both SnapshotEntry shape (payload.options, spec §2.4) and the
|
||||
# legacy flat shape used by existing unit tests (options at top level).
|
||||
payload_dict = module.get("payload")
|
||||
if isinstance(payload_dict, dict):
|
||||
options = list(payload_dict.get("options", []))
|
||||
else:
|
||||
options = list(module.get("options", []))
|
||||
|
||||
# Optional per-call sub-category filter: `@{uuid:warm,cool}` keeps
|
||||
# only options whose `sub_category` is in the requested list. Same
|
||||
# semantics as chain-level `instance.category_filter` but scoped to
|
||||
# this specific ref, so a shared library wildcard (e.g. `@{color}`)
|
||||
# can be narrowed differently at every call site without authoring
|
||||
# separate library entries. Empty post-filter pool → empty string
|
||||
# + warning so the user sees the unsatisfiable filter rather than
|
||||
# silently falling through to the unfiltered list.
|
||||
#
|
||||
# Null semantics (inverted 2026-05-25): the wildcard's `is_null`
|
||||
# option is INCLUDED by default — alongside whatever sub-cats the
|
||||
# filter lists. The reserved keyword `"null"` in the filter list
|
||||
# EXCLUDES the null option from the pool. So:
|
||||
# `@{uuid}` → all options (incl. null)
|
||||
# `@{uuid:warm}` → warm options + null
|
||||
# `@{uuid:warm,null}` → warm options, null excluded
|
||||
# `@{uuid:null}` → all non-null options (filter-only-null
|
||||
# = "exclude null, no sub-cat filter")
|
||||
# Sub-category names called literally `"null"` are forbidden by
|
||||
# WildcardHandler.validate_payload so the keyword can never clash
|
||||
# with a real sub-cat.
|
||||
sub_filter = tok.meta.get("sub_categories")
|
||||
if isinstance(sub_filter, list) and sub_filter:
|
||||
allowed_subs = set(sub_filter)
|
||||
exclude_null = "null" in allowed_subs
|
||||
allowed_subs.discard("null")
|
||||
options = [
|
||||
o for o in options
|
||||
if isinstance(o, dict)
|
||||
and (
|
||||
(o.get("is_null") and not exclude_null)
|
||||
or (
|
||||
not o.get("is_null")
|
||||
and (not allowed_subs or o.get("sub_category") in allowed_subs)
|
||||
)
|
||||
)
|
||||
]
|
||||
if not options:
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="ref_subcategory_empty_pool",
|
||||
severity="warn",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={"uuid": uuid, "sub_categories": list(sub_filter)},
|
||||
message=(
|
||||
f"@{{{uuid}:{','.join(sub_filter)}}} matched no options"
|
||||
),
|
||||
)
|
||||
return ""
|
||||
|
||||
# Apply chain-level constraints whose `target_wildcard_id` matches
|
||||
# this nested ref's uuid. Pre-2026-05 only top-level wildcards saw
|
||||
# constraints — nested `@{B}` from inside another wildcard's
|
||||
# option value silently bypassed every rule, defeating composed
|
||||
# pipelines. ResolveContext exposes `get_constraints` / `get_picks`
|
||||
# for this path; both default to empty for hand-built resolve
|
||||
# contexts in tests so the legacy fast path keeps working.
|
||||
get_constraints = getattr(ctx, "get_constraints", None)
|
||||
get_picks = getattr(ctx, "get_picks", None)
|
||||
if callable(get_constraints) and callable(get_picks):
|
||||
constraints = get_constraints()
|
||||
if constraints:
|
||||
from engine.modules._constraints import (
|
||||
apply_constraints_for_target,
|
||||
warn_excludes_all,
|
||||
)
|
||||
# First-instance one-shot semantic: thread the consumed
|
||||
# set so this nested-ref resolve path participates in the
|
||||
# same one-shot bookkeeping as top-level wildcard rolls.
|
||||
# See docs/superpowers/specs/2026-05-24-constraint-first-instance-design.md.
|
||||
get_consumed = getattr(ctx, "get_consumed_constraints", None)
|
||||
consumed = get_consumed() if callable(get_consumed) else set()
|
||||
options, any_applied = apply_constraints_for_target(
|
||||
options, uuid, constraints, get_picks(), ctx.warnings,
|
||||
consumed=consumed,
|
||||
)
|
||||
if any_applied:
|
||||
warn_excludes_all(options, uuid, ctx.warnings)
|
||||
|
||||
chosen = _pick_weighted(options, ctx.rng)
|
||||
if chosen is None:
|
||||
return ""
|
||||
@@ -455,6 +540,24 @@ def _resolve_ref(
|
||||
if not chosen_value:
|
||||
return ""
|
||||
|
||||
# Recursive resolve with depth+1, visited extended
|
||||
# Recursive resolve with depth+1, visited extended. This ref'd
|
||||
# wildcard is the carrier of any `@{X}` inside its OWN chosen option
|
||||
# value, so stamp (its uuid, the chosen option's id) on the frame for
|
||||
# the duration of the recursion, then restore the prior carrier. The
|
||||
# carrier_uid here is the library uuid (a ref'd wildcard has no row
|
||||
# `_uid`); a `pick` keyed on a top-level row uid won't match it —
|
||||
# that's the expected deeper-than-one-hop behaviour (positional
|
||||
# first/next/all cover deeper nesting). Mirrors the rng save/restore.
|
||||
nested_tokens = tokenize_text(chosen_value)
|
||||
get_carrier = getattr(ctx, "get_carrier", None)
|
||||
set_carrier = getattr(ctx, "set_carrier", None)
|
||||
if callable(get_carrier) and callable(set_carrier):
|
||||
saved_carrier = get_carrier()
|
||||
set_carrier(uuid, chosen.get("id"))
|
||||
try:
|
||||
return _resolve_tokens(
|
||||
nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,)
|
||||
)
|
||||
finally:
|
||||
set_carrier(*saved_carrier)
|
||||
return _resolve_tokens(nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,))
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Boolean sub-category filter — parser, matcher, normalizer, validators.
|
||||
|
||||
Grammar (precedence not > and > or; or/and are n-ary, flattened):
|
||||
expr := or
|
||||
or := and ( ("or" | ",") and )*
|
||||
and := unary ( "and" unary )*
|
||||
unary := "not" unary | atom
|
||||
atom := SUBCAT | "(" or ")"
|
||||
|
||||
Mirror: src/manager/parsing/subcatFilter.ts — keep behavior identical
|
||||
(shared fixture in tests/fixtures/subcat_filter_cases.json).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
RESERVED = {"and", "or", "not", "null"}
|
||||
# A sub-category NAME is one safe token (see validate_subcat_name).
|
||||
_NAME_RE = re.compile(r"^[^\s()!,#:}@{$]+$")
|
||||
_TOKEN_RE = re.compile(r"\s*(\(|\)|,|[^\s()!,#:}@{$]+)")
|
||||
|
||||
|
||||
class ParseError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _tokenize(s: str) -> list[str]:
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(s):
|
||||
m = _TOKEN_RE.match(s, i)
|
||||
if not m:
|
||||
if s[i:].strip() == "": # only trailing whitespace left
|
||||
break
|
||||
raise ParseError(f"unexpected character {s[i]!r}")
|
||||
out.append(m.group(1))
|
||||
i = m.end()
|
||||
return out
|
||||
|
||||
|
||||
def parse(s: str) -> dict[str, Any] | None:
|
||||
"""Parse an expression string to an AST, or None when empty."""
|
||||
toks = _tokenize(s)
|
||||
if not toks:
|
||||
return None
|
||||
pos = 0
|
||||
|
||||
def peek() -> str | None:
|
||||
return toks[pos] if pos < len(toks) else None
|
||||
|
||||
def eat() -> str:
|
||||
nonlocal pos
|
||||
t = toks[pos]
|
||||
pos += 1
|
||||
return t
|
||||
|
||||
def p_or() -> dict[str, Any]:
|
||||
kids = [p_and()]
|
||||
while peek() in ("or", ","):
|
||||
eat()
|
||||
kids.append(p_and())
|
||||
return kids[0] if len(kids) == 1 else {"op": "or", "kids": kids}
|
||||
|
||||
def p_and() -> dict[str, Any]:
|
||||
kids = [p_unary()]
|
||||
while peek() == "and":
|
||||
eat()
|
||||
kids.append(p_unary())
|
||||
return kids[0] if len(kids) == 1 else {"op": "and", "kids": kids}
|
||||
|
||||
def p_unary() -> dict[str, Any]:
|
||||
if peek() == "not":
|
||||
eat()
|
||||
return {"op": "not", "x": p_unary()}
|
||||
return p_atom()
|
||||
|
||||
def p_atom() -> dict[str, Any]:
|
||||
t = peek()
|
||||
if t == "(":
|
||||
eat()
|
||||
inner = p_or()
|
||||
if peek() != ")":
|
||||
raise ParseError("missing closing paren )")
|
||||
eat()
|
||||
return inner
|
||||
if t is None:
|
||||
raise ParseError("expression incomplete (missing a term)")
|
||||
if t in ("and", "or", "not", ")", ","):
|
||||
raise ParseError(f"unexpected token {t!r} (missing an operator/term?)")
|
||||
eat()
|
||||
return {"tag": t}
|
||||
|
||||
ast = p_or()
|
||||
if pos < len(toks):
|
||||
raise ParseError(f"unexpected token {toks[pos]!r} (missing an operator?)")
|
||||
return ast
|
||||
|
||||
|
||||
def matches(ast: dict[str, Any] | None, tags: set[str]) -> bool:
|
||||
"""True iff the option's `tags` satisfy the expression. Empty expr => True."""
|
||||
if ast is None:
|
||||
return True
|
||||
if "tag" in ast:
|
||||
return ast["tag"] in tags
|
||||
op = ast["op"]
|
||||
if op == "not":
|
||||
return not matches(ast["x"], tags)
|
||||
if op == "and":
|
||||
return all(matches(k, tags) for k in ast["kids"])
|
||||
return any(matches(k, tags) for k in ast["kids"]) # or
|
||||
|
||||
|
||||
def reads_as(ast: dict[str, Any] | None) -> str:
|
||||
"""Canonical pretty-print: flatten same-op chains; parenthesize only a
|
||||
child whose operator differs from the parent's."""
|
||||
if ast is None:
|
||||
return ""
|
||||
if "tag" in ast:
|
||||
return ast["tag"]
|
||||
op = ast["op"]
|
||||
if op == "not":
|
||||
c = ast["x"]
|
||||
inner = reads_as(c)
|
||||
return f"not ({inner})" if ("op" in c and c["op"] in ("and", "or")) else f"not {inner}"
|
||||
parts = []
|
||||
for k in ast["kids"]:
|
||||
inner = reads_as(k)
|
||||
if "op" in k and k["op"] in ("and", "or") and k["op"] != op:
|
||||
parts.append(f"({inner})")
|
||||
else:
|
||||
parts.append(inner)
|
||||
return f" {op} ".join(parts)
|
||||
|
||||
|
||||
def validate_subcat_name(name: str) -> str | None:
|
||||
"""Return an error string, or None when the name is a valid single token."""
|
||||
if not name:
|
||||
return "name is empty"
|
||||
if name.lower() in RESERVED:
|
||||
return f"reserved word: {name!r} (and/or/not/null are not allowed)"
|
||||
if any(ch.isspace() for ch in name):
|
||||
return "name must not contain whitespace"
|
||||
if not _NAME_RE.match(name):
|
||||
bad = next(ch for ch in name if ch in "()!,#:}@{$")
|
||||
return f"disallowed character: {bad!r}"
|
||||
return None
|
||||
|
||||
|
||||
def validate_expression(s: str, known: set[str]) -> str | None:
|
||||
"""Return an error string, or None when the expression is valid against `known`."""
|
||||
try:
|
||||
ast = parse(s)
|
||||
except ParseError as e:
|
||||
return str(e)
|
||||
if ast is None:
|
||||
return None
|
||||
seen: set[str] = set()
|
||||
|
||||
def walk(n: dict[str, Any]) -> None:
|
||||
if "tag" in n:
|
||||
seen.add(n["tag"])
|
||||
return
|
||||
if n["op"] == "not":
|
||||
walk(n["x"])
|
||||
return
|
||||
for k in n["kids"]:
|
||||
walk(k)
|
||||
|
||||
walk(ast)
|
||||
for t in seen:
|
||||
if t.lower() in RESERVED:
|
||||
return f"reserved word used as a term: {t!r}"
|
||||
if t not in known:
|
||||
return f"Unknown sub-category: {t!r}"
|
||||
return None
|
||||
+61
-39
@@ -13,26 +13,34 @@ import re
|
||||
|
||||
from engine.syntax.types import Token, TokenKind
|
||||
|
||||
# `$name` — identifier starts with letter or underscore, then word chars
|
||||
_VAR_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
|
||||
# `@{8hex}`, `@{8hex#name}`, `@{8hex:subcat[,subcat...]}`, or
|
||||
# `@{8hex#name:subcat[,subcat...]}` — exactly 8 lowercase hex chars,
|
||||
# optionally followed by a `#name` segment (display label cached at
|
||||
# write-time so a broken ref still tells the user what the wildcard
|
||||
# was called), optionally followed by a `:` + comma-separated
|
||||
# sub-category filter. Filter restricts the nested wildcard's pick to
|
||||
# options whose `sub_category` is in the supplied list — same
|
||||
# semantics as instance.category_filter at the chain level, but
|
||||
# scoped per-call so one library wildcard can be narrowed differently
|
||||
# from each call site. The name segment is purely informational —
|
||||
# resolver matches on uuid only.
|
||||
# `$name` or `$name.K` — identifier starts with letter or underscore, then
|
||||
# word chars, with an optional `.K` list-index accessor (SP2a). Group 2 = the
|
||||
# index digits when present.
|
||||
_VAR_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?")
|
||||
# 4-segment ref grammar (fixed order): `@{8hex [#name] [:expr] [!null]}`.
|
||||
# group 1 — uuid: exactly 8 lowercase hex chars.
|
||||
# group 2 — optional `#name`: display label cached at write-time so a
|
||||
# broken ref still tells the user what the wildcard was
|
||||
# called. Purely informational — the resolver matches uuid.
|
||||
# group 3 — optional `:expr`: a BOOLEAN sub-category filter expression
|
||||
# (`and`/`or`/`not`/parens/comma=or). Parsed + matched by the
|
||||
# shared `engine.syntax.subcat_filter` matcher; narrows the
|
||||
# nested pick to options whose tag set satisfies the
|
||||
# expression — same matcher as instance.category_filter.
|
||||
# group 4 — optional `!null`: the exclude-null marker (drops the null
|
||||
# option from the nested pick). Separate from the expression.
|
||||
#
|
||||
# Name char-class `[^#:}@{]` mirrors the wildcard-name validator so
|
||||
# names that would re-trigger this regex (or break it) are rejected at
|
||||
# the editor + API boundary, not at parse time.
|
||||
_REF_RE = re.compile(r"@\{([0-9a-f]{8})(?:#([^#:}@{]*))?(?::([^}]*))?\}")
|
||||
# Multi-pick prefix: {N$$sep$$ where N is one or more digits and sep can be empty.
|
||||
_MULTI_PREFIX_RE = re.compile(r"^\{(\d+)\$\$(.*?)\$\$", flags=re.DOTALL)
|
||||
# Name/expr char-classes exclude `!` (the null-marker delimiter) so the
|
||||
# segments stay unambiguous; sub-category names additionally forbid the
|
||||
# boolean grammar chars (see `subcat_filter.validate_subcat_name`),
|
||||
# enforced at the editor + API boundary, not at parse time.
|
||||
_REF_RE = re.compile(
|
||||
r"@\{([0-9a-f]{8})(?:#([^#:}@{!]*))?(?::([^}!]*))?(?:!([^}]*))?\}"
|
||||
)
|
||||
# Multi-pick prefix: {N$$sep$$ or {N-M~$$sep$$ — group 1 is the count (a fixed
|
||||
# `N` or a `N-M` range), group 2 is the optional `~` independent flag (repeats
|
||||
# allowed; absent = unique), group 3 is the separator (may be empty). SP2b.
|
||||
_MULTI_PREFIX_RE = re.compile(r"^\{(\d+(?:-\d+)?)(~?)\$\$(.*?)\$\$", flags=re.DOTALL)
|
||||
|
||||
|
||||
def _split_top_level_pipes(s: str) -> list[str]:
|
||||
@@ -54,7 +62,7 @@ def _split_top_level_pipes(s: str) -> list[str]:
|
||||
|
||||
def _scan_brace_block(
|
||||
text: str, start: int
|
||||
) -> tuple[int, list[str], int | None, str | None] | None:
|
||||
) -> tuple[int, list[str], tuple[int, int, bool] | None, str | None] | None:
|
||||
"""Try to scan a `{...}` block starting at `text[start] == '{'`.
|
||||
|
||||
Returns:
|
||||
@@ -97,13 +105,21 @@ def _scan_brace_block(
|
||||
body_with_braces = "{" + body # match against `{N$$sep$$...`
|
||||
multi_match = _MULTI_PREFIX_RE.match(body_with_braces)
|
||||
if multi_match:
|
||||
count = int(multi_match.group(1))
|
||||
sep = multi_match.group(2)
|
||||
count_raw = multi_match.group(1) # "N" or "N-M"
|
||||
independent = multi_match.group(2) == "~" # SP2b: repeats allowed
|
||||
sep = multi_match.group(3)
|
||||
# Body remaining after the prefix is the branch list
|
||||
prefix_len = multi_match.end() - 1 # subtract the leading `{` we added
|
||||
rest = body[prefix_len:]
|
||||
branches = _split_top_level_pipes(rest)
|
||||
return (end_index, branches, count, sep)
|
||||
if "-" in count_raw:
|
||||
lo, hi = count_raw.split("-", 1)
|
||||
cmin, cmax = int(lo), int(hi)
|
||||
else:
|
||||
cmin = cmax = int(count_raw)
|
||||
if cmin > cmax: # normalize a swapped range
|
||||
cmin, cmax = cmax, cmin
|
||||
return (end_index, branches, (cmin, cmax, independent), sep)
|
||||
|
||||
# Single-pick: must contain at least one top-level `|`
|
||||
branches = _split_top_level_pipes(body)
|
||||
@@ -169,12 +185,15 @@ def tokenize_text(text: str) -> list[Token]:
|
||||
m = _VAR_RE.match(text, i)
|
||||
if m:
|
||||
_flush_text(i)
|
||||
var_meta: dict[str, object] = {"name": m.group(1)}
|
||||
if m.group(2) is not None:
|
||||
var_meta["index"] = int(m.group(2))
|
||||
out.append(Token(
|
||||
kind=TokenKind.VAR,
|
||||
raw=m.group(0),
|
||||
start=i,
|
||||
end=m.end(),
|
||||
meta={"name": m.group(1)},
|
||||
meta=var_meta,
|
||||
))
|
||||
i = m.end()
|
||||
continue
|
||||
@@ -185,24 +204,21 @@ def tokenize_text(text: str) -> list[Token]:
|
||||
m = _REF_RE.match(text, i)
|
||||
if m:
|
||||
_flush_text(i)
|
||||
# Group 1: uuid. Group 2: optional display name. Group
|
||||
# 3: optional sub-category filter (split on comma, strip
|
||||
# whitespace, drop empties). Empty / whitespace-only
|
||||
# filter is equivalent to "no filter" — keeps
|
||||
# `@{xyz:}` from accidentally banning every option.
|
||||
filter_raw = m.group(3)
|
||||
sub_categories: list[str] = []
|
||||
if filter_raw is not None:
|
||||
sub_categories = [
|
||||
sc for sc in (s.strip() for s in filter_raw.split(","))
|
||||
if sc
|
||||
]
|
||||
# Group 1: uuid. Group 2: optional cached display name.
|
||||
# Group 3: optional boolean sub-category filter
|
||||
# expression (stored raw; the resolver parses it via the
|
||||
# shared subcat_filter matcher). A whitespace-only
|
||||
# `:expr` is "no filter" — keeps `@{xyz:}` from banning
|
||||
# every option. Group 4: the `!null` exclude-null marker.
|
||||
meta: dict = {"uuid": m.group(1)}
|
||||
name_raw = m.group(2)
|
||||
if name_raw:
|
||||
meta["name"] = name_raw
|
||||
if sub_categories:
|
||||
meta["sub_categories"] = sub_categories
|
||||
expr_raw = m.group(3)
|
||||
if expr_raw is not None and expr_raw.strip():
|
||||
meta["filter_expr"] = expr_raw
|
||||
if m.group(4) == "null":
|
||||
meta["exclude_null"] = True
|
||||
out.append(Token(
|
||||
kind=TokenKind.REF,
|
||||
raw=m.group(0),
|
||||
@@ -228,12 +244,18 @@ def tokenize_text(text: str) -> list[Token]:
|
||||
meta={"branches": branches},
|
||||
))
|
||||
else:
|
||||
cmin, cmax, independent = count
|
||||
out.append(Token(
|
||||
kind=TokenKind.DP_MULTI,
|
||||
raw=text[i:end_index],
|
||||
start=i,
|
||||
end=end_index,
|
||||
meta={"count": count, "sep": sep, "branches": branches},
|
||||
# `count` kept == max for back-compat readers; min/max
|
||||
# carry the range, independent carries the `~` flag (SP2b).
|
||||
meta={
|
||||
"min": cmin, "max": cmax, "independent": independent,
|
||||
"count": cmax, "sep": sep, "branches": branches,
|
||||
},
|
||||
))
|
||||
i = end_index
|
||||
continue
|
||||
|
||||
+65
-2
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import random
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import (
|
||||
Any,
|
||||
@@ -53,6 +54,66 @@ class Token:
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListVar:
|
||||
"""A list-valued variable plus the separator used to join it for a bare
|
||||
``$name`` reference (SP2a multi-select). Produced only by a multi-select
|
||||
wildcard; exists only inside one resolution pass — never persisted or
|
||||
published. ``$name`` renders ``sep.join(items)``; ``$name.K`` indexes
|
||||
(0-based; out-of-range -> "").
|
||||
"""
|
||||
|
||||
items: list[str]
|
||||
sep: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""A bare ``$name`` reference renders the list joined by ``sep``.
|
||||
Defining ``__str__`` (the @dataclass ``__repr__`` stays, for debug)
|
||||
keeps a list value that leaks into a plain ``str()`` context rendering
|
||||
as the user-facing joined string instead of ``ListVar(items=[...])``."""
|
||||
return self.sep.join(self.items)
|
||||
|
||||
|
||||
_VAR_ACCESSOR_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\.(\d+)$")
|
||||
|
||||
|
||||
def split_var_accessor(name: str) -> tuple[str, int | None]:
|
||||
"""Split a bare var reference into ``(base_name, index)`` (SP2a `.K`).
|
||||
|
||||
``"mood.0"`` -> ``("mood", 0)``; ``"mood"`` -> ``("mood", None)``. A name
|
||||
that is not an ``ident.digits`` shape is returned whole with
|
||||
``index=None`` so unusual keys pass through unchanged. Callers strip any
|
||||
leading ``$`` before calling.
|
||||
"""
|
||||
if not isinstance(name, str):
|
||||
return ("", None)
|
||||
m = _VAR_ACCESSOR_RE.match(name)
|
||||
if m:
|
||||
return (m.group(1), int(m.group(2)))
|
||||
return (name, None)
|
||||
|
||||
|
||||
def deref_var_value(value: str | ListVar | None, index: int | None) -> str:
|
||||
"""Render a resolved var value to a string, honoring an optional ``.K``
|
||||
list accessor (SP2a). ListVar: bare -> ``sep.join(items)``; ``.K`` ->
|
||||
``items[K]`` or ``""`` when out of range. A plain string behaves as a
|
||||
1-element list (``.0`` == itself, ``.K`` > 0 == ``""``). ``None`` -> ``""``.
|
||||
|
||||
Single source of the accessor contract: resolve.py, derivation_handler.py
|
||||
and converters.py all defer here so the read sites never drift.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, ListVar):
|
||||
if index is not None:
|
||||
return value.items[index] if 0 <= index < len(value.items) else ""
|
||||
return value.sep.join(value.items)
|
||||
s = value if isinstance(value, str) else str(value)
|
||||
if index is not None:
|
||||
return s if index == 0 else ""
|
||||
return s
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ResolveContext(Protocol):
|
||||
"""Interface the resolver depends on. Construct via build_resolve_ctx in
|
||||
@@ -67,8 +128,10 @@ class ResolveContext(Protocol):
|
||||
developer_mode: bool
|
||||
warnings: list[dict[str, Any]]
|
||||
|
||||
def get_var(self, name: str) -> str | None:
|
||||
"""Return the bound value of `name`, or None if unbound."""
|
||||
def get_var(self, name: str) -> str | ListVar | None:
|
||||
"""Return the bound value of `name`, or None if unbound. A
|
||||
multi-select wildcard binds a ``ListVar`` (SP2a); everything else
|
||||
binds a ``str``."""
|
||||
...
|
||||
|
||||
def get_module(self, uuid: str) -> dict[str, Any] | None:
|
||||
|
||||
+7
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-wildcard-pipeline",
|
||||
"version": "2.0.1",
|
||||
"version": "2.10.2",
|
||||
"private": true,
|
||||
"description": "Modular procedural prompt generation for ComfyUI",
|
||||
"author": "DumiFlex",
|
||||
@@ -27,15 +27,13 @@
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.2.6",
|
||||
"primeicons": "^7.0.0",
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "^4.4.5",
|
||||
"pinia": "^2.2.6",
|
||||
"primeicons": "^7.0.0"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.4.14",
|
||||
"postcss": "^8.4.47",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"@comfyorg/comfyui-frontend-types": "^1.42.15",
|
||||
"@commitlint/cli": "^19.5.0",
|
||||
"@commitlint/config-conventional": "^19.5.0",
|
||||
@@ -48,12 +46,15 @@
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"@vue/test-utils": "^2.4.8",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-vue": "^9.31.0",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.5.0",
|
||||
"husky": "^9.1.6",
|
||||
"jsdom": "^29.0.2",
|
||||
"postcss": "^8.4.47",
|
||||
"semantic-release": "^25.0.3",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-css-injected-by-js": "^3.5.2",
|
||||
|
||||
Generated
+3
@@ -20,6 +20,9 @@ importers:
|
||||
vue-router:
|
||||
specifier: ^4.4.5
|
||||
version: 4.6.4(vue@3.5.33(typescript@5.9.3))
|
||||
zod:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@comfyorg/comfyui-frontend-types':
|
||||
specifier: ^1.42.15
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-wildcard-pipeline"
|
||||
version = "2.0.1"
|
||||
version = "2.10.2"
|
||||
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -144,7 +144,89 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
|
||||
// in onUpdate/onValueRestored/microtask). Net ~0.5 KB gzip; 2 KB
|
||||
// headroom buffer to avoid the "17 bytes from budget" tightness from
|
||||
// the previous bump.
|
||||
const TOTAL_LIMIT = 395 * 1024; // 395 KB
|
||||
// Bumped 395 -> 405 KB on 2026-06-07 for SP1 Chunk G (in-graph multi-tag
|
||||
// pool editor). PoolSection gains grouped quick pills + an ƒ(x) Advanced
|
||||
// boolean-expression editor; OptionRow gains per-tag CATEGORY chips; and
|
||||
// the shared engine-parity subcat_filter parser (parse/matches/readsAs)
|
||||
// is now imported into the wildcard graph editor chunk. ~4 KB net gzip
|
||||
// across the lazy ContextWidget chunk; entry stays put. Approved with the
|
||||
// SP1 multi-tag + boolean-filter feature (docs/superpowers/specs/
|
||||
// 2026-06-06-wildcard-multi-subcategory-boolean-filter-design.md §4.2).
|
||||
// Bumped 405 -> 430 KB on 2026-06-11 for SP3 constraint reach UI headroom.
|
||||
// SP3 Task 13 lands the TS combineConstraintFactor mirror (+ shared corpus
|
||||
// parity test) now; the upcoming reach/constraint-matrix UI phases need the
|
||||
// total budget headroom in the lazy ContextWidget chunk. Approved alongside
|
||||
// the SP3 constraint feature.
|
||||
// Bumped 430 -> 431 KB on 2026-06-18 — two same-day landings share the
|
||||
// ceiling (both CSS/glue; the manager-only bulk UI adds nothing to js/):
|
||||
// - editor robustness: RichTextInput renderEpoch crash fix + useEditorDraft
|
||||
// flush-on-unmount/unload + ErrorBoundary copy + clipboard-shield wired
|
||||
// into _shared.ts / main.ts (Ctrl-A/C/V/X guard on editable inputs).
|
||||
// - instance-modal scroll/sticky: sticky header + runtime/footer dock +
|
||||
// own-scrollbar inner lists (options/values/rules/exceptions) + compact
|
||||
// exceptions trash, across all 6 edit modals.
|
||||
// Bumped 431 -> 432 KB on 2026-06-18 for the syntax + UI fix batch: inline
|
||||
// `{N::a|b}` branch weights (engine + TS resolver mirror), the filter / pick /
|
||||
// exclude-null MOD-dot fix, the "X of Y options" + "picks N-M" wildcard
|
||||
// subtitle, the library-aware nested-ref check (kills the false BROKEN REF),
|
||||
// and the constraint source-missing debug warning + label. ~0.4 KB net gzip
|
||||
// across the ContextWidget + conflicts chunks; entry stays put.
|
||||
// Bumped 432 -> 435 KB on 2026-06-22 for the canvas constraint-matrix grouping
|
||||
// parity. MatrixSection (the ComfyUI instance-modal grid) gains the SPA's
|
||||
// grouped-axis layout — column bands, row header chips, solo eyebrow, the
|
||||
// labelled "uncategorized" bucket, and frozen headers + both-axis scroll —
|
||||
// plus the shared components/shared/matrix-axis module the SPA matrix now
|
||||
// imports too. ~1.1 KB net gzip in the lazy ContextWidget chunk; entry stays
|
||||
// put. Approved with the matrix-parity feature request.
|
||||
// Bumped 435 -> 442 KB on 2026-06-23 for per-iteration seeds modal
|
||||
// (T8+T9). ContextLoopWidget gains the SeedListModal + SeedLockRow lazy
|
||||
// body (v-if gated) — adds ~5.5 KB net gzip to the ContextLoopWidget
|
||||
// lazy chunk. SeedListWidget gains a matching button in T9. Headroom
|
||||
// covers both T8 and T9 landings without per-task bump friction.
|
||||
// Bumped 442 -> 443 KB on 2026-06-23 for T9 (SeedListWidget seeds
|
||||
// button). The SeedListWidget chunk grew ~0.7 KB gzip adding the button
|
||||
// template + ref/computed script + seedbtn CSS; T8's headroom estimate
|
||||
// was tight. Minimal bump to the actual measured ceiling.
|
||||
// Bumped 443 -> 444 KB on 2026-06-23 for T12 (seed-list preview
|
||||
// effective base/count/strategy from wired loop). Adds preview.ts pure
|
||||
// resolver + graph-walk glue in seed_list.ts + previewStrategy prop in
|
||||
// SeedListWidget.vue. ~0.4 KB net gzip in the seed_list widget chunk.
|
||||
// Bumped 444 -> 445 KB on 2026-06-23 for T16 (seed modal paste-to-replace).
|
||||
// Adds parseSeedLocks in seed-derive + pasteAll/Paste button in SeedListModal.
|
||||
// Measured 454900 bytes (244 over the 444 ceiling); minimal bump.
|
||||
// Bumped 445 -> 446 KB on 2026-06-24 for UI-C (wire ContextWidget frame edits).
|
||||
// Adds imports of frame-cursor / frame-overrides / INSTANCE_FIELDS_PER_KIND into
|
||||
// the ContextWidget lazy chunk + the merge-on-open / write-redirect logic.
|
||||
// Measured 455772 bytes (92 over the 445 ceiling); minimal bump.
|
||||
// Bumped 446 -> 447 KB on 2026-06-24 for RF2 (frame-aware row quick-lock).
|
||||
// Adds toggleFrameLock helper in frame-overrides.ts + effectiveLockedSeed +
|
||||
// frame branch in toggleLockOnCard + frame-aware isLocked. Net ~168 bytes
|
||||
// gzip in the lazy ContextWidget chunk; entry stays put.
|
||||
// Bumped 447 -> 448 KB on 2026-06-24 for the symmetric per-frame enable
|
||||
// redesign (a `frame_enabled` override map replaces the `disabled_frames`
|
||||
// blocklist so a base-off module can be turned ON for select frames). Adds
|
||||
// frameEnabledAt / effectiveEnabled / frameEnableOverride / toggleFrameEnabled
|
||||
// to frame-overrides.ts, the effective-enabled checkbox + bidirectional
|
||||
// on-#k / off-#k badge in ModuleRow, and the .wp-mod-badge--on CSS. Measured
|
||||
// 457868 bytes (140 over the 447 ceiling); minimal bump.
|
||||
// Bumped 448 -> 449 KB on 2026-06-25 for the per-frame seed-lock correctness
|
||||
// work: the Loop's derived-series capture (loop_seeds UI payload + onExecuted
|
||||
// store + frameSeedReader preference, so the lock pins the PREVIOUS run's
|
||||
// per-frame seed under control_after_generate=randomize) plus the modal
|
||||
// Lock-button resolver (lock-seed-ctx provide/inject into SeedLockControls).
|
||||
// Landed exactly at the 448 ceiling (458752/458752); bump for headroom.
|
||||
// Bumped 449 -> 450 KB on 2026-06-28 for the row context-menu Hold toggle.
|
||||
// Adds HOLD_KINDS + isHoldable + toggleHold to ContextWidget plus the quick
|
||||
// "Hold across run" / "Release hold" menu item (2-click seed_scope toggle,
|
||||
// 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.
|
||||
// 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;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Generate engine-parity fixtures for the community validator suite.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
The community-web TS validators (`src/validators/`) are a hand-authored
|
||||
re-implementation of the engine's payload shapes. Re-implementations
|
||||
drift: fixed_values was published for weeks as `entries/{variable_name}`
|
||||
when the engine has always used `values/{id,name,value}`; wildcard
|
||||
options omitted the real `is_null` field; etc. Every drift surfaced as a
|
||||
publish failure in front of a user.
|
||||
|
||||
This script makes the ENGINE the single source of truth. For each module
|
||||
subtype it builds a sample payload, runs it through that subtype's own
|
||||
`Handler.validate_payload` (the engine authority -- raises if the sample
|
||||
is not engine-valid), and dumps the engine-row shape to a committed
|
||||
fixture. The companion test `src/validators/__tests__/engine-parity.test.ts`
|
||||
asserts the STRICT community validator accepts every fixture.
|
||||
|
||||
Result: if the engine shape changes (a handler gains/renames a field) the
|
||||
sample stops validating here and this script fails; if the TS validator
|
||||
drifts from the engine, the parity test fails. Drift is caught at CI time
|
||||
instead of in front of a publisher.
|
||||
|
||||
USAGE
|
||||
-----
|
||||
python scripts/dump_engine_shapes.py
|
||||
|
||||
Run it whenever an engine module shape changes, then commit the updated
|
||||
fixtures. Exit 0 = all samples engine-valid + written; exit 1 = a sample
|
||||
is no longer engine-valid (fix the sample to match the new shape).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Make `engine` importable when run as a bare script (mirrors the
|
||||
# tests/conftest.py sys.path shim).
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from engine.modules.combine_handler import CombineHandler # noqa: E402
|
||||
from engine.modules.constraint_handler import ConstraintHandler # noqa: E402
|
||||
from engine.modules.derivation_handler import DerivationHandler # noqa: E402
|
||||
from engine.modules.fixed_values_handler import FixedValuesHandler # noqa: E402
|
||||
from engine.modules.wildcard_handler import WildcardHandler # noqa: E402
|
||||
|
||||
# Output dir, relative to repo root (this file lives in scripts/).
|
||||
_OUT_DIR = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "src" / "validators" / "fixtures" / "engine-parity"
|
||||
)
|
||||
|
||||
# Each entry: (handler, type-specific payload). The payload must pass the
|
||||
# handler's validate_payload -- keep these as the MINIMAL real shape per
|
||||
# subtype, including fields that the strip-tolerant validator must
|
||||
# preserve (e.g. wildcard `is_null`).
|
||||
_SAMPLES: dict[str, tuple[type, dict]] = {
|
||||
"wildcard": (
|
||||
WildcardHandler,
|
||||
{
|
||||
"var_binding": "mood",
|
||||
"sub_categories": ["soft", "bold"],
|
||||
"options": [
|
||||
{"id": "opt00001", "value": "serene", "weight": 1, "sub_category": "soft"},
|
||||
# Null option: the is_null contract. Must round-trip
|
||||
# through the validator or install rebuilds it as an
|
||||
# invalid empty-value option.
|
||||
{"id": "opt00002", "value": "", "weight": 1, "sub_category": None, "is_null": True},
|
||||
],
|
||||
},
|
||||
),
|
||||
"fixed_values": (
|
||||
FixedValuesHandler,
|
||||
{"values": [{"id": "val00001", "name": "style", "value": "oil painting"}]},
|
||||
),
|
||||
"combine": (
|
||||
CombineHandler,
|
||||
{"template": "$mood $subject", "output_var": "scene", "input_vars": ["mood", "subject"]},
|
||||
),
|
||||
"derivation": (
|
||||
DerivationHandler,
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"id": "rule0001",
|
||||
"branches": [
|
||||
{
|
||||
"condition": {
|
||||
"var": "mood", "op": "equals", "value": "dramatic",
|
||||
},
|
||||
"action": {
|
||||
"target_var": "accent", "mode": "replace",
|
||||
"value": "cinematic lighting",
|
||||
},
|
||||
},
|
||||
],
|
||||
"else": {
|
||||
"action": {
|
||||
"target_var": "accent", "mode": "replace",
|
||||
"value": "soft lighting",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
"constraint": (
|
||||
ConstraintHandler,
|
||||
{
|
||||
"source_wildcard_id": "src00001",
|
||||
"target_wildcard_id": "tgt00001",
|
||||
"exceptions": [],
|
||||
"matrix": {"soft": {"bold": {"mode": "boost", "factor": 3}}},
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _engine_row(type_id: str, payload: dict) -> dict:
|
||||
"""Wrap a type-specific payload in the canonical engine-row shape
|
||||
the publisher ships (see manager/import-export/single-row-publish.ts:
|
||||
buildModulePublishable)."""
|
||||
return {
|
||||
"id": f"parity{type_id[:2]}",
|
||||
"type": type_id,
|
||||
"name": f"parity-{type_id}",
|
||||
"description": "",
|
||||
"category_id": None,
|
||||
"tags": [],
|
||||
"is_favorite": False,
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
failures: list[str] = []
|
||||
for type_id, (handler, payload) in _SAMPLES.items():
|
||||
try:
|
||||
# Engine authority: this raises if the sample is not a valid
|
||||
# payload for the CURRENT engine handler.
|
||||
handler.validate_payload(payload)
|
||||
except Exception as exc: # noqa: BLE001 -- report any validation failure
|
||||
failures.append(f"{type_id}: sample is no longer engine-valid: {exc}")
|
||||
continue
|
||||
row = _engine_row(type_id, payload)
|
||||
out = _OUT_DIR / f"{type_id}.json"
|
||||
out.write_text(json.dumps(row, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"[parity] wrote {out.relative_to(_OUT_DIR.parent.parent.parent)}")
|
||||
|
||||
if failures:
|
||||
print("\n[parity] FAILED -- update the sample(s) to match the engine:", file=sys.stderr)
|
||||
for f in failures:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"[parity] {len(_SAMPLES)} engine-valid fixtures written to {_OUT_DIR}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Runtime contracts shared with the community web service.
|
||||
*
|
||||
* The community service is the schema authority — the sister project
|
||||
* (this manager) consumes these contracts when importing/installing
|
||||
* payloads downloaded from the community side. Keep these in sync
|
||||
* with `web/src/api/types.ts` in the Wildcard-Pipeline-Community repo.
|
||||
*
|
||||
* This module deliberately holds runtime/transport contracts only.
|
||||
* Manager-internal types (engine row shapes, UI state) live under
|
||||
* `src/manager/api/types.ts`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One row from the community service's schema_catalog table (community
|
||||
* spec §3). Returned alongside a download payload so the installer can
|
||||
* plan its migration / tolerant-parse strategy without a second
|
||||
* round-trip. Mirrors the Pydantic schema in
|
||||
* `api/app/schemas/catalog.py` on the community side.
|
||||
*
|
||||
* Fields:
|
||||
* - `version`: schema version this row describes.
|
||||
* - `is_breaking_from_previous`: true when going from `version - 1`
|
||||
* to `version` introduced a NON-additive change (field removal,
|
||||
* type change, semantics change). Used by `decideInstallPath`'s
|
||||
* AND-fold check.
|
||||
* - `min_consumer_engine_version`: engine semver gate, or null when
|
||||
* no minimum is enforced.
|
||||
* - `notes`: free-form release notes for this schema bump.
|
||||
* - `created_at`: ISO timestamp the row was inserted, or null on
|
||||
* hand-seeded rows.
|
||||
*/
|
||||
export interface SchemaCatalogEntry {
|
||||
version: number;
|
||||
is_breaking_from_previous: boolean;
|
||||
min_consumer_engine_version: string | null;
|
||||
notes: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
@@ -183,6 +183,54 @@ describe("AssemblerHelper resolvedMap (#12 — boundary-disambiguation fix)", ()
|
||||
});
|
||||
});
|
||||
|
||||
describe("AssemblerHelper resolvedMap (SP2a — list-valued vars + .K accessor)", () => {
|
||||
it("joins a bare $mood (ListVar) and indexes $mood.0 / $mood.1", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: {
|
||||
upstreamVars: ["mood"],
|
||||
templateVars: ["mood"],
|
||||
template: "$mood.0, $mood.1, $mood",
|
||||
resolvedMap: { mood: { items: ["sleepy", "serene", "dramatic"], sep: ", " } },
|
||||
},
|
||||
});
|
||||
const preview = wrapper.find('[data-test="asm-preview"]');
|
||||
const varSpans = preview.findAll("span.res");
|
||||
expect(varSpans.length).toBe(3);
|
||||
expect(varSpans[0].text()).toBe("sleepy"); // $mood.0
|
||||
expect(varSpans[1].text()).toBe("serene"); // $mood.1
|
||||
expect(varSpans[2].text()).toBe("sleepy, serene, dramatic"); // bare $mood → joined
|
||||
// No stranded `.0` / `.1` accessor leaked into the literal spans.
|
||||
expect(preview.text()).not.toContain(".0");
|
||||
expect(preview.text()).not.toContain(".1");
|
||||
});
|
||||
|
||||
it("renders an out-of-range $mood.5 as empty, not a leaked accessor", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: {
|
||||
upstreamVars: ["mood"],
|
||||
templateVars: ["mood"],
|
||||
template: "[$mood.5]",
|
||||
resolvedMap: { mood: { items: ["a"], sep: ", " } },
|
||||
},
|
||||
});
|
||||
const preview = wrapper.find('[data-test="asm-preview"]');
|
||||
expect(preview.text()).toBe("[]");
|
||||
});
|
||||
|
||||
it("keeps an unresolved $name.K accessor visible in the chip text", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: {
|
||||
upstreamVars: [],
|
||||
templateVars: ["ghost"],
|
||||
template: "$ghost.2",
|
||||
resolvedMap: {},
|
||||
},
|
||||
});
|
||||
const preview = wrapper.find('[data-test="asm-preview"]');
|
||||
expect(preview.text()).toContain("$ghost.2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AssemblerHelper missing-chip click (#20 — autoremove regression)", () => {
|
||||
it("clicking a missing chip calls onRemoveVar with the var name", async () => {
|
||||
let removed = "";
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref, watch } from "vue";
|
||||
import { varColorClass } from "../shared/var-color";
|
||||
import ContextMenu, { type ContextMenuItem } from "../shared/ContextMenu.vue";
|
||||
import { kindIcon, type WpKind } from "../shared/kind-icons";
|
||||
import { applyVarAccessor, type ResolvedValue } from "../../widgets/richTokenize";
|
||||
|
||||
const props = defineProps<{
|
||||
/** Flat array of upstream variable names. Primary prop shape. */
|
||||
@@ -15,7 +16,7 @@ const props = defineProps<{
|
||||
* fragile parallel-walk over the resolved string. When present, takes
|
||||
* precedence over `resolved`.
|
||||
*/
|
||||
resolvedMap?: Record<string, string>;
|
||||
resolvedMap?: Record<string, ResolvedValue>;
|
||||
/**
|
||||
* Pre-resolved string — template with $var replaced by resolved values.
|
||||
* Legacy fallback when `resolvedMap` not provided. Suffers from
|
||||
@@ -81,7 +82,10 @@ const upstreamNames = computed(() => props.upstreamVars ?? []);
|
||||
// doubled, it can't appear in real prompts.
|
||||
const ESCAPE_PLACEHOLDER = "��";
|
||||
const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
||||
const TEMPLATE_VAR_RE = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
||||
// SP2a: capture an optional `.K` list accessor (group 2) so `$mood.0` consumes
|
||||
// the whole token — `m[0].length` then advances past `.K` (no stranded ".0"
|
||||
// literal) and group 2 drives applyVarAccessor in the substitution below.
|
||||
const TEMPLATE_VAR_RE = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?/g;
|
||||
|
||||
/** Variable names referenced in the template. */
|
||||
const templateVarsInternal = computed(() => {
|
||||
@@ -142,11 +146,16 @@ const previewTokens = computed<PreviewToken[]>(() => {
|
||||
const idx = m.index ?? 0;
|
||||
if (idx > last) tokens.push({ kind: "literal", text: props.template.slice(last, idx) });
|
||||
const name = m[1];
|
||||
const idxStr = m[2];
|
||||
const index = idxStr != null ? parseInt(idxStr, 10) : undefined;
|
||||
const has = Object.prototype.hasOwnProperty.call(props.resolvedMap, name);
|
||||
// Unresolved var → keep `$name` so user sees what's missing.
|
||||
// Resolved → join (bare $name) or index (.K) via the shared accessor;
|
||||
// unresolved → keep the raw `$name.K` so the user sees what's missing.
|
||||
tokens.push({
|
||||
kind: "var",
|
||||
text: has ? props.resolvedMap[name] : `$${name}`,
|
||||
text: has
|
||||
? applyVarAccessor(props.resolvedMap[name], index)
|
||||
: `$${name}${idxStr != null ? "." + idxStr : ""}`,
|
||||
varName: name,
|
||||
});
|
||||
last = idx + m[0].length;
|
||||
@@ -165,7 +174,7 @@ const previewTokens = computed<PreviewToken[]>(() => {
|
||||
for (const m of props.template.matchAll(TEMPLATE_VAR_RE)) {
|
||||
const idx = m.index ?? 0;
|
||||
if (idx > last) tokens.push({ kind: "literal", text: props.template.slice(last, idx) });
|
||||
tokens.push({ kind: "var", text: `$${m[1]}`, varName: m[1] });
|
||||
tokens.push({ kind: "var", text: m[0], varName: m[1] });
|
||||
last = idx + m[0].length;
|
||||
}
|
||||
if (last < props.template.length) tokens.push({ kind: "literal", text: props.template.slice(last) });
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ContextLoopWidget from "./ContextLoopWidget.vue";
|
||||
import SeedListModal from "../shared/SeedListModal.vue";
|
||||
import { emptyContextLoopConfig } from "./types";
|
||||
import { currentFrame } from "./frame-cursor";
|
||||
|
||||
describe("ContextLoopWidget", () => {
|
||||
it("renders defaults — hash chip active, both switches off, internal toggles on", () => {
|
||||
@@ -89,3 +91,90 @@ describe("ContextLoopWidget", () => {
|
||||
expect(w.find('[data-test="loop-iteration-internal"]').classes()).toContain("wp-loop__pi-btn--on");
|
||||
});
|
||||
});
|
||||
|
||||
function ws(extra: Partial<ReturnType<typeof emptyContextLoopConfig>> = {}) {
|
||||
return mount(ContextLoopWidget, {
|
||||
props: { modelValue: { ...emptyContextLoopConfig(), ...extra }, baseSeed: 42, count: 4 },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { teleport: true } },
|
||||
});
|
||||
}
|
||||
|
||||
describe("ContextLoopWidget seeds button", () => {
|
||||
it("renders the Per-iteration seeds button", () => {
|
||||
expect(ws().find('[data-test="loop-seeds-btn"]').exists()).toBe(true);
|
||||
});
|
||||
it("badge shows the locked count when >0", () => {
|
||||
expect(ws({ seed_locks: { "1": 9, "2": 8 } }).find('[data-test="loop-seeds-badge"]').text()).toMatch(/2 locked/i);
|
||||
});
|
||||
it("hides the badge when nothing is locked", () => {
|
||||
expect(ws().find('[data-test="loop-seeds-badge"]').exists()).toBe(false);
|
||||
});
|
||||
it("clicking opens the modal", async () => {
|
||||
const wr = ws();
|
||||
await wr.find('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
expect(wr.findComponent(SeedListModal).exists()).toBe(true);
|
||||
});
|
||||
it("capitalizes the bypass row label", () => {
|
||||
expect(ws().text()).toContain("Bypass loop");
|
||||
});
|
||||
it("passes the override hint to the modal only when Override Context seed is off", async () => {
|
||||
const off = ws(); // override_seed false by default → nudge to turn it on
|
||||
await off.find('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
expect(off.findComponent(SeedListModal).props("overrideHint")).toBeTruthy();
|
||||
|
||||
const on = ws({ override_seed: true });
|
||||
await on.find('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
expect(on.findComponent(SeedListModal).props("overrideHint")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextLoopWidget frame selector", () => {
|
||||
beforeEach(() => { currentFrame.value = null; });
|
||||
function fw(count = 3) {
|
||||
return mount(ContextLoopWidget, {
|
||||
props: { modelValue: emptyContextLoopConfig(), count },
|
||||
attachTo: document.body, global: { stubs: { teleport: true } },
|
||||
});
|
||||
}
|
||||
it("renders base + one chip per iteration", () => {
|
||||
expect(fw(3).findAll('[data-test^="loop-frame-"]')).toHaveLength(4);
|
||||
});
|
||||
it("clicking a frame chip sets the shared cursor; base clears it", async () => {
|
||||
const wr = fw(3);
|
||||
await wr.find('[data-test="loop-frame-2"]').trigger("click");
|
||||
expect(currentFrame.value).toBe(1);
|
||||
await wr.find('[data-test="loop-frame-base"]').trigger("click");
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
* host glue (`src/widgets/context_loop.ts`) serializes to JSON and
|
||||
* pushes via `host.setValue` so ComfyUI's widget value matches.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import type { ContextLoopConfig, LoopStrategy } from "./types";
|
||||
import SeedListModal from "../shared/SeedListModal.vue";
|
||||
import { currentFrame, setFrame } from "./frame-cursor";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -21,8 +23,16 @@ const props = withDefaults(
|
||||
/** Litegraph mode: 0 = ALWAYS (live), 2 = NEVER (muted), 4 = BYPASS.
|
||||
* Drives the dim overlay so canvas-side mute/bypass reads visually. */
|
||||
nodeMode?: number;
|
||||
/** Base seed forwarded from the host ComfyUI INT widget. */
|
||||
baseSeed?: number;
|
||||
/** Iteration count forwarded from the host ComfyUI INT widget. */
|
||||
count?: number;
|
||||
/** The seed each frame used on the PREVIOUS run, captured from the node's
|
||||
* 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;
|
||||
}>(),
|
||||
{ nodeMode: 0 },
|
||||
{ nodeMode: 0, baseSeed: 0, count: 1, previousSeeds: null },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ "update:modelValue": [next: ContextLoopConfig] }>();
|
||||
@@ -45,6 +55,24 @@ const OVERRIDE_SEED_TOOLTIP =
|
||||
|
||||
const isMuted = computed<boolean>(() => props.nodeMode === 2);
|
||||
const isBypassed = computed<boolean>(() => props.nodeMode === 4);
|
||||
const frameChips = computed(() =>
|
||||
Array.from({ length: Math.max(1, props.count ?? 1) }, (_, i) => i),
|
||||
);
|
||||
|
||||
const seedsOpen = ref(false);
|
||||
const lockedCount = computed(() => Object.keys(props.modelValue.seed_locks ?? {}).length);
|
||||
|
||||
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;
|
||||
@@ -108,6 +136,27 @@ function toggleTotalInternal(): void {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="wp-loop__seedbtn" data-test="loop-seeds-btn" @click="seedsOpen = true">
|
||||
<span class="wp-loop__seedbtn-ico"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="20" cy="18" r="1.4" fill="currentColor" stroke="none" /></svg></span>
|
||||
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>
|
||||
|
||||
<div class="wp-loop__section">
|
||||
<div class="wp-loop__label">edit frame</div>
|
||||
<div class="wp-loop__chips wp-loop__chips--frames" role="radiogroup" aria-label="Edit frame">
|
||||
<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, '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>
|
||||
|
||||
<div
|
||||
class="wp-loop__row"
|
||||
:title="OVERRIDE_SEED_TOOLTIP"
|
||||
@@ -169,7 +218,7 @@ function toggleTotalInternal(): void {
|
||||
</div>
|
||||
|
||||
<div class="wp-loop__row">
|
||||
<span class="wp-loop__row-label">bypass loop</span>
|
||||
<span class="wp-loop__row-label">Bypass loop</span>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-loop__switch"
|
||||
@@ -181,6 +230,13 @@ function toggleTotalInternal(): void {
|
||||
<span class="wp-loop__switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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" @update:bypass-frames="onBypassFrames" @close="seedsOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -204,9 +260,18 @@ function toggleTotalInternal(): void {
|
||||
}
|
||||
|
||||
.wp-loop__chips { display: flex; gap: 4px; }
|
||||
/* Frame chips ONLY (the strategy row has 3 fixed chips that must stay one
|
||||
row + show full labels): grid so 15+ frame chips WRAP instead of
|
||||
overflowing the node. auto-fill keeps a uniform cell width; the last row
|
||||
grows to 1fr to fill it. */
|
||||
.wp-loop__chips--frames {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(38px, 1fr));
|
||||
}
|
||||
.wp-loop__chip {
|
||||
flex: 1;
|
||||
padding: 4px 6px;
|
||||
text-align: center;
|
||||
background: var(--wp-bg-deep, var(--wp-bg, #0e1015));
|
||||
border: 1px solid var(--wp-border, #353841);
|
||||
border-radius: 3px;
|
||||
@@ -220,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;
|
||||
@@ -289,8 +362,8 @@ function toggleTotalInternal(): void {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-left: 2px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
@@ -306,8 +379,15 @@ function toggleTotalInternal(): void {
|
||||
border-color: var(--wp-accent, #c4b5fd);
|
||||
color: var(--wp-accent, #c4b5fd);
|
||||
}
|
||||
.wp-loop__pi-btn .pi { font-size: 11px; }
|
||||
.wp-loop__pi-btn-label { font: 600 12px var(--wp-font-mono, monospace); line-height: 1; }
|
||||
.wp-loop__pi-btn .pi { font-size: 9px; }
|
||||
.wp-loop__pi-btn-label { font: 600 10px var(--wp-font-mono, monospace); line-height: 1; }
|
||||
|
||||
.wp-loop__seedbtn { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 9px; background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); border-radius: 3px; color: var(--wp-text-muted, var(--wp-text2)); font: 600 10.5px var(--wp-font-sans); cursor: pointer; }
|
||||
.wp-loop__seedbtn:hover { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); }
|
||||
.wp-loop__seedbtn-ico { color: var(--wp-accent); display: flex; }
|
||||
.wp-loop__seedbtn-fill { flex: 1; }
|
||||
.wp-loop__seedbtn-badge { font: 600 8px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .05em; padding: 2px 6px; border-radius: 3px; color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.14); border: 1px solid color-mix(in srgb, var(--wp-accent) 40%, transparent); }
|
||||
.wp-loop__seedbtn-chev { color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
|
||||
/* Bypass-on visually dims the strategy/override/var rows since the loop
|
||||
* is effectively off. The bypass row itself stays bright. */
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { currentFrame, setFrame } from "./frame-cursor";
|
||||
|
||||
describe("frame-cursor", () => {
|
||||
beforeEach(() => { currentFrame.value = null; });
|
||||
it("defaults to null (base)", () => { expect(currentFrame.value).toBe(null); });
|
||||
it("setFrame updates the shared ref", () => {
|
||||
setFrame(2);
|
||||
expect(currentFrame.value).toBe(2);
|
||||
setFrame(null);
|
||||
expect(currentFrame.value).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
// Module-scoped reactive singleton: the "current editing frame" cursor, shared
|
||||
// across every ContextWidget + ContextLoopWidget Vue app on the page. Each node
|
||||
// mounts its own app, so provide/inject can't span them — a shared module import
|
||||
// is the cross-app channel (same pattern as context/drag-store.ts). `null` = base
|
||||
// (no frame). Session-only — never serialized into the workflow.
|
||||
export const currentFrame = ref<number | null>(null);
|
||||
|
||||
export function setFrame(frame: number | null): void {
|
||||
currentFrame.value = frame;
|
||||
}
|
||||
@@ -30,6 +30,8 @@ describe("parseContextLoopConfig", () => {
|
||||
bypass: true,
|
||||
iteration_internal: false,
|
||||
total_internal: false,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
|
||||
});
|
||||
@@ -42,6 +44,8 @@ describe("parseContextLoopConfig", () => {
|
||||
bypass: false,
|
||||
iteration_internal: true,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
|
||||
});
|
||||
@@ -65,8 +69,45 @@ describe("parseContextLoopConfig", () => {
|
||||
bypass: true,
|
||||
iteration_internal: false,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
const round = parseContextLoopConfig(serializeContextLoopConfig(cfg));
|
||||
expect(round).toEqual(cfg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextLoopConfig seed_locks", () => {
|
||||
it("defaults seed_locks to {}", () => {
|
||||
expect(emptyContextLoopConfig().seed_locks).toEqual({});
|
||||
});
|
||||
it("parses seed_locks", () => {
|
||||
expect(parseContextLoopConfig(JSON.stringify({ seed_locks: { "1": 999 } })).seed_locks).toEqual({ "1": 999 });
|
||||
});
|
||||
it("drops non-numeric lock values", () => {
|
||||
expect(parseContextLoopConfig(JSON.stringify({ seed_locks: { "1": "x", "2": 5 } })).seed_locks).toEqual({ "2": 5 });
|
||||
});
|
||||
it("round-trips through serialize", () => {
|
||||
const cfg = { ...emptyContextLoopConfig(), seed_locks: { "0": 7 } };
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,13 @@ export interface ContextLoopConfig {
|
||||
iteration_internal: boolean;
|
||||
/** Same idea for `$<iteration_var_name>_total`. */
|
||||
total_internal: boolean;
|
||||
/** 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"]);
|
||||
@@ -32,6 +39,8 @@ export function emptyContextLoopConfig(): ContextLoopConfig {
|
||||
bypass: false,
|
||||
iteration_internal: true,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +69,25 @@ export function parseContextLoopConfig(raw: string | null | undefined): ContextL
|
||||
if (typeof obj.bypass === "boolean") out.bypass = obj.bypass;
|
||||
if (typeof obj.iteration_internal === "boolean") out.iteration_internal = obj.iteration_internal;
|
||||
if (typeof obj.total_internal === "boolean") out.total_internal = obj.total_internal;
|
||||
if (obj.seed_locks && typeof obj.seed_locks === "object" && !Array.isArray(obj.seed_locks)) {
|
||||
const locks: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(obj.seed_locks as Record<string, unknown>)) {
|
||||
if (typeof v === "number" && Number.isFinite(v)) locks[k] = v;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import ContextWidget from "./ContextWidget.vue";
|
||||
import { baseCodename } from "../../extension/node-codename";
|
||||
import { _resetForTests as resetDriftStore } from "./drift-store";
|
||||
import { currentFrame } from "../context-loop/frame-cursor";
|
||||
|
||||
beforeEach(() => {
|
||||
// Phase 5.5.4 — the picker now fetches the library on mount. Stub
|
||||
@@ -171,7 +173,7 @@ describe("ContextWidget drift dot", () => {
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
// Live hash differs from embedded → drift.
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "h-LIVE" } }), { status: 200 });
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "h-LIVE" } } }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
@@ -215,7 +217,7 @@ describe("ContextWidget bulk refresh", () => {
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "h-A", bbbbbbbb: "h-B" } }), { status: 200 });
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "h-A" }, bbbbbbbb: { type: "wildcard", payload_hash: "h-B" } } }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
@@ -251,7 +253,7 @@ describe("ContextWidget bulk refresh", () => {
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
// Both embedded hashes differ from live → both drifted.
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "live-A", bbbbbbbb: "live-B" } }), { status: 200 });
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live-A" }, bbbbbbbb: { type: "wildcard", payload_hash: "live-B" } } }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
@@ -292,7 +294,7 @@ describe("ContextWidget bulk refresh", () => {
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "live-A" } }), { status: 200 });
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live-A" } } }), { status: 200 });
|
||||
}
|
||||
if (typeof url === "string" && url.endsWith("/embed-bundle")) {
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as { uuids: string[] };
|
||||
@@ -402,7 +404,7 @@ describe("ContextWidget bulk refresh", () => {
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "live-A" } }), { status: 200 });
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live-A" } } }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
@@ -727,6 +729,124 @@ describe("ContextWidget bundle ops via ctxmenu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextWidget Hold toggle via ctxmenu", () => {
|
||||
beforeEach(() => {
|
||||
// Hold is base-level; pin the shared frame cursor to base so the
|
||||
// menu item isn't disabled by a leftover edit-frame from another test.
|
||||
currentFrame.value = null;
|
||||
});
|
||||
afterEach(() => {
|
||||
currentFrame.value = null;
|
||||
});
|
||||
|
||||
it("Hold across run stamps seed_scope=hold; reopening offers Release hold which flips back to vary", async () => {
|
||||
resetDriftStore();
|
||||
const onChange = vi.fn();
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "aaaaaaaa", type: "wildcard", enabled: true,
|
||||
meta: { name: "Hair" }, entries: [],
|
||||
payload: { options: [], var_binding: "hair" }, payload_hash: "h-A",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 410, initialJson, upstreamVars: [], onChange },
|
||||
});
|
||||
await flushPromises();
|
||||
onChange.mockClear();
|
||||
|
||||
// Un-held module → item reads "Hold across run" and is enabled.
|
||||
await wrapper.find('[data-module-id="aaaaaaaa"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
let items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const hold = items.find((el) => el.textContent?.includes("Hold across run"));
|
||||
expect(hold, "Hold item should appear for a wildcard row").toBeTruthy();
|
||||
expect(hold!.classList.contains("wp-ctxmenu__item--disabled")).toBe(false);
|
||||
hold!.click();
|
||||
await flushPromises();
|
||||
|
||||
const afterHold = onChange.mock.calls[onChange.mock.calls.length - 1][0] as string;
|
||||
expect(JSON.parse(afterHold).modules[0].instance.seed_scope).toBe("hold");
|
||||
|
||||
// Held module → label swaps to "Release hold"; clicking it varies again.
|
||||
await wrapper.find('[data-module-id="aaaaaaaa"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const release = items.find((el) => el.textContent?.includes("Release hold"));
|
||||
expect(release, "Held row should offer Release hold").toBeTruthy();
|
||||
release!.click();
|
||||
await flushPromises();
|
||||
|
||||
const afterRelease = onChange.mock.calls[onChange.mock.calls.length - 1][0] as string;
|
||||
expect(JSON.parse(afterRelease).modules[0].instance.seed_scope).toBe("vary");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("disables the Hold item when a loop edit-frame is active (hold is base-only)", async () => {
|
||||
resetDriftStore();
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "aaaaaaaa", type: "wildcard", enabled: true,
|
||||
meta: { name: "Hair" }, entries: [],
|
||||
payload: { options: [], var_binding: "hair" }, payload_hash: "h-A",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 412, initialJson, upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
await flushPromises();
|
||||
currentFrame.value = 0; // user is editing frame #0 of a loop
|
||||
|
||||
await wrapper.find('[data-module-id="aaaaaaaa"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const hold = items.find((el) => el.textContent?.includes("Hold across run"));
|
||||
expect(hold, "Hold item still present while a frame is active").toBeTruthy();
|
||||
expect(hold!.classList.contains("wp-ctxmenu__item--disabled")).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("omits the Hold item for a constraint row (seed_scope not honored)", async () => {
|
||||
resetDriftStore();
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{ id: "aaaaaaaa", type: "wildcard", enabled: true, meta: { name: "Hair" }, entries: [], payload: { options: [], var_binding: "hair" } },
|
||||
{ id: "bbbbbbbb", type: "wildcard", enabled: true, meta: { name: "Outfit" }, entries: [], payload: { options: [], var_binding: "outfit" } },
|
||||
{
|
||||
id: "cccccccc", type: "constraint", enabled: true,
|
||||
meta: { name: "Hair × Outfit" }, entries: [],
|
||||
payload: {
|
||||
source_wildcard_id: "aaaaaaaa", target_wildcard_id: "bbbbbbbb",
|
||||
matrix: { blonde: { casual: { mode: "allow", factor: 1 } } }, exceptions: [],
|
||||
}, payload_hash: "h",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 411, initialJson, upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-module-id="cccccccc"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
expect(items.find((el) => el.textContent?.includes("Hold across run"))).toBeUndefined();
|
||||
expect(items.find((el) => el.textContent?.includes("Release hold"))).toBeUndefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextWidget summaryFor — constraint", () => {
|
||||
it("renders $src → $tgt · NxM with sibling lookup", async () => {
|
||||
resetDriftStore();
|
||||
@@ -1120,7 +1240,7 @@ describe("ContextWidget status badges", () => {
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "live" } }), { status: 200 });
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live" } } }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
@@ -1184,6 +1304,49 @@ describe("ContextWidget status badges", () => {
|
||||
});
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("renders 'clash' badge when the library row at this id is a different kind", async () => {
|
||||
resetDriftStore();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
// Same id, DIFFERENT kind in library (wildcard) than the embedded
|
||||
// constraint → type-conflict, not drift/missing.
|
||||
return new Response(
|
||||
JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live" } } }),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
nodeId: 802,
|
||||
initialJson: JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{ id: "aaaaaaaa", type: "constraint", enabled: true, meta: { name: "c" }, entries: [], payload: {}, payload_hash: "embedded" },
|
||||
],
|
||||
}),
|
||||
upstreamVars: [],
|
||||
onChange: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const badge = wrapper.find(".wp-mod-badge--clash");
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toBe("clash");
|
||||
});
|
||||
// type-conflict is mutually exclusive with drift + missing.
|
||||
expect(wrapper.find(".wp-mod-badge--drift").exists()).toBe(false);
|
||||
expect(wrapper.find(".wp-mod-badge--missing").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ── B3: kind chip next to module name (mockup v5 lines 681, 696, 711, 722, 733) ─
|
||||
@@ -2104,3 +2267,164 @@ describe("ContextWidget bundle MOD detection", () => {
|
||||
bundleHashes.value = null; // restore
|
||||
});
|
||||
});
|
||||
|
||||
// ── Node codename header chip ───────────────────────────────────────────────
|
||||
// The node header shows a FIXED, read-only codename — a stable
|
||||
// adjective-noun derived from the litegraph node id (node-codename.ts) — so
|
||||
// cross-node UI (constraint reach pick-list, pair popovers) can name WHICH
|
||||
// node a target instance lives in. POV-independent + unique on the canvas,
|
||||
// and NOT editable (replaced the old editable label + walk-position letter).
|
||||
|
||||
describe("ContextWidget node codename chip", () => {
|
||||
it("renders [data-test=node-label] showing the node's stable codename", () => {
|
||||
const wrapper = mount(ContextWidget, {
|
||||
props: {
|
||||
nodeId: 7000,
|
||||
initialJson: '{"version":1,"modules":[]}',
|
||||
upstreamVars: [],
|
||||
onChange: () => {},
|
||||
},
|
||||
});
|
||||
const chip = wrapper.find('[data-test="node-label"]');
|
||||
expect(chip.exists()).toBe(true);
|
||||
expect(chip.text()).toContain(baseCodename(7000));
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("is read-only — exposes no edit input", () => {
|
||||
const wrapper = mount(ContextWidget, {
|
||||
props: {
|
||||
nodeId: 7001,
|
||||
initialJson: '{"version":1,"modules":[]}',
|
||||
upstreamVars: [],
|
||||
onChange: () => {},
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('[data-test="node-label-input"]').exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("derives the codename from the node id, ignoring chainModules / position", () => {
|
||||
// The core fix for the old A/B/C scheme: the chip is purely id-derived
|
||||
// now, so it never depends on walk position or any chainModules label.
|
||||
const wrapper = mount(ContextWidget, {
|
||||
props: {
|
||||
nodeId: 7002,
|
||||
initialJson: '{"version":1,"modules":[]}',
|
||||
upstreamVars: [],
|
||||
chainModules: [
|
||||
{ id: "own", rowKey: "7002#own-uid", type: "wildcard", payload: {}, nodeLabel: "ignored-label" },
|
||||
],
|
||||
onChange: () => {},
|
||||
},
|
||||
});
|
||||
const chip = wrapper.find('[data-test="node-label"]');
|
||||
expect(chip.text()).toContain(baseCodename(7002));
|
||||
expect(chip.text()).not.toContain("ignored-label");
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Bulk toolbar: bundle-aware collapse / expand / toggle-all ────────────────
|
||||
// Toggle-all must operate on TOP-LEVEL units — standalone modules + bundle
|
||||
// MASTER toggles — never reach inside a bundle to flip child modules (the
|
||||
// master gates them; effective = master AND child). Collapse/expand-all must
|
||||
// flip bundle FRAMES too, not just module cards. The three buttons match the
|
||||
// small row-icon size.
|
||||
|
||||
describe("ContextWidget bulk toolbar — bundle-aware", () => {
|
||||
function mountMixed(opts: { bundleEnabled?: boolean; stdEnabled?: boolean } = {}) {
|
||||
const bundleEnabled = opts.bundleEnabled ?? true;
|
||||
const stdEnabled = opts.stdEnabled ?? true;
|
||||
const modules = [
|
||||
{
|
||||
id: "ch0aaaaa", _uid: "u0", type: "wildcard", enabled: true, collapsed: false,
|
||||
meta: { name: "ch0" }, entries: [], payload: { var_binding: "v0", options: [] },
|
||||
payload_hash: "ph0", bundle_origin: "bUID",
|
||||
},
|
||||
{
|
||||
id: "ch1aaaaa", _uid: "u1", type: "wildcard", enabled: true, collapsed: false,
|
||||
meta: { name: "ch1" }, entries: [], payload: { var_binding: "v1", options: [] },
|
||||
payload_hash: "ph1", bundle_origin: "bUID",
|
||||
},
|
||||
{
|
||||
id: "stdaaaaa", _uid: "u2", type: "wildcard", enabled: stdEnabled, collapsed: false,
|
||||
meta: { name: "std" }, entries: [], payload: { var_binding: "vs", options: [] },
|
||||
payload_hash: "phs",
|
||||
},
|
||||
];
|
||||
const bundles = [{
|
||||
_uid: "bUID", library_id: "lib_b", start_idx: 0, end_idx: 1,
|
||||
enabled: bundleEnabled, collapsed: false, name: "Group", color: null,
|
||||
}];
|
||||
const onChange = vi.fn();
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
nodeId: 9400,
|
||||
initialJson: JSON.stringify({ version: 1, modules, bundles }),
|
||||
upstreamVars: [],
|
||||
onChange,
|
||||
},
|
||||
});
|
||||
return { wrapper, onChange };
|
||||
}
|
||||
function lastParsed(onChange: ReturnType<typeof vi.fn>) {
|
||||
const calls = onChange.mock.calls;
|
||||
return JSON.parse(calls[calls.length - 1][0] as string) as {
|
||||
modules: Array<{ _uid: string; enabled: boolean; collapsed?: boolean }>;
|
||||
bundles: Array<{ _uid: string; enabled: boolean; collapsed: boolean }>;
|
||||
};
|
||||
}
|
||||
const findMod = (p: ReturnType<typeof lastParsed>, uid: string) =>
|
||||
p.modules.find((m) => m._uid === uid)!;
|
||||
|
||||
it("toggle-all flips the bundle MASTER + standalone module, leaving bundle children untouched", async () => {
|
||||
const { wrapper, onChange } = mountMixed();
|
||||
onChange.mockClear();
|
||||
await wrapper.find('[aria-label="Toggle all enabled"]').trigger("click");
|
||||
const p = lastParsed(onChange);
|
||||
expect(p.bundles[0].enabled).toBe(false); // master flipped off
|
||||
expect(findMod(p, "u2").enabled).toBe(false); // standalone flipped off
|
||||
// Children NOT reached — gated by the master, restore-safe.
|
||||
expect(findMod(p, "u0").enabled).toBe(true);
|
||||
expect(findMod(p, "u1").enabled).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("toggle-all re-enables when every top-level unit is already off", async () => {
|
||||
const { wrapper, onChange } = mountMixed({ bundleEnabled: false, stdEnabled: false });
|
||||
onChange.mockClear();
|
||||
await wrapper.find('[aria-label="Toggle all enabled"]').trigger("click");
|
||||
const p = lastParsed(onChange);
|
||||
expect(p.bundles[0].enabled).toBe(true);
|
||||
expect(findMod(p, "u2").enabled).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("collapse-all collapses bundle FRAMES + module cards; expand-all expands both", async () => {
|
||||
const { wrapper, onChange } = mountMixed();
|
||||
onChange.mockClear();
|
||||
await wrapper.find('[aria-label="Collapse all modules"]').trigger("click");
|
||||
let p = lastParsed(onChange);
|
||||
expect(p.bundles[0].collapsed).toBe(true);
|
||||
expect(p.modules.every((m) => m.collapsed === true)).toBe(true);
|
||||
onChange.mockClear();
|
||||
await wrapper.find('[aria-label="Expand all modules"]').trigger("click");
|
||||
p = lastParsed(onChange);
|
||||
expect(p.bundles[0].collapsed).toBe(false);
|
||||
expect(p.modules.every((m) => m.collapsed === false)).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("the three bulk buttons use the small row-icon size class", () => {
|
||||
const { wrapper } = mountMixed();
|
||||
for (const label of ["Collapse all modules", "Expand all modules", "Toggle all enabled"]) {
|
||||
const btn = wrapper.find(`[aria-label="${label}"]`);
|
||||
expect(btn.exists()).toBe(true);
|
||||
expect(btn.classes()).toContain("wp-btn--icon-sm");
|
||||
expect(btn.classes()).not.toContain("wp-btn--icon");
|
||||
}
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
emptyContextValue, newModuleId, newRowUid,
|
||||
type ContextWidgetValue, type ModuleEntry,
|
||||
} from "../../widgets/_shared";
|
||||
import { type ResolvedValue } from "../../widgets/richTokenize";
|
||||
import { scanConflicts, labelFor as conflictLabelFor, shortConflictLabel, type Conflict } from "../../extension/conflicts";
|
||||
import type { PairingBadge, RowPairings } from "../../extension/constraint-pairs";
|
||||
import type { ChainModule, PairingBadge, RowPairings } from "../../extension/constraint-pairs";
|
||||
import { baseCodename } from "../../extension/node-codename";
|
||||
import {
|
||||
getCollapseMode,
|
||||
getCollapsedByDefault,
|
||||
@@ -26,7 +28,9 @@ import BundleFrame from "./bundles/BundleFrame.vue";
|
||||
import { BundleFrameCtxKey, type BundleFrameCtx } from "./bundles/bundle-frame-ctx";
|
||||
import ModuleRow from "./ModuleRow.vue";
|
||||
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
|
||||
import { LockSeedKey } from "./lock-seed-ctx";
|
||||
import { buildBundleInsertion, type BundleLibraryEntry } from "./bundles/insert";
|
||||
import { isEnabled, type WildcardOption, type InstanceLike } from "./editors/wildcard/probability";
|
||||
import { buildLibraryChildrenWithIntegrity, toChildSnapshot } from "./bundles/save";
|
||||
import {
|
||||
cascadeRestoreForBundle,
|
||||
@@ -71,7 +75,9 @@ import { dragState, queueHandoff, pendingHandoffs, takeHandoffsFor } from "./dra
|
||||
import { nextBindingSuffix } from "./duplicates/binding-suffix";
|
||||
import { pushToast } from "../shared/toast-store";
|
||||
import { kindIcon } from "../shared/kind-icons";
|
||||
import { KIND_TITLE } from "./editors/_shell";
|
||||
import { KIND_TITLE, FRAME_OVERRIDABLE_FIELDS } from "./editors/_shell";
|
||||
import { currentFrame } from "../context-loop/frame-cursor";
|
||||
import { withFrameInstance, diffInstance, setFrameOverride, clearFrameOverride, toggleFrameLock, toggleFrameEnabled, dropRedundantFrameLockNulls, effectiveEnabled as feEffectiveEnabled, frameEnableOverride as feFrameEnableOverride } from "./frame-overrides";
|
||||
import { varColorClass } from "../shared/var-color";
|
||||
import wpLogoSvg from "../shared/wp-logo.svg?raw";
|
||||
import {
|
||||
@@ -84,6 +90,7 @@ import {
|
||||
subscribe as subscribeDrift,
|
||||
unsubscribe as unsubscribeDrift,
|
||||
} from "./drift-store";
|
||||
import { classifyOne, type CollisionState } from "../../manager/import-export/collision";
|
||||
import {
|
||||
getBundleCollapsedByDefault,
|
||||
getConfirmDestructiveBundle,
|
||||
@@ -98,13 +105,13 @@ const props = withDefaults(defineProps<{
|
||||
* Drives the combine modal's live-preview pane so users see the
|
||||
* template with vars substituted (e.g. `red portrait` instead of
|
||||
* `$style portrait`) at edit time. Optional for headless mounts. */
|
||||
upstreamResolved?: Record<string, string>;
|
||||
upstreamResolved?: Record<string, ResolvedValue>;
|
||||
/** Per-module resolved reader — given a moduleId, returns the map of
|
||||
* vars visible from that module's perspective (upstream chain +
|
||||
* earlier siblings, excluding the module itself). Modal uses this
|
||||
* for the combine live preview because static `upstreamResolved`
|
||||
* alone misses sibling bindings produced in the same node. */
|
||||
localResolvedReader?: (moduleId?: string) => Record<string, string>;
|
||||
localResolvedReader?: (moduleId?: string) => Record<string, ResolvedValue>;
|
||||
/** Wildcard module uuids reachable upstream of this node. Used by
|
||||
* the constraint-ordering scanner to validate constraint
|
||||
* source/target references — sources in the upstream chain are
|
||||
@@ -133,6 +140,13 @@ const props = withDefaults(defineProps<{
|
||||
* via `pairingFor(module._uid)` in `moduleRowCtx`. Optional for
|
||||
* headless mounts that don't simulate a graph. */
|
||||
pairings?: Map<string, RowPairings>;
|
||||
/** Flattened cross-node module chain (upstream + own + downstream
|
||||
* WP_Context nodes), computed at the mount layer alongside
|
||||
* `pairings`. Forwarded to ModuleEditModal → ConstraintInstanceModal
|
||||
* so the constraint modal can resolve a source/target wildcard that
|
||||
* lives in another Context node — same flatten the pair badges use.
|
||||
* Optional for headless mounts. */
|
||||
chainModules?: ChainModule[];
|
||||
/** Litegraph node mode: 0 ALWAYS, 2 NEVER (mute), 4 BYPASS.
|
||||
* Used to dim the body when the host node is muted/bypassed so
|
||||
* the runtime-skipped state is visually obvious. Other modes are
|
||||
@@ -140,6 +154,10 @@ const props = withDefaults(defineProps<{
|
||||
nodeMode?: number;
|
||||
/** Last-run seed reader keyed by module id. Null = user hasn't queued. */
|
||||
lastUsedSeedReader?: (moduleId?: string) => number | null;
|
||||
/** Per-frame seed reader — the seed THIS node rolls at iteration frame #k,
|
||||
* computed from the upstream Loop without a run. Drives the per-frame seed
|
||||
* lock so it pins the seed the module actually uses on that frame. */
|
||||
frameSeedReader?: (frame: number) => number | null;
|
||||
onChange: (json: string) => void;
|
||||
/** Called whenever the formula-computed minimum node width changes
|
||||
* (a conflict appears, a state badge gets attached). Mount glue
|
||||
@@ -668,15 +686,21 @@ function onBundlePushClosed(): void {
|
||||
pushBundleTargetUid.value = null;
|
||||
pushBundleCascadeScan.value = null;
|
||||
}
|
||||
const editingModule = computed<ModuleEntry | null>(() =>
|
||||
editingIdx.value != null ? (value.value.modules[editingIdx.value] ?? null) : null,
|
||||
);
|
||||
const editingModule = computed<ModuleEntry | null>(() => {
|
||||
if (editingIdx.value == null) return null;
|
||||
const m = value.value.modules[editingIdx.value] ?? null;
|
||||
return m ? withFrameInstance(m, currentFrame.value) : null;
|
||||
});
|
||||
|
||||
/** Per-option pair lookup for the currently-edited wildcard. Map keys
|
||||
* are option ids; values are pair badges whose `via.optionIds` include
|
||||
* that option. Drives the trailing `↪#N` chip inside the wildcard
|
||||
* modal's options table. Empty for non-wildcard modules and for
|
||||
* wildcards that aren't a constraint carrier. */
|
||||
/** Per-occurrence pair lookup for the currently-edited CARRIER module.
|
||||
* Map keys are the carrier's `via.optionIds` — wildcard option ids for a
|
||||
* wildcard carrier, engine branch keys (`${rule_id}:${bi}` / `:else`) for
|
||||
* a derivation carrier. Values are the pair badges whose nested `@{uuid}`
|
||||
* ref this occurrence hosts. Drives the trailing `↪#N` chip inside the
|
||||
* wildcard modal's options table AND the derivation modal's rule summary
|
||||
* rows. Empty for non-carrier modules. Carrier-type-agnostic: it reads
|
||||
* `viaInboundFor`, which `computePairingsFull` populates for every
|
||||
* CARRIER_TYPE. */
|
||||
const editingModuleViaOptionPairs = computed<Map<string, PairingBadge[]>>(() => {
|
||||
const m = editingModule.value;
|
||||
if (!m) return new Map();
|
||||
@@ -700,7 +724,7 @@ const editingModuleViaOptionPairs = computed<Map<string, PairingBadge[]>>(() =>
|
||||
* prop when the per-module reader isn't wired (legacy mounts /
|
||||
* headless tests). Recomputed on every editingIdx change so the
|
||||
* preview pane reflects the right perspective. */
|
||||
const resolvedForEditing = computed<Record<string, string>>(() => {
|
||||
const resolvedForEditing = computed<Record<string, ResolvedValue>>(() => {
|
||||
const m = editingModule.value;
|
||||
if (props.localResolvedReader && m) {
|
||||
return props.localResolvedReader(m.id);
|
||||
@@ -2013,6 +2037,9 @@ const conflicts = computed<Conflict[]>(() => {
|
||||
props.downstreamWildcardUuids,
|
||||
props.downstreamNestedReachUuids,
|
||||
pairOrphanByUid,
|
||||
// Live-library uuids — a nested @{} ref to any of these resolves at run
|
||||
// time (DB-expanded catalog), so it must not read as a broken ref.
|
||||
new Set(Object.keys(libraryHashes.value ?? {})),
|
||||
);
|
||||
// Filter by user's validation-strictness preference. The accessor
|
||||
// reads from the same module-level state map the panel onChange
|
||||
@@ -2053,6 +2080,17 @@ function viaInboundFor(rowUid: string): PairingBadge[] {
|
||||
return props.pairings?.get(key)?.viaInbound ?? [];
|
||||
}
|
||||
|
||||
/** SP3 contributor cluster — every constraint whose reach covers this
|
||||
* target-instance row, in per-target `#N` order. Reads the same
|
||||
* cross-node `pairings` prop as `pairingFor` / `viaInboundFor`,
|
||||
* prefixing the local `_uid` with our `nodeId`. ModuleRow renders these
|
||||
* BEFORE the module name: ≤2 as individual `#N` chips, ≥3 as one
|
||||
* collapsed `↥×N` chip. Empty for rows no constraint covers. */
|
||||
function contributorsFor(rowUid: string): PairingBadge[] {
|
||||
const key = `${props.nodeId}#${rowUid}`;
|
||||
return props.pairings?.get(key)?.contributors ?? [];
|
||||
}
|
||||
|
||||
function severityFor(id: string): "error" | "warning" | "info" | null {
|
||||
const list = conflictsByModule.value[id];
|
||||
if (!list?.length) return null;
|
||||
@@ -2140,25 +2178,47 @@ function conflictTooltip(id: string): string {
|
||||
* Returns false until `libraryHashes` first loads so we don't flash
|
||||
* "missing" everywhere while the initial fetch is in flight.
|
||||
*/
|
||||
/**
|
||||
* Identity verdict of an embedded row against the live library — or null
|
||||
* until the first poll lands / for local-only rows (no payload_hash).
|
||||
* Single source for the card dots. Content key is `payload_hash`, so
|
||||
* drift semantics match the historical behavior (content-only); the
|
||||
* `type` gate is the new cross-kind clash detector. Shared verdict logic
|
||||
* lives in `classifyOne` (import-export/collision).
|
||||
*/
|
||||
function matchStateOf(m: ModuleEntry): CollisionState | null {
|
||||
if (!m.payload_hash) return null;
|
||||
if (libraryHashes.value === null) return null;
|
||||
const live = libraryHashes.value[m.id];
|
||||
return classifyOne(
|
||||
m.type,
|
||||
m.payload_hash,
|
||||
live ? { type: live.type, contentKey: live.payload_hash } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function isMissingFromLibrary(m: ModuleEntry): boolean {
|
||||
if (!m.payload_hash) return false;
|
||||
if (libraryHashes.value === null) return false;
|
||||
return !(m.id in libraryHashes.value);
|
||||
return matchStateOf(m) === "no-collision";
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift = library still has this uuid, but the live `payload_hash`
|
||||
* differs from what was embedded into the workflow at pick time.
|
||||
* Independent of `isModified` (user overrides) and `isMissingFromLibrary`
|
||||
* (uuid gone). Reads `false` until the store's first fetch lands so we
|
||||
* don't flash drift before we know the truth.
|
||||
* Drift = library still has this uuid (SAME kind), but the live
|
||||
* `payload_hash` differs from what was embedded at pick time. Independent
|
||||
* of `isModified` (user overrides). Mutually exclusive with the clash
|
||||
* state below — a different-kind row is `type-conflict`, not drift.
|
||||
*/
|
||||
function isDrifted(m: ModuleEntry): boolean {
|
||||
if (!m.payload_hash) return false;
|
||||
if (libraryHashes.value === null) return false;
|
||||
const live = libraryHashes.value[m.id];
|
||||
if (live === undefined) return false; // covered by isMissingFromLibrary
|
||||
return live !== m.payload_hash;
|
||||
return matchStateOf(m) === "conflict";
|
||||
}
|
||||
|
||||
/**
|
||||
* Type clash = the live library row at this uuid is a DIFFERENT kind (the
|
||||
* 8-hex id-space is shared across all 5 kinds). Refresh / Push-update
|
||||
* would clobber an unrelated item, so the card offers neither — only
|
||||
* "Push to library…" as a fresh entry.
|
||||
*/
|
||||
function isTypeConflict(m: ModuleEntry): boolean {
|
||||
return matchStateOf(m) === "type-conflict";
|
||||
}
|
||||
|
||||
/** Per-card refresh — replace one drifted entry with the live snapshot.
|
||||
@@ -2759,7 +2819,16 @@ function isModified(m: ModuleEntry): boolean {
|
||||
if (nonEmptyObj(inst.option_weights)) return true;
|
||||
if (inst.mode && inst.mode !== "random") return true;
|
||||
if (inst.pinned_option_id) return true;
|
||||
if (nonEmptyArr(inst.category_filter)) return true;
|
||||
// `category_filter` is a STRING expr — the old `nonEmptyArr()` test
|
||||
// never matched it, so toggling the pool filter never lit the dot.
|
||||
if (typeof inst.category_filter === "string" && inst.category_filter.trim() !== "") return true;
|
||||
if (inst.exclude_null) return true;
|
||||
// Pick-range override (multi-pick / narrowed). Sparse — absent or 1..1
|
||||
// is the library default; anything else is a behavioural override.
|
||||
if (inst.pick_min != null && inst.pick_min !== 1) return true;
|
||||
if (inst.pick_max != null && inst.pick_max !== 1) return true;
|
||||
if (inst.pick_independent) return true;
|
||||
if (typeof inst.pick_separator === "string" && inst.pick_separator !== "") return true;
|
||||
return false;
|
||||
case "fixed_values":
|
||||
// Library-tracked: `values_overrides` non-empty = user edited entries,
|
||||
@@ -2820,11 +2889,35 @@ function isInternalable(m: ModuleEntry): boolean {
|
||||
}
|
||||
|
||||
function isLocked(m: ModuleEntry): boolean {
|
||||
return typeof m.instance?.locked_seed === "number";
|
||||
return typeof withFrameInstance(m, currentFrame.value).instance?.locked_seed === "number";
|
||||
}
|
||||
function isInternal(m: ModuleEntry): boolean {
|
||||
return !!m.instance?.internal;
|
||||
}
|
||||
/** Kinds whose handlers honor `seed_scope: hold` — wildcard, combine,
|
||||
* fixed_values, and derivation (which freezes its frame-0 output across a
|
||||
* loop, constrained picks + nested @{} refs included). Now equal to
|
||||
* SEED_LOCKABLE_KINDS, but kept distinct on purpose: lock-the-seed and
|
||||
* hold-the-value are different concepts that could diverge again. Gates
|
||||
* the row context-menu's quick Hold item + which RuntimeSections render
|
||||
* the toggle. */
|
||||
const HOLD_KINDS: ReadonlySet<string> = new Set([
|
||||
"wildcard", "combine", "fixed_values", "derivation",
|
||||
]);
|
||||
function isHoldable(m: ModuleEntry): boolean {
|
||||
return HOLD_KINDS.has(m.type);
|
||||
}
|
||||
function isHeld(m: ModuleEntry): boolean {
|
||||
return m.instance?.seed_scope === "hold";
|
||||
}
|
||||
function isOverriddenOnFrame(m: ModuleEntry): boolean {
|
||||
const k = currentFrame.value;
|
||||
return k != null && !!m.iteration_overrides?.[String(k)];
|
||||
}
|
||||
function effectiveLockedSeed(m: ModuleEntry): number | undefined {
|
||||
const v = withFrameInstance(m, currentFrame.value).instance?.locked_seed;
|
||||
return typeof v === "number" ? v : undefined;
|
||||
}
|
||||
|
||||
// ── Formula-driven minWidth ────────────────────────────────────────
|
||||
// Pull-based pattern: createDomWidgetHost reads this through a
|
||||
@@ -2878,6 +2971,22 @@ watch(
|
||||
function toggleLockOnCard(idx: number) {
|
||||
const m = value.value.modules[idx];
|
||||
if (!m) return;
|
||||
const k = currentFrame.value;
|
||||
if (k != null) {
|
||||
// Prefer the seed THIS module actually rolls at frame #k (computed from the
|
||||
// upstream Loop), so the lock pins the frame's real seed — not the single
|
||||
// representative seed a run surfaced. Falls back to last-used / stored when
|
||||
// no Loop drives the node.
|
||||
const frameSeed = props.frameSeedReader?.(k);
|
||||
const lastUsed = props.lastUsedSeedReader?.(m._uid ?? m.id);
|
||||
const fallback = typeof frameSeed === "number" ? frameSeed
|
||||
: typeof lastUsed === "number" ? lastUsed
|
||||
: (typeof m.instance?._ui?.last_locked_seed === "number" ? m.instance._ui.last_locked_seed : 0);
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = toggleFrameLock(m, k, fallback);
|
||||
commitModules(list);
|
||||
return;
|
||||
}
|
||||
const inst = m.instance ?? {};
|
||||
let nextInst: NonNullable<ModuleEntry["instance"]>;
|
||||
if (typeof inst.locked_seed === "number") {
|
||||
@@ -2920,7 +3029,9 @@ function toggleLockOnCard(idx: number) {
|
||||
// map-by-id pass would toggle every sibling at once. Indexing into
|
||||
// the array hits the specific instance the user clicked.
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = { ...m, instance: nextInst };
|
||||
// Unlocking base makes any frame `locked_seed: null` override redundant —
|
||||
// sweep it so the row stops showing a stale "override #k". No-op on lock.
|
||||
list[idx] = dropRedundantFrameLockNulls({ ...m, instance: nextInst });
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
@@ -2964,8 +3075,8 @@ function modifiedTooltip(m: ModuleEntry): string {
|
||||
case "wildcard":
|
||||
if (inst.mode === "pinned") bits.push("pinned");
|
||||
else if (inst.mode === "subcategory") bits.push("subset");
|
||||
if (nonEmptyArr(inst.category_filter)) {
|
||||
bits.push(`cats: ${(inst.category_filter as string[]).join(", ")}`);
|
||||
if (inst.category_filter) {
|
||||
bits.push(`cats: ${inst.category_filter}`);
|
||||
}
|
||||
if (Array.isArray(inst.enabled_options)) {
|
||||
bits.push(`${inst.enabled_options.length} option(s) enabled`);
|
||||
@@ -3134,14 +3245,37 @@ function summaryTokens(m: ModuleEntry): SummaryToken[] {
|
||||
// Card summary respects the per-instance binding override so a
|
||||
// renamed wildcard reads as `$<override>` on the canvas. Engine
|
||||
// applies the same precedence at run time.
|
||||
const inst = m.instance?.variable_binding;
|
||||
const instBind = m.instance?.variable_binding;
|
||||
const payloadBinding = (p.var_binding as string | undefined)?.trim();
|
||||
const binding = (typeof inst === "string" && inst.trim()) || payloadBinding;
|
||||
const opts = Array.isArray(p.options) ? p.options.length : 0;
|
||||
const binding = (typeof instBind === "string" && instBind.trim()) || payloadBinding;
|
||||
const head: SummaryToken = binding ? v(binding) : lit("wildcard");
|
||||
return opts
|
||||
? [head, lit(` · ${opts} option${opts === 1 ? "" : "s"}`)]
|
||||
: [head];
|
||||
const allOpts = (Array.isArray(p.options) ? p.options : []) as WildcardOption[];
|
||||
const total = allOpts.length;
|
||||
if (!total) return [head];
|
||||
const inst = (m.instance ?? {}) as InstanceLike & { pick_min?: number; pick_max?: number };
|
||||
const pmin = typeof inst.pick_min === "number" ? inst.pick_min : 1;
|
||||
const pmax = typeof inst.pick_max === "number" ? Math.max(pmin, inst.pick_max) : pmin;
|
||||
const multiActive = pmax > 1;
|
||||
// "X of Y options" only when a filter / enabled-subset actually narrows
|
||||
// the pool — a plain count when nothing is filtering (keeps the common
|
||||
// case clean). The null-option drop in multi mode alone never triggers it.
|
||||
const hasFilter =
|
||||
(typeof inst.category_filter === "string" && inst.category_filter.trim() !== "") ||
|
||||
Array.isArray(inst.enabled_options);
|
||||
const eligible = allOpts.filter((o) => isEnabled(o, inst, multiActive)).length;
|
||||
const tokens: SummaryToken[] = [
|
||||
head,
|
||||
lit(
|
||||
hasFilter && eligible < total
|
||||
? ` · ${eligible} of ${total} options`
|
||||
: ` · ${total} option${total === 1 ? "" : "s"}`,
|
||||
),
|
||||
];
|
||||
// Multi-pick hint — surface when the instance resolves more than one.
|
||||
if (pmax > 1) {
|
||||
tokens.push(lit(pmin === pmax ? ` · picks ${pmin}` : ` · picks ${pmin}-${pmax}`));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
case "combine": {
|
||||
// Same per-instance precedence as wildcard: rebinding via the v2
|
||||
@@ -3614,11 +3748,32 @@ function moveToEdge(idx: number, edge: "top" | "bottom") {
|
||||
|
||||
function toggleEnabled(idx: number) {
|
||||
if (idx < 0 || idx >= value.value.modules.length) return;
|
||||
const k = currentFrame.value;
|
||||
if (k != null) {
|
||||
// Frame-active: flip the module's per-frame enable override (symmetric —
|
||||
// base-off turns ON for this frame, base-on turns OFF). Pure logic lives in
|
||||
// frame-overrides.toggleFrameEnabled; base `enabled` is never touched here.
|
||||
const m = value.value.modules[idx];
|
||||
if (!m) return;
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = toggleFrameEnabled(m, k);
|
||||
commitModules(list);
|
||||
return;
|
||||
}
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = { ...list[idx], enabled: !list[idx].enabled };
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
// Thin wrappers binding the pure frame-overrides enable helpers to the reactive
|
||||
// `currentFrame` cursor — exposed to ModuleRow via moduleRowCtx.
|
||||
function effectiveEnabled(m: ModuleEntry): boolean {
|
||||
return feEffectiveEnabled(m, currentFrame.value);
|
||||
}
|
||||
function frameEnableOverride(m: ModuleEntry): "on" | "off" | null {
|
||||
return feFrameEnableOverride(m, currentFrame.value);
|
||||
}
|
||||
|
||||
function toggleCollapsed(idx: number) {
|
||||
const target = value.value.modules[idx];
|
||||
if (!target) return;
|
||||
@@ -3635,12 +3790,40 @@ function toggleCollapsed(idx: number) {
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
// Quick "Hold across run" toggle from the row context menu — flips base
|
||||
// `instance.seed_scope` between hold/vary without opening the editor
|
||||
// (the 4-click → 2-click ask). Base-level only: when a loop edit-frame
|
||||
// is active, hold applies to every frame, so we no-op here exactly as
|
||||
// the RuntimeSection disables its toggle. Caller gates on isHoldable;
|
||||
// re-checked here so a stray call can't stamp seed_scope on a kind
|
||||
// (constraint/derivation) whose handler ignores it.
|
||||
function toggleHold(idx: number): void {
|
||||
if (currentFrame.value != null) return;
|
||||
const target = value.value.modules[idx];
|
||||
if (!target || !isHoldable(target)) return;
|
||||
const held = target.instance?.seed_scope === "hold";
|
||||
// Annotate so the literal doesn't widen to `string` in the object
|
||||
// literal — instance.seed_scope is the "vary" | "hold" union.
|
||||
const next: "vary" | "hold" = held ? "vary" : "hold";
|
||||
const list = value.value.modules.map((m, i) =>
|
||||
i === idx
|
||||
? { ...m, instance: { ...(m.instance ?? {}), seed_scope: next } }
|
||||
: m,
|
||||
);
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
/** Bulk collapse/expand. Used by the section-header chevron — one
|
||||
* click flips every card to the same state. Idempotent if all
|
||||
* cards already match the target state (the deep watcher will
|
||||
* diff-eq and skip the onChange emit). */
|
||||
function setAllCollapsed(collapsed: boolean) {
|
||||
commitModules(value.value.modules.map((m) => ({ ...m, collapsed })));
|
||||
const nextModules = value.value.modules.map((m) => ({ ...m, collapsed }));
|
||||
// Bundle FRAMES carry their own `collapsed` flag (separate from the child
|
||||
// module cards). Collapse/expand-all flips both so the action visibly folds
|
||||
// bundle frames, not just loose modules.
|
||||
const nextBundles = (value.value.bundles ?? []).map((b) => ({ ...b, collapsed }));
|
||||
commitModules(nextModules, nextBundles);
|
||||
}
|
||||
|
||||
/** Toolbar counts — total modules + how many are effectively enabled
|
||||
@@ -3651,13 +3834,42 @@ const enabledCount = computed(() => {
|
||||
return value.value.modules.filter((m) => isModuleEffectivelyEnabled(m, bundleEnabled)).length;
|
||||
});
|
||||
|
||||
// ── Node codename chip ──────────────────────────────────────────────────────
|
||||
// A fixed, read-only CODENAME identifies THIS WP_Context node: a stable
|
||||
// `adjective-noun` derived from the litegraph node id (node-codename.ts).
|
||||
// POV-independent + unique on the canvas, and NOT user-editable — cross-node
|
||||
// UI (constraint reach pick-list, pair popovers) shows the SAME codename to
|
||||
// name WHICH node a target instance lives in. Replaced the old editable label
|
||||
// + walk-position A/B/C letter, which shifted by viewer (so two chain heads
|
||||
// both showed "A") and was editable (so not a stable identifier).
|
||||
const nodeCodename = computed<string>(() => baseCodename(props.nodeId));
|
||||
|
||||
/** Toolbar bulk actions — wrappers so the toolbar template stays compact. */
|
||||
function collapseAll(): void { setAllCollapsed(true); }
|
||||
function expandAll(): void { setAllCollapsed(false); }
|
||||
function toggleAllEnabled(): void {
|
||||
const anyEnabled = value.value.modules.some((m) => m.enabled);
|
||||
// If any are enabled, disable all; otherwise enable all.
|
||||
commitModules(value.value.modules.map((m) => ({ ...m, enabled: !anyEnabled })));
|
||||
// Operate at the TOP LEVEL only. A standalone module is one unit (its own
|
||||
// `enabled`); a bundle is one unit (its MASTER `enabled` gate). Children
|
||||
// inside a bundle are governed by the master (effective = master AND
|
||||
// child), so toggle-all NEVER reaches in to flip them — disabling a bundle
|
||||
// goes through its master, leaving each child's own `enabled` intact for a
|
||||
// restore-safe re-enable. Nested bundles (parent_uid set) follow their
|
||||
// parent master via the AND chain, so only top-level masters are flipped.
|
||||
const isChild = (m: ModuleEntry): boolean =>
|
||||
!!(m as ModuleEntry & { bundle_origin?: string | null }).bundle_origin;
|
||||
const bundles = value.value.bundles ?? [];
|
||||
const topModules = value.value.modules.filter((m) => !isChild(m));
|
||||
const topBundles = bundles.filter((b) => !b.parent_uid);
|
||||
// Direction: if ANY top-level unit is on, turn everything off; else on.
|
||||
const anyOn =
|
||||
topModules.some((m) => m.enabled !== false)
|
||||
|| topBundles.some((b) => b.enabled !== false);
|
||||
const next = !anyOn;
|
||||
const nextModules = value.value.modules.map((m) =>
|
||||
isChild(m) ? m : { ...m, enabled: next },
|
||||
);
|
||||
const nextBundles = bundles.map((b) => (b.parent_uid ? b : { ...b, enabled: next }));
|
||||
commitModules(nextModules, nextBundles);
|
||||
}
|
||||
|
||||
/** Open the SPA dashboard in a new tab. Mirrors `extension/topbar.ts`'s
|
||||
@@ -3698,7 +3910,22 @@ function saveEditedModule(updated: ModuleEntry & { _originalId?: string }) {
|
||||
const cleaned: ModuleEntry = { ...updated };
|
||||
delete (cleaned as { _originalId?: string })._originalId;
|
||||
const list = [...value.value.modules];
|
||||
list[targetIdx] = cleaned;
|
||||
const k = currentFrame.value;
|
||||
if (k == null) {
|
||||
list[targetIdx] = cleaned; // base edit — unchanged behavior
|
||||
} else {
|
||||
const baseModule = value.value.modules[targetIdx]; // base: payload + base instance intact
|
||||
const fields = FRAME_OVERRIDABLE_FIELDS[baseModule.type] ?? [];
|
||||
const delta = diffInstance(baseModule.instance, cleaned.instance, fields);
|
||||
// base payload + base instance preserved; the frame's override = the instance delta (replace, not merge)
|
||||
let next = clearFrameOverride(baseModule, k);
|
||||
if (Object.keys(delta).length) next = setFrameOverride(next, k, delta);
|
||||
list[targetIdx] = next;
|
||||
}
|
||||
// A base-unlock (here via the modal, or via the row toggle) makes any frame
|
||||
// `locked_seed: null` override redundant — sweep it so the row stops showing
|
||||
// a stale "override #k". No-op while the base stays locked.
|
||||
list[targetIdx] = dropRedundantFrameLockNulls(list[targetIdx]);
|
||||
commitModules(list);
|
||||
if (updated._originalId && updated._originalId !== updated.id) {
|
||||
nextTick(() => {
|
||||
@@ -3714,6 +3941,18 @@ function saveEditedModule(updated: ModuleEntry & { _originalId?: string }) {
|
||||
editingIdx.value = null;
|
||||
}
|
||||
|
||||
/** Revert the currently-edited module's frame-#k override back to the
|
||||
* base instance (i.e. remove the iteration_overrides[k] entry entirely).
|
||||
* Called from the frame banner's "Revert to base" button in ModuleEditModal. */
|
||||
function revertEditingFrame(): void {
|
||||
const idx = editingIdx.value;
|
||||
const k = currentFrame.value;
|
||||
if (idx == null || k == null) return;
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = clearFrameOverride(value.value.modules[idx], k);
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the BundleInstanceModal for `uid`. Snapshots the live
|
||||
* BundleInstance into `bundleDraft` so the user's typing in
|
||||
@@ -3917,6 +4156,26 @@ function openContextMenu(ev: MouseEvent, m: ModuleEntry, idx: number) {
|
||||
const items: ContextMenuItem[] = [
|
||||
{ label: "Edit", icon: "pi-pencil", onSelect: () => openEditModal(idx) },
|
||||
];
|
||||
// Quick Hold toggle — kept at the top so it's a 2-click reach
|
||||
// (right-click → Hold) instead of opening the editor and digging into
|
||||
// Runtime. Only for kinds that honor seed_scope. Hold is base-level;
|
||||
// when a loop edit-frame is active it applies to every frame, so we
|
||||
// surface it disabled with a hint rather than silently writing it.
|
||||
if (isHoldable(m)) {
|
||||
const held = isHeld(m);
|
||||
const frameActive = currentFrame.value != null;
|
||||
items.push({
|
||||
label: held ? "Release hold" : "Hold across run",
|
||||
icon: "pi-link",
|
||||
disabled: frameActive,
|
||||
subtitle: frameActive
|
||||
? "Applies to every frame — switch to base to change"
|
||||
: held
|
||||
? undefined
|
||||
: "Freeze this value for the whole batch",
|
||||
onSelect: () => toggleHold(idx),
|
||||
});
|
||||
}
|
||||
// Refresh + Save are mutually exclusive in normal use (a module is
|
||||
// either drifted OR missing OR clean), so hiding the inactive entry
|
||||
// beats greying it — matches Save's existing conditional-push pattern
|
||||
@@ -4593,7 +4852,7 @@ const moduleRowCtx: ModuleRowCtx = {
|
||||
KIND_TITLE,
|
||||
kindIcon, kindChipModifier, varColorClass,
|
||||
isCollapsed, isLocked, isInternal, isSeedLockable,
|
||||
isModified, isDrifted, isMissingFromLibrary,
|
||||
isModified, isDrifted, isMissingFromLibrary, isTypeConflict,
|
||||
severityFor, conflictTooltip, conflictBadgeText,
|
||||
modifiedTooltip, summaryFor, summaryTokens, siblingInfo,
|
||||
rowGap, draggingModuleUid, recentDropUids, pulseDelayFor,
|
||||
@@ -4602,8 +4861,23 @@ const moduleRowCtx: ModuleRowCtx = {
|
||||
onDragStart, onDragEnd, openContextMenu, onCardKeydown,
|
||||
pairingFor,
|
||||
viaInboundFor,
|
||||
contributorsFor,
|
||||
currentFrame,
|
||||
isHeld,
|
||||
isOverriddenOnFrame,
|
||||
effectiveLockedSeed,
|
||||
effectiveEnabled,
|
||||
frameEnableOverride,
|
||||
};
|
||||
provide(ModuleRowCtxKey, moduleRowCtx);
|
||||
// Seed source for the edit-modal's Lock button — resolves the seed the module
|
||||
// actually rolled (frame #k's captured seed when a frame is active, else the
|
||||
// last-run seed) so locking pins what the user saw, not a random number.
|
||||
provide(LockSeedKey, (m: ModuleEntry): number | null => {
|
||||
const k = currentFrame.value;
|
||||
if (k != null) return props.frameSeedReader?.(k) ?? null;
|
||||
return props.lastUsedSeedReader?.(m._uid ?? m.id) ?? null;
|
||||
});
|
||||
|
||||
const bundleFrameCtx: BundleFrameCtx = {
|
||||
bundleChildDriftCount,
|
||||
@@ -4636,6 +4910,23 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
|
||||
:data-mode-label="isMuted ? muteLabel : undefined"
|
||||
@dragleave="onContainerLeave"
|
||||
>
|
||||
<!-- Node identity strip. A fixed, read-only CODENAME naming THIS
|
||||
WP_Context node — a stable `adjective-noun` derived from the
|
||||
litegraph node id (node-codename.ts). POV-independent + unique on
|
||||
the canvas, NOT editable. Cross-node UI (constraint reach pick-list,
|
||||
pair popovers) shows the same codename to say WHICH node a target
|
||||
lives in. Sits above the toolbar/hero so it shows in every state. -->
|
||||
<div class="wp-node-id">
|
||||
<span class="wp-node-id__tag">node</span>
|
||||
<span
|
||||
class="wp-node-id__chip wp-node-id__chip--fixed"
|
||||
data-test="node-label"
|
||||
:title="`Stable id for this Context node — referenced by cross-node constraint UI (${nodeCodename})`"
|
||||
>
|
||||
<span class="wp-node-id__text">{{ nodeCodename }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Corrupt-workflow recovery panel (5.6). Surfaces when JSON parse fails
|
||||
or returns a non-object. View raw exposes the bad payload so users
|
||||
can copy it out before resetting. -->
|
||||
@@ -4699,21 +4990,21 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
|
||||
<span class="wp-w-toolbar-spacer" />
|
||||
<button
|
||||
type="button"
|
||||
class="wp-btn wp-btn--icon"
|
||||
class="wp-btn--icon-sm"
|
||||
title="Collapse all"
|
||||
aria-label="Collapse all modules"
|
||||
@click="collapseAll"
|
||||
><i class="pi pi-chevron-up" /></button>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-btn wp-btn--icon"
|
||||
class="wp-btn--icon-sm"
|
||||
title="Expand all"
|
||||
aria-label="Expand all modules"
|
||||
@click="expandAll"
|
||||
><i class="pi pi-chevron-down" /></button>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-btn wp-btn--icon"
|
||||
class="wp-btn--icon-sm"
|
||||
title="Toggle all enabled"
|
||||
aria-label="Toggle all enabled"
|
||||
@click="toggleAllEnabled"
|
||||
@@ -4721,8 +5012,8 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="wp-modules-frame"
|
||||
ref="modulesContainer"
|
||||
class="wp-modules-frame"
|
||||
>
|
||||
<div
|
||||
class="wp-modules"
|
||||
@@ -4848,10 +5139,13 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
|
||||
:upstream-resolved="resolvedForEditing"
|
||||
:sibling-vars="siblingNodeVars"
|
||||
:sibling-modules="value.modules"
|
||||
:chain-modules="chainModules"
|
||||
:via-option-pairs="editingModuleViaOptionPairs"
|
||||
:last-used-seed-reader="lastUsedSeedReader"
|
||||
:current-frame="currentFrame"
|
||||
@save="saveEditedModule"
|
||||
@close="editingIdx = null"
|
||||
@revert-frame="revertEditingFrame"
|
||||
/>
|
||||
|
||||
<!-- Bundle edit modal — sibling to ModuleEditModal, dispatched
|
||||
@@ -5700,6 +5994,12 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
|
||||
background: color-mix(in oklab, var(--wp-danger) 14%, transparent);
|
||||
border-color: var(--wp-danger);
|
||||
}
|
||||
/* clash → --wp-accent (violet): id reused by a DIFFERENT kind. Distinct
|
||||
* from modified/drift/missing so a cross-kind collision reads at a glance. */
|
||||
.wp-mod-dot--clash {
|
||||
background: color-mix(in oklab, var(--wp-accent) 14%, transparent);
|
||||
border-color: var(--wp-accent);
|
||||
}
|
||||
|
||||
/* ── Sibling badge ──────────────────────────────────────────────────────
|
||||
* Rendered when the same uuid appears more than once in this Context.
|
||||
@@ -5776,6 +6076,40 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
|
||||
.wp-w-count { font: 500 11px/1 var(--wp-font-mono); color: var(--wp-text-dim, var(--wp-text3)); padding: 2px 6px; background: var(--wp-bg-deep, var(--wp-bg)); border-radius: var(--wp-radius, 4px); }
|
||||
.wp-w-toolbar-spacer { flex: 1; }
|
||||
|
||||
/* ── Node identity chip (SP3 P7) ────────────────────────────────────────
|
||||
* A small, unobtrusive header strip naming this WP_Context node. Amber
|
||||
* accent (matches the design proposal); sits above the toolbar/hero in a
|
||||
* compact row so it never disturbs their layout. */
|
||||
.wp-node-id { display: flex; align-items: center; gap: 6px; padding: 2px 0 4px; }
|
||||
.wp-node-id__tag {
|
||||
font: 500 10px/1 var(--wp-font-sans, sans-serif);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.wp-node-id__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
max-width: 220px;
|
||||
padding: 2px 8px;
|
||||
font: 600 11px/1.2 var(--wp-font-sans, sans-serif);
|
||||
/* Neutral — a fixed codename is an identity, not an attention cue (was
|
||||
amber). Muted text + a faint text-tinted wash + standard border, the
|
||||
same neutral idiom used elsewhere in the widget; reads on light + dark. */
|
||||
color: var(--wp-text-muted, var(--wp-text2));
|
||||
background: color-mix(in srgb, var(--wp-text) 6%, transparent);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm, 4px);
|
||||
}
|
||||
/* Fixed codename — a read-only identity, not an interactive control. */
|
||||
.wp-node-id__chip--fixed { cursor: default; }
|
||||
.wp-node-id__text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Generic button system (Task 10) ────────────────────────────────────
|
||||
* .wp-btn--icon-sm (Task 9) already exists for inline card actions;
|
||||
* these are the larger toolbar/footer variants. */
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { nextTick } from "vue";
|
||||
import ModuleEditModal from "./ModuleEditModal.vue";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
import { _resetForTests } from "../../extension/preview-resolver";
|
||||
|
||||
// The v2 DerivationInstanceModal fetches /wp/api/modules on open to build
|
||||
// its `@{}` ref-data. Stub fetch so that round-trip settles harmlessly in
|
||||
// the derivation dispatcher tests below (shape mirrors ModulePickerModal).
|
||||
function stubEmptyModulesFetch(): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ items: [], total: 0 }),
|
||||
})) as unknown as typeof fetch,
|
||||
);
|
||||
}
|
||||
|
||||
// ModalShell uses <Teleport to="body">. VTU's `find` only walks the
|
||||
// component's own subtree, so disable teleport globally for these tests
|
||||
// — content lands inline where wrapper.find can reach it.
|
||||
@@ -523,7 +537,11 @@ describe("ModuleEditModal — combine v2 dispatcher", () => {
|
||||
// ── Derivation v2 dispatcher branch ────────────────────────────────────
|
||||
|
||||
describe("ModuleEditModal — derivation v2 dispatcher", () => {
|
||||
beforeEach(() => _resetForTests());
|
||||
beforeEach(() => {
|
||||
_resetForTests();
|
||||
stubEmptyModulesFetch();
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("renders DerivationInstanceModal (no tab strip) for derivation kind", async () => {
|
||||
const wrapper = mount(ModuleEditModal, {
|
||||
@@ -549,6 +567,47 @@ describe("ModuleEditModal — derivation v2 dispatcher", () => {
|
||||
const saved = wrapper.emitted("save")?.[0][0] as ModuleEntry;
|
||||
expect(saved.instance?.disabled_rule_ids).toEqual(["r1"]);
|
||||
});
|
||||
|
||||
// Bug parity: the shell must thread upstream + sibling vars into the
|
||||
// derivation modal so its action/condition override fields can offer
|
||||
// `$var` autocomplete — the SAME props the wildcard + combine modals
|
||||
// already receive. The `@{}` source is the LIBRARY (fetched inside the
|
||||
// modal), so sibling/chain MODULES are intentionally NOT forwarded.
|
||||
it("forwards upstream-vars + sibling-vars to DerivationInstanceModal", async () => {
|
||||
const wrapper = mount(ModuleEditModal, {
|
||||
...mountOpts,
|
||||
props: {
|
||||
visible: true,
|
||||
module: makeDerivation(),
|
||||
upstreamVars: ["age", "season"],
|
||||
siblingVars: ["mood"],
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
const dvm = wrapper.findComponent({ name: "DerivationInstanceModal" });
|
||||
expect(dvm.props("upstreamVars")).toEqual(["age", "season"]);
|
||||
expect(dvm.props("siblingVars")).toEqual(["mood"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Frame banner + revert-to-base (Phase E.1) ───────────────────────────
|
||||
|
||||
describe("ModuleEditModal — frame banner", () => {
|
||||
beforeEach(() => _resetForTests());
|
||||
|
||||
it("shows the frame banner + emits revert-frame when a frame is active", async () => {
|
||||
const w = mount(ModuleEditModal, { ...mountOpts, props: { visible: true, module: makeWildcard(), currentFrame: 1 } });
|
||||
await nextTick();
|
||||
expect(w.find('[data-test="modal-frame-banner"]').exists()).toBe(true);
|
||||
await w.find('[data-test="modal-frame-revert"]').trigger("click");
|
||||
expect(w.emitted("revert-frame")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("no frame banner on base (currentFrame null)", async () => {
|
||||
const w = mount(ModuleEditModal, { ...mountOpts, props: { visible: true, module: makeWildcard(), currentFrame: null } });
|
||||
await nextTick();
|
||||
expect(w.find('[data-test="modal-frame-banner"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Constraint v2 dispatcher branch ────────────────────────────────────
|
||||
@@ -583,6 +642,39 @@ describe("ModuleEditModal — constraint v2 dispatcher", () => {
|
||||
expect(saved.instance?.cell_mode_overrides).toEqual({ '["a","b"]': "exclude" });
|
||||
});
|
||||
|
||||
// Cross-node chain (SP3 P6 T16): the mount layer threads the flattened
|
||||
// chain as `chainModules`; the shell must forward it verbatim to the
|
||||
// constraint modal so a target wildcard in another Context node still
|
||||
// resolves. Resolution itself is covered in ConstraintInstanceModal.test.ts.
|
||||
it("forwards chainModules prop through to ConstraintInstanceModal", async () => {
|
||||
const chain = [
|
||||
{
|
||||
id: "bbbbbbbb",
|
||||
rowKey: "99#bbbbbbbb",
|
||||
type: "wildcard",
|
||||
displayName: "outfit",
|
||||
payload: { sub_categories: ["tee", "gown"], options: [] },
|
||||
},
|
||||
];
|
||||
const wrapper = mount(ModuleEditModal, {
|
||||
...mountOpts,
|
||||
props: { visible: true, module: makeConstraint(), chainModules: chain },
|
||||
});
|
||||
await nextTick();
|
||||
const cnm = wrapper.findComponent({ name: "ConstraintInstanceModal" });
|
||||
expect(cnm.props("chainModules")).toEqual(chain);
|
||||
});
|
||||
|
||||
it("leaves the child chainModules undefined when the prop is omitted", async () => {
|
||||
const wrapper = mount(ModuleEditModal, {
|
||||
...mountOpts,
|
||||
props: { visible: true, module: makeConstraint() },
|
||||
});
|
||||
await nextTick();
|
||||
const cnm = wrapper.findComponent({ name: "ConstraintInstanceModal" });
|
||||
expect(cnm.props("chainModules")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Reset overrides clears all override fields, preserves Identity", async () => {
|
||||
const m = {
|
||||
...makeConstraint(),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onBeforeUnmount } from "vue";
|
||||
|
||||
import ModalShell from "../shared/ModalShell.vue";
|
||||
import type { ModuleEntry, ModuleEntryKind } from "../../widgets/_shared";
|
||||
import { type ResolvedValue } from "../../widgets/richTokenize";
|
||||
import {
|
||||
INSTANCE_FIELDS_PER_KIND,
|
||||
type InstanceFieldKey,
|
||||
@@ -15,7 +17,7 @@ import FixedValuesInstanceModal from "./editors/fixed-values/FixedValuesInstance
|
||||
import CombineInstanceModal from "./editors/combine/CombineInstanceModal.vue";
|
||||
import DerivationInstanceModal from "./editors/derivation/DerivationInstanceModal.vue";
|
||||
import ConstraintInstanceModal from "./editors/constraint/ConstraintInstanceModal.vue";
|
||||
import type { PairingBadge } from "../../extension/constraint-pairs";
|
||||
import type { ChainModule, PairingBadge } from "../../extension/constraint-pairs";
|
||||
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -25,13 +27,18 @@ const props = defineProps<{
|
||||
upstreamVars?: string[];
|
||||
/** Resolved upstream `$name → value` map. Combine modal uses this
|
||||
* to render a live preview pane with vars substituted. */
|
||||
upstreamResolved?: Record<string, string>;
|
||||
upstreamResolved?: Record<string, ResolvedValue>;
|
||||
/** Variable names defined by OTHER modules in the same node. */
|
||||
siblingVars?: string[];
|
||||
/** Other modules in the same WP_Context node — used by the constraint
|
||||
* preview to resolve source/target uuids back to var-binding names.
|
||||
* May include the module being edited; lookups by uuid skip self. */
|
||||
siblingModules?: ModuleEntry[];
|
||||
/** Flattened cross-node module chain (upstream + own + downstream
|
||||
* WP_Context nodes). Forwarded to the constraint modal so it can
|
||||
* resolve a source/target wildcard that lives in another Context
|
||||
* node, not just `siblingModules` (same-node only). */
|
||||
chainModules?: ChainModule[];
|
||||
/** Per-option pair badges for the currently-edited wildcard when it
|
||||
* acts as a constraint carrier (target reached via nested `@{uuid}`).
|
||||
* Keyed by option id. Empty for non-wildcard or non-carrier rows. */
|
||||
@@ -45,11 +52,15 @@ const props = defineProps<{
|
||||
* generic chain seed.
|
||||
*/
|
||||
lastUsedSeedReader?: (moduleId?: string) => number | null;
|
||||
/** When a frame override context is active, the 0-based frame index.
|
||||
* Null / undefined = editing the base (no banner shown). */
|
||||
currentFrame?: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "save", value: ModuleEntry): void;
|
||||
(e: "close"): void;
|
||||
(e: "revert-frame"): void;
|
||||
}>();
|
||||
|
||||
// Draft state — owned by the modal. Cancel discards, Save commits via emit.
|
||||
@@ -57,8 +68,13 @@ const emit = defineEmits<{
|
||||
// via JSON round-trip (Proxy-safe at every depth, unlike structuredClone).
|
||||
const draft = ref<ModuleEntry | null>(null);
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v && props.module) {
|
||||
watch([() => props.visible, () => props.module], () => {
|
||||
if (props.visible && props.module) {
|
||||
// Re-clone on open AND whenever the source module changes under an open
|
||||
// modal — e.g. "Revert to base" clears the frame override, so editingModule
|
||||
// recomputes and the draft must resync to the base instance. (During normal
|
||||
// editing the draft is mutated locally; props.module identity is stable, so
|
||||
// this does not clobber in-progress edits.)
|
||||
draft.value = JSON.parse(JSON.stringify(props.module));
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
} else {
|
||||
@@ -141,7 +157,10 @@ function onConfirmDialogCancel(): void {
|
||||
* it's still on v1 and doesn't separate runtime from overrides.
|
||||
*/
|
||||
const RESET_FIELDS_PER_KIND: Record<ModuleEntryKind, readonly InstanceFieldKey[]> = {
|
||||
wildcard: ["enabled_options", "option_weights", "category_filter"],
|
||||
wildcard: [
|
||||
"enabled_options", "option_weights", "category_filter", "exclude_null",
|
||||
"pick_min", "pick_max", "pick_separator",
|
||||
],
|
||||
fixed_values: ["values_overrides", "enabled_options"],
|
||||
combine: ["template_override", "variable_binding"],
|
||||
derivation: [
|
||||
@@ -373,6 +392,23 @@ function cancel() {
|
||||
|
||||
<template>
|
||||
<ModalShell :visible="visible" @close="cancel">
|
||||
<!-- Column so the frame banner stacks ABOVE the per-kind modal card
|
||||
(each per-kind modal renders a plain card; ModalShell's wrapper is
|
||||
a centered flex ROW, so a bare banner sibling would land beside the
|
||||
card). align-self:stretch on the banner spans it to the card width. -->
|
||||
<div class="wp-frame-edit-body">
|
||||
<!-- Frame-context banner — shown when the user is editing overrides
|
||||
for a specific iteration frame (#k). Sits above all per-kind
|
||||
modal bodies. Invisible on base editing (currentFrame == null). -->
|
||||
<div
|
||||
v-if="currentFrame != null"
|
||||
class="wp-modal-frame-banner"
|
||||
data-test="modal-frame-banner"
|
||||
>
|
||||
<span>Editing frame #{{ currentFrame + 1 }} — changes affect only this frame.</span>
|
||||
<button type="button" data-test="modal-frame-revert" @click="emit('revert-frame')">Revert #{{ currentFrame + 1 }} to base</button>
|
||||
</div>
|
||||
|
||||
<!-- v2 wildcard branch — single-pane tailored modal renders its
|
||||
own header/footer/body. No v1 chrome (no .wp-medit wrapper,
|
||||
no tab strip, no LibraryRoundTripActions footer). -->
|
||||
@@ -383,6 +419,7 @@ function cancel() {
|
||||
:upstream-vars="upstreamVars"
|
||||
:sibling-vars="siblingVars"
|
||||
:via-option-pairs="viaOptionPairs"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -395,6 +432,7 @@ function cancel() {
|
||||
v-else-if="draft && draft.type === 'fixed_values'"
|
||||
:module="draft"
|
||||
:is-modified="instanceModified"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -412,6 +450,7 @@ function cancel() {
|
||||
:upstream-vars="upstreamVars"
|
||||
:upstream-resolved="upstreamResolved"
|
||||
:sibling-vars="siblingVars"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -422,11 +461,18 @@ function cancel() {
|
||||
<!-- v2 derivation branch — single-pane tailored modal. Library
|
||||
(rule conditions / branches / actions) stays in SPA; modal
|
||||
exposes only display-name override + per-rule disable
|
||||
toggles via instance.disabled_rule_ids. -->
|
||||
toggles via instance.disabled_rule_ids. upstream/sibling vars
|
||||
feed the rule override fields' `$var` autocomplete; the `@{}`
|
||||
nested-ref source is the LIBRARY (fetched inside the modal), so
|
||||
sibling/chain MODULES are intentionally NOT forwarded here. -->
|
||||
<DerivationInstanceModal
|
||||
v-else-if="draft && draft.type === 'derivation'"
|
||||
:module="draft"
|
||||
:is-modified="instanceModified"
|
||||
:upstream-vars="upstreamVars"
|
||||
:sibling-vars="siblingVars"
|
||||
:via-option-pairs="viaOptionPairs"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -444,13 +490,15 @@ function cancel() {
|
||||
:module="draft"
|
||||
:is-modified="instanceModified"
|
||||
:sibling-modules="siblingModules"
|
||||
:chain-modules="chainModules"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@save-to-library="onSaveToLibraryClick"
|
||||
@clear-all-overrides="onClearAllOverrides"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</ModalShell>
|
||||
|
||||
<!-- Themed confirm dialog — replaces window.confirm() for any
|
||||
@@ -487,5 +535,42 @@ function cancel() {
|
||||
* (WildcardInstanceModal, FixedValuesInstanceModal, etc.). The
|
||||
* `.wp-medit` chrome was the v1 tabbed shell — removed alongside
|
||||
* the v1 dispatch in the 2026-05-10 cleanup. */
|
||||
|
||||
.wp-frame-edit-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
.wp-modal-frame-banner {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 11px;
|
||||
border-radius: 6px;
|
||||
/* solid surface (amber mixed into the modal bg, not over the canvas) so the
|
||||
* text stays readable; amber = the override/warning accent used elsewhere. */
|
||||
background: color-mix(in oklab, var(--wp-amber) 14%, var(--wp-bg2));
|
||||
border: 1px solid color-mix(in oklab, var(--wp-amber) 38%, transparent);
|
||||
color: var(--wp-amber);
|
||||
font: 500 11px var(--wp-font-sans);
|
||||
}
|
||||
.wp-modal-frame-banner button {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid color-mix(in oklab, var(--wp-amber) 55%, transparent);
|
||||
background: color-mix(in oklab, var(--wp-amber) 20%, transparent);
|
||||
color: var(--wp-amber);
|
||||
font: 600 10px var(--wp-font-sans);
|
||||
cursor: pointer;
|
||||
}
|
||||
.wp-modal-frame-banner button:hover {
|
||||
background: color-mix(in oklab, var(--wp-amber) 32%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { ref } from "vue";
|
||||
import ModuleRow from "./ModuleRow.vue";
|
||||
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
import type { PairingBadge } from "../../extension/constraint-pairs";
|
||||
|
||||
/** Build a ModuleRowCtx whose pairing accessors return canned data and
|
||||
* whose ~30 helpers are inert stubs — ModuleRow only renders, so the
|
||||
* helpers just need to exist + be callable without throwing. */
|
||||
function makeCtx(over: Partial<ModuleRowCtx> = {}): ModuleRowCtx {
|
||||
const noop = (): void => {};
|
||||
return {
|
||||
KIND_TITLE: { wildcard: "Wildcard", constraint: "Constraint" },
|
||||
kindIcon: () => "pi pi-box",
|
||||
kindChipModifier: (t: string) => t,
|
||||
varColorClass: () => "var-1",
|
||||
isCollapsed: () => true,
|
||||
isLocked: () => false,
|
||||
isInternal: () => false,
|
||||
isSeedLockable: () => false,
|
||||
isModified: () => false,
|
||||
isDrifted: () => false,
|
||||
isMissingFromLibrary: () => false,
|
||||
isTypeConflict: () => false,
|
||||
severityFor: () => null,
|
||||
conflictTooltip: () => "",
|
||||
conflictBadgeText: () => null,
|
||||
modifiedTooltip: () => "",
|
||||
summaryFor: () => "",
|
||||
summaryTokens: () => [],
|
||||
siblingInfo: () => null,
|
||||
rowGap: () => null,
|
||||
draggingModuleUid: ref<string | null>(null),
|
||||
recentDropUids: ref(new Set<string>()),
|
||||
pulseDelayFor: () => "0ms",
|
||||
toggleCollapsed: noop,
|
||||
toggleEnabled: noop,
|
||||
removeModule: noop,
|
||||
toggleLockOnCard: noop,
|
||||
toggleInternalOnCard: noop,
|
||||
onDragStart: noop,
|
||||
onDragEnd: noop,
|
||||
openContextMenu: noop,
|
||||
onCardKeydown: noop,
|
||||
pairingFor: () => null,
|
||||
viaInboundFor: () => [],
|
||||
contributorsFor: () => [],
|
||||
currentFrame: ref<number | null>(null),
|
||||
isHeld: () => false,
|
||||
isOverriddenOnFrame: () => false,
|
||||
effectiveLockedSeed: () => undefined,
|
||||
effectiveEnabled: (m) => m.enabled !== false,
|
||||
frameEnableOverride: () => null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function module(over: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
id: "m1",
|
||||
_uid: "uid1",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
meta: { name: "subject" },
|
||||
entries: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function mountRow(ctx: ModuleRowCtx, mod: ModuleEntry) {
|
||||
return mount(ModuleRow, {
|
||||
props: { module: mod, idx: 0 },
|
||||
global: { provide: { [ModuleRowCtxKey as symbol]: ctx } },
|
||||
});
|
||||
}
|
||||
|
||||
const contrib = (n: number, colorIndex = n): PairingBadge => ({
|
||||
number: n,
|
||||
targetUuid: "aaaaaaaa",
|
||||
colorIndex,
|
||||
isOrphan: false,
|
||||
});
|
||||
|
||||
describe("ModuleRow.vue — badge before name", () => {
|
||||
it("renders the pair badge BEFORE the module name in DOM order", () => {
|
||||
const ctx = makeCtx({ contributorsFor: () => [contrib(1)] });
|
||||
const wrapper = mountRow(ctx, module());
|
||||
const html = wrapper.html();
|
||||
const badgeIdx = html.indexOf("wp-pair-badge");
|
||||
const nameIdx = html.indexOf("wp-module-name");
|
||||
expect(badgeIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(nameIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(badgeIdx).toBeLessThan(nameIdx);
|
||||
});
|
||||
|
||||
it("renders the sender badge before the name for a constraint row", () => {
|
||||
const senderBadge: PairingBadge = {
|
||||
number: 1,
|
||||
targetUuid: "aaaaaaaa",
|
||||
colorIndex: 2,
|
||||
isOrphan: false,
|
||||
reach: { mode: "all" },
|
||||
};
|
||||
const ctx = makeCtx({ pairingFor: () => senderBadge });
|
||||
const wrapper = mountRow(ctx, module({ type: "constraint", meta: { name: "no red" } }));
|
||||
const html = wrapper.html();
|
||||
expect(html.indexOf("wp-pair-badge")).toBeLessThan(html.indexOf("wp-module-name"));
|
||||
// Sender carries its reach suffix.
|
||||
expect(wrapper.text()).toContain("·all");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleRow.vue — contributor cluster", () => {
|
||||
it("renders one chip per contributor when there are 2 or fewer", () => {
|
||||
const ctx = makeCtx({ contributorsFor: () => [contrib(1), contrib(2)] });
|
||||
const wrapper = mountRow(ctx, module());
|
||||
expect(wrapper.findAll(".wp-pair-badge")).toHaveLength(2);
|
||||
expect(wrapper.find(".wp-pair-badge--collapse").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("renders exactly ONE collapse chip ↥×N when there are 3 or more contributors", () => {
|
||||
const ctx = makeCtx({ contributorsFor: () => [contrib(1), contrib(2), contrib(3)] });
|
||||
const wrapper = mountRow(ctx, module());
|
||||
const collapse = wrapper.findAll(".wp-pair-badge--collapse");
|
||||
expect(collapse).toHaveLength(1);
|
||||
expect(collapse[0].text()).toContain("↥×3");
|
||||
// The collapse chip REPLACES the individual chips — no per-contributor
|
||||
// direct chips alongside it.
|
||||
expect(wrapper.findAll(".wp-pair-badge")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders no contributor chip on a row no constraint covers", () => {
|
||||
const ctx = makeCtx({ contributorsFor: () => [] });
|
||||
const wrapper = mountRow(ctx, module());
|
||||
expect(wrapper.find(".wp-pair-badge").exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleRow.vue — iteration-aware badges", () => {
|
||||
it("shows a held badge when seed_scope is hold", () => {
|
||||
const mod = module({ instance: { seed_scope: "hold" } as ModuleEntry["instance"] });
|
||||
const ctx = makeCtx({ isHeld: (m) => m.instance?.seed_scope === "hold" });
|
||||
const wrapper = mountRow(ctx, mod);
|
||||
expect(wrapper.find('[data-test="mod-held"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows an override badge labelled with the current frame", () => {
|
||||
const frame = ref<number | null>(1);
|
||||
const mod = module({ iteration_overrides: { "1": { weight: 2 } } as ModuleEntry["iteration_overrides"] });
|
||||
const ctx = makeCtx({
|
||||
currentFrame: frame,
|
||||
isOverriddenOnFrame: (m) => {
|
||||
const k = frame.value;
|
||||
return k != null && !!m.iteration_overrides?.[String(k)];
|
||||
},
|
||||
});
|
||||
const wrapper = mountRow(ctx, mod);
|
||||
const badge = wrapper.find('[data-test="mod-override"]');
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toContain("#2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleRow.vue — per-frame enable/disable", () => {
|
||||
it("checkbox is unchecked when effectiveEnabled is false on the active frame", () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const ctx = makeCtx({ currentFrame: frame, effectiveEnabled: () => false });
|
||||
const wrapper = mountRow(ctx, module({ enabled: true }));
|
||||
const checkbox = wrapper.find('input[type="checkbox"]');
|
||||
expect(checkbox.exists()).toBe(true);
|
||||
expect((checkbox.element as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
|
||||
it("checkbox is checked when effectiveEnabled is true (base-off but on for this frame)", () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const ctx = makeCtx({ currentFrame: frame, effectiveEnabled: () => true });
|
||||
const wrapper = mountRow(ctx, module({ enabled: false }));
|
||||
const checkbox = wrapper.find('input[type="checkbox"]');
|
||||
expect((checkbox.element as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
it("shows an off-#k badge when frameEnableOverride is 'off'", () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const ctx = makeCtx({ currentFrame: frame, frameEnableOverride: () => "off" });
|
||||
const wrapper = mountRow(ctx, module({ enabled: true }));
|
||||
const badge = wrapper.find('[data-test="mod-frame-disabled"]');
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toContain("#3"); // frame index 2 (0-based) → "#3"
|
||||
});
|
||||
|
||||
it("shows an on-#k badge when frameEnableOverride is 'on'", () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const ctx = makeCtx({ currentFrame: frame, frameEnableOverride: () => "on" });
|
||||
const wrapper = mountRow(ctx, module({ enabled: false }));
|
||||
const badge = wrapper.find('[data-test="mod-frame-enabled"]');
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toContain("#3");
|
||||
});
|
||||
|
||||
it("checkbox change calls ctx.toggleEnabled with the row index", async () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const calls: number[] = [];
|
||||
const ctx = makeCtx({
|
||||
currentFrame: frame,
|
||||
toggleEnabled: (idx) => { calls.push(idx); },
|
||||
});
|
||||
const wrapper = mountRow(ctx, module({ enabled: true }));
|
||||
await wrapper.find('input[type="checkbox"]').trigger("change");
|
||||
expect(calls).toEqual([0]);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ if (!ctx) throw new Error("ModuleRow: missing moduleRowCtx provider");
|
||||
const {
|
||||
KIND_TITLE, kindIcon, kindChipModifier, varColorClass,
|
||||
isCollapsed, isLocked, isInternal, isSeedLockable,
|
||||
isModified, isDrifted, isMissingFromLibrary,
|
||||
isModified, isDrifted, isMissingFromLibrary, isTypeConflict,
|
||||
severityFor, conflictTooltip, conflictBadgeText,
|
||||
modifiedTooltip, summaryFor, summaryTokens, siblingInfo,
|
||||
rowGap,
|
||||
@@ -23,6 +23,7 @@ const {
|
||||
onDragStart, onDragEnd, openContextMenu, onCardKeydown,
|
||||
pairingFor,
|
||||
viaInboundFor,
|
||||
contributorsFor,
|
||||
} = ctx;
|
||||
</script>
|
||||
|
||||
@@ -35,7 +36,7 @@ const {
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
:class="{
|
||||
'wp-disabled': !module.enabled,
|
||||
'wp-disabled': !(ctx?.effectiveEnabled(module) ?? (module.enabled !== false)),
|
||||
'wp-conflict-error': severityFor(module._uid ?? module.id) === 'error',
|
||||
'wp-conflict-warning': severityFor(module._uid ?? module.id) === 'warning',
|
||||
'wp-conflict-info': severityFor(module._uid ?? module.id) === 'info',
|
||||
@@ -73,8 +74,12 @@ const {
|
||||
:title="isCollapsed(module) ? 'Expand' : 'Collapse'" @click="toggleCollapsed(idx)">
|
||||
<i :class="['pi', isCollapsed(module) ? 'pi-caret-right' : 'pi-caret-down']" aria-hidden="true"></i>
|
||||
</button>
|
||||
<label class="wp-toggle" draggable="false" :title="module.enabled ? 'Disable' : 'Enable'">
|
||||
<input type="checkbox" :checked="module.enabled" :aria-label="`enable ${module.meta.name}`" @change="toggleEnabled(idx)" />
|
||||
<label class="wp-toggle" draggable="false"
|
||||
:title="ctx && ctx.currentFrame.value != null ? (ctx.effectiveEnabled(module) ? 'Disable on this frame' : 'Enable on this frame') : (module.enabled ? 'Disable' : 'Enable')">
|
||||
<input type="checkbox"
|
||||
:checked="ctx ? ctx.effectiveEnabled(module) : module.enabled"
|
||||
:aria-label="`enable ${module.meta.name}`"
|
||||
@change="toggleEnabled(idx)" />
|
||||
<span class="wp-toggle-mark"></span>
|
||||
</label>
|
||||
<span class="wp-row-type-icon" :title="module.type" aria-hidden="true">
|
||||
@@ -84,11 +89,36 @@ const {
|
||||
class="wp-kind-chip" :class="`wp-kind-chip--${kindChipModifier(module.type)}`">
|
||||
{{ KIND_TITLE[module.type] ?? module.type }}
|
||||
</span>
|
||||
<!-- Constraint (SENDER) row: the constraint's own `#N` chip,
|
||||
carrying its reach selector (`·all` / `·first` / `·n2` /
|
||||
`·pick`) + the ↪ via-glyph when its target is reached through
|
||||
a nested ref. One chip per constraint row. -->
|
||||
<PairBadge
|
||||
v-if="pairingFor(module._uid ?? module.id)"
|
||||
v-if="module.type === 'constraint' && pairingFor(module._uid ?? module.id)"
|
||||
:pair="pairingFor(module._uid ?? module.id)!"
|
||||
:variant="module.type === 'constraint' ? 'sender' : 'direct'"
|
||||
variant="sender"
|
||||
/>
|
||||
<!-- Target (WILDCARD) row: the CONTRIBUTOR CLUSTER — every
|
||||
constraint whose reach covers this instance. ≤2 contributors
|
||||
render as individual `#N` chips; ≥3 collapse into ONE solid
|
||||
`↥×N` chip (popover lists them all) so a heavily-constrained
|
||||
row never sprawls into 3+ chips. -->
|
||||
<template v-else-if="module.type !== 'constraint' && contributorsFor(module._uid ?? module.id).length > 0">
|
||||
<template v-if="contributorsFor(module._uid ?? module.id).length <= 2">
|
||||
<PairBadge
|
||||
v-for="(c, ci) in contributorsFor(module._uid ?? module.id)"
|
||||
:key="`contrib-${c.number}-${c.targetUuid}-${ci}`"
|
||||
:pair="c"
|
||||
variant="direct"
|
||||
/>
|
||||
</template>
|
||||
<PairBadge
|
||||
v-else
|
||||
:pairs="contributorsFor(module._uid ?? module.id)"
|
||||
:count="contributorsFor(module._uid ?? module.id).length"
|
||||
variant="collapse"
|
||||
/>
|
||||
</template>
|
||||
<!-- Carrier-side: ONE collapsed `↪×N` chip aggregating every
|
||||
constraint whose target lives in one of THIS wildcard's
|
||||
nested @{} refs. Solid background; multi-color fallback when
|
||||
@@ -109,6 +139,10 @@ const {
|
||||
title="Drifted — library has a newer version. Right-click → Refresh from library." aria-hidden="true"></span>
|
||||
<span v-if="isDrifted(module)" class="wp-mod-badge wp-mod-badge--drift"
|
||||
title="Drifted — library has a newer version. Right-click → Refresh from library.">drift</span>
|
||||
<span v-if="isTypeConflict(module)" class="wp-mod-dot wp-mod-dot--clash"
|
||||
title="Id clash — a different kind of item uses this id in your library. Right-click → Push to library to add this one as a new entry." aria-hidden="true"></span>
|
||||
<span v-if="isTypeConflict(module)" class="wp-mod-badge wp-mod-badge--clash"
|
||||
title="Id clash — a different kind of item uses this id in your library. Right-click → Push to library to add this one as a new entry.">clash</span>
|
||||
<span v-if="isMissingFromLibrary(module)" class="wp-mod-dot wp-mod-dot--missing"
|
||||
title="Not in library — right-click → Save to library to add it" aria-hidden="true"></span>
|
||||
<span v-if="isMissingFromLibrary(module)" class="wp-mod-badge wp-mod-badge--missing"
|
||||
@@ -119,11 +153,15 @@ const {
|
||||
class="wp-conflict-badge"
|
||||
:class="`wp-conflict-badge--${severityFor(module._uid ?? module.id)}`"
|
||||
:title="conflictTooltip(module._uid ?? module.id)">{{ conflictBadgeText(module._uid ?? module.id) }}</span>
|
||||
<span v-if="ctx?.isHeld(module)" class="wp-mod-badge wp-mod-badge--clash" data-test="mod-held" title="Held across the run">held</span>
|
||||
<span v-if="ctx?.isOverriddenOnFrame(module)" class="wp-conflict-badge wp-conflict-badge--warning" data-test="mod-override" title="Overridden on this frame">override #{{ (ctx.currentFrame.value ?? 0) + 1 }}</span>
|
||||
<span v-if="ctx?.frameEnableOverride(module) === 'off'" class="wp-mod-badge wp-mod-badge--missing" data-test="mod-frame-disabled" title="Disabled on this frame">off #{{ (ctx.currentFrame.value ?? 0) + 1 }}</span>
|
||||
<span v-else-if="ctx?.frameEnableOverride(module) === 'on'" class="wp-mod-badge wp-mod-badge--on" data-test="mod-frame-enabled" title="Enabled on this frame">on #{{ (ctx.currentFrame.value ?? 0) + 1 }}</span>
|
||||
</span>
|
||||
<div class="wp-mod-actions" draggable="false">
|
||||
<button v-if="isSeedLockable(module)" type="button" class="wp-btn--icon-sm wp-btn--warn"
|
||||
:class="{ 'is-locked': isLocked(module) }" data-test="row-action-lock"
|
||||
:title="isLocked(module) ? `Locked seed: ${module.instance?.locked_seed}. Click to unlock.` : 'Lock seed'"
|
||||
:title="isLocked(module) ? `Locked seed: ${ctx?.effectiveLockedSeed(module)}. Click to unlock.` : 'Lock seed'"
|
||||
:aria-label="isLocked(module) ? 'Unlock seed' : 'Lock seed'"
|
||||
@click.stop="toggleLockOnCard(idx)"><i class="pi pi-lock" /></button>
|
||||
<button v-if="module.type === 'wildcard' || module.type === 'fixed_values' || module.type === 'combine' || module.type === 'derivation'"
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import PairBadge from "./PairBadge.vue";
|
||||
import type { PairingBadge } from "../../extension/constraint-pairs";
|
||||
|
||||
afterEach(() => {
|
||||
// Popover teleports to <body> — strip leftover overlays between tests.
|
||||
while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
|
||||
});
|
||||
|
||||
/** Minimal sender badge with a given reach selector. */
|
||||
function senderPair(reach: PairingBadge["reach"], over: Partial<PairingBadge> = {}): PairingBadge {
|
||||
return {
|
||||
number: 1,
|
||||
targetUuid: "aaaaaaaa",
|
||||
colorIndex: 3,
|
||||
isOrphan: false,
|
||||
reach,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PairBadge.vue — sender reach suffix", () => {
|
||||
it("renders ·all for the default all reach", () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
props: { pair: senderPair({ mode: "all" }), variant: "sender" },
|
||||
});
|
||||
expect(wrapper.text()).toContain("#1");
|
||||
expect(wrapper.text()).toContain("·all");
|
||||
});
|
||||
|
||||
it("renders ·first for first reach", () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
props: { pair: senderPair({ mode: "first" }), variant: "sender" },
|
||||
});
|
||||
expect(wrapper.text()).toContain("·first");
|
||||
});
|
||||
|
||||
it("renders ·n{count} for next reach", () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
props: { pair: senderPair({ mode: "next", count: 2 }), variant: "sender" },
|
||||
});
|
||||
expect(wrapper.text()).toContain("·n2");
|
||||
});
|
||||
|
||||
it("renders ·pick for pick reach", () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
props: { pair: senderPair({ mode: "pick", picks: [] }), variant: "sender" },
|
||||
});
|
||||
expect(wrapper.text()).toContain("·pick");
|
||||
});
|
||||
|
||||
it("keeps the via ↪ glyph alongside the reach suffix", () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
props: {
|
||||
pair: senderPair(
|
||||
{ mode: "all" },
|
||||
{ via: { carrierRowKey: "c1", carrierName: "backdrop", optionIds: ["o1"], routeChain: ["aaaaaaaa"] } },
|
||||
),
|
||||
variant: "sender",
|
||||
},
|
||||
});
|
||||
expect(wrapper.text()).toContain("↪");
|
||||
expect(wrapper.text()).toContain("·all");
|
||||
});
|
||||
|
||||
it("does not append a reach suffix to a direct (target) chip", () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
props: { pair: { number: 1, targetUuid: "aaaaaaaa", colorIndex: 3, isOrphan: false }, variant: "direct" },
|
||||
});
|
||||
expect(wrapper.text()).toContain("#1");
|
||||
expect(wrapper.text()).not.toContain("·");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PairBadge.vue — collapse variant", () => {
|
||||
const pairs: PairingBadge[] = [
|
||||
{ number: 1, targetUuid: "aaaaaaaa", colorIndex: 1, isOrphan: false },
|
||||
{ number: 2, targetUuid: "aaaaaaaa", colorIndex: 2, isOrphan: false },
|
||||
{ number: 3, targetUuid: "aaaaaaaa", colorIndex: 3, isOrphan: false },
|
||||
];
|
||||
|
||||
it("renders ↥×N from the pairs length", () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
props: { pairs, variant: "collapse" },
|
||||
});
|
||||
expect(wrapper.text()).toContain("↥×3");
|
||||
});
|
||||
|
||||
it("renders ↥×N from an explicit count prop", () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
props: { count: 4, pairs, variant: "collapse" },
|
||||
});
|
||||
expect(wrapper.text()).toContain("↥×4");
|
||||
});
|
||||
|
||||
it("carries the .wp-pair-badge--collapse class and not the carrier multi-color class", () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
props: { pairs, variant: "collapse" },
|
||||
});
|
||||
const chip = wrapper.find(".wp-pair-badge");
|
||||
expect(chip.exists()).toBe(true);
|
||||
expect(chip.classes()).toContain("wp-pair-badge--collapse");
|
||||
// Solid collapse chip is NOT the dim multi-color carrier surface.
|
||||
expect(chip.classes()).not.toContain("wp-pair-badge--via-multi");
|
||||
});
|
||||
|
||||
it("opens a popover listing every collapsed pair", async () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
attachTo: document.body,
|
||||
props: { pairs, variant: "collapse" },
|
||||
});
|
||||
await wrapper.find(".wp-pair-badge").trigger("click");
|
||||
await wrapper.vm.$nextTick();
|
||||
const pop = document.querySelector(".wp-pair-pop");
|
||||
expect(pop).not.toBeNull();
|
||||
// Each numbered entry should be present.
|
||||
expect(pop?.textContent).toContain("#1");
|
||||
expect(pop?.textContent).toContain("#2");
|
||||
expect(pop?.textContent).toContain("#3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PairBadge.vue — popover REACH + PATH rows", () => {
|
||||
it("renders a REACH row from the primary pair's reach", async () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
attachTo: document.body,
|
||||
props: { pair: senderPair({ mode: "next", count: 2 }), variant: "sender" },
|
||||
});
|
||||
await wrapper.find(".wp-pair-badge").trigger("click");
|
||||
await wrapper.vm.$nextTick();
|
||||
const pop = document.querySelector(".wp-pair-pop");
|
||||
expect(pop).not.toBeNull();
|
||||
expect(pop?.textContent?.toLowerCase()).toContain("reach");
|
||||
// "next 2" is the human label for a next/count reach.
|
||||
expect(pop?.textContent).toContain("next 2");
|
||||
});
|
||||
|
||||
it("renders a PATH row when via.routeChain is present", async () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
pair: senderPair(
|
||||
{ mode: "all" },
|
||||
{
|
||||
via: {
|
||||
carrierRowKey: "c1",
|
||||
carrierName: "backdrop",
|
||||
optionIds: ["o1"],
|
||||
routeChain: ["bbbbbbbb", "aaaaaaaa"],
|
||||
},
|
||||
},
|
||||
),
|
||||
variant: "sender",
|
||||
},
|
||||
});
|
||||
await wrapper.find(".wp-pair-badge").trigger("click");
|
||||
await wrapper.vm.$nextTick();
|
||||
const pop = document.querySelector(".wp-pair-pop");
|
||||
expect(pop).not.toBeNull();
|
||||
expect(pop?.textContent?.toLowerCase()).toContain("path");
|
||||
// Hops rendered (uuids resolve to themselves when no name cached).
|
||||
expect(pop?.textContent).toContain("bbbbbbbb");
|
||||
expect(pop?.textContent).toContain("aaaaaaaa");
|
||||
});
|
||||
});
|
||||
@@ -35,16 +35,21 @@ import { computed, nextTick, onBeforeUnmount, ref } from "vue";
|
||||
import type { PairingBadge } from "../../extension/constraint-pairs";
|
||||
import { cacheVersion, ensure, lookup } from "../../extension/preview-resolver";
|
||||
|
||||
type Variant = "sender" | "direct" | "carrier" | "option";
|
||||
type Variant = "sender" | "direct" | "carrier" | "option" | "collapse";
|
||||
|
||||
const props = defineProps<{
|
||||
/** Single-pair variants (sender / direct / option / carrier-with-1) — the
|
||||
* one pair this chip represents. Carrier variant accepts the array
|
||||
* form via `pairs` instead. */
|
||||
* one pair this chip represents. Carrier + collapse variants accept the
|
||||
* array form via `pairs` instead. */
|
||||
pair?: PairingBadge;
|
||||
/** Multi-pair carrier variant — aggregate inbound via-pairs collapsed
|
||||
* into one `↪×N` chip. Ignored for non-carrier variants. */
|
||||
/** Multi-pair carrier / collapse variant — aggregate the pairs this chip
|
||||
* stands in for, collapsed into one chip. Ignored for single-pair
|
||||
* variants. */
|
||||
pairs?: readonly PairingBadge[];
|
||||
/** Optional explicit count for the `collapse` variant. When omitted the
|
||||
* chip falls back to `pairs.length`. Lets a caller render `↥×N` from a
|
||||
* total even if it hands a partial list to the popover. */
|
||||
count?: number;
|
||||
variant: Variant;
|
||||
}>();
|
||||
|
||||
@@ -52,7 +57,7 @@ const props = defineProps<{
|
||||
* a list (collapsed) or a single (legacy / single-inbound). Other
|
||||
* variants always single. */
|
||||
const pairList = computed<readonly PairingBadge[]>(() => {
|
||||
if (props.variant === "carrier") {
|
||||
if (props.variant === "carrier" || props.variant === "collapse") {
|
||||
if (props.pairs && props.pairs.length > 0) return props.pairs;
|
||||
if (props.pair) return [props.pair];
|
||||
return [];
|
||||
@@ -170,8 +175,77 @@ function routeLabel(p: PairingBadge): string {
|
||||
return "Via this wildcard's nested ref";
|
||||
}
|
||||
|
||||
/** Compact reach suffix for the SENDER chip, appended after `#N`.
|
||||
* ALWAYS shown — the default `all` reads `·all` so the chip never
|
||||
* hides which instances a constraint covers:
|
||||
* - all → `·all`
|
||||
* - first → `·first`
|
||||
* - next → `·n{count}` (count coerced, default 1 → `·n1`)
|
||||
* - pick → `·pick`
|
||||
* Returns "" for any non-sender variant (only the originating
|
||||
* constraint carries its own reach). */
|
||||
const reachSuffix = computed<string>(() => {
|
||||
if (props.variant !== "sender") return "";
|
||||
const reach = primaryPair.value?.reach;
|
||||
if (!reach) return "";
|
||||
switch (reach.mode) {
|
||||
case "first":
|
||||
return "·first";
|
||||
case "next": {
|
||||
const cnt = Number.isFinite(Number(reach.count)) ? Number(reach.count) : 1;
|
||||
return `·n${cnt}`;
|
||||
}
|
||||
case "pick":
|
||||
return "·pick";
|
||||
default:
|
||||
return "·all";
|
||||
}
|
||||
});
|
||||
|
||||
/** Human-readable reach label for the popover REACH row. Mirrors
|
||||
* `reachSuffix` but spelled out: `all` / `first` / `next {count}` /
|
||||
* `pick`. Null when there's no reach to show (non-sender pairs). */
|
||||
function reachLabel(p: PairingBadge): string | null {
|
||||
const reach = p.reach;
|
||||
if (!reach) return null;
|
||||
switch (reach.mode) {
|
||||
case "first":
|
||||
return "first";
|
||||
case "next": {
|
||||
const cnt = Number.isFinite(Number(reach.count)) ? Number(reach.count) : 1;
|
||||
return `next ${cnt}`;
|
||||
}
|
||||
case "pick":
|
||||
return "pick";
|
||||
default:
|
||||
return "all";
|
||||
}
|
||||
}
|
||||
|
||||
/** Hop labels for the popover PATH row, target LAST. Resolves each uuid
|
||||
* in `via.routeChain` to its display name via the preview-resolver
|
||||
* cache (rendered `@name`), falling back to the bare uuid when no name
|
||||
* is known yet. Empty when the pair has no routeChain. */
|
||||
function pathHops(p: PairingBadge): string[] {
|
||||
const chain = p.via?.routeChain;
|
||||
if (!chain || chain.length === 0) return [];
|
||||
return chain.map((uuid) => {
|
||||
const name = nameForUuid(uuid);
|
||||
return name ? `@${name}` : uuid;
|
||||
});
|
||||
}
|
||||
|
||||
const chipClasses = computed<string[]>(() => {
|
||||
const cls = ["wp-pair-badge"];
|
||||
// Collapse chip is its OWN solid surface (var(--wp-accent-300) fill via
|
||||
// the modifier) — no per-target color class, no multi-color fallback,
|
||||
// no carrier styling. Same base geometry (pad / font / radius) as a
|
||||
// direct pair badge, just a different paint. Short-circuit so none of
|
||||
// the color/carrier/orphan branches below apply.
|
||||
if (props.variant === "collapse") {
|
||||
cls.push("wp-pair-badge--collapse");
|
||||
return cls;
|
||||
}
|
||||
if (isMultiColor.value) {
|
||||
cls.push("wp-pair-badge--via-multi");
|
||||
} else if (primaryPair.value) {
|
||||
@@ -201,14 +275,43 @@ const chipClasses = computed<string[]>(() => {
|
||||
* against `×`, and keeps the glyph readable at chip-size. */
|
||||
const carrierText = computed<string>(() => `↪ ×${pairList.value.length}`);
|
||||
|
||||
/** Collapse chip count — explicit `count` prop wins, else the popover
|
||||
* list length. Drives the `↥×N` glyph that REPLACES the individual
|
||||
* contributor chips on a target row with 3+ contributors. */
|
||||
const collapseCount = computed<number>(() =>
|
||||
typeof props.count === "number" ? props.count : pairList.value.length,
|
||||
);
|
||||
const collapseText = computed<string>(() => `↥×${collapseCount.value}`);
|
||||
|
||||
const popoverAriaLabel = computed<string>(() => {
|
||||
if (props.variant === "carrier") {
|
||||
return `${pairList.value.length} constraint pair${pairList.value.length === 1 ? "" : "s"} via nested refs`;
|
||||
}
|
||||
if (props.variant === "collapse") {
|
||||
return `${collapseCount.value} constraint contributor${collapseCount.value === 1 ? "" : "s"}`;
|
||||
}
|
||||
return primaryPair.value
|
||||
? `Constraint pair ${primaryPair.value.number}`
|
||||
: "Constraint pair";
|
||||
});
|
||||
|
||||
/** True when the popover lists MORE THAN ONE pair (collapsed carrier or
|
||||
* the new collapse contributor cluster). Drives the `×N` head, the
|
||||
* per-entry `#N` head, and the multi-entry title/hint copy. */
|
||||
const isMultiEntry = computed<boolean>(
|
||||
() =>
|
||||
(props.variant === "carrier" || props.variant === "collapse")
|
||||
&& pairList.value.length > 1,
|
||||
);
|
||||
|
||||
/** Head title for the multi-entry popover. Collapse (contributor
|
||||
* cluster) reads "Contributing constraints"; the carrier route chip
|
||||
* keeps its "Routed constraint pairs" wording. */
|
||||
const multiEntryTitle = computed<string>(() =>
|
||||
props.variant === "collapse"
|
||||
? "Contributing constraints"
|
||||
: "Routed constraint pairs",
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -230,12 +333,15 @@ const popoverAriaLabel = computed<string>(() => {
|
||||
>
|
||||
<template v-if="variant === 'sender' || variant === 'direct'">
|
||||
<span v-if="primaryPair?.isOrphan" class="wp-pair-badge__warn">!</span>
|
||||
<template v-if="variant === 'sender' && primaryPair?.via">#{{ primaryPair.number }} ↪</template>
|
||||
<template v-else>#{{ primaryPair?.number }}</template>
|
||||
<template v-if="variant === 'sender' && primaryPair?.via">#{{ primaryPair.number }} ↪{{ reachSuffix }}</template>
|
||||
<template v-else>#{{ primaryPair?.number }}{{ reachSuffix }}</template>
|
||||
</template>
|
||||
<template v-else-if="variant === 'carrier'">
|
||||
{{ carrierText }}
|
||||
</template>
|
||||
<template v-else-if="variant === 'collapse'">
|
||||
{{ collapseText }}
|
||||
</template>
|
||||
<template v-else>
|
||||
↪#{{ primaryPair?.number }}
|
||||
</template>
|
||||
@@ -252,10 +358,10 @@ const popoverAriaLabel = computed<string>(() => {
|
||||
@mouseleave="onLeave"
|
||||
>
|
||||
<div class="wp-pair-pop__head">
|
||||
<span v-if="variant === 'carrier' && pairList.length > 1" class="wp-pair-pop__num">×{{ pairList.length }}</span>
|
||||
<span v-if="isMultiEntry" class="wp-pair-pop__num">×{{ pairList.length }}</span>
|
||||
<span v-else-if="primaryPair" class="wp-pair-pop__num">#{{ primaryPair.number }}</span>
|
||||
<span class="wp-pair-pop__title">
|
||||
{{ variant === "carrier" && pairList.length > 1 ? "Routed constraint pairs" : "Constraint pair" }}
|
||||
{{ isMultiEntry ? multiEntryTitle : "Constraint pair" }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -264,13 +370,25 @@ const popoverAriaLabel = computed<string>(() => {
|
||||
class="wp-pair-pop__entry"
|
||||
:class="`wp-pair-pop__entry--c${p.colorIndex}`"
|
||||
>
|
||||
<div v-if="variant === 'carrier' && pairList.length > 1" class="wp-pair-pop__entry-head">
|
||||
<div v-if="isMultiEntry" class="wp-pair-pop__entry-head">
|
||||
<span class="wp-pair-pop__entry-num">#{{ p.number }}</span>
|
||||
</div>
|
||||
<div class="wp-pair-pop__row">
|
||||
<span class="wp-pair-pop__lbl">Route</span>
|
||||
<span class="wp-pair-pop__val">{{ routeLabel(p) }}</span>
|
||||
</div>
|
||||
<div v-if="reachLabel(p)" class="wp-pair-pop__row wp-pair-pop__row--reach">
|
||||
<span class="wp-pair-pop__lbl">Reach</span>
|
||||
<span class="wp-pair-pop__val wp-pair-pop__reach">{{ reachLabel(p) }}</span>
|
||||
</div>
|
||||
<div v-if="pathHops(p).length > 0" class="wp-pair-pop__row wp-pair-pop__row--path">
|
||||
<span class="wp-pair-pop__lbl">Path</span>
|
||||
<span class="wp-pair-pop__val wp-pair-pop__path">
|
||||
<template v-for="(hop, hi) in pathHops(p)" :key="`${hi}-${hop}`"><span
|
||||
class="wp-pair-pop__hop">{{ hop }}</span><span
|
||||
v-if="hi < pathHops(p).length - 1" class="wp-pair-pop__hop-arrow"> → </span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="wp-pair-pop__row">
|
||||
<span class="wp-pair-pop__lbl">Target</span>
|
||||
<span class="wp-pair-pop__val">
|
||||
@@ -296,6 +414,9 @@ const popoverAriaLabel = computed<string>(() => {
|
||||
The constraint claims the next downstream wildcard instance with this uuid.
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="variant === 'collapse'" class="wp-pair-pop__hint">
|
||||
Every constraint above covers this target instance. Each applies its own reach + weighting when it fires.
|
||||
</div>
|
||||
<div v-else class="wp-pair-pop__hint">
|
||||
Each constraint fires when this wildcard rolls an option carrying its target ref.
|
||||
</div>
|
||||
@@ -410,6 +531,23 @@ const popoverAriaLabel = computed<string>(() => {
|
||||
color: var(--wp-text-dim, var(--wp-text-muted, #8a8d99));
|
||||
line-height: 1.4;
|
||||
}
|
||||
/* Reach row — green so the constraint's coverage selector reads as a
|
||||
* distinct, positive fact in the card (mirrors the green reach accent
|
||||
* the reach-selector UI uses elsewhere). */
|
||||
.wp-pair-pop__reach {
|
||||
color: var(--wp-green, #6bc96f);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Path row — the transitive hop chain a constraint travels to reach a
|
||||
* nested target, target last. Wraps + uses a dim arrow between hops. */
|
||||
.wp-pair-pop__path {
|
||||
display: inline;
|
||||
word-break: break-word;
|
||||
}
|
||||
.wp-pair-pop__hop-arrow {
|
||||
color: var(--wp-text-dim, #7a7d88);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* Color-band the head badge color to match the chip. */
|
||||
.wp-pair-pop--c1 .wp-pair-pop__num { color: var(--wp-var-1); }
|
||||
|
||||
@@ -18,6 +18,7 @@ import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { bundleHashes } from "./drift-store";
|
||||
import { api } from "../../manager/api/client";
|
||||
import type { BundleInstance } from "../../widgets/_shared";
|
||||
import WpCheck from "@/components/shared/WpCheck.vue";
|
||||
|
||||
interface ChildPreview {
|
||||
name: string;
|
||||
@@ -393,11 +394,11 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
class="wp-ptl-cascade"
|
||||
data-test="pbtl-cascade"
|
||||
>
|
||||
<label class="wp-ptl-cascade__toggle">
|
||||
<input
|
||||
<span class="wp-ptl-cascade__toggle">
|
||||
<WpCheck
|
||||
v-model="cascadeEnabled"
|
||||
type="checkbox"
|
||||
data-test="pbtl-cascade-toggle"
|
||||
aria-label="Restore missing references first"
|
||||
/>
|
||||
<span>
|
||||
Restore
|
||||
@@ -408,7 +409,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
: "s"
|
||||
}} first
|
||||
</span>
|
||||
</label>
|
||||
</span>
|
||||
<div class="wp-ptl-cascade__hint">
|
||||
Cascade-creates fresh library entries for every missing
|
||||
child (bottom-up — modules first, then inner bundles)
|
||||
|
||||
@@ -253,9 +253,9 @@ describe("PushToLibraryModal", () => {
|
||||
pushResponse({ payload_hash: "h", bundles_updated: [], name: "test_w" });
|
||||
const wrap = await openModal(makeDraft());
|
||||
|
||||
const cb = document.querySelector<HTMLInputElement>('[data-test="ptl-propagate"]')!;
|
||||
cb.checked = false;
|
||||
cb.dispatchEvent(new Event("change"));
|
||||
// propagate defaults on; one click toggles it off (wp-check span).
|
||||
const cb = document.querySelector<HTMLElement>('[data-test="ptl-propagate"]')!;
|
||||
cb.click();
|
||||
await flushPromises();
|
||||
|
||||
document.querySelector<HTMLButtonElement>('[data-test="ptl-update"]')!.click();
|
||||
|
||||
@@ -29,6 +29,7 @@ import { forkModule } from "./duplicates/fork";
|
||||
import { hashes as libraryHashes } from "./drift-store";
|
||||
import { app } from "#comfyui/app";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
import WpCheck from "@/components/shared/WpCheck.vue";
|
||||
|
||||
interface Bundle {
|
||||
id: string;
|
||||
@@ -395,14 +396,14 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
<ul class="wp-ptl-bundles__list">
|
||||
<li v-for="b in bundles" :key="b.id">{{ b.name }}</li>
|
||||
</ul>
|
||||
<label class="wp-ptl-checkbox">
|
||||
<input
|
||||
<span class="wp-ptl-checkbox">
|
||||
<WpCheck
|
||||
v-model="propagate"
|
||||
type="checkbox"
|
||||
data-test="ptl-propagate"
|
||||
aria-label="Propagate update to those bundles"
|
||||
/>
|
||||
Propagate update to those bundles
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="siblings > 1" class="wp-ptl-note wp-ptl-note--warn" data-test="ptl-siblings">
|
||||
|
||||
@@ -0,0 +1,751 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { cascadeRestoreForBundle, rewriteChildId } from "./cascade-restore";
|
||||
import {
|
||||
bundleSnapshotModified,
|
||||
computeBundleFingerprint,
|
||||
} from "./bundle-fingerprint";
|
||||
import { api } from "../../../manager/api/client";
|
||||
import type { BundleRow, ModuleRow } from "../../../manager/api/types";
|
||||
import {
|
||||
emptyBundleInstance,
|
||||
type BundleInstance,
|
||||
type ModuleEntry,
|
||||
} from "../../../widgets/_shared";
|
||||
|
||||
/**
|
||||
* #2 regression: cascade-restore must remap references INSIDE a child's
|
||||
* payload (constraint source/target, @{} refs), not just the child's own id.
|
||||
* When Phase 1 re-creates a missing module under a fresh uuid, a sibling
|
||||
* constraint that points at the old uuid must be re-pointed too, or the
|
||||
* pushed bundle ships a dead source/target.
|
||||
*/
|
||||
describe("cascade-restore rewriteChildId — payload ref remap (#2)", () => {
|
||||
it("remaps a constraint child's source/target uuids to the restored module ids", () => {
|
||||
const moduleMap = new Map([
|
||||
["aaaaaaaa", "bbbbbbbb"],
|
||||
["cccccccc", "dddddddd"],
|
||||
]);
|
||||
const child = {
|
||||
id: "cn000001",
|
||||
type: "constraint",
|
||||
payload: { source_wildcard_id: "aaaaaaaa", target_wildcard_id: "cccccccc", matrix: {} },
|
||||
};
|
||||
const out = rewriteChildId(child, moduleMap, new Map());
|
||||
const p = out.payload as Record<string, unknown>;
|
||||
expect(p.source_wildcard_id).toBe("bbbbbbbb");
|
||||
expect(p.target_wildcard_id).toBe("dddddddd");
|
||||
});
|
||||
|
||||
it("remaps embedded @{uuid} refs in a child's payload (preserving #name)", () => {
|
||||
const moduleMap = new Map([["aabbccdd", "11223344"]]);
|
||||
const child = {
|
||||
id: "wc000001",
|
||||
type: "wildcard",
|
||||
payload: { options: [{ id: "o1", value: "see @{aabbccdd#color} here" }] },
|
||||
};
|
||||
const out = rewriteChildId(child, moduleMap, new Map());
|
||||
expect(JSON.stringify(out.payload)).toContain("@{11223344#color}");
|
||||
});
|
||||
|
||||
it("remaps refs found in a child's instance overrides too", () => {
|
||||
const moduleMap = new Map([["aaaaaaaa", "bbbbbbbb"]]);
|
||||
const child = {
|
||||
id: "cn000002",
|
||||
type: "constraint",
|
||||
payload: { source_wildcard_id: "aaaaaaaa" },
|
||||
instance: { target_select: { picks: [{ carrier_uid: "aaaaaaaa", option_id: "r1:0" }] } },
|
||||
};
|
||||
const out = rewriteChildId(child, moduleMap, new Map());
|
||||
expect(JSON.stringify(out.instance)).toContain("bbbbbbbb");
|
||||
expect(JSON.stringify(out.instance)).not.toContain("aaaaaaaa");
|
||||
});
|
||||
|
||||
it("leaves refs to non-restored (external) uuids untouched", () => {
|
||||
const child = {
|
||||
id: "cn000003",
|
||||
type: "constraint",
|
||||
payload: { source_wildcard_id: "eeeeeeee", target_wildcard_id: "ffffffff" },
|
||||
};
|
||||
const out = rewriteChildId(child, new Map([["aaaaaaaa", "bbbbbbbb"]]), new Map());
|
||||
const p = out.payload as Record<string, unknown>;
|
||||
expect(p.source_wildcard_id).toBe("eeeeeeee");
|
||||
expect(p.target_wildcard_id).toBe("ffffffff");
|
||||
});
|
||||
|
||||
it("still rewrites the child's own top-level id (module + bundle)", () => {
|
||||
const moduleMap = new Map([["aaaaaaaa", "bbbbbbbb"]]);
|
||||
const innerBundleMap = new Map([["bndl1111", "bndl2222"]]);
|
||||
const mod = rewriteChildId({ id: "aaaaaaaa", type: "wildcard", payload: {} }, moduleMap, innerBundleMap);
|
||||
expect(mod.id).toBe("bbbbbbbb");
|
||||
const bun = rewriteChildId({ id: "bndl1111", type: "bundle" }, moduleMap, innerBundleMap);
|
||||
expect(bun.id).toBe("bndl2222");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #2 end-to-end: the headline claim is a CHAIN — push → cascade restore
|
||||
* re-creates the missing wildcard under a fresh uuid → a sibling
|
||||
* constraint pointing at the dead uuid is repointed in the children the
|
||||
* outer ships. The pure `rewriteChildId` tests above lock the rewrite
|
||||
* primitive; this exercises `cascadeRestoreForBundle` itself (Phase 1
|
||||
* POST → Phase 3 children rebuild → Phase 4 workflow rebind) with the
|
||||
* module POST mocked. Also re-asserts the #2 invariant inside the live
|
||||
* flow: an embedded `@{uuid}` ref in the constraint follows the repoint
|
||||
* too, not just the bare source/target fields.
|
||||
*/
|
||||
describe("cascadeRestoreForBundle — end-to-end ref repoint (#2)", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mkModule(
|
||||
over: Partial<ModuleEntry> & { bundle_origin?: string },
|
||||
): ModuleEntry & { bundle_origin?: string } {
|
||||
return {
|
||||
id: "00000000",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
meta: { name: "" },
|
||||
entries: [],
|
||||
payload: {},
|
||||
instance: {},
|
||||
payload_hash: "h",
|
||||
_uid: "u",
|
||||
...over,
|
||||
} as ModuleEntry & { bundle_origin?: string };
|
||||
}
|
||||
|
||||
it("restores a missing wildcard and repoints its sibling constraint's source + embedded @{} ref", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({ id: "beef0001" }),
|
||||
text: async () => "",
|
||||
}) as unknown as Response,
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
// deadbeef = the wildcard whose library row was deleted upstream.
|
||||
const wildcard = mkModule({
|
||||
id: "deadbeef",
|
||||
type: "wildcard",
|
||||
_uid: "u-w",
|
||||
meta: { name: "Palette" },
|
||||
payload: { options: [] },
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
// A sibling constraint sources from deadbeef + carries an embedded
|
||||
// @{deadbeef#palette} ref inside an exception value. facade00 is an
|
||||
// EXTERNAL target (not restored) — it must survive untouched.
|
||||
const constraint = mkModule({
|
||||
id: "ca000001",
|
||||
type: "constraint",
|
||||
_uid: "u-c",
|
||||
meta: { name: "Pairing" },
|
||||
payload: {
|
||||
source_wildcard_id: "deadbeef",
|
||||
target_wildcard_id: "facade00",
|
||||
matrix: {},
|
||||
exceptions: [{ source_value: "see @{deadbeef#palette}", target_value: "sky" }],
|
||||
},
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const outer: BundleInstance = {
|
||||
...emptyBundleInstance("bndllib0"),
|
||||
_uid: "outer-uid",
|
||||
start_idx: 0,
|
||||
end_idx: 1,
|
||||
name: "Kit",
|
||||
};
|
||||
const modules = [wildcard, constraint];
|
||||
|
||||
const result = await cascadeRestoreForBundle({
|
||||
outer,
|
||||
modules,
|
||||
bundles: [outer],
|
||||
isModuleMissing: (m) => m.id === "deadbeef",
|
||||
isBundleMissing: () => false,
|
||||
});
|
||||
|
||||
// Only the wildcard was missing → exactly one module POST.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.restoredModuleCount).toBe(1);
|
||||
|
||||
const cn = result.rewrittenChildren.find((c) => c.type === "constraint");
|
||||
const p = cn?.payload as Record<string, unknown>;
|
||||
expect(p.source_wildcard_id).toBe("beef0001"); // repointed to the restored id
|
||||
expect(p.target_wildcard_id).toBe("facade00"); // external ref left alone
|
||||
// #2 invariant re-asserted in the live flow: embedded @{} ref follows too,
|
||||
// preserving its #name segment.
|
||||
expect(JSON.stringify(cn?.payload)).toContain("@{beef0001#palette}");
|
||||
expect(JSON.stringify(cn?.payload)).not.toContain("deadbeef");
|
||||
|
||||
// Workflow rebind swaps the dead id while preserving the per-instance _uid.
|
||||
const w = result.newModules.find((m) => m._uid === "u-w");
|
||||
expect(w?.id).toBe("beef0001");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* QA bug (2026-06-12): bundle push-with-cascade reattached the constraint's
|
||||
* TARGET but not its SOURCE — in the WORKFLOW. Root cause: Phase 4's
|
||||
* `newModules` swapped each module's own `id` but never rewrote payload refs,
|
||||
* so a restored constraint's source/target stayed pointed at the dead uuids
|
||||
* on the canvas (the pushed library children, Phase 3, were already correct).
|
||||
* This locks the workflow rebind: a restored module's INTERNAL refs follow
|
||||
* the restore too, both endpoints.
|
||||
*/
|
||||
describe("cascadeRestoreForBundle — Phase 4 workflow rebind rewrites payload refs", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mkMod(
|
||||
over: Partial<ModuleEntry> & { bundle_origin?: string },
|
||||
): ModuleEntry & { bundle_origin?: string } {
|
||||
return {
|
||||
id: "00000000",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
meta: { name: "" },
|
||||
entries: [],
|
||||
payload: {},
|
||||
instance: {},
|
||||
payload_hash: "h",
|
||||
_uid: "u",
|
||||
...over,
|
||||
} as ModuleEntry & { bundle_origin?: string };
|
||||
}
|
||||
|
||||
it("repoints a restored constraint's source AND target in newModules, not just rewrittenChildren", async () => {
|
||||
// Distinct id per POST, in outer-range order: subject, mood, constraint.
|
||||
const minted = ["beef0001", "beef0002", "ca110011"];
|
||||
let n = 0;
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
// `headers` present because Phase 1 Pass 2's corrective PUT for the
|
||||
// restored constraint routes through `api.modules.update` →
|
||||
// `request()` → `checkStartupId(resp)`, which reads `resp.headers`.
|
||||
headers: { get: () => null },
|
||||
json: async () => ({ id: minted[n++] }),
|
||||
text: async () => "",
|
||||
}) as unknown as Response,
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const subject = mkMod({
|
||||
id: "deadbeef",
|
||||
type: "wildcard",
|
||||
_uid: "u-s",
|
||||
meta: { name: "subject" },
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const mood = mkMod({
|
||||
id: "facade00",
|
||||
type: "wildcard",
|
||||
_uid: "u-m",
|
||||
meta: { name: "mood" },
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const constraint = mkMod({
|
||||
id: "ca000001",
|
||||
type: "constraint",
|
||||
_uid: "u-c",
|
||||
meta: { name: "pairing" },
|
||||
payload: { source_wildcard_id: "deadbeef", target_wildcard_id: "facade00", matrix: {} },
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const outer: BundleInstance = {
|
||||
...emptyBundleInstance("bndllib1"),
|
||||
_uid: "outer-uid",
|
||||
start_idx: 0,
|
||||
end_idx: 2,
|
||||
name: "Kit",
|
||||
};
|
||||
const modules = [subject, mood, constraint];
|
||||
|
||||
const result = await cascadeRestoreForBundle({
|
||||
outer,
|
||||
modules,
|
||||
bundles: [outer],
|
||||
isModuleMissing: (m) => ["deadbeef", "facade00", "ca000001"].includes(m.id),
|
||||
isBundleMissing: () => false,
|
||||
});
|
||||
|
||||
// All three restored → ids swapped in the workflow rebind.
|
||||
const s = result.newModules.find((m) => m._uid === "u-s");
|
||||
const t = result.newModules.find((m) => m._uid === "u-m");
|
||||
const c = result.newModules.find((m) => m._uid === "u-c");
|
||||
expect(s?.id).toBe("beef0001");
|
||||
expect(t?.id).toBe("beef0002");
|
||||
// The CANVAS constraint must repoint BOTH endpoints (the SRC-MISSING bug
|
||||
// was: only target followed the restore, source stayed dead).
|
||||
const p = c?.payload as Record<string, unknown>;
|
||||
expect(p.source_wildcard_id).toBe("beef0001");
|
||||
expect(p.target_wildcard_id).toBe("beef0002");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Phase 1 third-output bug (forward-ref): each missing module is POSTed
|
||||
* RAW to mint a fresh library id (Pass 1). For a restored constraint, that
|
||||
* standalone library entry ships DANGLING source/target — they still point
|
||||
* at the OLD (now-deleted) uuids, because the constraint is POSTed BEFORE
|
||||
* its target wildcard mints a new id (moduleMap is incomplete mid-loop).
|
||||
* Phase 3 (pushed bundle children) + Phase 4 (workflow rebind) both already
|
||||
* rewrite refs; Phase 1's standalone library entries did not.
|
||||
*
|
||||
* The fix is a two-pass Phase 1: Pass 1 mints ids (raw POST); Pass 2 runs
|
||||
* AFTER the loop (moduleMap COMPLETE), walkRemaps each restored module's
|
||||
* payload and, when refs actually changed, corrects the freshly-created
|
||||
* library entry via `api.modules.update`. A pure wildcard with no internal
|
||||
* refs must NOT trigger a corrective PUT.
|
||||
*/
|
||||
describe("cascadeRestoreForBundle — Phase 1 Pass 2 corrects standalone library refs", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mkMod(
|
||||
over: Partial<ModuleEntry> & { bundle_origin?: string },
|
||||
): ModuleEntry & { bundle_origin?: string } {
|
||||
return {
|
||||
id: "00000000",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
meta: { name: "" },
|
||||
entries: [],
|
||||
payload: {},
|
||||
instance: {},
|
||||
payload_hash: "h",
|
||||
_uid: "u",
|
||||
...over,
|
||||
} as ModuleEntry & { bundle_origin?: string };
|
||||
}
|
||||
|
||||
it("repoints a forward-ref constraint's source + target via api.modules.update, and skips a ref-free wildcard", async () => {
|
||||
// Outer range order is FORWARD-REF: the constraint sits BEFORE its
|
||||
// target wildcard, so moduleMap is incomplete when the constraint is
|
||||
// POSTed — only a post-loop Pass 2 can repoint target_wildcard_id.
|
||||
// [ subject (deadbeef), constraint (ca000001 → src deadbeef / tgt facade00), mood (facade00) ]
|
||||
// POSTs mint, in order: beef0001 (subject), ca110011 (constraint), beef0002 (mood).
|
||||
const minted = ["beef0001", "ca110011", "beef0002"];
|
||||
let n = 0;
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({ id: minted[n++] }),
|
||||
text: async () => "",
|
||||
}) as unknown as Response,
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
// Capture the corrective PUT(s) without hitting the network.
|
||||
const updateSpy = vi
|
||||
.spyOn(api.modules, "update")
|
||||
.mockImplementation(async (id: string) => ({ id }) as unknown as ModuleRow);
|
||||
|
||||
const subject = mkMod({
|
||||
id: "deadbeef",
|
||||
type: "wildcard",
|
||||
_uid: "u-s",
|
||||
meta: { name: "subject" },
|
||||
payload: { options: [] }, // no internal refs → must NOT trigger a PUT
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const constraint = mkMod({
|
||||
id: "ca000001",
|
||||
type: "constraint",
|
||||
_uid: "u-c",
|
||||
meta: { name: "pairing" },
|
||||
payload: {
|
||||
source_wildcard_id: "deadbeef",
|
||||
target_wildcard_id: "facade00",
|
||||
matrix: {},
|
||||
},
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const mood = mkMod({
|
||||
id: "facade00",
|
||||
type: "wildcard",
|
||||
_uid: "u-m",
|
||||
meta: { name: "mood" },
|
||||
payload: { options: [] }, // no internal refs → must NOT trigger a PUT
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const outer: BundleInstance = {
|
||||
...emptyBundleInstance("bndllib2"),
|
||||
_uid: "outer-uid",
|
||||
start_idx: 0,
|
||||
end_idx: 2,
|
||||
name: "Kit",
|
||||
};
|
||||
const modules = [subject, constraint, mood];
|
||||
|
||||
await cascadeRestoreForBundle({
|
||||
outer,
|
||||
modules,
|
||||
bundles: [outer],
|
||||
isModuleMissing: (m) => ["deadbeef", "ca000001", "facade00"].includes(m.id),
|
||||
isBundleMissing: () => false,
|
||||
});
|
||||
|
||||
// Exactly ONE corrective PUT — only the constraint carried sibling refs.
|
||||
expect(updateSpy).toHaveBeenCalledTimes(1);
|
||||
const [calledId, calledBody] = updateSpy.mock.calls[0];
|
||||
// Corrective PUT targets the constraint's freshly-minted id.
|
||||
expect(calledId).toBe("ca110011");
|
||||
const p = (calledBody as { payload?: Record<string, unknown> }).payload;
|
||||
expect(p).toBeDefined();
|
||||
// BOTH endpoints repointed despite the forward-ref POST order.
|
||||
expect(p?.source_wildcard_id).toBe("beef0001"); // subject's new id
|
||||
expect(p?.target_wildcard_id).toBe("beef0002"); // mood's new id (forward ref!)
|
||||
|
||||
// The two ref-free wildcards (beef0001, beef0002) were NOT corrected.
|
||||
const correctedIds = updateSpy.mock.calls.map((c) => c[0]);
|
||||
expect(correctedIds).not.toContain("beef0001");
|
||||
expect(correctedIds).not.toContain("beef0002");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* QA bug (2026-06-12): after a cascade restore the canvas flashed DRIFT on a
|
||||
* restored constraint. Root cause — Phase 4 rebound the workflow row's id +
|
||||
* payload (refs repointed) but kept the STALE frozen-snapshot payload_hash,
|
||||
* which no longer matched the corrected library entry's hash (a ref-free
|
||||
* wildcard didn't drift because its payload, hence hash, was unchanged).
|
||||
* Phase 1 now captures the library hash (POST + corrective update) and Phase 4
|
||||
* stamps it, mirroring the bundle inserted_at_hash sync.
|
||||
*/
|
||||
describe("cascadeRestoreForBundle — Phase 4 syncs payload_hash to the library entry", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mkMod(
|
||||
over: Partial<ModuleEntry> & { bundle_origin?: string },
|
||||
): ModuleEntry & { bundle_origin?: string } {
|
||||
return {
|
||||
id: "00000000",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
meta: { name: "" },
|
||||
entries: [],
|
||||
payload: {},
|
||||
instance: {},
|
||||
payload_hash: "stale-frozen-hash",
|
||||
_uid: "u",
|
||||
...over,
|
||||
} as ModuleEntry & { bundle_origin?: string };
|
||||
}
|
||||
|
||||
it("stamps the corrected library hash on a restored constraint (no DRIFT), and the POST hash on a ref-free wildcard", async () => {
|
||||
// POST mints {id, payload_hash} per restored module, in range order:
|
||||
// subject (deadbeef) → beef0001 / "wh-post" (ref-free)
|
||||
// constraint (ca000001, src deadbeef) → ca110011 / "cn-post"
|
||||
const minted = [
|
||||
{ id: "beef0001", payload_hash: "wh-post" },
|
||||
{ id: "ca110011", payload_hash: "cn-post" },
|
||||
];
|
||||
let n = 0;
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => minted[n++],
|
||||
text: async () => "",
|
||||
}) as unknown as Response,
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
// The corrective PUT returns the CORRECTED hash (the constraint's payload moved).
|
||||
vi.spyOn(api.modules, "update").mockImplementation(
|
||||
async (id: string) => ({ id, payload_hash: "cn-corrected" }) as unknown as ModuleRow,
|
||||
);
|
||||
|
||||
const subject = mkMod({
|
||||
id: "deadbeef",
|
||||
type: "wildcard",
|
||||
_uid: "u-s",
|
||||
meta: { name: "subject" },
|
||||
payload: { options: [] }, // ref-free → no corrective PUT → POST hash
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const constraint = mkMod({
|
||||
id: "ca000001",
|
||||
type: "constraint",
|
||||
_uid: "u-c",
|
||||
meta: { name: "pairing" },
|
||||
payload: { source_wildcard_id: "deadbeef", target_wildcard_id: "facade00", matrix: {} },
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const outer: BundleInstance = {
|
||||
...emptyBundleInstance("bndllib3"),
|
||||
_uid: "outer-uid",
|
||||
start_idx: 0,
|
||||
end_idx: 1,
|
||||
name: "Kit",
|
||||
};
|
||||
|
||||
const result = await cascadeRestoreForBundle({
|
||||
outer,
|
||||
modules: [subject, constraint],
|
||||
bundles: [outer],
|
||||
isModuleMissing: (m) => ["deadbeef", "ca000001"].includes(m.id),
|
||||
isBundleMissing: () => false,
|
||||
});
|
||||
|
||||
const s = result.newModules.find((m) => m._uid === "u-s");
|
||||
const c = result.newModules.find((m) => m._uid === "u-c");
|
||||
// Ref-free wildcard → its POST hash.
|
||||
expect(s?.payload_hash).toBe("wh-post");
|
||||
// Corrected constraint → the corrective-update hash, NOT the stale frozen
|
||||
// hash (which is what produced the DRIFT badge).
|
||||
expect(c?.payload_hash).toBe("cn-corrected");
|
||||
expect(c?.payload_hash).not.toBe("stale-frozen-hash");
|
||||
|
||||
// Gap #1 (holistic audit 2026-06-13): the PUSHED bundle children (Phase 3)
|
||||
// must carry the SAME live hashes. `toChildSnapshot` froze the stale
|
||||
// pre-cascade hash, so without the rewriteChildId hash-stamp the pushed
|
||||
// bundle ships a child whose payload was corrected but whose payload_hash
|
||||
// is stale → RE-INSERTING the bundle shows spurious DRIFT against the live
|
||||
// library entry.
|
||||
const childWild = result.rewrittenChildren.find((c2) => c2.type === "wildcard");
|
||||
const childCon = result.rewrittenChildren.find((c2) => c2.type === "constraint");
|
||||
expect(childWild?.payload_hash).toBe("wh-post");
|
||||
expect(childCon?.payload_hash).toBe("cn-corrected");
|
||||
expect(childCon?.payload_hash).not.toBe("stale-frozen-hash");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* QA bug (2026-06-15): pushing an outer bundle with the cascade "Restore N
|
||||
* missing references first" option re-creates a missing INNER bundle, but the
|
||||
* inner then flashes a false MODIFIED drift badge — even though it was just
|
||||
* freshly created and matches the library. Root cause — Phase 4's `newBundles`
|
||||
* rebinds each restored inner's `library_id` + `inserted_at_hash` but kept the
|
||||
* STALE `snapshot_fingerprint` captured before the restore. The drift detector
|
||||
* `bundleSnapshotModified` compares the LIVE fingerprint against that stale
|
||||
* stored value → they differ → false MODIFIED. (`onBundlePushSaved` only
|
||||
* recomputes the OUTER's fingerprint, never the inner ones.)
|
||||
*
|
||||
* The fix is the symmetric sibling of the inserted_at_hash sync: Phase 4 also
|
||||
* reconciles each RESTORED inner's `snapshot_fingerprint` to
|
||||
* `computeBundleFingerprint(rebound, newModules)` so the freshly-restored
|
||||
* content matches its stored baseline (no drift). Only restored inners are
|
||||
* touched — a genuinely-modified, non-restored bundle keeps its own
|
||||
* fingerprint so real drift still surfaces.
|
||||
*/
|
||||
describe("cascadeRestoreForBundle — Phase 4 reconciles restored inner-bundle fingerprint", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mkMod(
|
||||
over: Partial<ModuleEntry> & { bundle_origin?: string },
|
||||
): ModuleEntry & { bundle_origin?: string } {
|
||||
return {
|
||||
id: "00000000",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
meta: { name: "" },
|
||||
entries: [],
|
||||
payload: {},
|
||||
instance: {},
|
||||
payload_hash: "h",
|
||||
_uid: "u",
|
||||
...over,
|
||||
} as ModuleEntry & { bundle_origin?: string };
|
||||
}
|
||||
|
||||
it("clears false drift on a restored inner bundle carrying a stale snapshot_fingerprint", async () => {
|
||||
// No modules are missing → fetch (module POST) is never called; only the
|
||||
// inner bundle's library row was deleted upstream.
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
({ ok: true, json: async () => ({}), text: async () => "" }) as unknown as Response,
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
// The restored inner bundle's fresh library entry.
|
||||
const createSpy = vi
|
||||
.spyOn(api.bundles, "create")
|
||||
.mockResolvedValue({ id: "innernew", payload_hash: "inner-fresh-hash" } as BundleRow);
|
||||
|
||||
// Outer (range 0-2): a top-level wildcard at idx 0, then an inner bundle
|
||||
// covering idx 1-2 (two wildcards owned by the inner).
|
||||
const topWild = mkMod({
|
||||
id: "top00001",
|
||||
type: "wildcard",
|
||||
_uid: "u-top",
|
||||
meta: { name: "top" },
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const innerA = mkMod({
|
||||
id: "inna0001",
|
||||
type: "wildcard",
|
||||
_uid: "u-ia",
|
||||
meta: { name: "innerA" },
|
||||
payload_hash: "ha",
|
||||
bundle_origin: "inner-uid",
|
||||
});
|
||||
const innerB = mkMod({
|
||||
id: "innb0001",
|
||||
type: "wildcard",
|
||||
_uid: "u-ib",
|
||||
meta: { name: "innerB" },
|
||||
payload_hash: "hb",
|
||||
bundle_origin: "inner-uid",
|
||||
});
|
||||
const outer: BundleInstance = {
|
||||
...emptyBundleInstance("outerlib"),
|
||||
_uid: "outer-uid",
|
||||
start_idx: 0,
|
||||
end_idx: 2,
|
||||
name: "Kit",
|
||||
};
|
||||
// Missing inner bundle carrying a STALE fingerprint that won't match the
|
||||
// live content — this is what produces the false MODIFIED badge today.
|
||||
const inner: BundleInstance = {
|
||||
...emptyBundleInstance("innerlibOLD"),
|
||||
_uid: "inner-uid",
|
||||
parent_uid: "outer-uid",
|
||||
start_idx: 1,
|
||||
end_idx: 2,
|
||||
name: "Inner",
|
||||
snapshot_fingerprint: "v2:stale",
|
||||
};
|
||||
const modules = [topWild, innerA, innerB];
|
||||
const bundles = [outer, inner];
|
||||
|
||||
const result = await cascadeRestoreForBundle({
|
||||
outer,
|
||||
modules,
|
||||
bundles,
|
||||
isModuleMissing: () => false,
|
||||
isBundleMissing: (b) => b._uid === "inner-uid",
|
||||
});
|
||||
|
||||
// The inner bundle was restored (Phase 2 POST) and rebound (Phase 4).
|
||||
expect(createSpy).toHaveBeenCalledTimes(1);
|
||||
expect(result.restoredBundleCount).toBe(1);
|
||||
|
||||
const reboundInner = result.newBundles.find((b) => b._uid === "inner-uid")!;
|
||||
// Sanity: rebind swapped the dead library_id + synced inserted_at_hash.
|
||||
expect(reboundInner.library_id).toBe("innernew");
|
||||
expect(reboundInner.inserted_at_hash).toBe("inner-fresh-hash");
|
||||
|
||||
// The headline fix: the rebound inner's snapshot_fingerprint matches the
|
||||
// freshly-restored content → NO false drift. (Stale "v2:stale" would fail.)
|
||||
expect(reboundInner.snapshot_fingerprint).toBe(
|
||||
computeBundleFingerprint(reboundInner, result.newModules),
|
||||
);
|
||||
expect(bundleSnapshotModified(reboundInner, result.newModules)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT touch a non-restored, genuinely-modified bundle's fingerprint", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
({ ok: true, json: async () => ({}), text: async () => "" }) as unknown as Response,
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const createSpy = vi
|
||||
.spyOn(api.bundles, "create")
|
||||
.mockResolvedValue({ id: "innernew", payload_hash: "inner-fresh-hash" } as BundleRow);
|
||||
|
||||
// Outer (range 0-3): top-level wildcard, a MISSING inner (idx 1), and a
|
||||
// SIBLING inner (idx 2-3) that is NOT missing but genuinely drifted.
|
||||
const topWild = mkMod({
|
||||
id: "top00001",
|
||||
type: "wildcard",
|
||||
_uid: "u-top",
|
||||
meta: { name: "top" },
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const missingChild = mkMod({
|
||||
id: "miss0001",
|
||||
type: "wildcard",
|
||||
_uid: "u-miss",
|
||||
meta: { name: "missingChild" },
|
||||
payload_hash: "hm",
|
||||
bundle_origin: "missing-uid",
|
||||
});
|
||||
const siblingA = mkMod({
|
||||
id: "sibl0001",
|
||||
type: "wildcard",
|
||||
_uid: "u-sa",
|
||||
meta: { name: "siblingA" },
|
||||
payload_hash: "hs",
|
||||
bundle_origin: "sibling-uid",
|
||||
});
|
||||
const siblingB = mkMod({
|
||||
id: "sibl0002",
|
||||
type: "wildcard",
|
||||
_uid: "u-sb",
|
||||
meta: { name: "siblingB" },
|
||||
payload_hash: "hs2",
|
||||
bundle_origin: "sibling-uid",
|
||||
});
|
||||
const outer: BundleInstance = {
|
||||
...emptyBundleInstance("outerlib"),
|
||||
_uid: "outer-uid",
|
||||
start_idx: 0,
|
||||
end_idx: 3,
|
||||
name: "Kit",
|
||||
};
|
||||
const missingInner: BundleInstance = {
|
||||
...emptyBundleInstance("missinglibOLD"),
|
||||
_uid: "missing-uid",
|
||||
parent_uid: "outer-uid",
|
||||
start_idx: 1,
|
||||
end_idx: 1,
|
||||
name: "Missing",
|
||||
snapshot_fingerprint: "v2:stale",
|
||||
};
|
||||
// A genuine pre-existing drift baseline: the sibling's stored fingerprint
|
||||
// intentionally does NOT match its live content. Cascade must NOT erase it.
|
||||
const driftedSibling: BundleInstance = {
|
||||
...emptyBundleInstance("siblinglib"),
|
||||
_uid: "sibling-uid",
|
||||
parent_uid: "outer-uid",
|
||||
start_idx: 2,
|
||||
end_idx: 3,
|
||||
name: "Sibling",
|
||||
snapshot_fingerprint: "v2:genuinedrift",
|
||||
};
|
||||
const modules = [topWild, missingChild, siblingA, siblingB];
|
||||
const bundles = [outer, missingInner, driftedSibling];
|
||||
|
||||
const result = await cascadeRestoreForBundle({
|
||||
outer,
|
||||
modules,
|
||||
bundles,
|
||||
isModuleMissing: () => false,
|
||||
isBundleMissing: (b) => b._uid === "missing-uid",
|
||||
});
|
||||
|
||||
expect(createSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The non-restored sibling keeps its stored fingerprint untouched → its
|
||||
// genuine drift still surfaces. Blanket-recompute would have erased it.
|
||||
const reboundSibling = result.newBundles.find((b) => b._uid === "sibling-uid")!;
|
||||
expect(reboundSibling.snapshot_fingerprint).toBe("v2:genuinedrift");
|
||||
expect(bundleSnapshotModified(reboundSibling, result.newModules)).toBe(true);
|
||||
|
||||
// The restored inner is still clean (fix applies only to it).
|
||||
const reboundMissing = result.newBundles.find((b) => b._uid === "missing-uid")!;
|
||||
expect(bundleSnapshotModified(reboundMissing, result.newModules)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
buildLibraryChildrenWithIntegrity,
|
||||
toChildSnapshot,
|
||||
} from "./save";
|
||||
import { computeBundleFingerprint } from "./bundle-fingerprint";
|
||||
import { walkRemap } from "./uuid-remap";
|
||||
import type { BundleInstance, ModuleEntry } from "../../../widgets/_shared";
|
||||
|
||||
export interface CascadeRestoreInputs {
|
||||
@@ -91,7 +93,28 @@ export async function cascadeRestoreForBundle(
|
||||
// because inner-bundle children are still inside the outer's range —
|
||||
// they need their uuids in the moduleMap before Phase 2 rebuilds the
|
||||
// inner's children list.
|
||||
//
|
||||
// Two passes:
|
||||
// Pass 1 — POST each missing module RAW to MINT a fresh id. The raw
|
||||
// payload is just a seed; it may carry refs to SIBLING restored
|
||||
// modules that haven't minted their new ids yet (forward refs),
|
||||
// so the standalone library entry can ship dangling source/target
|
||||
// / `@{}` refs at this point.
|
||||
// Pass 2 — runs AFTER the loop, when `moduleMap` is COMPLETE. For
|
||||
// each restored module it walkRemaps the payload against the full
|
||||
// map; if any ref actually changed, it corrects the freshly-created
|
||||
// library entry via `api.modules.update`. This is the same rewrite
|
||||
// Phase 3 (pushed children) and Phase 4 (workflow rebind) apply —
|
||||
// the standalone library entries are the third output that needs it.
|
||||
// Ref-free modules (most wildcards) don't differ → no update.
|
||||
const moduleMap = new Map<string, string>();
|
||||
// Library payload_hash per restored module (keyed by OLD id). Phase 4 stamps
|
||||
// it on the workflow row so the canvas doesn't flash DRIFT against the
|
||||
// freshly-created/corrected library entry — mirror of the bundle
|
||||
// inserted_at_hash sync below. A corrected constraint is the case that bites:
|
||||
// its stale frozen-snapshot hash != the new entry's hash (its payload moved).
|
||||
const moduleHashMap = new Map<string, string>();
|
||||
const restored: { newId: string; module: ModuleEntry }[] = [];
|
||||
for (let i = outer.start_idx; i <= outer.end_idx; i++) {
|
||||
const m = modules[i];
|
||||
if (!m) continue;
|
||||
@@ -110,9 +133,32 @@ export async function cascadeRestoreForBundle(
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Module restore failed for "${baseName}": HTTP ${res.status} ${text}`);
|
||||
}
|
||||
const body = (await res.json()) as { id?: string };
|
||||
const body = (await res.json()) as { id?: string; payload_hash?: string };
|
||||
if (!body.id) throw new Error(`Module restore for "${baseName}" returned no id`);
|
||||
moduleMap.set(m.id, body.id);
|
||||
if (body.payload_hash) moduleHashMap.set(m.id, body.payload_hash);
|
||||
restored.push({ newId: body.id, module: m });
|
||||
}
|
||||
|
||||
// Pass 2: correct standalone library entries whose payload referenced a
|
||||
// sibling restored module. `moduleMap` is complete now, so forward refs
|
||||
// (a constraint POSTed before its target wildcard minted its id) resolve.
|
||||
const restoreTable: Record<string, string> = {};
|
||||
for (const [k, v] of moduleMap) restoreTable[k] = v;
|
||||
if (Object.keys(restoreTable).length > 0) {
|
||||
for (const { newId, module: m } of restored) {
|
||||
const original = m.payload ?? {};
|
||||
const rewrittenPayload = walkRemap(original, restoreTable) as Record<string, unknown>;
|
||||
// Only PUT when a ref actually moved — ref-free modules are unchanged.
|
||||
if (JSON.stringify(rewrittenPayload) === JSON.stringify(original)) continue;
|
||||
const updated = await api.modules.update(newId, {
|
||||
name: pickModuleName(m),
|
||||
payload: rewrittenPayload,
|
||||
});
|
||||
// The corrected payload changes the hash — capture it so Phase 4 stamps
|
||||
// the workflow row with the LIVE library hash (overrides the POST hash).
|
||||
moduleHashMap.set(m.id, updated.payload_hash);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: restore missing inner bundles ──────────────────────────
|
||||
@@ -130,7 +176,9 @@ export async function cascadeRestoreForBundle(
|
||||
if (inner.parent_uid !== outer._uid) continue;
|
||||
if (!isBundleMissing(inner)) continue;
|
||||
const integrity = buildLibraryChildrenWithIntegrity(inner, modules, bundles);
|
||||
const rewritten = integrity.children.map((c) => rewriteChildId(c, moduleMap, innerBundleMap));
|
||||
const rewritten = integrity.children.map((c) =>
|
||||
rewriteChildId(c, moduleMap, innerBundleMap, moduleHashMap),
|
||||
);
|
||||
const created = await api.bundles.create({
|
||||
name: inner.name || "bundle",
|
||||
color: inner.color ?? null,
|
||||
@@ -143,7 +191,7 @@ export async function cascadeRestoreForBundle(
|
||||
// ── Phase 3: build the outer's children with both rewrites applied ──
|
||||
const outerIntegrity = buildLibraryChildrenWithIntegrity(outer, modules, bundles);
|
||||
const rewrittenChildren = outerIntegrity.children.map((c) =>
|
||||
rewriteChildId(c, moduleMap, innerBundleMap),
|
||||
rewriteChildId(c, moduleMap, innerBundleMap, moduleHashMap),
|
||||
);
|
||||
|
||||
// ── Phase 4: rebind workflow state so MISSING badges clear ──────────
|
||||
@@ -152,18 +200,55 @@ export async function cascadeRestoreForBundle(
|
||||
// since-deleted library entry at insert time) wouldn't match the new
|
||||
// entry's hash and the BundleHeader would render LIBRARY UPDATED
|
||||
// until the user manually reset.
|
||||
const newModules = modules.map((m) =>
|
||||
moduleMap.has(m.id) ? { ...m, id: moduleMap.get(m.id)! } : m,
|
||||
);
|
||||
// Rewrite table shared with Phase 3's child rewrite. The workflow modules
|
||||
// need their INTERNAL refs repointed too — a restored constraint's
|
||||
// source/target + any embedded `@{}` ref — not just their own `id`.
|
||||
// Without this, after a cascade restore the canvas keeps a SRC/TGT-MISSING
|
||||
// constraint even though the pushed library entry (rewrittenChildren) is
|
||||
// correct: Phase 4 swapped ids but left payloads pointing at the dead uuids.
|
||||
const refTable: Record<string, string> = {};
|
||||
for (const [k, v] of moduleMap) refTable[k] = v;
|
||||
for (const [k, v] of innerBundleMap) refTable[k] = v;
|
||||
const hasRefs = Object.keys(refTable).length > 0;
|
||||
const newModules = modules.map((m) => {
|
||||
let next = moduleMap.has(m.id) ? { ...m, id: moduleMap.get(m.id)! } : m;
|
||||
if (hasRefs) {
|
||||
if (next.payload && typeof next.payload === "object") {
|
||||
next = { ...next, payload: walkRemap(next.payload, refTable) as ModuleEntry["payload"] };
|
||||
}
|
||||
if (next.instance && typeof next.instance === "object") {
|
||||
next = { ...next, instance: walkRemap(next.instance, refTable) as ModuleEntry["instance"] };
|
||||
}
|
||||
}
|
||||
// Sync payload_hash to the restored library entry so the canvas doesn't
|
||||
// flash DRIFT against it (mirror of newBundles' inserted_at_hash below).
|
||||
// moduleHashMap only holds restored modules, so a non-restored row keeps
|
||||
// its own hash.
|
||||
const newHash = moduleHashMap.get(m.id);
|
||||
if (newHash) next = { ...next, payload_hash: newHash };
|
||||
return next;
|
||||
});
|
||||
const newBundles = bundles.map((b) => {
|
||||
const next = innerBundleMap.get(b.library_id);
|
||||
if (!next) return b;
|
||||
const hash = innerHashMap.get(b.library_id);
|
||||
return {
|
||||
const rebound: BundleInstance = {
|
||||
...b,
|
||||
library_id: next,
|
||||
...(hash ? { inserted_at_hash: hash } : {}),
|
||||
};
|
||||
// Reconcile the restored inner's snapshot_fingerprint to its freshly-
|
||||
// restored content. Without this the rebound instance keeps the STALE
|
||||
// fingerprint captured before the restore, so `bundleSnapshotModified`
|
||||
// (live vs stored) flips true and the canvas flashes a false MODIFIED
|
||||
// badge on a bundle that was just re-created to match the library.
|
||||
// Mirror of the inserted_at_hash sync above. ONLY restored inners are
|
||||
// reconciled (this branch) — a non-restored bundle returns early and
|
||||
// keeps its own fingerprint, so genuine drift still surfaces.
|
||||
return {
|
||||
...rebound,
|
||||
snapshot_fingerprint: computeBundleFingerprint(rebound, newModules),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -175,24 +260,61 @@ export async function cascadeRestoreForBundle(
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply id rewrites to a single child entry. Bundle-typed children
|
||||
* look up in `innerBundleMap`; module-typed children look up in
|
||||
* `moduleMap`. Returns a NEW object only when a rewrite fires; otherwise
|
||||
* reuses the original reference to avoid churn for downstream diff
|
||||
* consumers. */
|
||||
/** Apply id rewrites to a single child entry. Two layers:
|
||||
*
|
||||
* 1. The child's OWN top-level `id` — bundle-typed children look up in
|
||||
* `innerBundleMap`; module-typed children in `moduleMap`.
|
||||
* 2. References INSIDE the child's `payload` + `instance` that point at
|
||||
* any restored module/inner-bundle — a constraint's
|
||||
* `source_wildcard_id` / `target_wildcard_id`, or an `@{uuid}` ref in
|
||||
* a wildcard/derivation value. Without (2), restoring a missing
|
||||
* wildcard re-pointed its own child id but left a sibling
|
||||
* constraint pointing at the dead uuid → the pushed bundle shipped a
|
||||
* broken source/target (#2). The deep rewrite reuses `walkRemap`.
|
||||
*
|
||||
* Returns a NEW object only when a rewrite fires; otherwise reuses the
|
||||
* original reference to avoid churn for downstream diff consumers. */
|
||||
function rewriteChildId(
|
||||
c: Record<string, unknown>,
|
||||
moduleMap: Map<string, string>,
|
||||
innerBundleMap: Map<string, string>,
|
||||
moduleHashMap?: ReadonlyMap<string, string>,
|
||||
): Record<string, unknown> {
|
||||
let next: Record<string, unknown> = c;
|
||||
|
||||
// (1) Own id.
|
||||
const id = typeof c.id === "string" ? c.id : "";
|
||||
if (!id) return c;
|
||||
if (c.type === "bundle") {
|
||||
const next = innerBundleMap.get(id);
|
||||
return next ? { ...c, id: next } : c;
|
||||
if (id) {
|
||||
const newId = c.type === "bundle" ? innerBundleMap.get(id) : moduleMap.get(id);
|
||||
if (newId) next = { ...next, id: newId };
|
||||
|
||||
// (1b) Sync the child snapshot's `payload_hash` to the restored library
|
||||
// entry's LIVE hash. `toChildSnapshot` froze the stale pre-cascade hash;
|
||||
// for a restored module whose refs were corrected (Phase 1 Pass-2) the
|
||||
// payload moved but the frozen hash didn't, so the pushed bundle would
|
||||
// ship a child whose payload ≠ its payload_hash → RE-INSERTING the bundle
|
||||
// shows spurious DRIFT against the live library entry. `moduleHashMap` is
|
||||
// keyed by OLD id and holds only restored modules (POST hash, overridden
|
||||
// by the corrective-update hash), so non-restored + bundle children are
|
||||
// left untouched.
|
||||
const freshHash = moduleHashMap?.get(id);
|
||||
if (freshHash) next = { ...next, payload_hash: freshHash };
|
||||
}
|
||||
const next = moduleMap.get(id);
|
||||
return next ? { ...c, id: next } : c;
|
||||
|
||||
// (2) Inner references (constraint source/target, @{} refs) → restored ids.
|
||||
const table: Record<string, string> = {};
|
||||
for (const [k, v] of moduleMap) table[k] = v;
|
||||
for (const [k, v] of innerBundleMap) table[k] = v;
|
||||
if (Object.keys(table).length > 0) {
|
||||
if (next.payload && typeof next.payload === "object") {
|
||||
next = { ...next, payload: walkRemap(next.payload, table) as Record<string, unknown> };
|
||||
}
|
||||
if (next.instance && typeof next.instance === "object") {
|
||||
next = { ...next, instance: walkRemap(next.instance, table) as Record<string, unknown> };
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Pre-scan to count what cascade would heal — used by the modal to
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractBundleChildren, type ExtractableChild } from "./extract-to-library";
|
||||
|
||||
// Deterministic id minter for tests: a1→n1, etc., in call order.
|
||||
function seqMinter(): () => string {
|
||||
let i = 0;
|
||||
return () => `new${++i}`;
|
||||
}
|
||||
|
||||
const wc = (id: string, value: string): ExtractableChild => ({
|
||||
id, type: "wildcard", meta: { name: `wc-${id}` },
|
||||
payload: { options: [{ id: "o1", value }] },
|
||||
});
|
||||
|
||||
describe("extractBundleChildren", () => {
|
||||
it("mints fresh ids and remaps an intra-bundle constraint's source/target", () => {
|
||||
const children: ExtractableChild[] = [
|
||||
wc("aaaa1111", "plain"),
|
||||
wc("bbbb2222", "plain"),
|
||||
{ id: "cccc3333", type: "constraint", meta: { name: "pair" },
|
||||
payload: { source_wildcard_id: "aaaa1111", target_wildcard_id: "bbbb2222", matrix: {}, exceptions: [] } },
|
||||
];
|
||||
const { modules, remap, skipped } = extractBundleChildren(children, seqMinter());
|
||||
expect(skipped).toBe(0);
|
||||
expect(modules).toHaveLength(3);
|
||||
expect(remap).toEqual({ aaaa1111: "new1", bbbb2222: "new2", cccc3333: "new3" });
|
||||
const con = modules.find((m) => m.type === "constraint")!;
|
||||
expect(con.id).toBe("new3");
|
||||
expect(con.payload.source_wildcard_id).toBe("new1");
|
||||
expect(con.payload.target_wildcard_id).toBe("new2");
|
||||
expect(con.name).toBe("pair");
|
||||
});
|
||||
|
||||
it("remaps a nested @{} ref pointing at a sibling child", () => {
|
||||
const children: ExtractableChild[] = [
|
||||
wc("aaaa1111", "plain"),
|
||||
wc("bbbb2222", "see @{aaaa1111} here"),
|
||||
];
|
||||
const { modules } = extractBundleChildren(children, seqMinter());
|
||||
const b = modules.find((m) => m.name === "wc-bbbb2222")!;
|
||||
expect(b.payload.options).toEqual([{ id: "o1", value: "see @{new1} here" }]);
|
||||
});
|
||||
|
||||
it("leaves refs pointing OUTSIDE the bundle verbatim", () => {
|
||||
const children: ExtractableChild[] = [
|
||||
{ id: "cccc3333", type: "constraint", meta: { name: "pair" },
|
||||
payload: { source_wildcard_id: "zzzz9999", target_wildcard_id: "zzzz9999", matrix: {}, exceptions: [] } },
|
||||
];
|
||||
const { modules } = extractBundleChildren(children, seqMinter());
|
||||
expect(modules[0].payload.source_wildcard_id).toBe("zzzz9999");
|
||||
});
|
||||
|
||||
it("skips bundle-typed children (counts them) and extracts only leaf modules", () => {
|
||||
const children: ExtractableChild[] = [
|
||||
wc("aaaa1111", "plain"),
|
||||
{ id: "dddd4444", type: "bundle", meta: { name: "nested" }, payload: {} },
|
||||
];
|
||||
const { modules, skipped } = extractBundleChildren(children, seqMinter());
|
||||
expect(skipped).toBe(1);
|
||||
expect(modules).toHaveLength(1);
|
||||
expect(modules[0].type).toBe("wildcard");
|
||||
});
|
||||
|
||||
it("carries name from meta.name, falling back to the id", () => {
|
||||
const children: ExtractableChild[] = [
|
||||
{ id: "aaaa1111", type: "wildcard", payload: { options: [] } },
|
||||
];
|
||||
const { modules } = extractBundleChildren(children, seqMinter());
|
||||
expect(modules[0].name).toBe("aaaa1111");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Bundle "extract to library" (Feature 4).
|
||||
*
|
||||
* Pure transform: take a bundle's frozen child snapshots and produce
|
||||
* standalone library-module entities with FRESH ids, remapping every
|
||||
* intra-bundle reference (constraint source/target ids + nested `@{uuid}`)
|
||||
* to the new ids. References pointing OUTSIDE the extracted set stay
|
||||
* verbatim (they'll resolve against whatever lives in the target library,
|
||||
* or become ordinary danglers eligible for reattach).
|
||||
*
|
||||
* Bundle-typed children are skipped (a nested bundle isn't a leaf module);
|
||||
* the count is returned so the caller can surface "N skipped". Per-instance
|
||||
* overrides are intentionally NOT extracted — a library module is the
|
||||
* definition (payload), instance overrides are graph-specific.
|
||||
*
|
||||
* No I/O. The id minter is injected (defaults to `newShortId`) so tests can
|
||||
* assert deterministic remaps.
|
||||
*/
|
||||
import { newShortId } from "../../../manager/utils/ids";
|
||||
import { walkRemap } from "./uuid-remap";
|
||||
|
||||
/** Minimal child-snapshot shape this reads. Bundle children carry more
|
||||
* (`instance`, `enabled`, `payload_hash`…) — ignored here. */
|
||||
export interface ExtractableChild {
|
||||
id: string;
|
||||
type: string;
|
||||
payload?: Record<string, unknown>;
|
||||
meta?: { name?: string; description?: string; tags?: string[] };
|
||||
}
|
||||
|
||||
/** A standalone library-module entity ready to install (engine-row shape). */
|
||||
export interface ExtractedModule {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ExtractResult {
|
||||
modules: ExtractedModule[];
|
||||
/** oldChildId → freshId, for every extracted leaf. */
|
||||
remap: Record<string, string>;
|
||||
/** count of bundle-typed children skipped. */
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export function extractBundleChildren(
|
||||
children: ExtractableChild[],
|
||||
mintId: () => string = newShortId,
|
||||
): ExtractResult {
|
||||
const leaves = children.filter((c) => c.type !== "bundle");
|
||||
const skipped = children.length - leaves.length;
|
||||
|
||||
// Pass 1: mint a fresh id for every leaf so intra-bundle refs can resolve
|
||||
// against the COMPLETE table (a constraint's sibling may come later).
|
||||
const remap: Record<string, string> = {};
|
||||
for (const c of leaves) remap[c.id] = mintId();
|
||||
|
||||
// Pass 2: walkRemap each payload with the full table + lift meta → fields.
|
||||
const modules: ExtractedModule[] = leaves.map((c) => {
|
||||
const mod: ExtractedModule = {
|
||||
id: remap[c.id],
|
||||
type: c.type,
|
||||
name: c.meta?.name ?? c.id,
|
||||
payload: walkRemap(c.payload ?? {}, remap) as Record<string, unknown>,
|
||||
};
|
||||
if (c.meta?.description) mod.description = c.meta.description;
|
||||
if (c.meta?.tags && c.meta.tags.length > 0) mod.tags = c.meta.tags;
|
||||
return mod;
|
||||
});
|
||||
|
||||
return { modules, remap, skipped };
|
||||
}
|
||||
@@ -1,182 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { remapBundleUuids, type ChildSnapshot } from "./uuid-remap";
|
||||
import { walkRemap } from "./uuid-remap";
|
||||
|
||||
describe("remapBundleUuids — id regeneration", () => {
|
||||
it("regenerates every child id to a fresh 12-char hex", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{ id: "aaaa1111", type: "wildcard" },
|
||||
{ id: "bbbb2222", type: "constraint" },
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
expect(result.children).toHaveLength(2);
|
||||
expect(result.children[0].id).not.toBe("aaaa1111");
|
||||
expect(result.children[1].id).not.toBe("bbbb2222");
|
||||
expect(result.children[0].id).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(result.children[1].id).toMatch(/^[0-9a-f]{12}$/);
|
||||
describe("walkRemap — partial remap table", () => {
|
||||
it("rewrites a whole-string field whose value is a remapped id", () => {
|
||||
const out = walkRemap("src11111", { src11111: "newsrc01" });
|
||||
expect(out).toBe("newsrc01");
|
||||
});
|
||||
|
||||
it("remap table maps every old id to its fresh id", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{ id: "aaaa1111", type: "wildcard" },
|
||||
{ id: "bbbb2222", type: "constraint" },
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
expect(result.remap.aaaa1111).toBe(result.children[0].id);
|
||||
expect(result.remap.bbbb2222).toBe(result.children[1].id);
|
||||
expect(Object.keys(result.remap)).toHaveLength(2);
|
||||
it("leaves a whole-string field untouched when not in the remap table", () => {
|
||||
const out = walkRemap("other999", { src11111: "newsrc01" });
|
||||
expect(out).toBe("other999");
|
||||
});
|
||||
|
||||
it("does not mutate input children", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{ id: "aaaa1111", type: "wildcard", payload: { var_binding: "x" } },
|
||||
];
|
||||
const snapshot = JSON.parse(JSON.stringify(children));
|
||||
remapBundleUuids(children);
|
||||
expect(children).toEqual(snapshot);
|
||||
it("rewrites an embedded @{uuid} ref when the uuid is in the table", () => {
|
||||
const out = walkRemap("see @{bbbb2222} here", { bbbb2222: "newbbbb1" });
|
||||
expect(out).toBe("see @{newbbbb1} here");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remapBundleUuids — @{uuid} text refs", () => {
|
||||
it("rewrites @{uuid} in nested payload string values when uuid is in remap", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{
|
||||
id: "aaaa1111",
|
||||
type: "wildcard",
|
||||
payload: { options: [{ value: "see @{bbbb2222} here" }] },
|
||||
},
|
||||
{ id: "bbbb2222", type: "wildcard" },
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
const newBbbb = result.remap.bbbb2222;
|
||||
const opt = (result.children[0].payload as Record<string, unknown>).options as Array<{ value: string }>;
|
||||
expect(opt[0].value).toBe(`see @{${newBbbb}} here`);
|
||||
it("preserves the #name and :subcat segments of an @{} ref", () => {
|
||||
const out = walkRemap("@{bbbb2222#Hair:long}", { bbbb2222: "newbbbb1" });
|
||||
expect(out).toBe("@{newbbbb1#Hair:long}");
|
||||
});
|
||||
|
||||
it("rewrites multiple @{uuid} refs in the same string", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{
|
||||
id: "aaaa1111",
|
||||
type: "combine",
|
||||
payload: { template: "$a @{bbbb2222} $b @{cccc3333} $c" },
|
||||
},
|
||||
{ id: "bbbb2222", type: "wildcard" },
|
||||
{ id: "cccc3333", type: "wildcard" },
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
const tpl = (result.children[0].payload as Record<string, string>).template;
|
||||
expect(tpl).toBe(`$a @{${result.remap.bbbb2222}} $b @{${result.remap.cccc3333}} $c`);
|
||||
it("leaves @{uuid} refs to non-remapped uuids verbatim", () => {
|
||||
const out = walkRemap("see @{xxxx9999} here", { bbbb2222: "newbbbb1" });
|
||||
expect(out).toBe("see @{xxxx9999} here");
|
||||
});
|
||||
|
||||
it("leaves @{uuid} refs to non-bundle uuids untouched", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{
|
||||
id: "aaaa1111",
|
||||
type: "wildcard",
|
||||
payload: { options: [{ value: "see @{xxxx9999} here" }] },
|
||||
},
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
const opt = (result.children[0].payload as Record<string, unknown>).options as Array<{ value: string }>;
|
||||
expect(opt[0].value).toBe("see @{xxxx9999} here");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remapBundleUuids — constraint refs", () => {
|
||||
it("rewrites source_wildcard_id + target_wildcard_id when both are in bundle", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{ id: "aabbcc11", type: "wildcard" },
|
||||
{ id: "ddeeff22", type: "wildcard" },
|
||||
{
|
||||
id: "11223344",
|
||||
type: "constraint",
|
||||
payload: {
|
||||
source_wildcard_id: "aabbcc11",
|
||||
target_wildcard_id: "ddeeff22",
|
||||
matrix: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
const con = result.children[2].payload as Record<string, string>;
|
||||
expect(con.source_wildcard_id).toBe(result.remap.aabbcc11);
|
||||
expect(con.target_wildcard_id).toBe(result.remap.ddeeff22);
|
||||
});
|
||||
|
||||
it("leaves source/target unchanged when they reference modules outside the bundle", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{
|
||||
id: "11223344",
|
||||
type: "constraint",
|
||||
payload: {
|
||||
source_wildcard_id: "99887766",
|
||||
target_wildcard_id: "55443322",
|
||||
matrix: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
const con = result.children[0].payload as Record<string, string>;
|
||||
expect(con.source_wildcard_id).toBe("99887766");
|
||||
expect(con.target_wildcard_id).toBe("55443322");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remapBundleUuids — instance fields", () => {
|
||||
it("rewrites uuid-valued instance fields too", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{ id: "aabbcc11", type: "wildcard" },
|
||||
{
|
||||
id: "11223344",
|
||||
type: "constraint",
|
||||
instance: {
|
||||
// Bare-string uuid reference — gets rewritten via remap
|
||||
// table lookup (full-string match).
|
||||
pinned_source: "aabbcc11",
|
||||
// Text containing `@{uuid}` — gets rewritten via the
|
||||
// embedded-ref regex pass.
|
||||
tooltip: "Pins to @{aabbcc11}",
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
const inst = result.children[1].instance as Record<string, string>;
|
||||
expect(inst.pinned_source).toBe(result.remap.aabbcc11);
|
||||
expect(inst.tooltip).toBe(`Pins to @{${result.remap.aabbcc11}}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remapBundleUuids — edge cases", () => {
|
||||
it("handles empty children list", () => {
|
||||
const result = remapBundleUuids([]);
|
||||
expect(result.children).toEqual([]);
|
||||
expect(result.remap).toEqual({});
|
||||
});
|
||||
|
||||
it("handles children with no payload or instance", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{ id: "aaaa1111", type: "wildcard" },
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
expect(result.children[0].payload).toBeUndefined();
|
||||
expect(result.children[0].instance).toBeUndefined();
|
||||
expect(result.children[0].id).toMatch(/^[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
it("recurses into arrays of arrays of objects", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{ id: "abcdef01", type: "x" },
|
||||
{
|
||||
id: "abcdef02",
|
||||
type: "wildcard",
|
||||
payload: {
|
||||
deeply: [[{ ref: "abcdef01", text: "see @{abcdef01}" }]],
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
const newId = result.remap.abcdef01;
|
||||
const deeply = (result.children[1].payload as Record<string, unknown>).deeply as Array<Array<{ ref: string; text: string }>>;
|
||||
expect(deeply[0][0].ref).toBe(newId);
|
||||
expect(deeply[0][0].text).toBe(`see @{${newId}}`);
|
||||
it("recurses into nested arrays + objects without mutating input", () => {
|
||||
const input = { deeply: [[{ ref: "abcdef01", text: "see @{abcdef01}" }]] };
|
||||
const snapshot = JSON.parse(JSON.stringify(input));
|
||||
const out = walkRemap(input, { abcdef01: "newid001" }) as {
|
||||
deeply: Array<Array<{ ref: string; text: string }>>;
|
||||
};
|
||||
expect(out.deeply[0][0].ref).toBe("newid001");
|
||||
expect(out.deeply[0][0].text).toBe("see @{newid001}");
|
||||
expect(input).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
* Pure-TS, no engine imports. Safe to import from frontend bundle code.
|
||||
*/
|
||||
|
||||
import { newRowUid } from "../../../widgets/_shared";
|
||||
|
||||
export interface ChildSnapshot {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -46,8 +44,10 @@ export interface RemapResult {
|
||||
const REF_RE = /@\{([0-9a-f]{6,16})(#[^#:}@{]*)?(:[^}]*)?\}/gi;
|
||||
|
||||
/** Deep walks an unknown value, applying the remap. Returns a new
|
||||
* object — never mutates input. */
|
||||
function walkRemap(value: unknown, remap: Record<string, string>): unknown {
|
||||
* object — never mutates input. Exported so the cascade-restore path
|
||||
* can re-point constraint source/target + `@{}` refs at restored module
|
||||
* uuids using a partial remap table (not a full id regen). */
|
||||
export function walkRemap(value: unknown, remap: Record<string, string>): unknown {
|
||||
if (typeof value === "string") {
|
||||
let out = value;
|
||||
// Whole-string match — e.g. constraint.source_wildcard_id = "src11111"
|
||||
@@ -77,25 +77,3 @@ function walkRemap(value: unknown, remap: Record<string, string>): unknown {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Regenerate every child uuid in the snapshot list, then deep-walk
|
||||
* every child's `payload` + `instance` rewriting any references to
|
||||
* match. Constraint source/target ids + `@{uuid}` text refs all flow
|
||||
* through `walkRemap` since they live inside `payload` / `instance`. */
|
||||
export function remapBundleUuids(children: ChildSnapshot[]): RemapResult {
|
||||
const remap: Record<string, string> = {};
|
||||
for (const c of children) {
|
||||
remap[c.id] = newRowUid();
|
||||
}
|
||||
const remapped: ChildSnapshot[] = children.map((c) => {
|
||||
const next: ChildSnapshot = { ...c, id: remap[c.id] };
|
||||
if (c.payload) {
|
||||
next.payload = walkRemap(c.payload, remap) as Record<string, unknown>;
|
||||
}
|
||||
if (c.instance) {
|
||||
next.instance = walkRemap(c.instance, remap) as Record<string, unknown>;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return { children: remapped, remap };
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ beforeEach(() => {
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "h-a", bbbbbbbb: "h-b" } }), { status: 200 });
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "h-a" }, bbbbbbbb: { type: "wildcard", payload_hash: "h-b" } } }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
@@ -32,7 +32,7 @@ describe("drift-store: subscribe/unsubscribe", () => {
|
||||
expect(hashes.value).toBeNull();
|
||||
subscribe();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(hashes.value).toEqual({ aaaaaaaa: "h-a", bbbbbbbb: "h-b" });
|
||||
expect(hashes.value).toEqual({ aaaaaaaa: { type: "wildcard", payload_hash: "h-a" }, bbbbbbbb: { type: "wildcard", payload_hash: "h-b" } });
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -82,7 +82,7 @@ describe("drift-store: forceRefresh", () => {
|
||||
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
import type { SnapshotEntry } from "./drift-store";
|
||||
import { refreshMany } from "./drift-store";
|
||||
import { refreshMany, mergeRefresh } from "./drift-store";
|
||||
|
||||
function mkEntry(over: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
@@ -250,7 +250,8 @@ describe("setLibraryHash — direct hash update without re-fetch", () => {
|
||||
setLibraryHash("abc123", "newhash456");
|
||||
expect(hashes.value).not.toBeNull();
|
||||
if (hashes.value) {
|
||||
expect(hashes.value["abc123"]).toBe("newhash456");
|
||||
// Optimistic entry stores { payload_hash } (type filled by next poll).
|
||||
expect(hashes.value["abc123"]).toEqual({ payload_hash: "newhash456" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -258,6 +259,43 @@ describe("setLibraryHash — direct hash update without re-fetch", () => {
|
||||
setLibraryHash("a", "ha");
|
||||
setLibraryHash("b", "hb");
|
||||
setLibraryHash("a", "ha-updated");
|
||||
expect(hashes.value).toEqual({ a: "ha-updated", b: "hb" });
|
||||
expect(hashes.value).toEqual({
|
||||
a: { payload_hash: "ha-updated" },
|
||||
b: { payload_hash: "hb" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("drift-store: mergeRefresh type-guard", () => {
|
||||
it("throws when the live snapshot is a DIFFERENT kind than the embedded row", () => {
|
||||
const embedded = mkEntry({ id: "aabbccdd", type: "constraint" });
|
||||
const live: SnapshotEntry = {
|
||||
snapshot_version: 1,
|
||||
uuid: "aabbccdd",
|
||||
type: "wildcard",
|
||||
name: "w",
|
||||
payload: {},
|
||||
payload_hash: "h2",
|
||||
source: { kind: "user" },
|
||||
};
|
||||
// A same-id, different-kind library row must never merge over the
|
||||
// embedded item — it would silently change the item's kind.
|
||||
expect(() => mergeRefresh(embedded, live)).toThrow(/type mismatch/i);
|
||||
});
|
||||
|
||||
it("merges normally when the kinds match", () => {
|
||||
const embedded = mkEntry({ id: "aabbccdd", type: "wildcard" });
|
||||
const live: SnapshotEntry = {
|
||||
snapshot_version: 1,
|
||||
uuid: "aabbccdd",
|
||||
type: "wildcard",
|
||||
name: "w2",
|
||||
payload: { options: [] },
|
||||
payload_hash: "h2",
|
||||
source: { kind: "user" },
|
||||
};
|
||||
const merged = mergeRefresh(embedded, live);
|
||||
expect(merged.type).toBe("wildcard");
|
||||
expect(merged.payload_hash).toBe("h2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,16 +20,18 @@ export interface RefreshResult {
|
||||
failed: { id: string; reason: string }[];
|
||||
}
|
||||
|
||||
/** Live-library hashes keyed by uuid. `null` while the first fetch is in
|
||||
/** Live-library identity map keyed by uuid: each value carries the row's
|
||||
* `{type, payload_hash}`. `type` enables cross-kind id-clash detection;
|
||||
* `payload_hash` is the drift signal. `null` while the first fetch is in
|
||||
* flight — predicates downstream use this to avoid flashing "missing" /
|
||||
* "drift" before we know the truth.
|
||||
*
|
||||
* Invariant: values are always non-empty strings once the map is non-null.
|
||||
* Consumers may assume `live === undefined` is equivalent to `!(uuid in
|
||||
* hashes.value)` — the API never emits explicit-undefined entries. If this
|
||||
* ever changes, both `isMissingFromLibrary` and `isDrifted` in
|
||||
* `ContextWidget.vue` need to be tightened together. */
|
||||
export const hashes: Ref<Record<string, string> | null> = ref(null);
|
||||
* `type` is optional because the optimistic `setLibraryHash` writes a
|
||||
* payload_hash-only entry (the 5s poll fills `type` shortly after).
|
||||
* Consumers run `classifyOne` (import-export/collision) against these
|
||||
* entries: `live === undefined` ≡ `!(uuid in hashes.value)` — the API
|
||||
* never emits explicit-undefined entries. */
|
||||
export const hashes: Ref<Record<string, { type?: string; payload_hash: string }> | null> = ref(null);
|
||||
|
||||
/** Same shape as `hashes` but keyed by bundle library uuid. Polled
|
||||
* in parallel with module hashes so bundle drift detection
|
||||
@@ -94,13 +96,35 @@ function sameHashes(
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Object-aware sibling of `sameHashes` for the module map, whose values
|
||||
* are `{type, payload_hash}` rather than flat strings. Same purpose: gate
|
||||
* `hashes.value` reassignment so the 5s poll doesn't mint a new ref (and
|
||||
* fire reactivity) when nothing changed. */
|
||||
function sameModuleHashes(
|
||||
a: Record<string, { type?: string; payload_hash: string }> | null,
|
||||
b: Record<string, { type?: string; payload_hash: string }>,
|
||||
): boolean {
|
||||
if (a === null) return false;
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
for (const k of keysA) {
|
||||
const va = a[k];
|
||||
const vb = b[k];
|
||||
if (!vb || va.type !== vb.type || va.payload_hash !== vb.payload_hash) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function fetchModuleHashes(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("/wp/api/modules/hashes");
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as { hashes?: Record<string, string> };
|
||||
const body = (await res.json()) as {
|
||||
hashes?: Record<string, { type?: string; payload_hash: string }>;
|
||||
};
|
||||
if (body && body.hashes && typeof body.hashes === "object") {
|
||||
if (!sameHashes(hashes.value, body.hashes)) {
|
||||
if (!sameModuleHashes(hashes.value, body.hashes)) {
|
||||
hashes.value = body.hashes;
|
||||
}
|
||||
}
|
||||
@@ -189,6 +213,15 @@ export async function refreshMany(modules: ModuleEntry[]): Promise<RefreshResult
|
||||
* actually move.
|
||||
*/
|
||||
export function mergeRefresh(m: ModuleEntry, live: SnapshotEntry): ModuleEntry {
|
||||
if (m.type !== live.type) {
|
||||
// Defense-in-depth: a same-id, DIFFERENT-kind library row must never be
|
||||
// merged over an embedded item (it would silently change the item's
|
||||
// kind). The UI suppresses Refresh for type-conflict rows; this guards
|
||||
// the path even if a caller bypasses that.
|
||||
throw new Error(
|
||||
`refresh type mismatch: embedded ${m.type} vs library ${live.type} at id ${m.id}`,
|
||||
);
|
||||
}
|
||||
const merged: ModuleEntry & { bundle_origin?: string } = {
|
||||
type: live.type,
|
||||
meta: {
|
||||
@@ -220,8 +253,12 @@ export function mergeRefresh(m: ModuleEntry, live: SnapshotEntry): ModuleEntry {
|
||||
*
|
||||
* See: docs/superpowers/specs/2026-05-07-instance-overrides-modal-design.md §8.5
|
||||
*/
|
||||
export function setLibraryHash(id: string, hash: string): void {
|
||||
hashes.value = { ...(hashes.value ?? {}), [id]: hash };
|
||||
export function setLibraryHash(id: string, payloadHash: string): void {
|
||||
// Optimistic entry — `type` omitted (the 5s poll fills it). Enough to
|
||||
// clear the missing/drift dot immediately: classifyOne sees the id
|
||||
// present + payload_hash match → silent-skip. A type-conflict can't
|
||||
// arise here (you're saving your own entry), so omitting type is safe.
|
||||
hashes.value = { ...(hashes.value ?? {}), [id]: { payload_hash: payloadHash } };
|
||||
}
|
||||
|
||||
/** Test-only — reset internal counters between cases. */
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
.wp-wcm__head { --modal-accent: var(--wp-kind-wildcard); }
|
||||
|
||||
.wp-cbm__head, .wp-cnm__head, .wp-dvm__head, .wp-fvm__head, .wp-wcm__head {
|
||||
/* Pinned to the top of the scrolling modal body so the title + close
|
||||
* stay visible while the content scrolls. Opaque (gradient bottoms out
|
||||
* at --wp-bg2) so scrolled content doesn't bleed through. */
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
* `runtime-seed-up`, `runtime-seed-down` — so the existing test
|
||||
* suites pass without modification.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { computed, inject } from "vue";
|
||||
import type { ModuleEntry } from "../../../../widgets/_shared";
|
||||
import { LockSeedKey } from "../../lock-seed-ctx";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -36,6 +37,10 @@ const props = withDefaults(
|
||||
);
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
// The seed the module actually rolled — provided by ContextWidget. Used as the
|
||||
// default when locking so "Lock" pins what the user saw, not a fresh random.
|
||||
const lockSeedFor = inject(LockSeedKey, null);
|
||||
|
||||
const instance = computed(() => props.module.instance ?? {});
|
||||
const locked = computed(() => typeof instance.value.locked_seed === "number");
|
||||
const seedValue = computed(() =>
|
||||
@@ -71,7 +76,11 @@ function onLockClick(): void {
|
||||
} else {
|
||||
const ui = instance.value._ui ?? {};
|
||||
const remembered = typeof ui.last_locked_seed === "number" ? ui.last_locked_seed : null;
|
||||
const seed = remembered ?? randomSeed();
|
||||
// Prefer the seed the module ACTUALLY rolled (frame #k's captured seed, or
|
||||
// the last-run seed) so locking reproduces what the user saw; then the
|
||||
// remembered seed; a fresh random only as a last resort.
|
||||
const resolved = lockSeedFor?.(props.module);
|
||||
const seed = typeof resolved === "number" ? resolved : (remembered ?? randomSeed());
|
||||
emitInstance({
|
||||
locked_seed: seed,
|
||||
_ui: { ...ui, last_locked_seed: seed },
|
||||
|
||||
@@ -31,6 +31,17 @@ describe("preview-tokens", () => {
|
||||
expect(tokenize("$x", "derivation")[0]).toMatchObject({ kind: "var", invalid: false });
|
||||
expect(tokenize("$x", "assembler")[0]).toMatchObject({ kind: "var", invalid: false });
|
||||
});
|
||||
|
||||
it("tokenizes a $mood.0 list accessor as ONE var token with index (SP2a)", () => {
|
||||
const toks = tokenize("$mood.0", "combine");
|
||||
expect(toks).toHaveLength(1);
|
||||
expect(toks[0]).toMatchObject({ kind: "var", varName: "mood", index: 0, raw: "$mood.0" });
|
||||
});
|
||||
|
||||
it("bare $mood has no index; $mood.12 captures a multi-digit index (SP2a)", () => {
|
||||
expect(tokenize("$mood", "combine")[0].index).toBeUndefined();
|
||||
expect(tokenize("$mood.12", "combine")[0]).toMatchObject({ varName: "mood", index: 12 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("REF token", () => {
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface PreviewToken {
|
||||
raw: string;
|
||||
invalid?: boolean;
|
||||
varName?: string;
|
||||
/** SP2a `.K` list accessor on a var token (`$mood.0` -> index 0). */
|
||||
index?: number;
|
||||
refUuid?: string;
|
||||
branches?: string[];
|
||||
count?: number;
|
||||
@@ -37,7 +39,10 @@ export interface PreviewToken {
|
||||
const VAR_VALID_SURFACES = new Set<Surface>(["combine", "derivation", "assembler"]);
|
||||
const REF_VALID_SURFACES = new Set<Surface>(["wildcard"]);
|
||||
|
||||
const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)/y;
|
||||
// Group 2 = optional `.K` list accessor (SP2a) so `$mood.0` is ONE var token
|
||||
// (base name in m[1], index in m[2]) — mirrors engine tokenize.py +
|
||||
// richTokenize.ts, so the `.K` never strands as a separate text token.
|
||||
const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?/y;
|
||||
// Optional `:subcat[,subcat]` per-call sub-category filter — empty
|
||||
// filter is equivalent to no filter, sub-categories are stripped of
|
||||
// whitespace + comma-separated.
|
||||
@@ -45,7 +50,11 @@ const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)/y;
|
||||
// filter. Consumers below only use m[1] (uuid) — name + subcat are
|
||||
// preserved verbatim in m[0] for display.
|
||||
const REF_RE = /@\{([0-9a-f]{8})(?:#([^#:}@{]*))?(?::([^}]*))?\}/y;
|
||||
const DP_MULTI_RE = /\{(\d+)\$\$([^$]*)\$\$([^}]*)\}/y;
|
||||
// SP2b: accept a range count `N-M` and the `~` independent flag in the
|
||||
// multi-pick prefix so the canvas preview colours `{2-4~$$sep$$…}` too.
|
||||
// `count` parses the leading integer (the min) — enough for preview colouring
|
||||
// + the deterministic combine resolve.
|
||||
const DP_MULTI_RE = /\{(\d+(?:-\d+)?~?)\$\$([^$]*)\$\$([^}]*)\}/y;
|
||||
const DP_BRACE_RE = /\{([^{}]*\|[^{}]*)\}/y;
|
||||
|
||||
function pushText(out: PreviewToken[], buf: string): void {
|
||||
@@ -80,6 +89,7 @@ export function tokenize(text: string, surface: Surface): PreviewToken[] {
|
||||
kind: "var",
|
||||
raw: m[0],
|
||||
varName: m[1],
|
||||
index: m[2] !== undefined ? Number.parseInt(m[2], 10) : undefined,
|
||||
invalid: !VAR_VALID_SURFACES.has(surface),
|
||||
});
|
||||
i += m[0].length;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { INSTANCE_FIELDS_PER_KIND } from "./_shell";
|
||||
import { FRAME_OVERRIDABLE_FIELDS, INSTANCE_FIELDS_PER_KIND } from "./_shell";
|
||||
|
||||
describe("INSTANCE_FIELDS_PER_KIND registry", () => {
|
||||
it("lists exactly the fields each kind exposes per spec §5.5", () => {
|
||||
@@ -7,21 +7,26 @@ describe("INSTANCE_FIELDS_PER_KIND registry", () => {
|
||||
// v2 dropped `mode` + `pinned_option_id` — see
|
||||
// docs/superpowers/specs/2026-05-08-instance-overrides-v2-design.md §6.1.
|
||||
"variable_binding", "enabled_options",
|
||||
"option_weights", "category_filter", "locked_seed", "internal",
|
||||
"option_weights", "category_filter", "exclude_null", "locked_seed", "seed_scope", "internal",
|
||||
// SP2a multi-select (2026-06-08): per-instance count range + separator.
|
||||
// SP2c: pick_independent (allow repeats / with replacement).
|
||||
"pick_min", "pick_max", "pick_separator", "pick_independent",
|
||||
]);
|
||||
expect(INSTANCE_FIELDS_PER_KIND.fixed_values).toEqual([
|
||||
// `locked_seed` added in the combine + fixed_values syntax-parity
|
||||
// cycle (2026-05-08): fixed_values now resolves `{a|b|c}` per
|
||||
// value, so it gains seed-lock parity with wildcard + combine.
|
||||
"values_overrides", "enabled_options", "locked_seed",
|
||||
// `seed_scope` added for iteration-aware hold/vary control.
|
||||
"values_overrides", "enabled_options", "locked_seed", "seed_scope",
|
||||
]);
|
||||
expect(INSTANCE_FIELDS_PER_KIND.combine).toEqual([
|
||||
// v2 single-pane modal exposes per-instance overrides:
|
||||
// - template_override (combine v2, this cycle)
|
||||
// - variable_binding (matches wildcard's identity-section UX)
|
||||
// - locked_seed (parity with wildcard + fixed_values)
|
||||
// - seed_scope (iteration-aware hold/vary control)
|
||||
// - internal (existing hide-from-prompt toggle)
|
||||
"template_override", "variable_binding", "locked_seed", "internal",
|
||||
"template_override", "variable_binding", "locked_seed", "seed_scope", "internal",
|
||||
]);
|
||||
expect(INSTANCE_FIELDS_PER_KIND.derivation).toEqual([
|
||||
// Tier-D modal expansion (2026-05-10): per-rule + per-branch
|
||||
@@ -46,6 +51,21 @@ describe("INSTANCE_FIELDS_PER_KIND registry", () => {
|
||||
"exception_mode_overrides",
|
||||
"exception_factor_overrides",
|
||||
"extra_exceptions",
|
||||
// SP3 reach selector override (parity with engine INSTANCE_SCHEMAS).
|
||||
"target_select",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FRAME_OVERRIDABLE_FIELDS", () => {
|
||||
const EXCLUDED = ["seed_scope", "variable_binding", "internal"] as const;
|
||||
for (const kind of Object.keys(FRAME_OVERRIDABLE_FIELDS) as Array<keyof typeof FRAME_OVERRIDABLE_FIELDS>) {
|
||||
it(`${kind}: excludes run-level/structural fields`, () => {
|
||||
for (const ex of EXCLUDED) expect(FRAME_OVERRIDABLE_FIELDS[kind]).not.toContain(ex);
|
||||
});
|
||||
it(`${kind}: is a subset of INSTANCE_FIELDS_PER_KIND`, () => {
|
||||
const sup = new Set(INSTANCE_FIELDS_PER_KIND[kind]);
|
||||
for (const f of FRAME_OVERRIDABLE_FIELDS[kind]) expect(sup.has(f)).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,7 +12,9 @@ export const KIND_TITLE: Record<string, string> = {
|
||||
|
||||
export type InstanceFieldKey =
|
||||
| "variable_binding" | "enabled_options" | "option_weights" | "category_filter"
|
||||
| "mode" | "pinned_option_id" | "locked_seed" | "internal" | "values_overrides"
|
||||
| "exclude_null"
|
||||
| "pick_min" | "pick_max" | "pick_separator" | "pick_independent"
|
||||
| "mode" | "pinned_option_id" | "locked_seed" | "seed_scope" | "internal" | "values_overrides"
|
||||
| "template_override"
|
||||
| "disabled_rule_ids"
|
||||
// Derivation tier-D modal expansion (2026-05-10 cycle)
|
||||
@@ -26,7 +28,9 @@ export type InstanceFieldKey =
|
||||
| "cell_factor_overrides"
|
||||
| "exception_mode_overrides"
|
||||
| "exception_factor_overrides"
|
||||
| "extra_exceptions";
|
||||
| "extra_exceptions"
|
||||
// SP3 reach selector override (shape validated engine-side).
|
||||
| "target_select";
|
||||
|
||||
/** Single source of truth for which instance fields each kind exposes.
|
||||
* Drives:
|
||||
@@ -42,10 +46,13 @@ export const INSTANCE_FIELDS_PER_KIND: Record<ModuleEntryKind, readonly Instance
|
||||
// implicit in pool state. Engine handler still reads them on
|
||||
// legacy snapshots; UI never writes them.
|
||||
"variable_binding", "enabled_options",
|
||||
"option_weights", "category_filter", "locked_seed", "internal",
|
||||
"option_weights", "category_filter", "exclude_null", "locked_seed", "seed_scope", "internal",
|
||||
// SP2a multi-select: count range + join separator (per-instance, local).
|
||||
// SP2c: pick_independent (allow repeats / with replacement).
|
||||
"pick_min", "pick_max", "pick_separator", "pick_independent",
|
||||
],
|
||||
fixed_values: ["values_overrides", "enabled_options", "locked_seed"],
|
||||
combine: ["template_override", "variable_binding", "locked_seed", "internal"],
|
||||
fixed_values: ["values_overrides", "enabled_options", "locked_seed", "seed_scope"],
|
||||
combine: ["template_override", "variable_binding", "locked_seed", "seed_scope", "internal"],
|
||||
derivation: [
|
||||
// Tier-D modal expansion (2026-05-10):
|
||||
// - rule-group overrides (cleared by Reset btn)
|
||||
@@ -70,5 +77,25 @@ export const INSTANCE_FIELDS_PER_KIND: Record<ModuleEntryKind, readonly Instance
|
||||
"exception_mode_overrides",
|
||||
"exception_factor_overrides",
|
||||
"extra_exceptions",
|
||||
// SP3 reach selector override (per-instance). Interior shape
|
||||
// (mode/count/picks) is validated engine-side, not by the type-only
|
||||
// instance schema. Kept in parity with engine INSTANCE_SCHEMAS.
|
||||
"target_select",
|
||||
],
|
||||
};
|
||||
|
||||
// Subset of INSTANCE_FIELDS_PER_KIND that is meaningful to override PER-FRAME.
|
||||
// Excludes run-level (seed_scope), structural/reference (variable_binding), and
|
||||
// internal (base-only; per-frame visibility unsupported in v1). Used only by the
|
||||
// per-frame save-diff so toggling Hold/binding/Hide on a frame records no override.
|
||||
export const FRAME_OVERRIDABLE_FIELDS: Record<ModuleEntryKind, readonly InstanceFieldKey[]> = {
|
||||
wildcard: ["enabled_options", "option_weights", "category_filter", "exclude_null",
|
||||
"locked_seed", "pick_min", "pick_max", "pick_separator", "pick_independent"],
|
||||
fixed_values: ["values_overrides", "enabled_options", "locked_seed"],
|
||||
combine: ["template_override", "locked_seed"],
|
||||
derivation: ["disabled_rule_ids", "disabled_branch_keys", "action_value_overrides",
|
||||
"condition_value_overrides", "rule_order_override", "locked_seed"],
|
||||
constraint: ["disabled_exception_keys", "disabled_matrix_cells", "cell_mode_overrides",
|
||||
"cell_factor_overrides", "exception_mode_overrides", "exception_factor_overrides",
|
||||
"extra_exceptions", "target_select"],
|
||||
};
|
||||
|
||||
@@ -102,6 +102,7 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
|
||||
:library-color="libraryColor"
|
||||
@update="(patch) => emit('update', patch)"
|
||||
/>
|
||||
<div class="wp-bdm__dock">
|
||||
<RuntimeSection
|
||||
:lock-state="lockState"
|
||||
:internal-state="internalState"
|
||||
@@ -157,12 +158,13 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
|
||||
Save
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-bdm { background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); width: 720px; max-width: 100%; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; font-family: var(--wp-font-sans, sans-serif); font-size: 12px; color: var(--wp-text); }
|
||||
.wp-bdm__head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--modal-accent, var(--wp-accent)) 18%, var(--wp-bg2)) 0%, var(--wp-bg2) 100%); border-bottom: 1px solid var(--wp-border); }
|
||||
.wp-bdm { background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); width: 720px; max-width: 100%; max-height: 80vh; display: flex; flex-direction: column; overflow-y: auto; font-family: var(--wp-font-sans, sans-serif); font-size: 12px; color: var(--wp-text); }
|
||||
.wp-bdm__head { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--modal-accent, var(--wp-accent)) 18%, var(--wp-bg2)) 0%, var(--wp-bg2) 100%); border-bottom: 1px solid var(--wp-border); }
|
||||
.wp-bdm__head-icon { color: var(--modal-accent, var(--wp-accent)); font-size: 18px; width: 24px; text-align: center; }
|
||||
.wp-bdm__title-block { flex: 1; min-width: 0; }
|
||||
.wp-bdm__title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
@@ -175,6 +177,7 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
|
||||
.wp-bdm__close { background: transparent; border: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; padding: 4px; }
|
||||
.wp-bdm__close:hover { color: var(--wp-text); }
|
||||
.wp-bdm__close .pi { font-size: 12px; }
|
||||
.wp-bdm__dock { position: sticky; bottom: 0; z-index: 2; background: var(--wp-bg2); }
|
||||
.wp-bdm__foot { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--wp-bg3); border-top: 1px solid var(--wp-border); }
|
||||
.wp-bdm__spa-link { display: inline-flex; align-items: center; gap: 5px; font: 10px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); text-decoration: none; }
|
||||
.wp-bdm__spa-link:hover { color: var(--wp-accent-text, var(--wp-text)); }
|
||||
@@ -183,6 +186,8 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
|
||||
.wp-bdm__hint kbd { font: 9px var(--wp-font-mono); background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); padding: 1px 4px; border-radius: 2px; color: var(--wp-text-muted, var(--wp-text2)); }
|
||||
.wp-bdm__btn { padding: 5px 12px; border: 1px solid var(--wp-border); border-radius: 3px; background: transparent; color: var(--wp-text-muted, var(--wp-text2)); font: 11px var(--wp-font-sans); cursor: pointer; }
|
||||
.wp-bdm__btn--primary { border-color: var(--wp-accent); background: var(--wp-accent); color: white; }
|
||||
.wp-bdm__btn:hover { border-color: var(--wp-border-strong, var(--wp-border2)); color: var(--wp-text); }
|
||||
.wp-bdm__btn--primary:hover { border-color: var(--wp-accent2, var(--wp-accent)); background: var(--wp-accent2, var(--wp-accent)); color: white; }
|
||||
.wp-bdm__btn--quiet { border-color: transparent; color: var(--wp-text-dim, var(--wp-text3)); display: inline-flex; align-items: center; gap: 5px; font-size: 10px; }
|
||||
.wp-bdm__btn--quiet:hover { border-color: var(--wp-border); color: var(--wp-text-muted, var(--wp-text2)); }
|
||||
.wp-bdm__btn--quiet .pi { font-size: 10px; }
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* override, locked seed, hide-from-prompt.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { type ResolvedValue } from "../../../../widgets/richTokenize";
|
||||
import type { ModuleEntry } from "../../../../widgets/_shared";
|
||||
import IdentitySection from "./sections/IdentitySection.vue";
|
||||
import TemplateSection from "./sections/TemplateSection.vue";
|
||||
@@ -30,10 +31,13 @@ const props = withDefaults(
|
||||
upstreamVars?: string[];
|
||||
/** Resolved upstream-var snapshot — drives the TemplateSection's
|
||||
* live preview pane with substituted values. */
|
||||
upstreamResolved?: Record<string, string>;
|
||||
upstreamResolved?: Record<string, ResolvedValue>;
|
||||
/** Sibling var names produced by other modules in this same Context
|
||||
* node. Combined with upstreamVars to populate the dropdown. */
|
||||
siblingVars?: string[];
|
||||
/** When true, a frame override context is active. Run-level and
|
||||
* structural controls are disabled in frame mode. */
|
||||
frameActive?: boolean;
|
||||
}>(),
|
||||
{
|
||||
isDrifted: false,
|
||||
@@ -99,6 +103,7 @@ function onSpaClick(): void {
|
||||
:module="module"
|
||||
:upstream-vars="upstreamVars"
|
||||
:sibling-vars="siblingVars"
|
||||
:frame-active="frameActive"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<TemplateSection
|
||||
@@ -108,7 +113,8 @@ function onSpaClick(): void {
|
||||
:sibling-vars="siblingVars"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
<div class="wp-cbm__dock">
|
||||
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-cbm__foot">
|
||||
<a
|
||||
@@ -146,6 +152,7 @@ function onSpaClick(): void {
|
||||
<button type="button" class="wp-cbm__btn" data-test="cbm-cancel" @click="emit('cancel')">Cancel</button>
|
||||
<button type="button" class="wp-cbm__btn wp-cbm__btn--primary" data-test="cbm-save" @click="emit('save')">Save</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -159,7 +166,7 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
@@ -167,6 +174,12 @@ function onSpaClick(): void {
|
||||
/* Head styling lives in src/components/context/editors/_modal-head.css
|
||||
* (imported once by ContextWidget). Every modal in this family shares
|
||||
* the same accent-tinted gradient + chip + close button. */
|
||||
.wp-cbm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-cbm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -211,6 +224,15 @@ function onSpaClick(): void {
|
||||
background: var(--wp-accent);
|
||||
color: white;
|
||||
}
|
||||
.wp-cbm__btn:hover {
|
||||
border-color: var(--wp-border-strong, var(--wp-border2));
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-cbm__btn--primary:hover {
|
||||
border-color: var(--wp-accent2, var(--wp-accent));
|
||||
background: var(--wp-accent2, var(--wp-accent));
|
||||
color: white;
|
||||
}
|
||||
.wp-cbm__btn--quiet {
|
||||
border-color: transparent;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// R3: IdentitySection frame-active gating — combine variant.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import IdentitySection from "./IdentitySection.vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
|
||||
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
id: "cb12cd34",
|
||||
type: "combine",
|
||||
enabled: true,
|
||||
meta: { name: "final_prompt" },
|
||||
entries: [],
|
||||
payload: { output_var: "final_prompt", template: "$style portrait" },
|
||||
instance: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("combine IdentitySection — frameActive gating on variable binding", () => {
|
||||
it("binding input is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="id-binding"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("binding input is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="id-binding"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("binding input IS disabled when frameActive=true", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="id-binding"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows binding frame-active hint when frameActive=true", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="id-binding-frame-hint"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show binding frame-active hint when frameActive=false", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="id-binding-frame-hint"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,17 @@ describe("combine IdentitySection", () => {
|
||||
expect(patch.instance?.variable_binding).toBe("header_text");
|
||||
});
|
||||
|
||||
it("strips comma + punctuation from the binding on input (var names must be identifiers)", async () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule() } });
|
||||
const input = w.find<HTMLInputElement>('[data-test="id-binding"]');
|
||||
input.element.value = "head,er;text!";
|
||||
await input.trigger("input");
|
||||
const updates = w.emitted("update")!;
|
||||
const patch = updates[updates.length - 1][0] as Partial<ModuleEntry>;
|
||||
expect(patch.instance?.variable_binding).toBe("headertext");
|
||||
expect(input.element.value).toBe("headertext");
|
||||
});
|
||||
|
||||
it("empty binding emits null collapse", async () => {
|
||||
const w = mount(IdentitySection, {
|
||||
props: { module: makeModule({ instance: { variable_binding: "header_text" } }) },
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { computed } from "vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
import { patchInstance } from "../../instance/patch";
|
||||
import { stripNonIdentifierChars } from "../../../../../manager/utils/slug";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -20,6 +21,9 @@ const props = withDefaults(
|
||||
upstreamVars?: string[];
|
||||
/** Names produced by other modules in the SAME Context node. */
|
||||
siblingVars?: string[];
|
||||
/** When true, a frame is active — the variable binding input is
|
||||
* structural (not per-frame) so it is disabled with a hint. */
|
||||
frameActive?: boolean;
|
||||
}>(),
|
||||
{ upstreamVars: () => [], siblingVars: () => [] },
|
||||
);
|
||||
@@ -86,7 +90,12 @@ function onNameInput(ev: Event): void {
|
||||
}
|
||||
|
||||
function onBindingInput(ev: Event): void {
|
||||
const raw = (ev.target as HTMLInputElement).value;
|
||||
const el = ev.target as HTMLInputElement;
|
||||
// Strip non-identifier chars on keystroke (the binding becomes a `$var`);
|
||||
// force the DOM so the controlled input stays in lock-step. See wildcard
|
||||
// IdentitySection for the rationale.
|
||||
const raw = stripNonIdentifierChars(el.value);
|
||||
if (el.value !== raw) el.value = raw;
|
||||
emit("update", patchInstance(props.module, "variable_binding", raw.length > 0 ? raw : null));
|
||||
}
|
||||
|
||||
@@ -151,6 +160,7 @@ function onResetBinding(): void {
|
||||
:value="bindingValue"
|
||||
:placeholder="libraryBinding"
|
||||
aria-label="Variable binding"
|
||||
:disabled="frameActive || undefined"
|
||||
@input="onBindingInput"
|
||||
/>
|
||||
</div>
|
||||
@@ -165,6 +175,11 @@ function onResetBinding(): void {
|
||||
><i class="pi pi-replay" aria-hidden="true" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="frameActive"
|
||||
class="id__frame-hint"
|
||||
data-test="id-binding-frame-hint"
|
||||
>Applies to every frame — switch to base to change.</p>
|
||||
<div
|
||||
v-if="collidesWith !== null"
|
||||
class="id__collision"
|
||||
@@ -297,4 +312,9 @@ function onResetBinding(): void {
|
||||
border: 1px solid color-mix(in srgb, var(--wp-accent) 35%, transparent);
|
||||
}
|
||||
.id__collision .pi { font-size: 11px; margin-top: 1px; flex-shrink: 0; }
|
||||
.id__frame-hint {
|
||||
margin: 4px 0 0;
|
||||
font: 10px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// R3: RuntimeSection frame-active gating — combine variant.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import RuntimeSection from "./RuntimeSection.vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
|
||||
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
id: "cb12cd34",
|
||||
type: "combine",
|
||||
enabled: true,
|
||||
meta: { name: "final_prompt" },
|
||||
entries: [],
|
||||
payload: { output_var: "final_prompt", template: "$style portrait" },
|
||||
instance: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("combine RuntimeSection — frameActive gating", () => {
|
||||
it("hide toggle is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hold toggle is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hide toggle is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hold toggle is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hide toggle IS disabled when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("hold toggle IS disabled when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows frame-active hint when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show frame-active hint when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -112,4 +112,21 @@ describe("combine RuntimeSection", () => {
|
||||
await w.find('[data-test="runtime-hide"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.internal).toBe(true);
|
||||
});
|
||||
|
||||
it("seed-scope toggle is off (vary) by default", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hold"]').classes()).not.toContain("toggle--on");
|
||||
});
|
||||
|
||||
it("clicking seed-scope toggle emits patch with seed_scope = hold", async () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
await w.find('[data-test="runtime-hold"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.seed_scope).toBe("hold");
|
||||
});
|
||||
|
||||
it("clicking seed-scope toggle when held emits seed_scope = vary", async () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
|
||||
await w.find('[data-test="runtime-hold"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.seed_scope).toBe("vary");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { computed } from "vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
import SeedLockControls from "../../_shared/SeedLockControls.vue";
|
||||
|
||||
const props = defineProps<{ module: ModuleEntry }>();
|
||||
const props = defineProps<{ module: ModuleEntry; frameActive?: boolean }>();
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
const instance = computed(() => props.module.instance ?? {});
|
||||
@@ -21,6 +21,11 @@ const internal = computed(() => instance.value.internal === true);
|
||||
function onHideClick(): void {
|
||||
emit("update", { instance: { ...instance.value, internal: !internal.value } });
|
||||
}
|
||||
|
||||
const held = computed(() => instance.value.seed_scope === "hold");
|
||||
function onHoldClick(): void {
|
||||
emit("update", { instance: { ...instance.value, seed_scope: held.value ? "vary" : "hold" } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,11 +39,30 @@ function onHideClick(): void {
|
||||
data-test="runtime-hide"
|
||||
role="switch"
|
||||
:aria-checked="internal"
|
||||
:disabled="frameActive || undefined"
|
||||
@click="onHideClick"
|
||||
>
|
||||
<i class="pi pi-eye-slash" aria-hidden="true" />
|
||||
Hide from prompt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle"
|
||||
:class="{ 'toggle--on': held }"
|
||||
data-test="runtime-hold"
|
||||
role="switch"
|
||||
:aria-checked="held"
|
||||
:disabled="frameActive || undefined"
|
||||
@click="onHoldClick"
|
||||
>
|
||||
<i class="pi pi-link" aria-hidden="true" />
|
||||
Hold across run
|
||||
</button>
|
||||
<p
|
||||
v-if="frameActive"
|
||||
class="runtime__frame-hint"
|
||||
data-test="runtime-frame-hint"
|
||||
>Hide from prompt and Hold across run apply to every frame — switch to base to change.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -78,4 +102,13 @@ function onHideClick(): void {
|
||||
background: rgba(99, 102, 241, 0.10);
|
||||
}
|
||||
.toggle--on .pi { color: var(--wp-accent-text, var(--wp-text)); }
|
||||
.toggle:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.runtime__frame-hint {
|
||||
margin: 4px 0 0;
|
||||
font: 10px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -245,7 +245,7 @@ describe("combine TemplateSection", () => {
|
||||
expect(unresolved.text()).toBe("$unknown");
|
||||
});
|
||||
|
||||
it("live preview leaves alternations + repeats raw (need RNG to resolve)", () => {
|
||||
it("resolves an inline alternation deterministically (first branch) + $vars (#1)", () => {
|
||||
const w = mount(TemplateSection, {
|
||||
props: {
|
||||
module: makeModule({
|
||||
@@ -259,7 +259,62 @@ describe("combine TemplateSection", () => {
|
||||
});
|
||||
const pane = w.find('[data-test="tpl-preview-resolved"]');
|
||||
expect(pane.exists()).toBe(true);
|
||||
// Alt stays raw, $color resolves.
|
||||
expect(pane.text()).toBe("{red|blue} shiny");
|
||||
// Seedless preview → deterministic first branch. `$color` resolves.
|
||||
expect(pane.text()).toBe("red shiny");
|
||||
});
|
||||
|
||||
it("resolves a multi-pick to its first N branches joined by the separator (#1)", () => {
|
||||
const w = mount(TemplateSection, {
|
||||
props: {
|
||||
module: makeModule({
|
||||
payload: { output_var: "out", template: "{2$$, $$a|b|c}" },
|
||||
}),
|
||||
upstreamResolved: {},
|
||||
},
|
||||
});
|
||||
const pane = w.find('[data-test="tpl-preview-resolved"]');
|
||||
expect(pane.exists()).toBe(true);
|
||||
expect(pane.text()).toBe("a, b");
|
||||
});
|
||||
|
||||
it("resolves $vars nested inside an inline branch (#1)", () => {
|
||||
const w = mount(TemplateSection, {
|
||||
props: {
|
||||
module: makeModule({
|
||||
payload: { output_var: "out", template: "{$mood|calm}" },
|
||||
}),
|
||||
upstreamResolved: { mood: "serene" },
|
||||
},
|
||||
});
|
||||
const pane = w.find('[data-test="tpl-preview-resolved"]');
|
||||
expect(pane.text()).toBe("serene");
|
||||
});
|
||||
|
||||
it("resolves a $color.0 list accessor without leaking the .K literal (SP2a)", () => {
|
||||
const w = mount(TemplateSection, {
|
||||
props: {
|
||||
module: makeModule({ payload: { output_var: "out", template: "$color.0 hat" } }),
|
||||
upstreamResolved: { color: "red" },
|
||||
},
|
||||
});
|
||||
const pane = w.find('[data-test="tpl-preview-resolved"]');
|
||||
expect(pane.exists()).toBe(true);
|
||||
// `$color.0` resolves via the shared accessor (string = 1-element list,
|
||||
// `.0` == itself); the literal ".0" must NOT survive as a text token.
|
||||
expect(pane.text()).toBe("red hat");
|
||||
expect(pane.find(".tpl-tok--var-resolved").text()).toBe("red");
|
||||
});
|
||||
|
||||
it("indexes a list-valued $mood.0 in the resolved preview (SP2a)", () => {
|
||||
const w = mount(TemplateSection, {
|
||||
props: {
|
||||
module: makeModule({ payload: { output_var: "out", template: "$mood.0 | $mood" } }),
|
||||
upstreamResolved: { mood: { items: ["serene", "sleepy"], sep: ", " } },
|
||||
},
|
||||
});
|
||||
const pane = w.find('[data-test="tpl-preview-resolved"]');
|
||||
// `$mood.0` -> first item "serene"; bare `$mood` -> joined list. The whole
|
||||
// list never leaks into the `.0` slot.
|
||||
expect(pane.text()).toBe("serene | serene, sleepy");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
import { patchInstance } from "../../instance/patch";
|
||||
import { varColorClass } from "../../../../shared/var-color";
|
||||
import { tokenize, type PreviewToken } from "../../_shared/preview-tokens";
|
||||
import { applyVarAccessor, type ResolvedValue } from "../../../../../widgets/richTokenize";
|
||||
|
||||
// Async-import the rich-text editor so the chunk is split into its own
|
||||
// asset and only pulled in when the combine instance modal opens. Bundle
|
||||
@@ -34,7 +35,7 @@ const props = withDefaults(
|
||||
* with `$var` substitutions visible at edit time so users see the
|
||||
* same string the assembler will read. Empty map = preview pane
|
||||
* hidden (nothing to substitute). */
|
||||
upstreamResolved?: Record<string, string>;
|
||||
upstreamResolved?: Record<string, ResolvedValue>;
|
||||
/** Vars produced by other modules in the SAME Context node. */
|
||||
siblingVars?: string[];
|
||||
}>(),
|
||||
@@ -92,6 +93,42 @@ const previewTokens = computed<PreviewToken[]>(() =>
|
||||
// preview). The result is the closest static approximation of what
|
||||
// the assembler will read at runtime, so users can spot upstream-name
|
||||
// typos before queue time.
|
||||
/** Deterministic inline-block resolution for the seedless combine preview:
|
||||
* an alternation `{a|b}` resolves to its FIRST branch; a multi-pick
|
||||
* `{N$$sep$$a|b|c}` to its first `N` branches joined by the separator
|
||||
* (clamped to what's available). Nested `$vars` in the chosen branch(es)
|
||||
* resolve via the upstream map so the preview shows a concrete example
|
||||
* string instead of the literal `{a|b}`. Deterministic (not random) because
|
||||
* the preview has no seed; runtime resolution still rolls the real RNG. Refs
|
||||
* stay literal — combine never recurses into nested wildcards. */
|
||||
function resolveInlineText(s: string, map: Record<string, ResolvedValue>): string {
|
||||
let out = "";
|
||||
for (const tok of tokenize(s, "combine")) out += resolveInlineTok(tok, map);
|
||||
return out;
|
||||
}
|
||||
function resolveInlineTok(tok: PreviewToken, map: Record<string, ResolvedValue>): string {
|
||||
switch (tok.kind) {
|
||||
case "var":
|
||||
return !tok.invalid && tok.varName
|
||||
&& Object.prototype.hasOwnProperty.call(map, tok.varName)
|
||||
? applyVarAccessor(map[tok.varName], tok.index)
|
||||
: tok.raw;
|
||||
case "escape":
|
||||
return tok.literal ?? "";
|
||||
case "alt": {
|
||||
const b = tok.branches ?? [];
|
||||
return b.length > 0 ? resolveInlineText(b[0], map) : "";
|
||||
}
|
||||
case "repeat": {
|
||||
const b = tok.branches ?? [];
|
||||
const n = Math.max(0, Math.min(tok.count ?? 0, b.length));
|
||||
return b.slice(0, n).map((br) => resolveInlineText(br, map)).join(tok.separator ?? "");
|
||||
}
|
||||
default:
|
||||
return tok.raw; // text, ref (literal on combine)
|
||||
}
|
||||
}
|
||||
|
||||
interface ResolvedToken {
|
||||
text: string;
|
||||
/** Source kind so the renderer can color-code: `var-resolved` paints
|
||||
@@ -110,9 +147,15 @@ const resolvedTokens = computed<ResolvedToken[]>(() => {
|
||||
tokens.push({ text: tok.raw, kind: "var-unresolved", varName: tok.varName });
|
||||
break;
|
||||
}
|
||||
const value = map[tok.varName];
|
||||
if (typeof value === "string") {
|
||||
tokens.push({ text: value, kind: "var-resolved", varName: tok.varName });
|
||||
// SP2a: resolve via the shared accessor so `$mood.0` indexes (and a
|
||||
// bare `$mood` joins a list) — the `.K` never strands as literal text.
|
||||
// Presence is keyed on the base name, not the value's type.
|
||||
if (Object.prototype.hasOwnProperty.call(map, tok.varName)) {
|
||||
tokens.push({
|
||||
text: applyVarAccessor(map[tok.varName], tok.index),
|
||||
kind: "var-resolved",
|
||||
varName: tok.varName,
|
||||
});
|
||||
} else {
|
||||
tokens.push({ text: tok.raw, kind: "var-unresolved", varName: tok.varName });
|
||||
}
|
||||
@@ -122,10 +165,14 @@ const resolvedTokens = computed<ResolvedToken[]>(() => {
|
||||
tokens.push({ text: tok.literal ?? "", kind: "literal" });
|
||||
break;
|
||||
case "ref":
|
||||
// Combine never recurses into nested wildcards — ref stays literal.
|
||||
tokens.push({ text: tok.raw, kind: "literal" });
|
||||
break;
|
||||
case "alt":
|
||||
case "repeat":
|
||||
// Leave as raw — these need RNG / chain resolution.
|
||||
tokens.push({ text: tok.raw, kind: "literal" });
|
||||
// Resolve inline blocks deterministically so the preview shows a
|
||||
// concrete example string instead of the literal `{a|b}`.
|
||||
tokens.push({ text: resolveInlineTok(tok, map), kind: "literal" });
|
||||
break;
|
||||
case "text":
|
||||
default:
|
||||
@@ -136,11 +183,13 @@ const resolvedTokens = computed<ResolvedToken[]>(() => {
|
||||
return tokens;
|
||||
});
|
||||
|
||||
/** Show the live-preview pane only when the template references at
|
||||
* least one upstream-resolvable var. No vars = nothing to substitute,
|
||||
* pane stays hidden so the section doesn't grow unnecessarily. */
|
||||
/** Show the live-preview pane when there's something to resolve: an
|
||||
* upstream-resolvable var, OR an inline block (`{a|b}` / `{N$$…}`) that the
|
||||
* preview now expands deterministically. Otherwise stay hidden so the
|
||||
* section doesn't grow for a plain static template. */
|
||||
const hasResolvableVar = computed(() =>
|
||||
resolvedTokens.value.some((t) => t.kind === "var-resolved"),
|
||||
resolvedTokens.value.some((t) => t.kind === "var-resolved")
|
||||
|| previewTokens.value.some((t) => t.kind === "alt" || t.kind === "repeat"),
|
||||
);
|
||||
|
||||
// Detected variables = VAR tokens (deduped, library order preserved).
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ConstraintInstanceModal from "./ConstraintInstanceModal.vue";
|
||||
|
||||
// Preview-resolver lookups MISS so the source uuid reads as dangling.
|
||||
vi.mock("../../../../extension/preview-resolver", () => ({
|
||||
cacheVersion: { value: 0 },
|
||||
ensure: vi.fn(),
|
||||
lookup: () => undefined,
|
||||
}));
|
||||
|
||||
function moduleWithDanglingSource() {
|
||||
return {
|
||||
id: "c0ffee00",
|
||||
_uid: "u1",
|
||||
type: "constraint" as const,
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
entries: [],
|
||||
payload_hash: "h",
|
||||
meta: { name: "warm-only", description: "", tags: [] },
|
||||
payload: {
|
||||
source_wildcard_id: "deadbeef", // missing locally
|
||||
target_wildcard_id: "facade00", // present as a sibling below
|
||||
matrix: { warm: { rough: { mode: "boost", factor: 2 } } },
|
||||
exceptions: [{ source_value: "@{deadbeef:warm}", target_value: "x", mode: "allow", factor: 1 }],
|
||||
},
|
||||
instance: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Same dangling source, but a TWO-ROW matrix: `warm` survives under
|
||||
// candidate beef0001 (subs ["warm","cold"]) while `cool` VANISHES, so a
|
||||
// source reattach to beef0001 drops every cell in the `cool` row (2 cells).
|
||||
function moduleWithDroppingSource() {
|
||||
return {
|
||||
id: "c0ffee01",
|
||||
_uid: "u1",
|
||||
type: "constraint" as const,
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
entries: [],
|
||||
payload_hash: "h",
|
||||
meta: { name: "warm-only", description: "", tags: [] },
|
||||
payload: {
|
||||
source_wildcard_id: "deadbeef", // missing locally
|
||||
target_wildcard_id: "facade00", // present as a sibling below
|
||||
matrix: {
|
||||
warm: { rough: { mode: "boost", factor: 2 } }, // survives → 0 dropped
|
||||
cool: { rough: { mode: "ban", factor: 0 }, smooth: { mode: "boost", factor: 2 } }, // vanishes → 2 cells dropped
|
||||
},
|
||||
exceptions: [],
|
||||
},
|
||||
instance: {},
|
||||
};
|
||||
}
|
||||
|
||||
function siblings() {
|
||||
return [
|
||||
{
|
||||
id: "facade00", _uid: "u2", type: "wildcard" as const, enabled: true, collapsed: false,
|
||||
entries: [], meta: { name: "texture", description: "", tags: [] },
|
||||
payload: { sub_categories: ["rough"], options: [{ id: "o1", value: "matte" }] },
|
||||
instance: {},
|
||||
},
|
||||
{
|
||||
id: "beef0001", _uid: "u3", type: "wildcard" as const, enabled: true, collapsed: false,
|
||||
entries: [], meta: { name: "colour", description: "", tags: [] },
|
||||
payload: { sub_categories: ["warm", "cold"], options: [{ id: "o2", value: "red" }] },
|
||||
instance: {},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe("ConstraintInstanceModal — dangling source reattach", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("raises the reattach banner for the dangling source only", () => {
|
||||
const w = mount(ConstraintInstanceModal, {
|
||||
props: { module: moduleWithDanglingSource(), siblingModules: siblings() },
|
||||
});
|
||||
expect(w.find("[data-test='reattach-row-source']").exists()).toBe(true);
|
||||
expect(w.find("[data-test='reattach-row-target']").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("reattach repoints source_wildcard_id + embedded @{} ref and emits save-to-library", async () => {
|
||||
const w = mount(ConstraintInstanceModal, {
|
||||
props: { module: moduleWithDanglingSource(), siblingModules: siblings() },
|
||||
});
|
||||
await w.find("[data-test='reattach-btn-source']").trigger("click");
|
||||
await w.find("[data-test-id='reattach-candidate-beef0001']").trigger("click");
|
||||
await w.find("[data-test='reattach-confirm-source']").trigger("click");
|
||||
|
||||
const updates = w.emitted("update");
|
||||
expect(updates).toBeTruthy();
|
||||
const patch = updates![updates!.length - 1][0] as { payload: Record<string, unknown> };
|
||||
expect(patch.payload.source_wildcard_id).toBe("beef0001");
|
||||
const exc = (patch.payload.exceptions as Array<{ source_value: string }>)[0];
|
||||
expect(exc.source_value).toBe("@{beef0001:warm}"); // segments preserved
|
||||
expect(w.emitted("save-to-library")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("previews the per-cell dropped count for a non-surviving source pick (pre-confirm)", async () => {
|
||||
const w = mount(ConstraintInstanceModal, {
|
||||
props: { module: moduleWithDroppingSource(), siblingModules: siblings() },
|
||||
});
|
||||
await w.find("[data-test='reattach-btn-source']").trigger("click");
|
||||
// Select candidate beef0001 (subs ["warm","cold"]) WITHOUT confirming:
|
||||
// the `cool` row (2 cells) vanishes, `warm` (1 cell) survives → 2 dropped.
|
||||
await w.find("[data-test-id='reattach-candidate-beef0001']").trigger("click");
|
||||
|
||||
const dropped = w.find("[data-test='reattach-dropped-source']");
|
||||
expect(dropped.exists()).toBe(true);
|
||||
expect(dropped.text()).toContain("2 cells dropped");
|
||||
});
|
||||
|
||||
// A dangling ref makes the whole constraint a read-only snapshot: the
|
||||
// modal threads `stranded` into both the matrix + exceptions sections so
|
||||
// they render their read-only treatment (muted/locked matrix, static
|
||||
// colored exception chips), mirroring the SPA's stranded read-only view.
|
||||
it("marks the matrix + exceptions sections stranded when a ref is dangling", () => {
|
||||
const w = mount(ConstraintInstanceModal, {
|
||||
props: { module: moduleWithDanglingSource(), siblingModules: siblings() },
|
||||
});
|
||||
expect(w.findComponent({ name: "MatrixSection" }).props("stranded")).toBe(true);
|
||||
expect(w.findComponent({ name: "ExceptionsSection" }).props("stranded")).toBe(true);
|
||||
// The rendered read-only affordances surface too.
|
||||
expect(w.find("[data-test='mx-readonly-lock']").exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cached source/target ref NAME (display-only, additive) ─────────
|
||||
//
|
||||
// The constraint payload caches the source/target wildcard display name
|
||||
// (`source_wildcard_name`/`target_wildcard_name`) so the broken-reference
|
||||
// banner reads "Source wildcard 'Starter subject' (deadbeef)" even after the
|
||||
// wildcard is deleted (the live lookup misses → name unrecoverable otherwise).
|
||||
|
||||
/** Dangling source, but the payload carries the cached display name. The
|
||||
* preview-resolver lookup MISSES (mock returns undefined) and the wildcard
|
||||
* is not a sibling, so `wildcardName` yields "" — the banner must fall back
|
||||
* to the payload's cached `source_wildcard_name`. */
|
||||
function moduleWithCachedSourceName() {
|
||||
return {
|
||||
id: "c0ffee02",
|
||||
_uid: "u1",
|
||||
type: "constraint" as const,
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
entries: [],
|
||||
payload_hash: "h",
|
||||
meta: { name: "warm-only", description: "", tags: [] },
|
||||
payload: {
|
||||
source_wildcard_id: "deadbeef", // missing locally → dangling
|
||||
target_wildcard_id: "facade00", // present sibling
|
||||
source_wildcard_name: "Starter subject", // cached on write
|
||||
matrix: { warm: { rough: { mode: "boost", factor: 2 } } },
|
||||
exceptions: [],
|
||||
},
|
||||
instance: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ConstraintInstanceModal — cached source ref name", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("shows the cached source name + uuid in the dangling banner", () => {
|
||||
const w = mount(ConstraintInstanceModal, {
|
||||
props: { module: moduleWithCachedSourceName(), siblingModules: siblings() },
|
||||
});
|
||||
const row = w.find("[data-test='reattach-row-source']");
|
||||
expect(row.exists()).toBe(true);
|
||||
const txt = row.text();
|
||||
expect(txt).toContain("Starter subject"); // cached name survives deletion
|
||||
expect(txt).toContain("deadbeef"); // alongside the uuid prefix
|
||||
});
|
||||
|
||||
it("reattach stamps source_wildcard_name from the emitted newName", async () => {
|
||||
const w = mount(ConstraintInstanceModal, {
|
||||
props: { module: moduleWithCachedSourceName(), siblingModules: siblings() },
|
||||
});
|
||||
await w.find("[data-test='reattach-btn-source']").trigger("click");
|
||||
await w.find("[data-test-id='reattach-candidate-beef0001']").trigger("click");
|
||||
await w.find("[data-test='reattach-confirm-source']").trigger("click");
|
||||
|
||||
const updates = w.emitted("update");
|
||||
expect(updates).toBeTruthy();
|
||||
const patch = updates![updates!.length - 1][0] as { payload: Record<string, unknown> };
|
||||
expect(patch.payload.source_wildcard_id).toBe("beef0001");
|
||||
// The repointed name is the picked candidate's name ("colour").
|
||||
expect(patch.payload.source_wildcard_name).toBe("colour");
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ConstraintInstanceModal from "./ConstraintInstanceModal.vue";
|
||||
import type { ModuleEntry } from "../../../../widgets/_shared";
|
||||
import TargetReachSection from "./sections/TargetReachSection.vue";
|
||||
import type { ModuleEntry, TargetSelect } from "../../../../widgets/_shared";
|
||||
|
||||
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
@@ -268,4 +269,162 @@ describe("ConstraintInstanceModal", () => {
|
||||
expect(matrix.props("sourceSubs")).toEqual(["live_warm", "live_cool"]);
|
||||
_resetForTests();
|
||||
});
|
||||
|
||||
// ── Cross-node chain resolution (SP3 P6 T16) ──
|
||||
// The target wildcard lives in a DOWNSTREAM Context node, so it is
|
||||
// absent from `siblingModules` (same-node only). The mount layer
|
||||
// threads the flattened cross-node chain as `chainModules`; the modal
|
||||
// must resolve the target's name + subcats + option values from it
|
||||
// instead of degrading to raw-uuid / matrix-key fallbacks.
|
||||
it("resolves target wildcard from chainModules when it lives in a downstream node", () => {
|
||||
const m = makeModule({
|
||||
payload: {
|
||||
source_wildcard_id: "wc_color",
|
||||
target_wildcard_id: "wc_fabric",
|
||||
// Deliberately empty matrix so any axis output MUST come from the
|
||||
// live wildcard (via chainModules), not from saved-matrix keys.
|
||||
matrix: {},
|
||||
exceptions: [],
|
||||
},
|
||||
});
|
||||
// Same-node siblings hold only the source; the target is cross-node.
|
||||
const sourceSibling: ModuleEntry = {
|
||||
id: "wc_color",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
meta: { name: "color" },
|
||||
entries: [],
|
||||
payload: { sub_categories: ["red", "blue"], options: [{ id: "o1", value: "red" }] },
|
||||
};
|
||||
// Cross-node chain entry for the target — shape is ChainModule
|
||||
// (`displayName` not `meta.name`, `rowKey` graph-unique).
|
||||
const targetChain = {
|
||||
id: "wc_fabric",
|
||||
rowKey: "99#wc_fabric",
|
||||
type: "wildcard",
|
||||
displayName: "fabric",
|
||||
payload: {
|
||||
var_binding: "fabric",
|
||||
sub_categories: ["cotton", "silk"],
|
||||
options: [
|
||||
{ id: "f0", value: "denim" },
|
||||
{ id: "f1", value: "linen" },
|
||||
],
|
||||
},
|
||||
};
|
||||
const w = mount(ConstraintInstanceModal, {
|
||||
props: {
|
||||
module: m,
|
||||
siblingModules: [sourceSibling],
|
||||
chainModules: [targetChain],
|
||||
},
|
||||
});
|
||||
const matrix = w.findComponent({ name: "MatrixSection" });
|
||||
// Target name resolves from the chain entry (var_binding > displayName).
|
||||
expect(matrix.props("targetName")).toBe("fabric");
|
||||
// Target subcats come from the cross-node wildcard, not matrix keys.
|
||||
expect(matrix.props("targetSubs")).toEqual(["cotton", "silk"]);
|
||||
// Source still resolves from the same-node sibling.
|
||||
expect(matrix.props("sourceName")).toBe("color");
|
||||
expect(matrix.props("sourceSubs")).toEqual(["red", "blue"]);
|
||||
const exSection = w.findComponent({ name: "ExceptionsSection" });
|
||||
// Target option values flow from the chain entry into autocomplete.
|
||||
expect(exSection.props("targetValues")).toEqual(["denim", "linen"]);
|
||||
});
|
||||
|
||||
it("chainModules wins over siblingModules for the same uuid (chain is the superset)", () => {
|
||||
const m = makeModule({
|
||||
payload: {
|
||||
source_wildcard_id: "wc_color",
|
||||
target_wildcard_id: "wc_fabric",
|
||||
matrix: {},
|
||||
exceptions: [],
|
||||
},
|
||||
});
|
||||
// Same uuid present in BOTH: chain entry must win so the merge is
|
||||
// safe for the current node (which appears inside the chain too).
|
||||
const sourceSibling: ModuleEntry = {
|
||||
id: "wc_color",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
meta: { name: "sibling_color" },
|
||||
entries: [],
|
||||
payload: { sub_categories: ["sib_red"], options: [] },
|
||||
};
|
||||
const sourceChain = {
|
||||
id: "wc_color",
|
||||
rowKey: "7#wc_color",
|
||||
type: "wildcard",
|
||||
displayName: "chain_color",
|
||||
payload: { sub_categories: ["chain_red", "chain_blue"], options: [] },
|
||||
};
|
||||
const w = mount(ConstraintInstanceModal, {
|
||||
props: {
|
||||
module: m,
|
||||
siblingModules: [sourceSibling],
|
||||
chainModules: [sourceChain],
|
||||
},
|
||||
});
|
||||
const matrix = w.findComponent({ name: "MatrixSection" });
|
||||
expect(matrix.props("sourceName")).toBe("chain_color");
|
||||
expect(matrix.props("sourceSubs")).toEqual(["chain_red", "chain_blue"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConstraintInstanceModal — target reach instance override", () => {
|
||||
// The modal collapses a reach choice to `null` ("inherit library") ONLY
|
||||
// when it equals the EFFECTIVE library default — so a real per-instance
|
||||
// override (including an explicit `all` over a non-all library default)
|
||||
// is preserved and reaches the engine's `instance ?? payload ?? all`.
|
||||
|
||||
function emitReach(w: ReturnType<typeof mount>, next: TargetSelect) {
|
||||
return w.findComponent(TargetReachSection).vm.$emit("update:modelValue", next);
|
||||
}
|
||||
|
||||
function lastReach(w: ReturnType<typeof mount>): TargetSelect | null | undefined {
|
||||
const updates = w.emitted("update") as Array<[Partial<ModuleEntry>]> | undefined;
|
||||
if (!updates?.length) return undefined;
|
||||
return updates[updates.length - 1][0].instance?.target_select;
|
||||
}
|
||||
|
||||
function moduleWithLibraryReach(reach?: TargetSelect): ModuleEntry {
|
||||
const m = makeModule();
|
||||
if (reach) (m.payload as Record<string, unknown>).target_select = reach;
|
||||
return m;
|
||||
}
|
||||
|
||||
it("collapses 'all' to null when the library default is already all (inherit)", async () => {
|
||||
const w = mount(ConstraintInstanceModal, { props: { module: moduleWithLibraryReach() } });
|
||||
emitReach(w, { mode: "all" });
|
||||
await w.vm.$nextTick();
|
||||
expect(lastReach(w)).toBeNull();
|
||||
});
|
||||
|
||||
it("stores an explicit 'all' override when the library default is non-all (first)", async () => {
|
||||
// Regression guard: the old `mode === "all"` collapse dropped this to
|
||||
// null, so the engine fell back to the library `first` and the user's
|
||||
// widen-to-all was silently ignored.
|
||||
const w = mount(ConstraintInstanceModal, {
|
||||
props: { module: moduleWithLibraryReach({ mode: "first" }) },
|
||||
});
|
||||
emitReach(w, { mode: "all" });
|
||||
await w.vm.$nextTick();
|
||||
expect(lastReach(w)).toEqual({ mode: "all" });
|
||||
});
|
||||
|
||||
it("collapses to null when the choice equals a non-all library default (first == first)", async () => {
|
||||
const w = mount(ConstraintInstanceModal, {
|
||||
props: { module: moduleWithLibraryReach({ mode: "first" }) },
|
||||
});
|
||||
emitReach(w, { mode: "first" });
|
||||
await w.vm.$nextTick();
|
||||
expect(lastReach(w)).toBeNull();
|
||||
});
|
||||
|
||||
it("stores a non-default reach (next N) as an explicit override", async () => {
|
||||
const w = mount(ConstraintInstanceModal, { props: { module: moduleWithLibraryReach() } });
|
||||
emitReach(w, { mode: "next", count: 2 });
|
||||
await w.vm.$nextTick();
|
||||
expect(lastReach(w)).toEqual({ mode: "next", count: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
* `locked_seed` for this kind. Dropping the section is honest;
|
||||
* dimmed dead UI would lie.
|
||||
*/
|
||||
import { computed, watch } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import type { ModuleEntry } from "../../../../widgets/_shared";
|
||||
import type { ChainModule, TargetSelect } from "../../../../extension/constraint-pairs";
|
||||
import IdentitySection from "./sections/IdentitySection.vue";
|
||||
import MatrixSection from "./sections/MatrixSection.vue";
|
||||
import TargetReachSection from "./sections/TargetReachSection.vue";
|
||||
import ExceptionsSection from "./sections/ExceptionsSection.vue";
|
||||
// Library fallback for cross-Context constraints. When the referenced
|
||||
// source/target wildcard isn't a sibling in this WP_Context (lives in
|
||||
@@ -28,6 +30,20 @@ import ExceptionsSection from "./sections/ExceptionsSection.vue";
|
||||
// exception chips, and autocomplete suggestions reflect the live
|
||||
// wildcard instead of the saved-matrix's frozen keys.
|
||||
import { cacheVersion, ensure, lookup } from "../../../../extension/preview-resolver";
|
||||
import ConstraintReattachSection from "./sections/ConstraintReattachSection.vue";
|
||||
import ConfirmDialog from "../../../shared/ConfirmDialog.vue";
|
||||
import { walkRemap } from "../../bundles/uuid-remap";
|
||||
import { buildWildcardRefData } from "../../../../manager/utils/library-suggestions";
|
||||
import type { ModuleRow } from "../../../../manager/api/types";
|
||||
import { api } from "../../../../manager/api/client";
|
||||
import {
|
||||
fetchCommunityPostDetail,
|
||||
downloadCommunityVersion,
|
||||
type CommunityDepEdge,
|
||||
} from "../../../../manager/community/community-posts";
|
||||
import { collectTransitiveDeps } from "../../../../manager/community/transitive-deps";
|
||||
import { downloadDepsForDangling } from "../../../../manager/community/download-and-reattach";
|
||||
import { installEnvelope } from "../../../../manager/import-export/install";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -40,6 +56,20 @@ const props = withDefaults(
|
||||
* matrix axes (sub_categories) + extra-exception autocomplete
|
||||
* (option values). Optional; falls back to empty axes. */
|
||||
siblingModules?: ModuleEntry[];
|
||||
/** Flattened cross-node module chain (upstream + own + downstream
|
||||
* WP_Context nodes), computed at the mount layer. Lets the modal
|
||||
* resolve a source/target wildcard that lives in a DOWNSTREAM (or
|
||||
* upstream) Context node — not just the same node. A superset of
|
||||
* `siblingModules` (the own node's modules appear in the chain
|
||||
* too), so merging is safe; chain entries win on uuid collision.
|
||||
* Optional — undefined in headless/legacy mounts falls back to
|
||||
* `siblingModules` alone. */
|
||||
chainModules?: ChainModule[];
|
||||
/** When true, a frame override context is active. Accepted for
|
||||
* interface uniformity; constraint has no RuntimeSection and its
|
||||
* IdentitySection carries no variable-binding input, so nothing
|
||||
* is forwarded to a child here. */
|
||||
frameActive?: boolean;
|
||||
}>(),
|
||||
{ isDrifted: false, isModified: false, siblingModules: () => [] },
|
||||
);
|
||||
@@ -65,11 +95,54 @@ function spaUrl(): string {
|
||||
interface WildcardPayload {
|
||||
sub_categories?: string[];
|
||||
options?: Array<{ value?: string }>;
|
||||
tag_groups?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
function findWildcardModule(id: string | null | undefined): ModuleEntry | null {
|
||||
/** Normalised module shape the resolution lookups consume. Unifies the
|
||||
* two source shapes: `ModuleEntry` (same-node siblings — name under
|
||||
* `meta.name`) and `ChainModule` (cross-node chain — name under
|
||||
* `displayName`). Carrying a flat `name` lets `findWildcardModule` +
|
||||
* `wildcardName` stay shape-agnostic. */
|
||||
interface LookupModule {
|
||||
id: string;
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** Merge of the cross-node chain (`chainModules`) and same-node
|
||||
* siblings (`siblingModules`), keyed by uuid. Chain entries win on
|
||||
* collision: the chain is a superset that already includes the current
|
||||
* node's modules, and it carries the authoritative cross-node view, so
|
||||
* preferring it keeps same-node behaviour identical (same payload) while
|
||||
* adding reach to downstream/upstream targets. Deduped by id. */
|
||||
const modulesForLookup = computed<LookupModule[]>(() => {
|
||||
const byId = new Map<string, LookupModule>();
|
||||
// Siblings first so chain entries (added second) overwrite on uuid.
|
||||
for (const s of props.siblingModules) {
|
||||
if (s.type !== "wildcard") continue;
|
||||
byId.set(s.id, {
|
||||
id: s.id,
|
||||
type: s.type,
|
||||
payload: (s.payload ?? {}) as Record<string, unknown>,
|
||||
name: s.meta?.name,
|
||||
});
|
||||
}
|
||||
for (const c of props.chainModules ?? []) {
|
||||
if (c.type !== "wildcard") continue;
|
||||
byId.set(c.id, {
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
payload: (c.payload ?? {}) as Record<string, unknown>,
|
||||
name: c.displayName,
|
||||
});
|
||||
}
|
||||
return [...byId.values()];
|
||||
});
|
||||
|
||||
function findWildcardModule(id: string | null | undefined): LookupModule | null {
|
||||
if (!id) return null;
|
||||
const m = props.siblingModules.find((x) => x.id === id);
|
||||
const m = modulesForLookup.value.find((x) => x.id === id);
|
||||
if (!m || m.type !== "wildcard") return null;
|
||||
return m;
|
||||
}
|
||||
@@ -79,13 +152,18 @@ function findWildcard(id: string | null | undefined): WildcardPayload | null {
|
||||
return m ? ((m.payload ?? {}) as WildcardPayload) : null;
|
||||
}
|
||||
|
||||
/** Sibling first, then preview-resolver cache. Returns the wildcard's
|
||||
* display name; falls back to varBinding for cross-Context refs that
|
||||
* have a `$var` name but no human title. Empty string when neither
|
||||
* source knows the uuid. */
|
||||
/** Chain/sibling first, then preview-resolver cache. Returns the
|
||||
* wildcard's display name; for chain entries the `displayName` is
|
||||
* `meta.name` at flatten time. A `$var`-bound wildcard with no human
|
||||
* title (common for cross-node chain entries) falls to its
|
||||
* `var_binding`, then the cache's varBinding/name. Empty string when
|
||||
* no source knows the uuid. Display name is preferred over var_binding
|
||||
* so a same-node sibling's human title still wins (unchanged). */
|
||||
function wildcardName(id: string | null | undefined): string {
|
||||
const m = findWildcardModule(id);
|
||||
if (m?.meta?.name) return m.meta.name;
|
||||
if (m?.name) return m.name;
|
||||
const binding = (m?.payload?.var_binding as string | undefined)?.trim().replace(/^\$+/, "");
|
||||
if (binding) return binding;
|
||||
if (!id) return "";
|
||||
void cacheVersion.value;
|
||||
const hit = lookup(id);
|
||||
@@ -112,8 +190,19 @@ watch(
|
||||
interface ConstraintPayload {
|
||||
source_wildcard_id?: string;
|
||||
target_wildcard_id?: string;
|
||||
/** Cached display names, stamped on write so the broken-reference banner
|
||||
* can show the name after the wildcard is deleted (the live lookup misses
|
||||
* then). Display-only; the engine never reads them. */
|
||||
source_wildcard_name?: string;
|
||||
target_wildcard_name?: string;
|
||||
matrix?: Record<string, Record<string, unknown>>;
|
||||
exceptions?: Array<{ source_value?: string; target_value?: string; source?: string; target?: string }>;
|
||||
target_select?: TargetSelect;
|
||||
/** Community post this constraint was installed from (engine migration
|
||||
* 013, denormalized onto the picked module's payload). Drives Feature 2's
|
||||
* "Download from community" affordance — only when set can the modal pull
|
||||
* a missing dependency. Absent for hand-authored / non-community modules. */
|
||||
community_post_slug?: string;
|
||||
}
|
||||
|
||||
/** Pull the wildcard's live sub_categories, preferring sibling module
|
||||
@@ -201,6 +290,19 @@ const targetValues = computed<string[]>(() => {
|
||||
return Array.from(set);
|
||||
});
|
||||
|
||||
/** Per-axis grouping (tag_groups) for the matrix headers — orders + colours
|
||||
* the row/col tags by sub-category axis. Sibling/chain wildcard payloads
|
||||
* carry it; the preview-resolver cache does not, so a cross-context ref
|
||||
* resolved only via cache groups flat (acceptable degradation). */
|
||||
const sourceGroups = computed<Record<string, string[]>>(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
return findWildcard(pl.source_wildcard_id)?.tag_groups ?? {};
|
||||
});
|
||||
const targetGroups = computed<Record<string, string[]>>(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
return findWildcard(pl.target_wildcard_id)?.tag_groups ?? {};
|
||||
});
|
||||
|
||||
const sourceName = computed(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
return wildcardName(pl.source_wildcard_id);
|
||||
@@ -210,6 +312,282 @@ const targetName = computed(() => {
|
||||
return wildcardName(pl.target_wildcard_id);
|
||||
});
|
||||
|
||||
/** Cached display name for the broken-reference banner. Prefer the LIVE
|
||||
* resolved name (`wildcardName`), falling back to the payload's stamped
|
||||
* `*_wildcard_name` when the wildcard is dangling — that's the whole point
|
||||
* of the cache: the live lookup returns "" once the wildcard is gone, so the
|
||||
* banner would otherwise show the bare uuid. Display-only. */
|
||||
const sourceCachedName = computed(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
return sourceName.value || pl.source_wildcard_name || "";
|
||||
});
|
||||
const targetCachedName = computed(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
return targetName.value || pl.target_wildcard_name || "";
|
||||
});
|
||||
|
||||
/** A source/target id is DANGLING when non-empty but it resolves to nothing
|
||||
* — neither a sibling/chain wildcard (findWildcardModule) nor the
|
||||
* preview-resolver cache (lookup). Today this silently falls back to matrix
|
||||
* keys; here it raises the reattach banner (spec Component B "Detection"). */
|
||||
function isDangling(id: string | undefined): boolean {
|
||||
if (typeof id !== "string" || id.length === 0) return false;
|
||||
if (findWildcardModule(id)) return false;
|
||||
void cacheVersion.value;
|
||||
return lookup(id) === undefined;
|
||||
}
|
||||
const danglingSource = computed(() =>
|
||||
isDangling(((props.module.payload ?? {}) as ConstraintPayload).source_wildcard_id),
|
||||
);
|
||||
const danglingTarget = computed(() =>
|
||||
isDangling(((props.module.payload ?? {}) as ConstraintPayload).target_wildcard_id),
|
||||
);
|
||||
const hasDangling = computed(() => danglingSource.value || danglingTarget.value);
|
||||
const danglingSourceUuid = computed(() =>
|
||||
((props.module.payload ?? {}) as ConstraintPayload).source_wildcard_id ?? "",
|
||||
);
|
||||
const danglingTargetUuid = computed(() =>
|
||||
((props.module.payload ?? {}) as ConstraintPayload).target_wildcard_id ?? "",
|
||||
);
|
||||
|
||||
/** WildcardRefData for the reattach dropdown — from the same cross-node +
|
||||
* sibling wildcard set the modal already merges (modulesForLookup). */
|
||||
const reattachRefData = computed(() => {
|
||||
const rows = modulesForLookup.value.map((m) => ({
|
||||
id: m.id, name: m.name ?? "", type: "wildcard", payload: m.payload,
|
||||
}) as unknown as ModuleRow);
|
||||
return buildWildcardRefData(rows);
|
||||
});
|
||||
|
||||
/** Live pre-confirm reattach selection, surfaced by the section's `@pick`.
|
||||
* Drives the dropped-cell preview; reset to null when the section abandons
|
||||
* the pick (`@pickcleared`) or after a reattach is handled. */
|
||||
const reattachPick = ref<{ side: "source" | "target"; uuid: string } | null>(null);
|
||||
|
||||
/** Cells the picked candidate would DROP from the saved matrix, counted at
|
||||
* the cell level (not axis keys) so the pre-confirm preview is honest:
|
||||
* - a vanished SOURCE row drops every cell in that row;
|
||||
* - a vanished TARGET key drops one cell per row that carries it.
|
||||
* The candidate's sub_categories come from the same ref-data the dropdown
|
||||
* picks from; an empty/unknown set means every current key vanishes. */
|
||||
const reattachDroppedCellCount = computed(() => {
|
||||
const pick = reattachPick.value;
|
||||
if (!pick) return 0;
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
const matrix = pl.matrix ?? {};
|
||||
const newSubs = new Set(reattachRefData.value.uuidToSubCategories.get(pick.uuid) ?? []);
|
||||
let dropped = 0;
|
||||
if (pick.side === "source") {
|
||||
for (const [srcKey, row] of Object.entries(matrix)) {
|
||||
if (!newSubs.has(srcKey)) dropped += Object.keys((row ?? {}) as Record<string, unknown>).length;
|
||||
}
|
||||
} else {
|
||||
for (const row of Object.values(matrix)) {
|
||||
for (const tgtKey of Object.keys((row ?? {}) as Record<string, unknown>)) {
|
||||
if (!newSubs.has(tgtKey)) dropped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return dropped;
|
||||
});
|
||||
|
||||
/** Conservative blast-radius signal: the constraint reaches beyond the
|
||||
* current node when the cross-node chain spans more than one context. The
|
||||
* modal can't cheaply run a full reverse-dep, so this errs toward the
|
||||
* WARNING (the safer default for a silent cross-context library mutation). */
|
||||
const referencedElsewhere = computed(() => {
|
||||
const chain = props.chainModules ?? [];
|
||||
const contexts = new Set(chain.map((m) => m.rowKey.split("#")[0]));
|
||||
return contexts.size > 1;
|
||||
});
|
||||
|
||||
function onReattach(payload: { side: "source" | "target"; oldUuid: string; newUuid: string; newName: string }): void {
|
||||
const pl = (props.module.payload ?? {}) as Record<string, unknown>;
|
||||
// Single walkRemap pass over the constraint's OWN payload: repoints the
|
||||
// source/target whole-string id AND any embedded @{oldUuid} in exception
|
||||
// values (segments preserved). Matrix axes re-derive from the new wildcard
|
||||
// via the existing liveSubCategories → sourceSubs/targetSubs path. Mirrors
|
||||
// cascade-restore.ts:rewriteChildId, which already walkRemaps a constraint
|
||||
// payload to repoint source/target + @{} refs.
|
||||
const rewritten = walkRemap(pl, { [payload.oldUuid]: payload.newUuid }) as Record<string, unknown>;
|
||||
// Re-cache the display name from the picked candidate (walkRemap leaves the
|
||||
// OLD cached name in place — it only rewrites the uuid). Display-only; feeds
|
||||
// the broken-reference banner if this new wildcard is later deleted too.
|
||||
if (payload.side === "source") rewritten.source_wildcard_name = payload.newName;
|
||||
else rewritten.target_wildcard_name = payload.newName;
|
||||
emit("update", { payload: rewritten });
|
||||
// Reattach edits source/target — library-defining — so it rides
|
||||
// Save-to-library (rewrites the library row → every context that uses it).
|
||||
emit("save-to-library");
|
||||
// Pick consumed — clear the live preview so a stale dropped-cell count
|
||||
// can't survive into the next reattach.
|
||||
reattachPick.value = null;
|
||||
}
|
||||
|
||||
// ── Download-missing-dep-from-community (Feature 2) ──────────────────
|
||||
//
|
||||
// Mirrors ConstraintEditor.vue's SPA flow, adapted to the canvas modal's
|
||||
// data sources. The modal has no moduleStore catalog — it resolves refs via
|
||||
// the preview-resolver cache (`ensure`/`lookup`). After install, the dep
|
||||
// lands at its original id (installEnvelope preserves publisher ids); a
|
||||
// fresh `ensure([uuid])` repopulates the cache and the `isDangling`
|
||||
// computed (which reads `cacheVersion`) clears the banner. Gated by the
|
||||
// post slug being present on the picked module's payload — absent (e.g. a
|
||||
// hand-authored constraint) means the affordance never shows.
|
||||
|
||||
/** The constraint's community post slug, if it was installed from one. */
|
||||
const constraintModalSlug = computed<string>(
|
||||
() => ((props.module.payload ?? {}) as ConstraintPayload).community_post_slug ?? "",
|
||||
);
|
||||
|
||||
/** The constraint post's OWN dependency edges, lazily fetched once. `null`
|
||||
* = not fetched; `[]` = fetched (or failed → "no downloadable deps"). */
|
||||
const constraintPostDeps = ref<CommunityDepEdge[] | null>(null);
|
||||
|
||||
function hasEdgeFor(uuid: string): boolean {
|
||||
if (!uuid) return false;
|
||||
return !!constraintPostDeps.value?.some((e) => e.module_id === uuid);
|
||||
}
|
||||
|
||||
const downloadableSides = computed(() => ({
|
||||
source: danglingSource.value && hasEdgeFor(danglingSourceUuid.value),
|
||||
target: danglingTarget.value && hasEdgeFor(danglingTargetUuid.value),
|
||||
}));
|
||||
|
||||
// Lazily fetch the post's dependency edges the first time a side dangles on
|
||||
// a community-installed constraint. Errors degrade to `[]`.
|
||||
watch(
|
||||
[hasDangling, constraintModalSlug],
|
||||
async ([dangling, slug]) => {
|
||||
if (!dangling || !slug) return;
|
||||
if (constraintPostDeps.value !== null) return;
|
||||
try {
|
||||
const detail = await fetchCommunityPostDetail(slug);
|
||||
constraintPostDeps.value = detail.dependencies;
|
||||
} catch {
|
||||
constraintPostDeps.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const downloadConfirmOpen = ref(false);
|
||||
const downloadConfirmBody = ref("");
|
||||
const pendingDownloadSide = ref<"source" | "target" | null>(null);
|
||||
const pendingDownloadUuid = ref<string | null>(null);
|
||||
const downloading = ref(false);
|
||||
const downloadError = ref<string>("");
|
||||
|
||||
async function onDownloadReattach({ side }: { side: "source" | "target" }): Promise<void> {
|
||||
const uuid = side === "source" ? danglingSourceUuid.value : danglingTargetUuid.value;
|
||||
if (!uuid || !constraintPostDeps.value) return;
|
||||
const provider = constraintPostDeps.value.find((e) => e.module_id === uuid);
|
||||
if (!provider) return;
|
||||
const { slugs, capped } = await collectTransitiveDeps(
|
||||
[provider.slug],
|
||||
async (s) => (await fetchCommunityPostDetail(s)).dependencies.map((e) => e.slug).filter(Boolean),
|
||||
);
|
||||
downloadConfirmBody.value =
|
||||
`Download ${slugs.length} post(s) from the community:\n` +
|
||||
slugs.map((s) => `• ${s}`).join("\n") +
|
||||
(capped ? "\n(+ more — list capped)" : "");
|
||||
pendingDownloadSide.value = side;
|
||||
pendingDownloadUuid.value = uuid;
|
||||
downloadConfirmOpen.value = true;
|
||||
}
|
||||
|
||||
async function confirmDownload(): Promise<void> {
|
||||
downloadConfirmOpen.value = false;
|
||||
downloading.value = true;
|
||||
downloadError.value = "";
|
||||
try {
|
||||
const res = await downloadDepsForDangling({
|
||||
danglingUuid: pendingDownloadUuid.value!,
|
||||
constraintDeps: constraintPostDeps.value!,
|
||||
fetchDetail: fetchCommunityPostDetail,
|
||||
download: downloadCommunityVersion,
|
||||
install: (env, deps, origin) =>
|
||||
installEnvelope({ envelope: env }, { importExport: api.importExport, dependencies: deps, origin }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
downloadError.value = res.error ?? "Could not download the missing dependency.";
|
||||
return;
|
||||
}
|
||||
// Refresh the preview-resolver cache for the (now-installed) uuid so the
|
||||
// dangling computed re-evaluates. The dep lands at its original id, so
|
||||
// this resolves the reference; the banner clears on the next tick.
|
||||
const uuid = pendingDownloadUuid.value!;
|
||||
ensure([uuid]);
|
||||
} finally {
|
||||
downloading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Target reach (SP3) ──────────────────────────────────────────
|
||||
//
|
||||
// Effective `target_select`: per-instance override wins, then the
|
||||
// library payload value, then the engine default `{mode:"all"}`. The
|
||||
// section reads this; writes flow back into `instance.target_select`.
|
||||
const targetSelect = computed<TargetSelect>(() => {
|
||||
const inst = props.module.instance?.target_select;
|
||||
if (inst && typeof inst === "object") return inst;
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
if (pl.target_select && typeof pl.target_select === "object") return pl.target_select;
|
||||
return { mode: "all" };
|
||||
});
|
||||
|
||||
const targetWildcardId = computed<string>(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
return pl.target_wildcard_id ?? "";
|
||||
});
|
||||
|
||||
/** The edited constraint's row id, as it appears in `chainModules`
|
||||
* (`${nodeId}#${_uid}`). The pick checklist walks DOWNSTREAM of this
|
||||
* row, so it must match the chain's `rowKey` — find the constraint chain
|
||||
* entry by `_uid` suffix (preferred) or `id`. Falls back to the bare
|
||||
* `_uid`/`id` when the chain is absent (headless/legacy mounts) — the
|
||||
* section then renders an empty checklist rather than crashing. */
|
||||
const constraintUid = computed<string>(() => {
|
||||
const uid = props.module._uid;
|
||||
const chain = props.chainModules ?? [];
|
||||
const hit = chain.find((m) => {
|
||||
if (m.type !== "constraint") return false;
|
||||
if (uid && m.rowKey.endsWith(`#${uid}`)) return true;
|
||||
return m.id === props.module.id;
|
||||
});
|
||||
return hit?.rowKey ?? uid ?? props.module.id;
|
||||
});
|
||||
|
||||
/** True when `next` is exactly what the constraint resolves to with NO
|
||||
* per-instance override — the EFFECTIVE library default
|
||||
* (`payload.target_select`, itself defaulting to `{mode:"all"}`). Only then
|
||||
* is collapsing to `null` ("inherit library") lossless. */
|
||||
function reachEqualsLibraryDefault(next: TargetSelect): boolean {
|
||||
const lib = ((props.module.payload ?? {}) as ConstraintPayload).target_select ?? { mode: "all" };
|
||||
if (next.mode !== lib.mode) return false;
|
||||
if (next.mode === "next") return Number(next.count ?? 1) === Number(lib.count ?? 1);
|
||||
// `pick` never matches the (pick-less) library default; first/all match on mode.
|
||||
return next.mode !== "pick";
|
||||
}
|
||||
|
||||
/** Persist a new `target_select` onto the instance. Mirrors the modal's
|
||||
* other instance writes (full-replacement values, shallow-merged).
|
||||
* Collapse to `null` ONLY when `next` equals the effective library default
|
||||
* (null = "inherit library") — keeps an untouched reach out of the instance
|
||||
* while PRESERVING a real override. The old `mode === "all"` collapse lost
|
||||
* an explicit `all` placed over a non-all library default: the engine's
|
||||
* `instance ?? payload ?? all` then fell back to the library value, so the
|
||||
* user's widen-to-all was silently ignored. */
|
||||
function onTargetSelect(next: TargetSelect): void {
|
||||
const inherits = reachEqualsLibraryDefault(next);
|
||||
emit("update", {
|
||||
instance: {
|
||||
...(props.module.instance ?? {}),
|
||||
target_select: inherits ? null : next,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Tell the ExceptionsSection whether the source / target wildcard
|
||||
* carries a null option. Drives the pi-ban chip render on library
|
||||
* exception rows whose value is the empty string (null option key). */
|
||||
@@ -262,10 +640,12 @@ const targetOptionsById = computed<ReadonlyMap<string, string>>(() => {
|
||||
* chip the value editor shows — instead of raw `@{c0f09840}` text. */
|
||||
const uuidToName = computed<ReadonlyMap<string, string>>(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const sib of props.siblingModules) {
|
||||
if (sib.type !== "wildcard") continue;
|
||||
const name = sib.meta?.name;
|
||||
if (typeof name === "string" && name.length > 0) m.set(sib.id, name);
|
||||
// modulesForLookup already merges chain (cross-node) + siblings and
|
||||
// filters to wildcards, so nested `@{uuid}` refs pointing at a
|
||||
// downstream-node wildcard resolve to a name too (not raw uuid).
|
||||
for (const mod of modulesForLookup.value) {
|
||||
const name = mod.name;
|
||||
if (typeof name === "string" && name.length > 0) m.set(mod.id, name);
|
||||
}
|
||||
return m;
|
||||
});
|
||||
@@ -300,6 +680,26 @@ function onSpaClick(): void {
|
||||
><i class="pi pi-times" aria-hidden="true" /></button>
|
||||
</header>
|
||||
|
||||
<ConstraintReattachSection
|
||||
v-if="hasDangling"
|
||||
:dangling-source="danglingSource"
|
||||
:dangling-target="danglingTarget"
|
||||
:source-uuid="danglingSourceUuid"
|
||||
:source-cached-name="sourceCachedName"
|
||||
:target-uuid="danglingTargetUuid"
|
||||
:target-cached-name="targetCachedName"
|
||||
:ref-data="reattachRefData"
|
||||
:referenced-elsewhere="referencedElsewhere"
|
||||
:dropped-cell-count="reattachDroppedCellCount"
|
||||
:downloadable-sides="downloadableSides"
|
||||
@reattach="onReattach"
|
||||
@pick="reattachPick = $event"
|
||||
@pickcleared="reattachPick = null"
|
||||
@downloadreattach="onDownloadReattach"
|
||||
/>
|
||||
<p v-if="downloadError" class="wp-cnm__dl-error" data-test="cnm-download-error">
|
||||
<i class="pi pi-exclamation-triangle" aria-hidden="true" /> {{ downloadError }}
|
||||
</p>
|
||||
<IdentitySection :module="module" @update="onUpdate" />
|
||||
<MatrixSection
|
||||
:module="module"
|
||||
@@ -307,6 +707,9 @@ function onSpaClick(): void {
|
||||
:target-subs="targetSubs"
|
||||
:source-name="sourceName"
|
||||
:target-name="targetName"
|
||||
:source-groups="sourceGroups"
|
||||
:target-groups="targetGroups"
|
||||
:stranded="hasDangling"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<ExceptionsSection
|
||||
@@ -318,9 +721,19 @@ function onSpaClick(): void {
|
||||
:source-options-by-id="sourceOptionsById"
|
||||
:target-options-by-id="targetOptionsById"
|
||||
:uuid-to-name="uuidToName"
|
||||
:stranded="hasDangling"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
|
||||
<div class="wp-cnm__dock">
|
||||
<TargetReachSection
|
||||
:model-value="targetSelect"
|
||||
:chain-modules="chainModules"
|
||||
:constraint-uid="constraintUid"
|
||||
:target-wildcard-id="targetWildcardId"
|
||||
:target-name="targetName"
|
||||
@update:model-value="onTargetSelect"
|
||||
/>
|
||||
<footer class="wp-cnm__foot">
|
||||
<a
|
||||
v-if="isLibraryTracked"
|
||||
@@ -357,6 +770,18 @@ function onSpaClick(): void {
|
||||
<button type="button" class="wp-cnm__btn" data-test="cnm-cancel" @click="emit('cancel')">Cancel</button>
|
||||
<button type="button" class="wp-cnm__btn wp-cnm__btn--primary" data-test="cnm-save" @click="emit('save')">Save</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Feature 2: confirm pulling the missing dependency (+ its closure)
|
||||
from the community before any network/install side effect. -->
|
||||
<ConfirmDialog
|
||||
:visible="downloadConfirmOpen"
|
||||
title="Download dependencies"
|
||||
:body="downloadConfirmBody"
|
||||
confirm-label="Download & install"
|
||||
@confirm="confirmDownload"
|
||||
@cancel="downloadConfirmOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -370,13 +795,19 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
}
|
||||
/* Head styling lives in src/components/context/editors/_modal-head.css
|
||||
* (imported once by ContextWidget). */
|
||||
.wp-cnm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-cnm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -421,6 +852,15 @@ function onSpaClick(): void {
|
||||
background: var(--wp-accent);
|
||||
color: white;
|
||||
}
|
||||
.wp-cnm__btn:hover {
|
||||
border-color: var(--wp-border-strong, var(--wp-border2));
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-cnm__btn--primary:hover {
|
||||
border-color: var(--wp-accent2, var(--wp-accent));
|
||||
background: var(--wp-accent2, var(--wp-accent));
|
||||
color: white;
|
||||
}
|
||||
.wp-cnm__btn--quiet {
|
||||
border-color: transparent;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
@@ -434,4 +874,17 @@ function onSpaClick(): void {
|
||||
color: var(--wp-text-muted, var(--wp-text2));
|
||||
}
|
||||
.wp-cnm__btn--quiet .pi { font-size: 10px; }
|
||||
/* Inline download-failure note under the reattach banner — the modal has no
|
||||
* toast surface, so a failed community pull reports here. */
|
||||
.wp-cnm__dl-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 8px 16px;
|
||||
font: 11px var(--wp-font-sans);
|
||||
color: var(--wp-danger, var(--wp-red));
|
||||
background: color-mix(in oklab, var(--wp-danger, #ef4444) 8%, transparent);
|
||||
}
|
||||
.wp-cnm__dl-error .pi { font-size: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ConstraintReattachSection from "./ConstraintReattachSection.vue";
|
||||
import type { WildcardRefData } from "../../../../../manager/utils/library-suggestions";
|
||||
|
||||
function refData(): WildcardRefData {
|
||||
return {
|
||||
uuidToName: new Map([["beef0001", "colour"]]),
|
||||
uuidToSubCategories: new Map([["beef0001", ["warm", "cold"]]]),
|
||||
uuidToHasNull: new Map([["beef0001", false]]),
|
||||
uuidToOptionsCount: new Map([["beef0001", 2]]),
|
||||
uuidToOptionTagSets: new Map([["beef0001", [["warm"], ["cold"]]]]),
|
||||
uuidToTagGroups: new Map([["beef0001", {}]]),
|
||||
};
|
||||
}
|
||||
|
||||
function mountSection(overrides = {}) {
|
||||
return mount(ConstraintReattachSection, {
|
||||
props: {
|
||||
danglingSource: true,
|
||||
danglingTarget: false,
|
||||
sourceUuid: "deadbeef",
|
||||
sourceCachedName: "old colour",
|
||||
targetUuid: "",
|
||||
targetCachedName: "",
|
||||
refData: refData(),
|
||||
referencedElsewhere: false,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("ConstraintReattachSection", () => {
|
||||
it("renders a remap banner row for the dangling source only", () => {
|
||||
const w = mountSection();
|
||||
expect(w.find("[data-test='reattach-row-source']").exists()).toBe(true);
|
||||
expect(w.find("[data-test='reattach-row-target']").exists()).toBe(false);
|
||||
expect(w.text()).toContain("deadbeef".slice(0, 8));
|
||||
});
|
||||
|
||||
it("Reattach opens the dropdown and confirm emits the side + old/new uuids", async () => {
|
||||
const w = mountSection();
|
||||
await w.find("[data-test='reattach-btn-source']").trigger("click");
|
||||
await w.find("[data-test-id='reattach-candidate-beef0001']").trigger("click");
|
||||
await w.find("[data-test='reattach-confirm-source']").trigger("click");
|
||||
const ev = w.emitted("reattach");
|
||||
expect(ev).toBeTruthy();
|
||||
expect(ev![0][0]).toEqual({
|
||||
side: "source",
|
||||
oldUuid: "deadbeef",
|
||||
newUuid: "beef0001",
|
||||
newName: "colour",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the library-mutation blast-radius warning when referenced elsewhere", async () => {
|
||||
const w = mountSection({ referencedElsewhere: true });
|
||||
await w.find("[data-test='reattach-btn-source']").trigger("click");
|
||||
const warn = w.find("[data-test='reattach-blast-radius']");
|
||||
expect(warn.exists()).toBe(true);
|
||||
expect(warn.text()).toMatch(/everywhere it.s used/i);
|
||||
});
|
||||
|
||||
it("hides the blast-radius warning when only the current context uses it", async () => {
|
||||
const w = mountSection({ referencedElsewhere: false });
|
||||
await w.find("[data-test='reattach-btn-source']").trigger("click");
|
||||
expect(w.find("[data-test='reattach-blast-radius']").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("shows a dropped-cells preview note for the picked candidate", async () => {
|
||||
const w = mountSection({ droppedCellCount: 3 });
|
||||
await w.find("[data-test='reattach-btn-source']").trigger("click");
|
||||
await w.find("[data-test-id='reattach-candidate-beef0001']").trigger("click");
|
||||
expect(w.find("[data-test='reattach-dropped-source']").text()).toMatch(/3 cells dropped/);
|
||||
});
|
||||
|
||||
it("floats the cached-name match to the top of the candidate list", async () => {
|
||||
const rd = refData();
|
||||
rd.uuidToName = new Map([["aaa00000", "zzz"], ["beef0001", "colour"]]);
|
||||
const w = mountSection({ sourceCachedName: "colour", refData: rd });
|
||||
await w.find("[data-test='reattach-btn-source']").trigger("click");
|
||||
const first = w.findAll("[data-test='reattach-candidate']")[0];
|
||||
expect(first.find("[data-test='reattach-cand-name']").text()).toBe("colour");
|
||||
});
|
||||
|
||||
it("shows each candidate's uuid + option/sub-category meta so duplicates are distinguishable", async () => {
|
||||
const rd = refData();
|
||||
// Two library wildcards share the name "colour" — only the uuid + content
|
||||
// tell them apart. Without these the picker is a coin-flip.
|
||||
rd.uuidToName = new Map([["beef0001", "colour"], ["beef0002", "colour"]]);
|
||||
rd.uuidToSubCategories = new Map([["beef0001", ["warm", "cold"]], ["beef0002", ["hue"]]]);
|
||||
rd.uuidToOptionsCount = new Map([["beef0001", 2], ["beef0002", 9]]);
|
||||
const w = mountSection({ refData: rd });
|
||||
await w.find("[data-test='reattach-btn-source']").trigger("click");
|
||||
const c1 = w.find("[data-test-id='reattach-candidate-beef0001']");
|
||||
const c2 = w.find("[data-test-id='reattach-candidate-beef0002']");
|
||||
// The 8-hex uuid is the definitive disambiguator.
|
||||
expect(c1.text()).toContain("beef0001");
|
||||
expect(c2.text()).toContain("beef0002");
|
||||
// Option count + sub-categories give a human-readable tiebreaker.
|
||||
expect(c1.text()).toContain("2 opts");
|
||||
expect(c1.text()).toContain("warm");
|
||||
expect(c2.text()).toContain("9 opts");
|
||||
expect(c2.text()).toContain("hue");
|
||||
});
|
||||
|
||||
it("renders the community-download button only on a downloadable side", () => {
|
||||
const w = mountSection({ downloadableSides: { source: true, target: false } });
|
||||
expect(w.find("[data-test='reattach-download-source']").exists()).toBe(true);
|
||||
// Target isn't dangling here, but even its (absent) row shouldn't carry
|
||||
// the download button when not flagged downloadable.
|
||||
expect(w.find("[data-test='reattach-download-target']").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the community-download button when the side isn't downloadable", () => {
|
||||
const w = mountSection({ downloadableSides: { source: false, target: false } });
|
||||
expect(w.find("[data-test='reattach-download-source']").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("omits the community-download button by default (no downloadableSides prop)", () => {
|
||||
const w = mountSection();
|
||||
expect(w.find("[data-test='reattach-download-source']").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("emits downloadreattach with the side when the download button is clicked", async () => {
|
||||
const w = mountSection({ downloadableSides: { source: true, target: false } });
|
||||
await w.find("[data-test='reattach-download-source']").trigger("click");
|
||||
const ev = w.emitted("downloadreattach");
|
||||
expect(ev).toBeTruthy();
|
||||
expect(ev![0][0]).toEqual({ side: "source" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ConstraintReattachSection — heals a constraint whose source/target
|
||||
* wildcard uuid is missing locally (#4). Renders ABOVE Identity in the
|
||||
* constraint surfaces (canvas modal + SPA editor). One red row per dangling
|
||||
* endpoint: the dead uuid (+ cached name), a Reattach button opening the
|
||||
* same searchable wildcard dropdown #3 uses, a dropped-cell preview count,
|
||||
* and the library-mutation blast-radius warning.
|
||||
*
|
||||
* SCOPE BOUNDARY (task_5200c1fc) — DO NOT CONFLATE WITH THE ENGINE FIX:
|
||||
* Reattach SIDESTEPS the engine pick-bucket collision in the common case
|
||||
* (re-pointing at a distinct local uuid yields a unique __wp_picks__ key).
|
||||
* It does NOT fix the collision: remapping two broken constraints onto the
|
||||
* SAME local wildcard re-manifests it (two instances of one library
|
||||
* wildcard share the pick bucket; the second pick clobbers the first). The
|
||||
* engine disambiguation (key picks on per-instance _uid + source-INSTANCE
|
||||
* binding) is tracked separately as task_5200c1fc. This is UI-layer
|
||||
* reference-healing only.
|
||||
*
|
||||
* RECONCILE IS DESTRUCTIVE: the dropped-cell count is previewed before
|
||||
* confirm; CONFIRM is the point of no return (the parent runs walkRemap +
|
||||
* rides Save-to-library); CANCEL is the only undo. No post-confirm undo of
|
||||
* dropped cells in v1.
|
||||
*/
|
||||
import { ref } from "vue";
|
||||
import type { WildcardRefData } from "../../../../../manager/utils/library-suggestions";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
danglingSource: boolean;
|
||||
danglingTarget: boolean;
|
||||
sourceUuid: string;
|
||||
sourceCachedName: string;
|
||||
targetUuid: string;
|
||||
targetCachedName: string;
|
||||
refData: WildcardRefData;
|
||||
/** True when the constraint is referenced beyond the current context
|
||||
* (reverse-dep / reach data). Drives the blast-radius warning. */
|
||||
referencedElsewhere: boolean;
|
||||
/** Optional preview: cells that would be dropped by the picked candidate
|
||||
* (computed by the parent against the candidate's sub_categories). */
|
||||
droppedCellCount?: number;
|
||||
/** Per-side gate for the "Download from community" affordance (Feature 2).
|
||||
* True only when the constraint was installed from a community post AND
|
||||
* that post declares a dependency edge providing the dangling uuid — i.e.
|
||||
* the missing wildcard is actually pullable. The parent owns that check;
|
||||
* the section just renders the button when the matching side is flagged. */
|
||||
downloadableSides?: { source: boolean; target: boolean };
|
||||
}>(), { droppedCellCount: 0, downloadableSides: () => ({ source: false, target: false }) });
|
||||
|
||||
const emit = defineEmits<{
|
||||
reattach: [payload: { side: "source" | "target"; oldUuid: string; newUuid: string; newName: string }];
|
||||
/** Live pre-confirm selection — lets the parent derive the dropped-cell
|
||||
* preview against the candidate's sub_categories. Fired on every pick. */
|
||||
pick: [payload: { side: "source" | "target"; uuid: string }];
|
||||
/** Selection abandoned (dropdown opened/closed without confirming, or a
|
||||
* confirm consumed it) — parent resets its dropped-cell preview to 0. */
|
||||
pickcleared: [];
|
||||
/** "Download from community" clicked for a side (Feature 2). The parent
|
||||
* resolves the dep closure, opens its own confirm dialog, then pulls +
|
||||
* installs; reattach falls out of local resolution afterwards. */
|
||||
downloadreattach: [payload: { side: "source" | "target" }];
|
||||
}>();
|
||||
|
||||
type Side = "source" | "target";
|
||||
const openSide = ref<Side | null>(null);
|
||||
const query = ref("");
|
||||
const picked = ref<{ uuid: string; name: string } | null>(null);
|
||||
|
||||
interface Candidate { uuid: string; name: string; count: number; subCats: string[] }
|
||||
|
||||
function candidates(): Candidate[] {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
// Cached name of the open side is the SORT KEY (auto-suggest), mirroring
|
||||
// #3 RemapRefPopup: exact match floats to top WITHOUT filtering others out.
|
||||
const cached = (openSide.value === "target" ? props.targetCachedName : props.sourceCachedName).trim().toLowerCase();
|
||||
const out: Candidate[] = [];
|
||||
const dead = new Set([props.sourceUuid, props.targetUuid]);
|
||||
for (const [uuid, name] of props.refData.uuidToName) {
|
||||
if (dead.has(uuid)) continue; // can't re-pick the dangling uuid
|
||||
if (q && !name.toLowerCase().includes(q)) continue;
|
||||
out.push({
|
||||
uuid,
|
||||
name,
|
||||
count: props.refData.uuidToOptionsCount.get(uuid) ?? 0,
|
||||
subCats: props.refData.uuidToSubCategories.get(uuid) ?? [],
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => {
|
||||
const ax = a.name.toLowerCase() === cached ? 0 : 1;
|
||||
const bx = b.name.toLowerCase() === cached ? 0 : 1;
|
||||
return ax !== bx ? ax - bx : a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
/** One-line disambiguator under the name: option count + declared
|
||||
* sub-categories. Same-named wildcards differ here (or, failing that, by the
|
||||
* always-shown uuid). */
|
||||
function candMeta(c: Candidate): string {
|
||||
const opts = `${c.count} opt${c.count === 1 ? "" : "s"}`;
|
||||
return c.subCats.length ? `${opts} · ${c.subCats.join(", ")}` : opts;
|
||||
}
|
||||
|
||||
function openDropdown(side: Side): void {
|
||||
openSide.value = side;
|
||||
// Empty query keeps every live candidate visible; the cached name is the
|
||||
// sort key in candidates() (auto-suggest), floating an exact match to top
|
||||
// without filtering the rest out (mirrors #3 RemapRefPopup).
|
||||
query.value = "";
|
||||
picked.value = null;
|
||||
// Opening (or re-opening the other side) abandons any prior selection —
|
||||
// tell the parent so a stale dropped-cell preview can't linger.
|
||||
emit("pickcleared");
|
||||
}
|
||||
|
||||
function pick(c: Candidate): void {
|
||||
picked.value = c;
|
||||
// Surface the live pre-confirm pick so the parent can derive the
|
||||
// dropped-cell preview before the irreversible confirm.
|
||||
if (openSide.value) emit("pick", { side: openSide.value, uuid: c.uuid });
|
||||
}
|
||||
|
||||
function confirm(side: Side): void {
|
||||
if (!picked.value) return;
|
||||
emit("reattach", {
|
||||
side,
|
||||
oldUuid: side === "source" ? props.sourceUuid : props.targetUuid,
|
||||
newUuid: picked.value.uuid,
|
||||
newName: picked.value.name,
|
||||
});
|
||||
openSide.value = null;
|
||||
picked.value = null;
|
||||
// Confirm consumes the pick; the parent resets its preview (the reattach
|
||||
// handler also clears it, this keeps the two emits self-consistent).
|
||||
emit("pickcleared");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rb" data-test="reattach-section">
|
||||
<div class="rb__label">Broken reference</div>
|
||||
|
||||
<div v-if="danglingSource" class="rb__row" data-test="reattach-row-source">
|
||||
<i class="pi pi-exclamation-triangle rb__warn-icon" aria-hidden="true" />
|
||||
<span class="rb__txt">
|
||||
Source wildcard <code>{{ sourceUuid.slice(0, 8) }}</code>
|
||||
<span v-if="sourceCachedName"> (was “{{ sourceCachedName }}”)</span>
|
||||
is not in your library.
|
||||
</span>
|
||||
<button type="button" class="rb__btn" data-test="reattach-btn-source" @click="openDropdown('source')">Reattach</button>
|
||||
<button
|
||||
v-if="downloadableSides.source"
|
||||
type="button"
|
||||
class="rb__btn rb__btn--download"
|
||||
data-test="reattach-download-source"
|
||||
@click="emit('downloadreattach', { side: 'source' })"
|
||||
><i class="pi pi-cloud-download" aria-hidden="true" /> Download from community</button>
|
||||
|
||||
<div v-if="openSide === 'source'" class="rb__picker">
|
||||
<input v-model="query" type="text" class="rb__search" placeholder="Search wildcards…" spellcheck="false" aria-label="Search wildcards to reattach source" />
|
||||
<ul class="rb__list">
|
||||
<li
|
||||
v-for="c in candidates()"
|
||||
:key="c.uuid"
|
||||
class="rb__cand"
|
||||
data-test="reattach-candidate"
|
||||
:data-test-id="`reattach-candidate-${c.uuid}`"
|
||||
:class="{ 'rb__cand--picked': c.uuid === picked?.uuid }"
|
||||
@click="pick(c)"
|
||||
>
|
||||
<span class="rb__cand-main">
|
||||
<span class="rb__cand-name" data-test="reattach-cand-name">{{ c.name }}</span>
|
||||
<code class="rb__cand-uuid">{{ c.uuid }}</code>
|
||||
</span>
|
||||
<span class="rb__cand-meta">{{ candMeta(c) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="picked && (droppedCellCount ?? 0) > 0" class="rb__dropped" data-test="reattach-dropped-source">
|
||||
{{ droppedCellCount }} cells dropped — not on “{{ picked.name }}”.
|
||||
</p>
|
||||
<p v-if="referencedElsewhere" class="rb__blast" data-test="reattach-blast-radius">
|
||||
<i class="pi pi-info-circle" aria-hidden="true" />
|
||||
This changes the constraint everywhere it's used.
|
||||
</p>
|
||||
<p class="rb__note">Cancel is the only undo — confirm is final.</p>
|
||||
<button type="button" class="rb__confirm" data-test="reattach-confirm-source" :disabled="!picked || undefined" @click="confirm('source')">Confirm reattach</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="danglingTarget" class="rb__row" data-test="reattach-row-target">
|
||||
<i class="pi pi-exclamation-triangle rb__warn-icon" aria-hidden="true" />
|
||||
<span class="rb__txt">
|
||||
Target wildcard <code>{{ targetUuid.slice(0, 8) }}</code>
|
||||
<span v-if="targetCachedName"> (was “{{ targetCachedName }}”)</span>
|
||||
is not in your library.
|
||||
</span>
|
||||
<button type="button" class="rb__btn" data-test="reattach-btn-target" @click="openDropdown('target')">Reattach</button>
|
||||
<button
|
||||
v-if="downloadableSides.target"
|
||||
type="button"
|
||||
class="rb__btn rb__btn--download"
|
||||
data-test="reattach-download-target"
|
||||
@click="emit('downloadreattach', { side: 'target' })"
|
||||
><i class="pi pi-cloud-download" aria-hidden="true" /> Download from community</button>
|
||||
|
||||
<div v-if="openSide === 'target'" class="rb__picker">
|
||||
<input v-model="query" type="text" class="rb__search" placeholder="Search wildcards…" spellcheck="false" aria-label="Search wildcards to reattach target" />
|
||||
<ul class="rb__list">
|
||||
<li
|
||||
v-for="c in candidates()"
|
||||
:key="c.uuid"
|
||||
class="rb__cand"
|
||||
data-test="reattach-candidate"
|
||||
:data-test-id="`reattach-candidate-${c.uuid}`"
|
||||
:class="{ 'rb__cand--picked': c.uuid === picked?.uuid }"
|
||||
@click="pick(c)"
|
||||
>
|
||||
<span class="rb__cand-main">
|
||||
<span class="rb__cand-name" data-test="reattach-cand-name">{{ c.name }}</span>
|
||||
<code class="rb__cand-uuid">{{ c.uuid }}</code>
|
||||
</span>
|
||||
<span class="rb__cand-meta">{{ candMeta(c) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="picked && (droppedCellCount ?? 0) > 0" class="rb__dropped" data-test="reattach-dropped-target">
|
||||
{{ droppedCellCount }} cells dropped — not on “{{ picked.name }}”.
|
||||
</p>
|
||||
<p v-if="referencedElsewhere" class="rb__blast" data-test="reattach-blast-radius">
|
||||
<i class="pi pi-info-circle" aria-hidden="true" />
|
||||
This changes the constraint everywhere it's used.
|
||||
</p>
|
||||
<p class="rb__note">Cancel is the only undo — confirm is final.</p>
|
||||
<button type="button" class="rb__confirm" data-test="reattach-confirm-target" :disabled="!picked || undefined" @click="confirm('target')">Confirm reattach</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rb {
|
||||
padding: 12px 16px;
|
||||
background: color-mix(in oklab, var(--wp-danger, #ef4444) 6%, var(--wp-bg2, #15151f));
|
||||
border-bottom: 1px solid var(--wp-border-soft, var(--wp-border));
|
||||
}
|
||||
.rb__label {
|
||||
font: 600 9px var(--wp-font-sans);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--wp-danger, #ef4444);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.rb__row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font: 11px var(--wp-font-sans); padding: 4px 0; }
|
||||
.rb__warn-icon { color: var(--wp-danger, #ef4444); font-size: 12px; }
|
||||
.rb__txt { flex: 1; color: var(--wp-text, #e7e7ee); }
|
||||
.rb__txt code { font-family: var(--wp-font-mono, monospace); }
|
||||
.rb__btn,
|
||||
.rb__confirm {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--wp-accent, #8b5cf6);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--wp-accent-text, var(--wp-text));
|
||||
font: 11px var(--wp-font-sans);
|
||||
cursor: pointer;
|
||||
}
|
||||
.rb__confirm { background: var(--wp-accent, #8b5cf6); color: #fff; margin-top: 6px; }
|
||||
/* Secondary affordance beside Reattach — pulls the missing dep from the
|
||||
* community. Quieter than the primary accent border so the manual dropdown
|
||||
* stays the obvious first action; the cloud icon signals the network pull. */
|
||||
.rb__btn--download {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-color: var(--wp-border, rgba(255,255,255,0.12));
|
||||
color: var(--wp-text-muted, var(--wp-text2, var(--wp-text)));
|
||||
}
|
||||
.rb__btn--download:hover {
|
||||
border-color: var(--wp-accent, #8b5cf6);
|
||||
color: var(--wp-accent-text, var(--wp-text));
|
||||
}
|
||||
.rb__btn--download .pi { font-size: 11px; }
|
||||
.rb__picker {
|
||||
flex-basis: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--wp-border, rgba(255,255,255,0.12));
|
||||
border-radius: 6px;
|
||||
background: var(--wp-bg, #11111b);
|
||||
}
|
||||
.rb__search {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 9px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--wp-border, rgba(255,255,255,0.12));
|
||||
background: var(--wp-bg-2, #15151f);
|
||||
color: var(--wp-text, #e7e7ee);
|
||||
font: 12px var(--wp-font-mono, monospace);
|
||||
outline: none;
|
||||
}
|
||||
.rb__list { list-style: none; margin: 6px 0; padding: 0; max-height: 150px; overflow-y: auto; }
|
||||
.rb__cand { display: flex; flex-direction: column; gap: 2px; padding: 5px 8px; border-radius: 4px; cursor: pointer; color: var(--wp-text, #e7e7ee); }
|
||||
.rb__cand:hover { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 18%, transparent); }
|
||||
.rb__cand--picked { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 28%, transparent); }
|
||||
.rb__cand-main { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
||||
.rb__cand-name { font: 11px var(--wp-font-sans); }
|
||||
.rb__cand-uuid { flex-shrink: 0; font: 10px var(--wp-font-mono, monospace); color: var(--wp-text-dim, #6e6e7c); }
|
||||
.rb__cand-meta { font: 10px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text-dim, #6e6e7c)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rb__dropped { margin: 6px 0 0; font: 11px var(--wp-font-sans); color: var(--wp-warn, #f59e0b); }
|
||||
.rb__blast { margin: 6px 0 0; display: flex; align-items: center; gap: 5px; font: 11px var(--wp-font-sans); color: var(--wp-warn, #f59e0b); }
|
||||
.rb__note { margin: 6px 0 0; font: 10px var(--wp-font-sans); color: var(--wp-text-dim, #6e6e7c); }
|
||||
</style>
|
||||
@@ -300,3 +300,114 @@ describe("constraint ExceptionsSection", () => {
|
||||
expect(src.find(".pi-ban").exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Colored mode chips (glyph + label), mirroring the SPA ──────────
|
||||
//
|
||||
// The SPA exception table renders each mode as a colored chip with a
|
||||
// glyph (↑ boost / ↓ reduce / × exclude / · neutral) + label. The canvas
|
||||
// mode chip keeps its click-to-cycle behaviour but gains the glyph so the
|
||||
// two surfaces speak one visual language.
|
||||
describe("constraint ExceptionsSection — mode chip glyph", () => {
|
||||
it("editable mode chip renders the mode glyph alongside the label", () => {
|
||||
const w = mount(ExceptionsSection, {
|
||||
props: { module: makeModule(), sourceValues: SOURCE_VALUES, targetValues: TARGET_VALUES },
|
||||
});
|
||||
// Row 0 is exclude → × glyph; row 1 is boost → ↑ glyph.
|
||||
const chip0 = w.find('[data-test="ex-mode-0"]');
|
||||
expect(chip0.find(".ex__mode-glyph").exists()).toBe(true);
|
||||
expect(chip0.find(".ex__mode-glyph").text()).toBe("×");
|
||||
const chip1 = w.find('[data-test="ex-mode-1"]');
|
||||
expect(chip1.find(".ex__mode-glyph").text()).toBe("↑");
|
||||
});
|
||||
|
||||
it("editable rows carry a per-row mode accent class keyed to the mode", () => {
|
||||
const w = mount(ExceptionsSection, {
|
||||
props: { module: makeModule(), sourceValues: SOURCE_VALUES, targetValues: TARGET_VALUES },
|
||||
});
|
||||
expect(w.find('[data-test="ex-row-0"]').classes()).toContain("ex__row--exclude");
|
||||
expect(w.find('[data-test="ex-row-1"]').classes()).toContain("ex__row--boost");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Stranded (read-only) exceptions, mirroring the SPA ─────────────
|
||||
//
|
||||
// When the parent modal reports a dangling source/target wildcard
|
||||
// (`stranded`), the exception list becomes a read-only snapshot: static
|
||||
// colored chips (no cycle button), values as text, and none of the
|
||||
// edit affordances (checkbox / factor input / trash / add-extra).
|
||||
function strandedModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return makeModule({
|
||||
instance: {
|
||||
extra_exceptions: [
|
||||
{ source_value: "silver", target_value: "ivory", mode: "boost", factor: 2.0 },
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("constraint ExceptionsSection — stranded read-only", () => {
|
||||
it("renders the mode as a STATIC chip (no cycle button) when stranded", () => {
|
||||
const w = mount(ExceptionsSection, {
|
||||
props: {
|
||||
module: strandedModule(),
|
||||
sourceValues: SOURCE_VALUES,
|
||||
targetValues: TARGET_VALUES,
|
||||
stranded: true,
|
||||
},
|
||||
});
|
||||
// The cycle button is gone; a read-only chip stands in its place.
|
||||
expect(w.find('[data-test="ex-mode-0"]').exists()).toBe(false);
|
||||
const ro = w.find('[data-test="ex-mode-ro-0"]');
|
||||
expect(ro.exists()).toBe(true);
|
||||
// It is NOT a button (no click-to-cycle); a span snapshot.
|
||||
expect(ro.element.tagName).not.toBe("BUTTON");
|
||||
// It still carries the glyph + label of the mode (row 0 = exclude).
|
||||
expect(ro.find(".ex__mode-glyph").text()).toBe("×");
|
||||
expect(ro.text().toLowerCase()).toContain("exclude");
|
||||
});
|
||||
|
||||
it("drops all edit affordances (checkbox / factor / trash / add-extra) when stranded", () => {
|
||||
const w = mount(ExceptionsSection, {
|
||||
props: {
|
||||
module: strandedModule(),
|
||||
sourceValues: SOURCE_VALUES,
|
||||
targetValues: TARGET_VALUES,
|
||||
stranded: true,
|
||||
},
|
||||
});
|
||||
expect(w.find('[data-test="ex-cb-0"]').exists()).toBe(false);
|
||||
expect(w.find('[data-test="ex-factor-1"]').exists()).toBe(false);
|
||||
expect(w.find('[data-test="ex-add-extra"]').exists()).toBe(false);
|
||||
expect(w.find('[data-test="ex-extra-trash-0"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("renders extra exceptions as read-only rows (no autocomplete inputs) when stranded", () => {
|
||||
const w = mount(ExceptionsSection, {
|
||||
props: {
|
||||
module: strandedModule(),
|
||||
sourceValues: SOURCE_VALUES,
|
||||
targetValues: TARGET_VALUES,
|
||||
stranded: true,
|
||||
},
|
||||
});
|
||||
// The extra row still shows, but as a static snapshot.
|
||||
expect(w.find('[data-test="ex-extra-0"]').exists()).toBe(true);
|
||||
expect(w.findAllComponents({ name: "VarAutocompleteInput" }).length).toBe(0);
|
||||
});
|
||||
|
||||
it("control: a healthy (non-stranded) constraint keeps the cycle button + edit controls", () => {
|
||||
const w = mount(ExceptionsSection, {
|
||||
props: {
|
||||
module: strandedModule(),
|
||||
sourceValues: SOURCE_VALUES,
|
||||
targetValues: TARGET_VALUES,
|
||||
// stranded omitted → defaults false
|
||||
},
|
||||
});
|
||||
expect(w.find('[data-test="ex-mode-0"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="ex-mode-ro-0"]').exists()).toBe(false);
|
||||
expect(w.find('[data-test="ex-cb-0"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="ex-add-extra"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,18 @@ import RichTextPreview from "../../../../../manager/components/RichTextPreview.v
|
||||
|
||||
type Mode = "allow" | "exclude" | "boost" | "reduce";
|
||||
|
||||
/** Mode → glyph + label for the colored mode chips. Glyphs mirror the
|
||||
* matrix's MODE_ICON / cellGlyph convention (boost ↑ / reduce ↓ /
|
||||
* exclude × / allow ·) and the SPA ConstraintEditor `MODE_META`, so the
|
||||
* whole constraint surface speaks one visual language. Colour stays in
|
||||
* the `ex__mode-chip--{mode}` CSS classes (already token-mapped). */
|
||||
const MODE_META: Record<Mode, { glyph: string; label: string }> = {
|
||||
boost: { glyph: "↑", label: "Boost" },
|
||||
reduce: { glyph: "↓", label: "Reduce" },
|
||||
exclude: { glyph: "×", label: "Exclude" },
|
||||
allow: { glyph: "·", label: "Neutral" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Engine accepts BOTH legacy (`source` / `target`) and tier 2
|
||||
* (`source_value` / `target_value`) library exception shapes — see
|
||||
@@ -103,12 +115,19 @@ const props = withDefaults(defineProps<{
|
||||
* raw `@{c0f09840}` text. Built by the parent modal from the
|
||||
* Context's wildcard catalog. */
|
||||
uuidToName?: ReadonlyMap<string, string>;
|
||||
/** Read-only recovery view: the source/target wildcard was deleted, so
|
||||
* the exception list is a snapshot for understanding only — static
|
||||
* colored chips, values as text, no edit controls. Reattach a live
|
||||
* wildcard (banner above) to edit. Mirrors the SPA ConstraintEditor's
|
||||
* stranded read-only exceptions table. */
|
||||
stranded?: boolean;
|
||||
}>(), {
|
||||
sourceHasNull: false,
|
||||
targetHasNull: false,
|
||||
sourceOptionsById: () => new Map(),
|
||||
targetOptionsById: () => new Map(),
|
||||
uuidToName: () => new Map(),
|
||||
stranded: false,
|
||||
});
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
@@ -285,18 +304,24 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
<section class="ex" data-test="ex-section">
|
||||
<div class="ex__label">Exceptions</div>
|
||||
|
||||
<div class="ex__list">
|
||||
<div
|
||||
v-for="(exc, i) in libraryExceptions"
|
||||
:key="libKey(exc)"
|
||||
class="ex__row"
|
||||
:class="{ 'ex__row--off': disabledKeys.has(libKey(exc)) }"
|
||||
:class="[
|
||||
`ex__row--${effectiveMode(exc)}`,
|
||||
{ 'ex__row--off': disabledKeys.has(libKey(exc)) },
|
||||
]"
|
||||
:data-test="`ex-row-${i}`"
|
||||
>
|
||||
<!-- Styled checkbox matching OptionRow's wildcard pattern — a
|
||||
role-checkbox span with inline SVG tick. Replaces the bare
|
||||
native input for visual parity with the wildcard / fixed-
|
||||
values edit modals. -->
|
||||
values edit modals. Hidden when stranded: a broken constraint
|
||||
is a read-only snapshot, so the enable/disable toggle is gone. -->
|
||||
<span
|
||||
v-if="!stranded"
|
||||
class="ex__check"
|
||||
:class="{ 'ex__check--on': !disabledKeys.has(libKey(exc)) }"
|
||||
:data-test="`ex-cb-${i}`"
|
||||
@@ -356,15 +381,36 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<!-- Editable: click-to-cycle chip with glyph + label. Read-only
|
||||
(stranded): the same colored chip as a static span — no cycle. -->
|
||||
<button
|
||||
v-if="!stranded"
|
||||
type="button"
|
||||
class="ex__mode-chip"
|
||||
:class="`ex__mode-chip--${effectiveMode(exc)}`"
|
||||
:data-test="`ex-mode-${i}`"
|
||||
@click="onLibModeCycle(exc)"
|
||||
>{{ effectiveMode(exc) }}</button>
|
||||
>
|
||||
<span class="ex__mode-glyph" aria-hidden="true">{{ MODE_META[effectiveMode(exc)].glyph }}</span>
|
||||
{{ MODE_META[effectiveMode(exc)].label }}
|
||||
</button>
|
||||
<span
|
||||
v-if="effectiveMode(exc) === 'boost' || effectiveMode(exc) === 'reduce'"
|
||||
v-else
|
||||
class="ex__mode-chip ex__mode-chip--ro"
|
||||
:class="`ex__mode-chip--${effectiveMode(exc)}`"
|
||||
:data-test="`ex-mode-ro-${i}`"
|
||||
>
|
||||
<span class="ex__mode-glyph" aria-hidden="true">{{ MODE_META[effectiveMode(exc)].glyph }}</span>
|
||||
{{ MODE_META[effectiveMode(exc)].label }}
|
||||
</span>
|
||||
<!-- Factor: editable spinner when healthy; static ×N text when stranded. -->
|
||||
<span
|
||||
v-if="stranded && (effectiveMode(exc) === 'boost' || effectiveMode(exc) === 'reduce')"
|
||||
class="ex__factor-ro"
|
||||
:data-test="`ex-factor-ro-${i}`"
|
||||
>×{{ effectiveFactor(exc) }}</span>
|
||||
<span
|
||||
v-else-if="effectiveMode(exc) === 'boost' || effectiveMode(exc) === 'reduce'"
|
||||
class="ex__factor-wrap"
|
||||
@wheel.stop
|
||||
>
|
||||
@@ -403,8 +449,37 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-for="(exc, i) in extras" :key="`extra-${i}`" class="ex__row ex__row--extra" :data-test="`ex-extra-${i}`">
|
||||
<div
|
||||
v-for="(exc, i) in extras"
|
||||
:key="`extra-${i}`"
|
||||
class="ex__row ex__row--extra"
|
||||
:class="`ex__row--${exc.mode}`"
|
||||
:data-test="`ex-extra-${i}`"
|
||||
>
|
||||
<span class="ex__extra-badge" data-test="ex-extra-badge">extra</span>
|
||||
<!-- Editable inputs when healthy; read-only chips when stranded.
|
||||
A stranded constraint is a snapshot, so extras lock alongside
|
||||
the library rows (matches the SPA read-only exceptions table). -->
|
||||
<template v-if="stranded">
|
||||
<span class="ex__pair">
|
||||
<span class="ex__src"><RichTextPreview :value="excSrc(exc, props.sourceOptionsById)" :uuid-to-name="uuidToName" surface="wildcard" /></span>
|
||||
<span class="ex__arrow">→</span>
|
||||
<span class="ex__tgt"><RichTextPreview :value="excTgt(exc, props.targetOptionsById)" :uuid-to-name="uuidToName" surface="wildcard" /></span>
|
||||
</span>
|
||||
<span
|
||||
class="ex__mode-chip ex__mode-chip--ro"
|
||||
:class="`ex__mode-chip--${exc.mode}`"
|
||||
:data-test="`ex-extra-mode-ro-${i}`"
|
||||
>
|
||||
<span class="ex__mode-glyph" aria-hidden="true">{{ MODE_META[exc.mode].glyph }}</span>
|
||||
{{ MODE_META[exc.mode].label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="exc.mode === 'boost' || exc.mode === 'reduce'"
|
||||
class="ex__factor-ro"
|
||||
>×{{ exc.factor }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<VarAutocompleteInput
|
||||
:model-value="excSrc(exc, props.sourceOptionsById)"
|
||||
:suggestions="[...sourceValues]"
|
||||
@@ -427,7 +502,10 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
class="ex__mode-chip"
|
||||
:class="`ex__mode-chip--${exc.mode}`"
|
||||
@click="onExtraModeCycle(i)"
|
||||
>{{ exc.mode }}</button>
|
||||
>
|
||||
<span class="ex__mode-glyph" aria-hidden="true">{{ MODE_META[exc.mode].glyph }}</span>
|
||||
{{ MODE_META[exc.mode].label }}
|
||||
</button>
|
||||
<span
|
||||
v-if="exc.mode === 'boost' || exc.mode === 'reduce'"
|
||||
class="ex__factor-wrap"
|
||||
@@ -472,14 +550,23 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
aria-label="Remove extra exception"
|
||||
@click="onExtraTrash(i)"
|
||||
><i class="pi pi-trash" /></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!stranded"
|
||||
type="button"
|
||||
class="ex__add-extra"
|
||||
data-test="ex-add-extra"
|
||||
@click="onAddExtra"
|
||||
><i class="pi pi-plus" /> Add extra exception</button>
|
||||
|
||||
<!-- Stranded snapshot has no editable list; a hint points to the
|
||||
reattach banner above (mirrors the SPA read-only exceptions). -->
|
||||
<p v-else class="ex__readonly-hint" data-test="ex-readonly-hint">
|
||||
Reattach a live wildcard to edit these exceptions.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -496,11 +583,18 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ex__list {
|
||||
/* Long exception lists scroll in place; the label + add button stay put. */
|
||||
max-height: 34vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ex__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
/* right padding keeps the trash off the section edge + the list
|
||||
* scrollbar when the exceptions list scrolls. */
|
||||
padding: 4px 8px 4px 0;
|
||||
font: 11px var(--wp-font-mono);
|
||||
border-bottom: 1px dashed var(--wp-border-soft, var(--wp-border));
|
||||
}
|
||||
@@ -510,6 +604,28 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
border-left: 2px solid var(--wp-status-modified, #fb923c);
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
/* ── Per-row mode accent — left bar + faint wash keyed to the row's
|
||||
* effective mode, mirroring the SPA ConstraintEditor `.cn-ex-row`.
|
||||
* Library rows only: the `--extra` orange accent owns its border-left,
|
||||
* so its row keeps the "instance-only" cue. The `allow`/neutral case
|
||||
* intentionally has no accent (no rule worth flagging). */
|
||||
.ex__row:not(.ex__row--extra) {
|
||||
border-left: 3px solid transparent;
|
||||
padding-left: 6px;
|
||||
}
|
||||
.ex__row--boost:not(.ex__row--extra) {
|
||||
border-left-color: color-mix(in srgb, var(--wp-success, #22c55e) 60%, transparent);
|
||||
background: color-mix(in srgb, var(--wp-success, #22c55e) 5%, transparent);
|
||||
}
|
||||
.ex__row--reduce:not(.ex__row--extra) {
|
||||
border-left-color: color-mix(in srgb, var(--wp-warn, #f97316) 60%, transparent);
|
||||
background: color-mix(in srgb, var(--wp-warn, #f97316) 5%, transparent);
|
||||
}
|
||||
.ex__row--exclude:not(.ex__row--extra) {
|
||||
border-left-color: color-mix(in srgb, var(--wp-danger, #ef4444) 60%, transparent);
|
||||
background: color-mix(in srgb, var(--wp-danger, #ef4444) 5%, transparent);
|
||||
}
|
||||
.ex__pair {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
@@ -592,6 +708,9 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ex__mode-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--wp-border);
|
||||
color: var(--wp-text);
|
||||
@@ -600,6 +719,22 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Glyph mirrors the matrix cell icons (↑ ↓ × ·) — mono so it lines up. */
|
||||
.ex__mode-glyph {
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
/* Read-only mode chip (stranded) — a static span, so kill the pointer
|
||||
* affordance; colour stays via the shared ex__mode-chip--{mode} class. */
|
||||
.ex__mode-chip--ro { cursor: default; }
|
||||
/* Static factor text (stranded) — the ×N the editable spinner would
|
||||
* show, rendered inert. */
|
||||
.ex__factor-ro {
|
||||
font: 10px var(--wp-font-mono);
|
||||
color: var(--wp-text-muted, var(--wp-text2));
|
||||
}
|
||||
/* Mode chip palette mirrors the matrix legend: boost = green,
|
||||
* reduce = orange/warn, exclude = red/danger. Allow uses the accent
|
||||
@@ -675,17 +810,17 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: color-mix(in srgb, var(--wp-text) 3%, transparent);
|
||||
border: 1px solid var(--wp-border);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.ex__trash:hover {
|
||||
background: color-mix(in srgb, var(--wp-danger, #e05252) 18%, transparent);
|
||||
@@ -714,4 +849,9 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
color: var(--wp-accent-text, var(--wp-text));
|
||||
}
|
||||
.ex__add-extra .pi { font-size: 10px; }
|
||||
.ex__readonly-hint {
|
||||
margin: 8px 0 0;
|
||||
font: 10px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -93,6 +93,64 @@ describe("constraint MatrixSection — layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("constraint MatrixSection — grouped axes", () => {
|
||||
it("renders a column band per target axis + a row header chip per source axis", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule(),
|
||||
sourceSubs: SOURCE_SUBS,
|
||||
targetSubs: TARGET_SUBS,
|
||||
sourceGroups: { warmth: ["red", "blue"] },
|
||||
targetGroups: { fabric: ["cotton", "silk"] },
|
||||
},
|
||||
});
|
||||
expect(w.findAll(".mx-th-band").map((b) => b.text()).filter((t) => t.length)).toEqual(["fabric"]);
|
||||
expect(w.findAll(".mx-grp-head").map((h) => h.text())).toEqual(["warmth"]);
|
||||
// Cells stay keyed by tag name, so the grouped layout is reorder-safe.
|
||||
expect(w.find('[data-test="mx-cell-red-silk"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="mx-cell-blue-cotton"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("a solo source axis folds its name into the tag eyebrow (no header chip)", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule(),
|
||||
sourceSubs: ["red", "blue", "green"],
|
||||
targetSubs: TARGET_SUBS,
|
||||
sourceGroups: { warmth: ["red", "blue"], shade: ["green"] },
|
||||
},
|
||||
});
|
||||
expect(w.findAll(".mx-grp-head").map((h) => h.text())).toEqual(["warmth"]); // shade is solo
|
||||
const solo = w.find(".mx-th-row.solo");
|
||||
expect(solo.exists()).toBe(true);
|
||||
expect(solo.text()).toContain("shade");
|
||||
expect(solo.text()).toContain("green");
|
||||
});
|
||||
|
||||
it("labels the leftover ungrouped run 'uncategorized' instead of leaving it blank", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule(),
|
||||
sourceSubs: ["red", "blue", "extra"],
|
||||
targetSubs: ["cotton", "silk", "wool"],
|
||||
sourceGroups: { warmth: ["red", "blue"] },
|
||||
targetGroups: { fabric: ["cotton", "silk"] },
|
||||
},
|
||||
});
|
||||
expect(w.findAll(".mx-grp-head").map((h) => h.text())).toEqual(["warmth", "uncategorized"]);
|
||||
expect(w.findAll(".mx-th-band").map((b) => b.text()).filter((t) => t.length))
|
||||
.toEqual(["fabric", "uncategorized"]);
|
||||
});
|
||||
|
||||
it("stays flat (no bands / chips) when no groups are passed", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: { module: makeModule(), sourceSubs: ["red"], targetSubs: ["cotton"] },
|
||||
});
|
||||
expect(w.findAll(".mx-th-band").length).toBe(0);
|
||||
expect(w.findAll(".mx-grp-head").length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("constraint MatrixSection — override marker", () => {
|
||||
it("renders override marker when mode differs from library", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
@@ -355,6 +413,66 @@ describe("constraint MatrixSection — legacy migration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Stranded (read-only) matrix, mirroring the SPA ConstraintMatrix ─
|
||||
//
|
||||
// When the parent modal reports a dangling source/target wildcard
|
||||
// (`stranded`), the grid becomes a read-only snapshot of the configured
|
||||
// rules: cells are inert (no popover opens), the section root carries a
|
||||
// `mx--readonly` modifier (dashed frame + muted cells via CSS), and a
|
||||
// read-only lock affordance replaces the click-to-edit interaction.
|
||||
describe("constraint MatrixSection — stranded read-only", () => {
|
||||
it("marks the section read-only and shows a lock affordance when stranded", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule(),
|
||||
sourceSubs: SOURCE_SUBS,
|
||||
targetSubs: TARGET_SUBS,
|
||||
stranded: true,
|
||||
},
|
||||
});
|
||||
expect(w.find('[data-test="mx-section"]').classes()).toContain("mx--readonly");
|
||||
expect(w.find('[data-test="mx-readonly-lock"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("cells are inert — clicking does NOT open the rule popover when stranded", async () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule(),
|
||||
sourceSubs: SOURCE_SUBS,
|
||||
targetSubs: TARGET_SUBS,
|
||||
stranded: true,
|
||||
},
|
||||
});
|
||||
await w.find('[data-test="mx-cell-red-cotton"]').trigger("click");
|
||||
expect(w.findComponent({ name: "CellRulePopover" }).exists()).toBe(false);
|
||||
expect(w.find('[data-test="mx-cell-red-cotton"]').classes()).not.toContain("open");
|
||||
});
|
||||
|
||||
it("cells keep their s-{state} hue class so the snapshot stays legible", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule(),
|
||||
sourceSubs: SOURCE_SUBS,
|
||||
targetSubs: TARGET_SUBS,
|
||||
stranded: true,
|
||||
},
|
||||
});
|
||||
// Same mode hues as the editable grid (muting is a CSS concern).
|
||||
expect(w.find('[data-test="mx-cell-red-silk"]').classes()).toContain("s-boost");
|
||||
expect(w.find('[data-test="mx-cell-blue-silk"]').classes()).toContain("s-reduce");
|
||||
});
|
||||
|
||||
it("control: a healthy (non-stranded) matrix stays interactive", async () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: { module: makeModule(), sourceSubs: SOURCE_SUBS, targetSubs: TARGET_SUBS },
|
||||
});
|
||||
expect(w.find('[data-test="mx-section"]').classes()).not.toContain("mx--readonly");
|
||||
expect(w.find('[data-test="mx-readonly-lock"]').exists()).toBe(false);
|
||||
await w.find('[data-test="mx-cell-red-cotton"]').trigger("click");
|
||||
expect(w.findComponent({ name: "CellRulePopover" }).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("constraint MatrixSection — keyboard", () => {
|
||||
it("Escape closes the popover", async () => {
|
||||
const w = mount(MatrixSection, {
|
||||
|
||||
@@ -15,12 +15,19 @@
|
||||
* three (`allow` / `disabled` / missing) as runtime passthrough,
|
||||
* so the collapse is engine-equivalent.
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
import { encodeKey } from "../../instance/keys";
|
||||
import { patchInstance } from "../../instance/patch";
|
||||
import CellRulePopover from "../CellRulePopover.vue";
|
||||
import MatrixLegend from "../MatrixLegend.vue";
|
||||
import {
|
||||
groupHue as tagHue,
|
||||
groupLabel,
|
||||
isBucket,
|
||||
orderByGroups,
|
||||
toGroups,
|
||||
} from "../../../../shared/matrix-axis";
|
||||
|
||||
type Mode = "neutral" | "exclude" | "boost" | "reduce";
|
||||
|
||||
@@ -33,8 +40,20 @@ const props = withDefaults(
|
||||
targetSubs: readonly string[];
|
||||
sourceName?: string;
|
||||
targetName?: string;
|
||||
/** Sub-category axes (tag_groups) of the source/target wildcards. When
|
||||
* present, the matrix orders + colours its rows / cols by axis (bands on
|
||||
* the columns, header chips on the rows) — same treatment as the SPA
|
||||
* ConstraintMatrix. Empty → flat source/target-tinted headers. */
|
||||
sourceGroups?: Record<string, string[]>;
|
||||
targetGroups?: Record<string, string[]>;
|
||||
/** Read-only recovery view: the source/target wildcard was deleted, so
|
||||
* the configured rules are shown for understanding only. Cells don't
|
||||
* open the rule popover; the grid frame + cells mute to a snapshot.
|
||||
* Reattach a live wildcard (banner above) to edit. Mirrors the SPA
|
||||
* ConstraintMatrix `readonly` prop / `wp-mx--readonly` treatment. */
|
||||
stranded?: boolean;
|
||||
}>(),
|
||||
{ sourceName: "", targetName: "" },
|
||||
{ sourceName: "", targetName: "", sourceGroups: () => ({}), targetGroups: () => ({}), stranded: false },
|
||||
);
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
@@ -55,6 +74,26 @@ const cellFactorOverrides = computed<Record<string, number>>(
|
||||
() => (instance.value.cell_factor_overrides as Record<string, number> | null) ?? {},
|
||||
);
|
||||
|
||||
// ── Axis grouping — order + group + colour rows/cols by sub-category axis,
|
||||
// shared with the SPA ConstraintMatrix via the matrix-axis module. ──
|
||||
const orderedSources = computed(() => orderByGroups([...props.sourceSubs], props.sourceGroups));
|
||||
const orderedTargets = computed(() => orderByGroups([...props.targetSubs], props.targetGroups));
|
||||
const rowGroups = computed(() => toGroups(orderedSources.value));
|
||||
const colGroups = computed(() => toGroups(orderedTargets.value));
|
||||
const hasColBands = computed(() => colGroups.value.some((g) => !isBucket(g)));
|
||||
const hasRowGroups = computed(() => rowGroups.value.some((g) => !isBucket(g)));
|
||||
|
||||
// Measure the column band row's height so the tag row beneath it sticks at
|
||||
// the right `top` offset, independent of font sizing.
|
||||
const gridEl = ref<HTMLElement | null>(null);
|
||||
function measureBands(): void {
|
||||
const grid = gridEl.value;
|
||||
if (!grid) return;
|
||||
const band = grid.querySelector<HTMLElement>(".mx-th-band");
|
||||
grid.style.setProperty("--mx-band-h", `${band?.offsetHeight ?? 0}px`);
|
||||
}
|
||||
watch(colGroups, () => void nextTick(measureBands));
|
||||
|
||||
/** Reads `payload.matrix[src][tgt]`. Returns null when no library rule. */
|
||||
function libCell(src: string, tgt: string): Cell | null {
|
||||
const cell = matrix.value[src]?.[tgt];
|
||||
@@ -128,7 +167,10 @@ function cellFactorText(src: string, tgt: string): string {
|
||||
function cellAriaLabel(src: string, tgt: string): string {
|
||||
const s = effectiveState(src, tgt);
|
||||
const factor = (s === "boost" || s === "reduce") ? ` ×${effectiveFactor(src, tgt).toFixed(1)}` : "";
|
||||
return `Rule: ${src} → ${tgt}, current state ${s}${factor}. Click to edit.`;
|
||||
// Stranded → the grid is a read-only snapshot, so drop the "Click to
|
||||
// edit" affordance from the label (the cell is inert).
|
||||
const suffix = props.stranded ? " (read-only)" : ". Click to edit.";
|
||||
return `Rule: ${src} → ${tgt}, current state ${s}${factor}${suffix}`;
|
||||
}
|
||||
|
||||
// ── Popover ────────────────────────────────────────────────────
|
||||
@@ -170,6 +212,8 @@ function cssEscape(s: string): string {
|
||||
}
|
||||
|
||||
function onCellClick(src: string, tgt: string): void {
|
||||
// Read-only recovery view (deleted source/target): cells are inert.
|
||||
if (props.stranded) return;
|
||||
const open = openPopover.value;
|
||||
if (open && open.src === src && open.tgt === tgt) {
|
||||
openPopover.value = null;
|
||||
@@ -292,18 +336,29 @@ onMounted(() => {
|
||||
document.addEventListener("keydown", onDocKeydown);
|
||||
window.addEventListener("scroll", reanchorPopover, true);
|
||||
window.addEventListener("resize", reanchorPopover);
|
||||
window.addEventListener("resize", measureBands);
|
||||
void nextTick(measureBands);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("mousedown", onDocPointerDown, true);
|
||||
document.removeEventListener("keydown", onDocKeydown);
|
||||
window.removeEventListener("scroll", reanchorPopover, true);
|
||||
window.removeEventListener("resize", reanchorPopover);
|
||||
window.removeEventListener("resize", measureBands);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx" data-test="mx-section">
|
||||
<div class="mx__label">Rule matrix</div>
|
||||
<section class="mx" :class="{ 'mx--readonly': stranded }" data-test="mx-section">
|
||||
<div class="mx__label-row">
|
||||
<div class="mx__label">Rule matrix</div>
|
||||
<!-- Stranded ref → the grid is a read-only snapshot, so a lock pill
|
||||
replaces the click-to-edit interaction. Reattaching a live
|
||||
wildcard (banner above) is the only way back to editing. -->
|
||||
<span v-if="stranded" class="mx__lock-pill" data-test="mx-readonly-lock">
|
||||
<i class="pi pi-lock" aria-hidden="true" /> Read-only
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mx-axes">
|
||||
<span class="mx-axis mx-axis--src" data-test="mx-axis-src">
|
||||
@@ -316,40 +371,109 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mx-grid">
|
||||
<div ref="gridEl" class="mx-grid">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="mx-corner" data-test="mx-corner">
|
||||
<th class="mx-corner" :rowspan="hasColBands ? 2 : 1" data-test="mx-corner">
|
||||
<span class="row-1">↓ source</span>
|
||||
<span class="row-2">→ target</span>
|
||||
</th>
|
||||
<th v-for="t in targetSubs" :key="t" class="mx-th-col">{{ t }}</th>
|
||||
<!-- Grouped cols: a band row names each axis, spanning its tags. -->
|
||||
<template v-if="hasColBands">
|
||||
<th
|
||||
v-for="(grp, gi) in colGroups"
|
||||
:key="`band-${gi}`"
|
||||
class="mx-th-band"
|
||||
:class="{ 'mx-th-band--bucket': isBucket(grp) }"
|
||||
:colspan="grp.tags.length"
|
||||
:style="{ '--ax': tagHue(grp, 'target', true) }"
|
||||
:title="groupLabel(grp)"
|
||||
><span class="chip">{{ groupLabel(grp) }}</span></th>
|
||||
</template>
|
||||
<!-- Flat cols: tag headers sit directly in the single header row. -->
|
||||
<template v-else>
|
||||
<th
|
||||
v-for="t in targetSubs"
|
||||
:key="t"
|
||||
class="mx-th-col"
|
||||
:style="{ '--ax': 'var(--wp-constraint-target)' }"
|
||||
><span class="chip">{{ t }}</span></th>
|
||||
</template>
|
||||
</tr>
|
||||
<tr v-if="hasColBands">
|
||||
<template v-for="(grp, gi) in colGroups" :key="`coltags-${gi}`">
|
||||
<th
|
||||
v-for="(t, ti) in grp.tags"
|
||||
:key="t"
|
||||
class="mx-th-col"
|
||||
:class="{ 'group-start': !isBucket(grp) && ti === 0 }"
|
||||
:style="{ '--ax': tagHue(grp, 'target', hasColBands) }"
|
||||
><span class="chip">{{ t }}</span></th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in sourceSubs" :key="s">
|
||||
<th class="mx-th-row">{{ s }}</th>
|
||||
<td v-for="t in targetSubs" :key="`${s}-${t}`" class="mx-td">
|
||||
<div
|
||||
:class="cellClasses(s, t)"
|
||||
:data-test="`mx-cell-${s}-${t}`"
|
||||
:aria-label="cellAriaLabel(s, t)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onCellClick(s, t)"
|
||||
@keydown.enter.prevent="onCellClick(s, t)"
|
||||
@keydown.space.prevent="onCellClick(s, t)"
|
||||
<template v-for="(grp, gi) in rowGroups" :key="`rowgrp-${gi}`">
|
||||
<!-- Header chip: a named multi-tag axis, OR the labelled
|
||||
uncategorized bucket (when the matrix has any named axis). A
|
||||
named solo axis folds into an eyebrow instead (below). -->
|
||||
<tr
|
||||
v-if="(!isBucket(grp) && grp.tags.length > 1) || (isBucket(grp) && hasRowGroups)"
|
||||
class="mx-grp-row"
|
||||
>
|
||||
<th
|
||||
class="mx-grp-head"
|
||||
:class="{ 'mx-grp-head--bucket': isBucket(grp) }"
|
||||
:colspan="orderedTargets.length + 1"
|
||||
:style="{ '--ax': tagHue(grp, 'source', true) }"
|
||||
:title="groupLabel(grp)"
|
||||
><span class="chip">{{ groupLabel(grp) }}</span></th>
|
||||
</tr>
|
||||
<tr v-for="s in grp.tags" :key="s">
|
||||
<th
|
||||
class="mx-th-row"
|
||||
:class="{
|
||||
'group-start': !isBucket(grp) && s === grp.tags[0],
|
||||
solo: !isBucket(grp) && grp.tags.length === 1,
|
||||
}"
|
||||
:style="{ '--ax': tagHue(grp, 'source', hasRowGroups) }"
|
||||
>
|
||||
<span class="glyph">{{ cellGlyph(s, t) }}</span>
|
||||
<span v-if="cellShowsFactor(s, t)" class="factor">{{ cellFactorText(s, t) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<span v-if="!isBucket(grp) && grp.tags.length === 1" class="chip">
|
||||
<span class="eye">{{ grp.axisName }}</span>
|
||||
<span class="v">{{ s }}</span>
|
||||
</span>
|
||||
<span v-else class="chip">{{ s }}</span>
|
||||
</th>
|
||||
<!-- role/tabindex stay static even when stranded (mirrors the
|
||||
SPA ConstraintMatrix): the cell is still a labelled
|
||||
snapshot a screen-reader can land on; `onCellClick` early-
|
||||
returns so it's inert. -->
|
||||
<td v-for="t in orderedTargets" :key="`${s}-${t.tag}`" class="mx-td">
|
||||
<div
|
||||
:class="cellClasses(s, t.tag)"
|
||||
:data-test="`mx-cell-${s}-${t.tag}`"
|
||||
:aria-label="cellAriaLabel(s, t.tag)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onCellClick(s, t.tag)"
|
||||
@keydown.enter.prevent="onCellClick(s, t.tag)"
|
||||
@keydown.space.prevent="onCellClick(s, t.tag)"
|
||||
>
|
||||
<span class="glyph">{{ cellGlyph(s, t.tag) }}</span>
|
||||
<span v-if="cellShowsFactor(s, t.tag)" class="factor">{{ cellFactorText(s, t.tag) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p v-if="stranded" class="mx__readonly-hint" data-test="mx-readonly-hint">
|
||||
Reattach a live wildcard to edit these rules.
|
||||
</p>
|
||||
|
||||
<MatrixLegend />
|
||||
|
||||
<!-- Popover lives in a body-level portal so its hover state stays
|
||||
@@ -390,13 +514,34 @@ onBeforeUnmount(() => {
|
||||
background: var(--wp-bg2);
|
||||
border-bottom: 1px solid var(--wp-border-soft, var(--wp-border));
|
||||
}
|
||||
.mx__label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.mx__label {
|
||||
font: 600 9px var(--wp-font-sans);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
/* Read-only lock pill — mirrors the SPA ConstraintEditor `.cn-lock-pill`
|
||||
* (Rule matrix actions when stranded). */
|
||||
.mx__lock-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--wp-radius, 6px);
|
||||
font: 600 9px var(--wp-font-sans);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
background: color-mix(in srgb, var(--wp-text) 4%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--wp-text) 10%, transparent);
|
||||
}
|
||||
.mx__lock-pill .pi { font-size: 9px; }
|
||||
|
||||
/* ── Axis tags ─────────────────────────────────────────────── */
|
||||
.mx-axes {
|
||||
@@ -427,48 +572,125 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.mx-axis .arrow { font: 14px var(--wp-font-mono); line-height: 1; }
|
||||
|
||||
/* ── Grid frame ────────────────────────────────────────────── */
|
||||
/* ── Grid frame — scrolls on BOTH axes; headers + corner freeze ── */
|
||||
.mx-grid {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
max-height: 52vh;
|
||||
overflow: auto;
|
||||
background: var(--wp-bg-deep, #0e1015);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
/* No padding on the SCROLL container — see ConstraintMatrix: sticky pins
|
||||
* headers at the padding edge while overflow still renders scrolling cells
|
||||
* inside the padding, so padding leaves a scrolled-cell bleed strip on the
|
||||
* top/left. Content sits flush; the border + radius give the frame. */
|
||||
padding: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.mx-grid table { border-collapse: separate; border-spacing: 4px; }
|
||||
/* `separate` + zero spacing (NOT `collapse`) — sticky header cells with
|
||||
* border-collapse have a Chromium paint bug where scrolled cells bleed through
|
||||
* the frozen row/column. The 4px inter-cell gap still comes from th/td padding,
|
||||
* so the layout is unchanged. */
|
||||
.mx-grid table { border-collapse: separate; border-spacing: 0; }
|
||||
/* 2px cell padding = the 4px inter-cell gap. Sticky headers fill with the
|
||||
* OPAQUE grid bg so scrolled cells can't bleed through them. */
|
||||
.mx-grid th,
|
||||
.mx-grid td { padding: 0; vertical-align: middle; }
|
||||
.mx-grid td { padding: 2px; vertical-align: middle; }
|
||||
.mx-grid thead th,
|
||||
.mx-grid tbody th {
|
||||
position: sticky;
|
||||
background: var(--wp-bg-deep, #0e1015);
|
||||
}
|
||||
.mx-grid thead th { top: 0; z-index: 2; }
|
||||
.mx-grid tbody th { left: 0; z-index: 1; }
|
||||
/* Band row pins to the top; the tag row sticks just beneath it (offset = the
|
||||
* measured band height). Corner outranks both so it stays in the angle. */
|
||||
.mx-grid thead th.mx-th-band { top: 0; z-index: 3; }
|
||||
.mx-grid thead th.mx-th-col { top: var(--mx-band-h, 0px); z-index: 2; }
|
||||
.mx-grid thead th.mx-corner { top: 0; left: 0; z-index: 5; padding: 5px 8px; }
|
||||
|
||||
.mx-corner {
|
||||
width: 96px;
|
||||
height: 32px;
|
||||
font: 9px var(--wp-font-mono);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
padding: 0 8px !important;
|
||||
background: color-mix(in srgb, var(--wp-text) 4%, transparent);
|
||||
}
|
||||
.mx-corner .row-1 { display: block; color: var(--wp-constraint-source-text); line-height: 1.3; }
|
||||
.mx-corner .row-2 { display: block; color: var(--wp-constraint-target-text); line-height: 1.3; }
|
||||
.mx-th-col {
|
||||
width: 64px;
|
||||
height: 28px;
|
||||
font: 700 10px var(--wp-font-sans);
|
||||
color: var(--wp-constraint-target-text);
|
||||
background: color-mix(in oklab, var(--wp-constraint-target) 10%, transparent);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
|
||||
/* Column band — axis name spanning its tags, atop the tag row. */
|
||||
.mx-th-band .chip {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 22px; padding: 0 8px;
|
||||
font: 700 9px var(--wp-font-sans);
|
||||
letter-spacing: 0.06em; text-transform: uppercase;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
border-radius: 4px 4px 0 0;
|
||||
color: color-mix(in srgb, var(--ax) 82%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 15%, var(--wp-bg-deep, #0e1015));
|
||||
border-bottom: 2px solid color-mix(in srgb, var(--ax) 55%, transparent);
|
||||
}
|
||||
.mx-th-row {
|
||||
width: 96px;
|
||||
height: 36px;
|
||||
.mx-th-band--bucket .chip { border-bottom-style: dashed; }
|
||||
|
||||
/* Column tag chip — per-tag header below the band (or the only header row
|
||||
* when columns are ungrouped). */
|
||||
.mx-th-col .chip {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 64px; min-height: 28px; padding: 3px 2px;
|
||||
font: 700 10px var(--wp-font-sans);
|
||||
color: var(--wp-constraint-source-text);
|
||||
background: color-mix(in oklab, var(--wp-constraint-source) 10%, transparent);
|
||||
border-radius: 4px;
|
||||
text-align: right;
|
||||
padding: 0 12px !important;
|
||||
border-radius: 0 0 4px 4px;
|
||||
color: color-mix(in srgb, var(--ax) 72%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 9%, var(--wp-bg-deep, #0e1015));
|
||||
}
|
||||
.mx-th-col.group-start .chip { border-left: 2px solid color-mix(in srgb, var(--ax) 45%, transparent); }
|
||||
|
||||
/* Row group header chip — names the axis above its tags, pinned to the left
|
||||
* edge so it stays visible during horizontal scroll. The <th> spans the row
|
||||
* but stays transparent; only the chip is visible + sticky. */
|
||||
.mx-grp-row .mx-grp-head {
|
||||
position: relative; z-index: 1; background: transparent;
|
||||
padding: 4px 0 1px; text-align: left;
|
||||
}
|
||||
.mx-grp-head .chip {
|
||||
position: sticky; left: 0;
|
||||
display: inline-flex; align-items: center;
|
||||
max-width: 220px; height: 22px; padding: 0 11px;
|
||||
font: 700 9px var(--wp-font-sans);
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: color-mix(in srgb, var(--ax) 80%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 16%, var(--wp-bg-deep, #0e1015));
|
||||
border-left: 3px solid var(--ax);
|
||||
box-shadow: 0 1px 4px color-mix(in srgb, var(--wp-bg, #000) 55%, transparent);
|
||||
}
|
||||
.mx-grp-head--bucket .chip { border-left-style: dashed; }
|
||||
|
||||
/* Row tag chip — one per source sub-category, left accent in the axis hue. */
|
||||
.mx-th-row .chip {
|
||||
display: flex; align-items: center; justify-content: flex-start;
|
||||
width: 96px; height: 36px; padding: 0 10px;
|
||||
font: 700 10px var(--wp-font-sans);
|
||||
text-align: left;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: color-mix(in srgb, var(--ax) 76%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 10%, var(--wp-bg-deep, #0e1015));
|
||||
border-left: 2px solid color-mix(in srgb, var(--ax) 48%, transparent);
|
||||
}
|
||||
.mx-th-row.solo .chip {
|
||||
flex-direction: column; align-items: flex-start; justify-content: center; gap: 2px;
|
||||
border-left-width: 3px; border-left-color: var(--ax);
|
||||
}
|
||||
.mx-th-row.solo .eye {
|
||||
max-width: 78px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font: 700 7px var(--wp-font-sans);
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--ax) 72%, var(--wp-text-dim));
|
||||
}
|
||||
.mx-th-row.solo .v { font: 600 11px var(--wp-font-sans); color: var(--wp-text); }
|
||||
.mx-td { position: relative; }
|
||||
|
||||
/* ── Body cells ────────────────────────────────────────────── */
|
||||
@@ -523,6 +745,42 @@ onBeforeUnmount(() => {
|
||||
.glyph { font-size: 14px; line-height: 1; }
|
||||
.factor { font-size: 11px; font-weight: 700; }
|
||||
|
||||
/* ── Read-only recovery view (deleted source/target) ───────────
|
||||
* Mirrors the SPA ConstraintMatrix `wp-mx--readonly`. Cells are
|
||||
* inert: no pointer, no hover lift. The grid frame turns DASHED and
|
||||
* the colored cells drop to ~1/3 of their editable intensity (8% bg /
|
||||
* 18% border, text softened toward the dim grey) so the snapshot reads
|
||||
* as "look, don't touch" while the boost/reduce/exclude hues stay
|
||||
* legible. */
|
||||
.mx--readonly .mx-grid {
|
||||
border: 1px dashed color-mix(in srgb, var(--wp-text) 16%, transparent);
|
||||
}
|
||||
.mx--readonly .mx-cell { cursor: default; }
|
||||
.mx--readonly .mx-cell:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.mx--readonly .mx-cell.s-boost {
|
||||
background: color-mix(in srgb, var(--wp-success, #22c55e) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--wp-success, #22c55e) 18%, transparent);
|
||||
color: color-mix(in srgb, var(--wp-success, #22c55e) 70%, var(--wp-text-dim));
|
||||
}
|
||||
.mx--readonly .mx-cell.s-reduce {
|
||||
background: color-mix(in srgb, var(--wp-warn, #f97316) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--wp-warn, #f97316) 18%, transparent);
|
||||
color: color-mix(in srgb, var(--wp-warn, #f97316) 70%, var(--wp-text-dim));
|
||||
}
|
||||
.mx--readonly .mx-cell.s-exclude {
|
||||
background: color-mix(in srgb, var(--wp-danger, #ef4444) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--wp-danger, #ef4444) 18%, transparent);
|
||||
color: color-mix(in srgb, var(--wp-danger, #ef4444) 70%, var(--wp-text-dim));
|
||||
}
|
||||
.mx__readonly-hint {
|
||||
margin: 8px 0 0;
|
||||
font: 10px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
|
||||
/* Popover is teleported to body so positioning is handled by inline
|
||||
* styles on the anchor wrapper. Nothing extra needed here. */
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// Constraint TargetReachSection — reach selector for the edited
|
||||
// constraint's `target_select`. Segmented control first | next N | all |
|
||||
// pick; `next` reveals a min-1 stepper, `pick` reveals a checklist of
|
||||
// reachable downstream target occurrences (direct + nested-via-carrier)
|
||||
// built from `chainModules`. Ticking a row toggles its `picks` entry.
|
||||
//
|
||||
// Pick identity MUST match what the engine + computePairingsFull match
|
||||
// on: a direct occurrence is its target instance row's `rowKey`; a nested
|
||||
// occurrence is `(carrier rowKey, option_id)`.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import TargetReachSection from "./TargetReachSection.vue";
|
||||
import type { ChainModule, TargetSelect } from "../../../../../extension/constraint-pairs";
|
||||
|
||||
const TARGET_UUID = "ffffffff";
|
||||
|
||||
/** Minimal chain: a constraint, a direct downstream target instance, and
|
||||
* a downstream carrier whose option transitively refs the same target.
|
||||
* Ordered execution-order — the constraint sits first so both the
|
||||
* direct + carrier rows are DOWNSTREAM of it. */
|
||||
function makeChain(): ChainModule[] {
|
||||
return [
|
||||
{
|
||||
id: "cn0",
|
||||
rowKey: "1#cn0",
|
||||
type: "constraint",
|
||||
payload: { source_wildcard_id: "src0", target_wildcard_id: TARGET_UUID },
|
||||
displayName: "color-mood",
|
||||
nodeLabel: "A",
|
||||
},
|
||||
{
|
||||
id: TARGET_UUID,
|
||||
rowKey: "1#mood-a",
|
||||
type: "wildcard",
|
||||
payload: { var_binding: "mood", options: [{ id: "m0", value: "calm" }] },
|
||||
displayName: "mood",
|
||||
nodeLabel: "A",
|
||||
},
|
||||
{
|
||||
id: "carrier0",
|
||||
rowKey: "2#backdrop-b",
|
||||
type: "wildcard",
|
||||
payload: { options: [{ id: "opt_a", value: `@{${TARGET_UUID}}` }] },
|
||||
displayName: "backdrop",
|
||||
nodeLabel: "B",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const CONSTRAINT_UID = "1#cn0";
|
||||
|
||||
/** Last emitted value (project TS lib predates Array.prototype.at). */
|
||||
function last(xs: TargetSelect[]): TargetSelect | undefined {
|
||||
return xs[xs.length - 1];
|
||||
}
|
||||
|
||||
function mountSection(
|
||||
modelValue: TargetSelect,
|
||||
opts: Partial<{ chainModules: ChainModule[]; targetName: string }> = {},
|
||||
) {
|
||||
const updates: TargetSelect[] = [];
|
||||
const w = mount(TargetReachSection, {
|
||||
props: {
|
||||
modelValue,
|
||||
constraintUid: CONSTRAINT_UID,
|
||||
targetWildcardId: TARGET_UUID,
|
||||
targetName: opts.targetName ?? "test-target",
|
||||
chainModules: opts.chainModules ?? makeChain(),
|
||||
// Reflect emitted values back into the prop — the modal owns
|
||||
// `target_select` state, so the section re-renders from the new
|
||||
// model on every change (segmented mode reveals stepper/checklist).
|
||||
"onUpdate:modelValue": (v: TargetSelect) => {
|
||||
updates.push(v);
|
||||
void w.setProps({ modelValue: v });
|
||||
},
|
||||
},
|
||||
});
|
||||
return { w, updates };
|
||||
}
|
||||
|
||||
describe("TargetReachSection — segmented control", () => {
|
||||
it("emits {mode:'next', count:2} after clicking 'next N' then setting the stepper to 2", async () => {
|
||||
const { w, updates } = mountSection({ mode: "all" });
|
||||
await w.find('[data-test="reach-mode-next"]').trigger("click");
|
||||
// Entering next defaults count:1.
|
||||
expect(last(updates)).toEqual({ mode: "next", count: 1 });
|
||||
await w.vm.$nextTick();
|
||||
const stepper = w.find<HTMLInputElement>('[data-test="reach-count"]');
|
||||
expect(stepper.exists()).toBe(true);
|
||||
await stepper.setValue("2");
|
||||
expect(last(updates)).toEqual({ mode: "next", count: 2 });
|
||||
});
|
||||
|
||||
it("stepper ▲ button increments the count", async () => {
|
||||
const { w, updates } = mountSection({ mode: "next", count: 2 });
|
||||
await w.find('[data-test="reach-count-up"]').trigger("click");
|
||||
expect(last(updates)).toEqual({ mode: "next", count: 3 });
|
||||
});
|
||||
|
||||
it("stepper ▼ button decrements the count", async () => {
|
||||
const { w, updates } = mountSection({ mode: "next", count: 3 });
|
||||
await w.find('[data-test="reach-count-down"]').trigger("click");
|
||||
expect(last(updates)).toEqual({ mode: "next", count: 2 });
|
||||
});
|
||||
|
||||
it("stepper ▼ button is disabled at the minimum (count 1)", () => {
|
||||
const { w } = mountSection({ mode: "next", count: 1 });
|
||||
const down = w.find('[data-test="reach-count-down"]');
|
||||
expect(down.exists()).toBe(true);
|
||||
expect(down.attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits {mode:'all'} when 'all' clicked", async () => {
|
||||
const { w, updates } = mountSection({ mode: "next", count: 3 });
|
||||
await w.find('[data-test="reach-mode-all"]').trigger("click");
|
||||
expect(last(updates)).toEqual({ mode: "all" });
|
||||
});
|
||||
|
||||
it("default all → no checklist + no stepper rendered (minimal)", () => {
|
||||
const { w } = mountSection({ mode: "all" });
|
||||
expect(w.find('[data-test="reach-count"]').exists()).toBe(false);
|
||||
expect(w.find('[data-test="reach-picklist"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TargetReachSection — pick checklist", () => {
|
||||
it("pick mode lists reachable instances (direct + nested), nested tagged + showing node label; ticking emits the right picks entry", async () => {
|
||||
const { w, updates } = mountSection({ mode: "pick", picks: [] });
|
||||
const list = w.find('[data-test="reach-picklist"]');
|
||||
expect(list.exists()).toBe(true);
|
||||
|
||||
// Direct row for the mood instance + nested row for the backdrop carrier.
|
||||
const directRow = w.find('[data-test="reach-pick-1#mood-a"]');
|
||||
const nestedRow = w.find('[data-test="reach-pick-2#backdrop-b::opt_a"]');
|
||||
expect(directRow.exists()).toBe(true);
|
||||
expect(nestedRow.exists()).toBe(true);
|
||||
|
||||
// Nested row carries the `nested` tag + the carrier's node label (B).
|
||||
expect(nestedRow.text().toLowerCase()).toContain("nested");
|
||||
expect(nestedRow.text()).toContain("B");
|
||||
// Direct row tagged `direct`.
|
||||
expect(directRow.text().toLowerCase()).toContain("direct");
|
||||
|
||||
// Tick the direct row → picks gains the direct entry keyed by the
|
||||
// BARE _uid (engine identity), NOT the rowKey. The row's data-test
|
||||
// still uses the full rowKey (`1#mood-a`); the persisted uid strips
|
||||
// the `1#` node prefix → `mood-a`.
|
||||
await directRow.find(".wp-check").trigger("click");
|
||||
expect(last(updates)).toEqual({
|
||||
mode: "pick",
|
||||
picks: [{ kind: "direct", uid: "mood-a" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("nested row labels the @TARGET ref (display name) hosted in the carrier (display name, no @)", () => {
|
||||
const { w } = mountSection({ mode: "pick", picks: [] }, { targetName: "test-target" });
|
||||
const nestedRow = w.find('[data-test="reach-pick-2#backdrop-b::opt_a"]');
|
||||
expect(nestedRow.exists()).toBe(true);
|
||||
const txt = nestedRow.text();
|
||||
// The nested ref is `@{target}` — the `@` belongs to the TARGET, shown by
|
||||
// display name: "@test-target".
|
||||
expect(txt).toContain("@test-target");
|
||||
// Host carrier named by DISPLAY NAME, NOT @-prefixed (you don't @ the host).
|
||||
expect(txt).toContain("in backdrop");
|
||||
expect(txt).not.toContain("@backdrop");
|
||||
expect(txt).not.toContain("@subject");
|
||||
});
|
||||
|
||||
it("ticking a nested row emits a nested picks entry keyed by (bare carrier _uid, option_id)", async () => {
|
||||
const { w, updates } = mountSection({ mode: "pick", picks: [] });
|
||||
await w.find('[data-test="reach-pick-2#backdrop-b::opt_a"] .wp-check').trigger("click");
|
||||
// carrier_uid is the BARE _uid (`backdrop-b`), stripped of the `2#`
|
||||
// node prefix — the engine's nested carrier_ctx stamps bare uids.
|
||||
expect(last(updates)).toEqual({
|
||||
mode: "pick",
|
||||
picks: [{ kind: "nested", carrier_uid: "backdrop-b", option_id: "opt_a" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("un-ticking a picked row removes its entry (bare-uid pick matches the row)", async () => {
|
||||
const { w, updates } = mountSection({
|
||||
mode: "pick",
|
||||
picks: [{ kind: "direct", uid: "mood-a" }],
|
||||
});
|
||||
await w.find('[data-test="reach-pick-1#mood-a"] .wp-check').trigger("click");
|
||||
expect(last(updates)).toEqual({ mode: "pick", picks: [] });
|
||||
});
|
||||
|
||||
it("empty chainModules → empty checklist + a hint, no crash", () => {
|
||||
const { w } = mountSection({ mode: "pick", picks: [] }, { chainModules: [] });
|
||||
expect(w.find('[data-test="reach-pick-empty"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="reach-pick-1#mood-a"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("pick mode lists a derivation-nested occurrence keyed by branch key", () => {
|
||||
const deriv: ChainModule = {
|
||||
id: "d1aaaaaa", rowKey: "2#deriv-d", type: "derivation", displayName: "my deriv", nodeLabel: "B",
|
||||
payload: { rules: [{ id: "r1", branches: [{ action: { value: `@{${TARGET_UUID}}` } }] }] },
|
||||
};
|
||||
// Derivation is downstream of the constraint row — append after makeChain().
|
||||
const { w } = mountSection({ mode: "pick", picks: [] }, { chainModules: [...makeChain(), deriv], targetName: "test-target" });
|
||||
const row = w.find('[data-test="reach-pick-2#deriv-d::r1:0"]');
|
||||
expect(row.exists()).toBe(true);
|
||||
expect(row.text()).toContain("@test-target");
|
||||
expect(row.text()).toContain("in my deriv");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,535 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Constraint TargetReachSection — the reach selector for the edited
|
||||
* constraint's `target_select`. Mirrors the engine reach model
|
||||
* (`engine/modules/_constraints.py::apply_constraints_for_target`):
|
||||
* each constraint covers SOME subset of the downstream instances of its
|
||||
* `target_wildcard_id`. Modes:
|
||||
* - `all` — every downstream target occurrence (default; minimal UI).
|
||||
* - `first` — only the first encounter.
|
||||
* - `next N` — the first N encounters (stepper, min 1).
|
||||
* - `pick` — an explicit checklist of reachable occurrences.
|
||||
*
|
||||
* A pick names an occurrence the SAME way the engine + the static
|
||||
* `computePairingsFull` layer do:
|
||||
* - direct: a downstream `wildcard` row whose `id === targetWildcardId`,
|
||||
* named by its row `rowKey` (the per-instance uid the engine matches).
|
||||
* - nested: a downstream carrier whose option value transitively refs
|
||||
* `@{targetWildcardId}`, named by `(carrier rowKey, option_id)`.
|
||||
*
|
||||
* Library-defining edits stay in the SPA; this is a per-instance override
|
||||
* surfaced on the modal. Writes flow up via `update:modelValue` — the
|
||||
* modal threads the new `TargetSelect` into `module.instance.target_select`.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
carrierOptionIdsFor,
|
||||
type ChainModule,
|
||||
type TargetSelect,
|
||||
} from "../../../../../extension/constraint-pairs";
|
||||
import WpCheck from "../../../../shared/WpCheck.vue";
|
||||
|
||||
type PickEntry = NonNullable<TargetSelect["picks"]>[number];
|
||||
|
||||
/** Strip the badge-layer `${nodeId}#` prefix off a rowKey, yielding the
|
||||
* bare per-instance `_uid`. The persisted `pick` identity MUST be the
|
||||
* bare `_uid` because the ENGINE matches `firing_uid =
|
||||
* ctx["__wp_current_module_uid__"]` (the bare `_uid`, no node prefix —
|
||||
* see engine/pipeline.py + _constraints.py::_occurrence_matches). The
|
||||
* `nodeId#` prefix is a graph-wide-uniqueness convenience for the badge
|
||||
* map only; `_uid` is globally unique on its own. Keeping the full
|
||||
* rowKey for display keys, persisting the bare uid for engine match. */
|
||||
const bareUid = (rk: string): string => {
|
||||
const i = rk.indexOf("#");
|
||||
return i >= 0 ? rk.slice(i + 1) : rk;
|
||||
};
|
||||
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Current `target_select` (effective: instance override or library,
|
||||
* defaulting to `{mode:"all"}`). */
|
||||
modelValue?: TargetSelect;
|
||||
/** Flattened cross-node execution chain — the pick checklist is the
|
||||
* reachable downstream target occurrences within it. Optional;
|
||||
* empty/undefined renders an empty checklist (no crash). */
|
||||
chainModules?: ChainModule[];
|
||||
/** The edited constraint's row id (rowKey/`_uid`) — used to find its
|
||||
* position in the chain so the walk only collects DOWNSTREAM rows. */
|
||||
constraintUid: string;
|
||||
/** The constraint's `target_wildcard_id` — the uuid direct rows match
|
||||
* on + nested carriers transitively ref. */
|
||||
targetWildcardId: string;
|
||||
/** Display name of the target wildcard (the thing being constrained).
|
||||
* Used to label nested-pick rows as "<target> via @<carrier>" so the
|
||||
* user sees WHAT is reached, not the carrier name twice. */
|
||||
targetName?: string;
|
||||
}>(),
|
||||
{ modelValue: () => ({ mode: "all" }), chainModules: () => [], targetName: "" },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ "update:modelValue": [next: TargetSelect] }>();
|
||||
|
||||
const mode = computed<TargetSelect["mode"]>(() => props.modelValue?.mode ?? "all");
|
||||
const count = computed<number>(() => {
|
||||
const c = Number(props.modelValue?.count);
|
||||
return Number.isFinite(c) && c >= 1 ? Math.floor(c) : 1;
|
||||
});
|
||||
const picks = computed<PickEntry[]>(() => {
|
||||
const p = props.modelValue?.picks;
|
||||
return Array.isArray(p) ? p : [];
|
||||
});
|
||||
|
||||
/** Target wildcard's display name for nested-pick labels; "target" when the
|
||||
* modal didn't supply one (headless / legacy mounts). */
|
||||
const targetLabel = computed<string>(() => props.targetName?.trim() || "target");
|
||||
|
||||
const MODES: Array<{ key: TargetSelect["mode"]; label: string }> = [
|
||||
{ key: "first", label: "first" },
|
||||
{ key: "next", label: "next N" },
|
||||
{ key: "all", label: "all" },
|
||||
{ key: "pick", label: "pick" },
|
||||
];
|
||||
|
||||
/** Switch reach mode. Entering `next` defaults `count:1`; entering `pick`
|
||||
* defaults `picks:[]`. `first`/`all` carry no extra fields. Preserves the
|
||||
* current count/picks when re-entering the same mode is a no-op (we only
|
||||
* emit a fresh selector). */
|
||||
function setMode(next: TargetSelect["mode"]): void {
|
||||
if (next === mode.value) return;
|
||||
switch (next) {
|
||||
case "next":
|
||||
emit("update:modelValue", { mode: "next", count: count.value || 1 });
|
||||
break;
|
||||
case "pick":
|
||||
emit("update:modelValue", { mode: "pick", picks: picks.value });
|
||||
break;
|
||||
default:
|
||||
emit("update:modelValue", { mode: next });
|
||||
}
|
||||
}
|
||||
|
||||
function onCountInput(ev: Event): void {
|
||||
const raw = Number((ev.target as HTMLInputElement).value);
|
||||
const n = Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 1;
|
||||
emit("update:modelValue", { mode: "next", count: n });
|
||||
}
|
||||
|
||||
/** Custom stepper ▲/▼. The native number-input spinners are suppressed
|
||||
* (they only show on hover and render inconsistently inside ComfyUI's
|
||||
* embedded DOM), so explicit buttons give the always-visible arrows the
|
||||
* user asked for. Clamped at the engine minimum of 1 (a `next 0` reach
|
||||
* covers nothing). */
|
||||
function bumpCount(delta: number): void {
|
||||
const next = Math.max(1, count.value + delta);
|
||||
emit("update:modelValue", { mode: "next", count: next });
|
||||
}
|
||||
|
||||
// ── Pick checklist ──────────────────────────────────────────────
|
||||
//
|
||||
// Build a uuid → option-value-strings lookup from the chain (first-seen
|
||||
// wins, carriers only), EXACTLY as computePairingsFull does, so the
|
||||
// transitive carrier scan matches what the badge layer + engine resolve.
|
||||
const CARRIER_TYPES = new Set(["wildcard", "derivation"]);
|
||||
const lookup = computed<(uuid: string) => string[]>(() => {
|
||||
const byUuid = new Map<string, string[]>();
|
||||
for (const m of props.chainModules) {
|
||||
if (!CARRIER_TYPES.has(m.type) || byUuid.has(m.id)) continue;
|
||||
const values: string[] = [];
|
||||
if (m.type === "derivation") {
|
||||
// Derivation action values can host @{uuid} refs (engine stamps
|
||||
// branchKey as the option_id). Mirrors refBearingEntries in
|
||||
// constraint-pairs.ts — but here we only need the VALUE strings
|
||||
// for the transitive reachPath lookup, not the ids.
|
||||
const p = m.payload as {
|
||||
rules?: Array<{
|
||||
branches?: Array<{ action?: { value?: string } }>;
|
||||
else?: { action?: { value?: string } } | null;
|
||||
}>;
|
||||
};
|
||||
for (const r of Array.isArray(p.rules) ? p.rules : []) {
|
||||
for (const b of Array.isArray(r?.branches) ? r.branches : []) {
|
||||
const v = b?.action?.value;
|
||||
if (typeof v === "string") values.push(v);
|
||||
}
|
||||
const elseV = r?.else?.action?.value;
|
||||
if (typeof elseV === "string") values.push(elseV);
|
||||
}
|
||||
} else {
|
||||
const opts = (m.payload as { options?: Array<{ value?: string }> }).options;
|
||||
if (!Array.isArray(opts)) continue;
|
||||
for (const o of opts) if (typeof o?.value === "string") values.push(o.value);
|
||||
}
|
||||
byUuid.set(m.id, values);
|
||||
}
|
||||
return (uuid: string) => byUuid.get(uuid) ?? [];
|
||||
});
|
||||
|
||||
interface PickRow {
|
||||
/** Stable key + data-test suffix. */
|
||||
key: string;
|
||||
entry: PickEntry;
|
||||
name: string;
|
||||
kind: "direct" | "nested";
|
||||
nodeLabel?: string;
|
||||
/** Per-uuid duplicate disambiguator, e.g. "2 of 3" — omitted when the
|
||||
* occurrence is unique among its kind+name siblings. */
|
||||
ordinal?: string;
|
||||
}
|
||||
|
||||
/** Walk the chain DOWNSTREAM of the edited constraint, collecting every
|
||||
* reachable occurrence of `targetWildcardId`: direct top-level instances
|
||||
* + transitive carriers. Identity matches the engine /
|
||||
* computePairingsFull (rowKey for direct; carrier rowKey + option_id for
|
||||
* nested). Empty when the chain is empty or the constraint isn't found. */
|
||||
const pickRows = computed<PickRow[]>(() => {
|
||||
const chain = props.chainModules;
|
||||
const start = chain.findIndex((m) => m.rowKey === props.constraintUid);
|
||||
if (start < 0) return [];
|
||||
const rows: PickRow[] = [];
|
||||
const tgt = props.targetWildcardId;
|
||||
for (let j = start + 1; j < chain.length; j++) {
|
||||
const cm = chain[j];
|
||||
if (cm.type === "wildcard" && cm.id === tgt) {
|
||||
// Direct top-level instance.
|
||||
rows.push({
|
||||
// `key`/`data-test` keep the full rowKey (display-only, graph-
|
||||
// wide unique); the persisted pick `uid` is the BARE _uid the
|
||||
// engine matches (see bareUid).
|
||||
key: cm.rowKey,
|
||||
entry: { kind: "direct", uid: bareUid(cm.rowKey) },
|
||||
name: cm.displayName?.trim() || cm.id,
|
||||
kind: "direct",
|
||||
nodeLabel: cm.nodeLabel,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Transitive carrier — one row per matching option id.
|
||||
const match = carrierOptionIdsFor(cm, tgt, lookup.value);
|
||||
for (const optionId of match.optionIds) {
|
||||
rows.push({
|
||||
// `key`/`data-test` keep the full rowKey (display-only); the
|
||||
// persisted `carrier_uid` is the BARE _uid the engine's nested
|
||||
// carrier_ctx stamps (wildcard_handler stamps bare `_uid`).
|
||||
key: `${cm.rowKey}::${optionId}`,
|
||||
entry: { kind: "nested", carrier_uid: bareUid(cm.rowKey), option_id: optionId },
|
||||
// The nested ref IS the TARGET (`@{target}`), hosted inside this
|
||||
// carrier's option. Name it "@<target-display-name> in <carrier-
|
||||
// display-name>" — the `@` belongs to the referenced TARGET, NOT the
|
||||
// host; both shown by display name (not binding). The host carrier
|
||||
// here happens to be the constraint's source wildcard.
|
||||
name: `@${targetLabel.value} in ${cm.displayName?.trim() || cm.id}`,
|
||||
kind: "nested",
|
||||
nodeLabel: cm.nodeLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Duplicate disambiguator: when several rows share kind+name (two
|
||||
// instances of the same target wildcard downstream), stamp "#N of M".
|
||||
const groups = new Map<string, PickRow[]>();
|
||||
for (const r of rows) {
|
||||
const g = `${r.kind}:${r.name}`;
|
||||
const arr = groups.get(g);
|
||||
if (arr) arr.push(r);
|
||||
else groups.set(g, [r]);
|
||||
}
|
||||
for (const arr of groups.values()) {
|
||||
if (arr.length < 2) continue;
|
||||
arr.forEach((r, i) => { r.ordinal = `${i + 1} of ${arr.length}`; });
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
function samePick(a: PickEntry, b: PickEntry): boolean {
|
||||
if (a.kind === "direct" && b.kind === "direct") return a.uid === b.uid;
|
||||
if (a.kind === "nested" && b.kind === "nested") {
|
||||
return a.carrier_uid === b.carrier_uid && a.option_id === b.option_id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPicked(entry: PickEntry): boolean {
|
||||
return picks.value.some((p) => samePick(p, entry));
|
||||
}
|
||||
|
||||
function togglePick(entry: PickEntry): void {
|
||||
const cur = picks.value;
|
||||
const next = isPicked(entry)
|
||||
? cur.filter((p) => !samePick(p, entry))
|
||||
: [...cur, entry];
|
||||
emit("update:modelValue", { mode: "pick", picks: next });
|
||||
}
|
||||
|
||||
// ── Consequence hint ────────────────────────────────────────────
|
||||
const reachCount = computed(() => pickRows.value.length);
|
||||
const hint = computed<string>(() => {
|
||||
const n = reachCount.value;
|
||||
const tgtN = `${n} downstream ${n === 1 ? "target" : "targets"}`;
|
||||
switch (mode.value) {
|
||||
case "all":
|
||||
return `→ re-weights all ${tgtN}`;
|
||||
case "first":
|
||||
return n > 0 ? "→ re-weights the first downstream target" : "→ no downstream target to cover";
|
||||
case "next": {
|
||||
const k = count.value;
|
||||
return `→ re-weights the first ${k} downstream ${k === 1 ? "target" : "targets"}`;
|
||||
}
|
||||
case "pick": {
|
||||
const sel = picks.value.length;
|
||||
return `→ re-weights ${sel} picked ${sel === 1 ? "target" : "targets"}`;
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rh" data-test="reach-section">
|
||||
<div class="rh__label">Target reach</div>
|
||||
|
||||
<div class="rh-seg" role="group" aria-label="Target reach mode">
|
||||
<button
|
||||
v-for="m in MODES"
|
||||
:key="m.key"
|
||||
type="button"
|
||||
class="rh-btn"
|
||||
:class="{ active: mode === m.key }"
|
||||
:aria-pressed="mode === m.key"
|
||||
:data-test="`reach-mode-${m.key}`"
|
||||
@click="setMode(m.key)"
|
||||
>{{ m.label }}</button>
|
||||
|
||||
<span v-if="mode === 'next'" class="rh-step">
|
||||
<input
|
||||
class="rh-step__field"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
:value="count"
|
||||
aria-label="Number of downstream targets"
|
||||
data-test="reach-count"
|
||||
@input="onCountInput"
|
||||
/>
|
||||
<span class="rh-step__arrows">
|
||||
<button
|
||||
type="button"
|
||||
class="rh-step__arrow"
|
||||
aria-label="Increase count"
|
||||
data-test="reach-count-up"
|
||||
@click="bumpCount(1)"
|
||||
>▲</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rh-step__arrow"
|
||||
aria-label="Decrease count"
|
||||
:disabled="count <= 1"
|
||||
data-test="reach-count-down"
|
||||
@click="bumpCount(-1)"
|
||||
>▼</button>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'pick'" class="rh-pick" data-test="reach-picklist">
|
||||
<div
|
||||
v-if="pickRows.length === 0"
|
||||
class="rh-pick__empty"
|
||||
data-test="reach-pick-empty"
|
||||
>No reachable target instances — use first / next / all.</div>
|
||||
<!-- WpCheck is the interactive control (own role/keyboard + aria-
|
||||
label); the row is presentational so the checkbox is the single
|
||||
focus/click target — no redundant row-level handler. -->
|
||||
<div
|
||||
v-for="row in pickRows"
|
||||
:key="row.key"
|
||||
class="rh-pick__row"
|
||||
:data-test="`reach-pick-${row.key}`"
|
||||
>
|
||||
<WpCheck
|
||||
:model-value="isPicked(row.entry)"
|
||||
:aria-label="`Cover ${row.name}`"
|
||||
@update:model-value="togglePick(row.entry)"
|
||||
/>
|
||||
<span class="rh-pick__name">{{ row.name }}</span>
|
||||
<span class="rh-pick__tag" :class="`rh-pick__tag--${row.kind}`">{{ row.kind }}</span>
|
||||
<span v-if="row.nodeLabel" class="rh-pick__node">{{ row.nodeLabel }}</span>
|
||||
<span v-if="row.ordinal" class="rh-pick__ord">#{{ row.ordinal }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rh__hint" data-test="reach-hint">{{ hint }}</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rh {
|
||||
padding: 12px 16px;
|
||||
/* Subtle neutral demarcation. The earlier accent wash + inset rail read
|
||||
as too loud; a faint text-tinted panel (same idiom as `.rh-seg` /
|
||||
row hover below) sets the section apart on both light + dark without
|
||||
shouting. */
|
||||
background: color-mix(in srgb, var(--wp-text) 3%, transparent);
|
||||
border-bottom: 1px solid var(--wp-border-soft, var(--wp-border));
|
||||
}
|
||||
.rh__label {
|
||||
font: 600 9px var(--wp-font-sans);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── Segmented control — mirrors CellRulePopover `.pop-btn` group. ── */
|
||||
.rh-seg {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--wp-text) 2%, transparent);
|
||||
}
|
||||
.rh-btn {
|
||||
padding: 6px 12px;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--wp-border);
|
||||
background: transparent;
|
||||
color: var(--wp-text-muted, var(--wp-text2));
|
||||
font: 600 10px var(--wp-font-sans);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rh-btn:last-of-type { border-right: 0; }
|
||||
.rh-btn:hover { color: var(--wp-text); background: color-mix(in srgb, var(--wp-text) 6%, transparent); }
|
||||
.rh-btn.active {
|
||||
/* White-on-accent, matching the modal footer's `.wp-cnm__btn--primary`
|
||||
* (the accent is a saturated purple in both light + dark themes, so a
|
||||
* hard white reads on both — `--wp-accent-on` isn't reliably defined). */
|
||||
background: var(--wp-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Stepper sits flush after the segmented group: number field + a stacked
|
||||
▲/▼ arrow column. */
|
||||
.rh-step {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
border-left: 1px solid var(--wp-border);
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
}
|
||||
.rh-step__field {
|
||||
width: 38px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0 6px;
|
||||
font: 600 12px var(--wp-font-mono);
|
||||
color: var(--wp-text);
|
||||
text-align: center;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
/* Native spinners are suppressed in favour of the custom always-visible
|
||||
▲/▼ buttons (native arrows only appear on hover + render inconsistently
|
||||
in ComfyUI's embedded DOM). */
|
||||
.rh-step__field::-webkit-outer-spin-button,
|
||||
.rh-step__field::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.rh-step__field:focus { outline: none; }
|
||||
|
||||
.rh-step__arrows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--wp-border);
|
||||
}
|
||||
.rh-step__arrow {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--wp-text-muted, var(--wp-text2));
|
||||
font-size: 7px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rh-step__arrow + .rh-step__arrow { border-top: 1px solid var(--wp-border); }
|
||||
.rh-step__arrow:hover:not(:disabled) {
|
||||
color: var(--wp-text);
|
||||
background: color-mix(in srgb, var(--wp-text) 8%, transparent);
|
||||
}
|
||||
.rh-step__arrow:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* ── Pick checklist ─────────────────────────────────────────────── */
|
||||
.rh-pick {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.rh-pick__empty {
|
||||
font: 10px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
padding: 4px 2px;
|
||||
}
|
||||
.rh-pick__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font: 11px var(--wp-font-sans);
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.rh-pick__row:hover { background: color-mix(in srgb, var(--wp-text) 5%, transparent); }
|
||||
.rh-pick__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--wp-font-mono);
|
||||
}
|
||||
.rh-pick__tag {
|
||||
font: 600 8px var(--wp-font-sans);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.rh-pick__tag--direct {
|
||||
color: var(--wp-constraint-target-text, var(--wp-text-muted, var(--wp-text2)));
|
||||
background: color-mix(in oklab, var(--wp-constraint-target, var(--wp-accent)) 18%, transparent);
|
||||
}
|
||||
.rh-pick__tag--nested {
|
||||
color: var(--wp-accent-text, var(--wp-text-muted, var(--wp-text2)));
|
||||
background: color-mix(in oklab, var(--wp-accent) 18%, transparent);
|
||||
}
|
||||
.rh-pick__node {
|
||||
font: 600 9px var(--wp-font-mono);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 3px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
.rh-pick__ord {
|
||||
font: 9px var(--wp-font-mono);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
|
||||
.rh__hint {
|
||||
margin-top: 8px;
|
||||
font: 10px var(--wp-font-mono);
|
||||
color: var(--wp-text-muted, var(--wp-text2));
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@
|
||||
// reset overrides + drift kebab + cancel/save. Header icon: pi-arrow-right-arrow-left
|
||||
// (same icon picker.ts uses for derivation kind).
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import DerivationInstanceModal from "./DerivationInstanceModal.vue";
|
||||
import type { ModuleEntry } from "../../../../widgets/_shared";
|
||||
@@ -112,3 +112,131 @@ describe("DerivationInstanceModal", () => {
|
||||
expect(w.emitted("clear-all-overrides")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Library-catalog fetch + ref-data wiring (@{} parity) ──────────────
|
||||
//
|
||||
// On open the modal fetches /wp/api/modules ONCE, builds the wildcard
|
||||
// `@{}` ref-data via buildWildcardRefData, and forwards it (+ the
|
||||
// $var suggestion list from upstream/sibling vars) down to RulesSection
|
||||
// so the canvas reaches full parity with the SPA derivation editor.
|
||||
// Mirrors the fetch pattern in ModulePickerModal (`{ items: ModuleRow[] }`).
|
||||
|
||||
interface FakeRow {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const LIBRARY_ROWS: FakeRow[] = [
|
||||
// This derivation module itself — must be EXCLUDED from refSuggestions.
|
||||
{ id: "dv012345", type: "derivation", name: "mood-rules", payload: { rules: [] } },
|
||||
{
|
||||
id: "aabbccdd",
|
||||
type: "wildcard",
|
||||
name: "color",
|
||||
payload: {
|
||||
var_binding: "color",
|
||||
sub_categories: ["warm", "cool"],
|
||||
options: [
|
||||
{ id: "o1", value: "red", sub_categories: ["warm"] },
|
||||
{ id: "o2", value: "blue", sub_categories: ["cool"] },
|
||||
],
|
||||
tag_groups: { temp: ["warm", "cool"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "eeff0011",
|
||||
type: "wildcard",
|
||||
name: "outfit",
|
||||
payload: { var_binding: "outfit", options: [{ id: "o1", value: "tee" }] },
|
||||
},
|
||||
// A non-wildcard row — must NOT contribute to refSuggestions.
|
||||
{ id: "cc001122", type: "combine", name: "combo", payload: { output_var: "x" } },
|
||||
];
|
||||
|
||||
function stubModulesFetch(rows: FakeRow[]): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () =>
|
||||
url.includes("/wp/api/modules") ? { items: rows, total: rows.length } : {},
|
||||
})) as unknown as typeof fetch,
|
||||
);
|
||||
}
|
||||
|
||||
/** Flush the fetch → json() microtask chain + a nextTick so the
|
||||
* ref-data computed re-evaluates after the catalog populates. */
|
||||
async function flushFetch(w: ReturnType<typeof mount>): Promise<void> {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await w.vm.$nextTick();
|
||||
}
|
||||
|
||||
describe("DerivationInstanceModal — @{} ref-data fetch + forwarding", () => {
|
||||
beforeEach(() => stubModulesFetch(LIBRARY_ROWS));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("fetches /wp/api/modules once when the modal mounts", async () => {
|
||||
const w = mount(DerivationInstanceModal, { props: { module: makeModule() } });
|
||||
await flushFetch(w);
|
||||
const calls = (globalThis.fetch as unknown as { mock: { calls: unknown[][] } }).mock.calls;
|
||||
const modulesCalls = calls.filter((c) => String(c[0]).includes("/wp/api/modules"));
|
||||
expect(modulesCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("forwards wildcard refSuggestions (excluding self + non-wildcards) to RulesSection", async () => {
|
||||
const w = mount(DerivationInstanceModal, { props: { module: makeModule() } });
|
||||
await flushFetch(w);
|
||||
const rules = w.findComponent({ name: "RulesSection" });
|
||||
const refs = rules.props("refSuggestions") as string[];
|
||||
// Only the two wildcard rows; sorted by display name (color < outfit).
|
||||
expect(refs).toEqual(["aabbccdd", "eeff0011"]);
|
||||
// Self id + the combine row are excluded.
|
||||
expect(refs).not.toContain("dv012345");
|
||||
expect(refs).not.toContain("cc001122");
|
||||
});
|
||||
|
||||
it("forwards the six buildWildcardRefData maps to RulesSection", async () => {
|
||||
const w = mount(DerivationInstanceModal, { props: { module: makeModule() } });
|
||||
await flushFetch(w);
|
||||
const rules = w.findComponent({ name: "RulesSection" });
|
||||
const uuidToName = rules.props("uuidToName") as Map<string, string>;
|
||||
expect(uuidToName.get("aabbccdd")).toBe("color");
|
||||
expect((rules.props("uuidToSubCategories") as Map<string, string[]>).get("aabbccdd")).toEqual(["warm", "cool"]);
|
||||
expect((rules.props("uuidToOptionsCount") as Map<string, number>).get("aabbccdd")).toBe(2);
|
||||
expect((rules.props("uuidToHasNull") as Map<string, boolean>).get("aabbccdd")).toBe(false);
|
||||
expect(rules.props("uuidToOptionTagSets")).toBeInstanceOf(Map);
|
||||
expect((rules.props("uuidToTagGroups") as Map<string, Record<string, string[]>>).get("aabbccdd")).toEqual({ temp: ["warm", "cool"] });
|
||||
});
|
||||
|
||||
it("forwards upstream + sibling vars as the $var suggestion list", async () => {
|
||||
const w = mount(DerivationInstanceModal, {
|
||||
props: { module: makeModule(), upstreamVars: ["age"], siblingVars: ["mood"] },
|
||||
});
|
||||
await flushFetch(w);
|
||||
const rules = w.findComponent({ name: "RulesSection" });
|
||||
const vars = rules.props("varSuggestions") as string[];
|
||||
expect(vars).toContain("age");
|
||||
expect(vars).toContain("mood");
|
||||
});
|
||||
|
||||
it("does not crash + passes empty ref-data before the fetch resolves", () => {
|
||||
// No flush — assert the synchronous mount path is safe (maps empty).
|
||||
const w = mount(DerivationInstanceModal, { props: { module: makeModule() } });
|
||||
const rules = w.findComponent({ name: "RulesSection" });
|
||||
expect((rules.props("refSuggestions") as string[]) ?? []).toEqual([]);
|
||||
expect((rules.props("uuidToName") as Map<string, string>).size).toBe(0);
|
||||
});
|
||||
|
||||
it("survives a failed /wp/api/modules fetch (empty ref-data, no throw)", async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 500, json: async () => ({}) })) as unknown as typeof fetch);
|
||||
const w = mount(DerivationInstanceModal, { props: { module: makeModule() } });
|
||||
await flushFetch(w);
|
||||
const rules = w.findComponent({ name: "RulesSection" });
|
||||
expect((rules.props("refSuggestions") as string[]) ?? []).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user