Compare commits
234
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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/
|
||||
|
||||
+264
@@ -1,3 +1,267 @@
|
||||
## 🎉 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)
|
||||
|
||||
@@ -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,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
|
||||
+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
|
||||
|
||||
|
||||
|
||||
@@ -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":
|
||||
@@ -296,7 +339,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 +363,9 @@ 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, resolve_ctx, carrier_key=branch_carrier_key(rule_id, bi)
|
||||
)
|
||||
if pair is not None:
|
||||
out[pair[0]] = pair[1]
|
||||
applied = True
|
||||
@@ -328,7 +373,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 +386,9 @@ 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, resolve_ctx, carrier_key=branch_carrier_key(rule_id, "else")
|
||||
)
|
||||
if pair is not None:
|
||||
out[pair[0]] = pair[1]
|
||||
return out
|
||||
|
||||
@@ -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`
|
||||
|
||||
+427
-182
@@ -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__"])
|
||||
@@ -434,6 +657,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 +709,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 +730,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}
|
||||
|
||||
+124
-34
@@ -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:
|
||||
@@ -249,10 +252,12 @@ class PipelineEngine:
|
||||
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__", {})
|
||||
|
||||
for index, module in enumerate(modules):
|
||||
# Normalise id/type/enabled reads to work for both dicts and objects.
|
||||
@@ -275,12 +280,21 @@ 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
|
||||
@@ -351,15 +365,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 +489,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 +512,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 +548,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
|
||||
@@ -591,4 +614,71 @@ class PipelineEngine:
|
||||
"message": message,
|
||||
})
|
||||
|
||||
# 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,28 @@ 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",
|
||||
"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
|
||||
@@ -86,6 +108,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 +126,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 +167,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
|
||||
|
||||
+235
-143
@@ -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:
|
||||
@@ -209,53 +217,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 +340,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 +492,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 +514,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 +529,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.1.0",
|
||||
"version": "2.3.1",
|
||||
"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.1.0"
|
||||
version = "2.3.1"
|
||||
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -144,7 +144,28 @@ 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.
|
||||
const TOTAL_LIMIT = 431 * 1024; // 431 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,6 +1,7 @@
|
||||
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";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -171,7 +172,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 +216,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 +252,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 +293,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 +403,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 });
|
||||
}),
|
||||
@@ -1120,7 +1121,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 +1185,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 +2148,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,
|
||||
@@ -84,6 +86,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 +101,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 +136,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
|
||||
@@ -672,11 +682,15 @@ const editingModule = computed<ModuleEntry | null>(() =>
|
||||
editingIdx.value != null ? (value.value.modules[editingIdx.value] ?? null) : 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 +714,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);
|
||||
@@ -2053,6 +2067,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 +2165,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.
|
||||
@@ -2964,8 +3011,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`);
|
||||
@@ -3640,7 +3687,12 @@ function toggleCollapsed(idx: number) {
|
||||
* 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 +3703,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
|
||||
@@ -4593,7 +4674,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,6 +4683,7 @@ const moduleRowCtx: ModuleRowCtx = {
|
||||
onDragStart, onDragEnd, openContextMenu, onCardKeydown,
|
||||
pairingFor,
|
||||
viaInboundFor,
|
||||
contributorsFor,
|
||||
};
|
||||
provide(ModuleRowCtxKey, moduleRowCtx);
|
||||
|
||||
@@ -4636,6 +4718,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 +4798,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 +4820,8 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="wp-modules-frame"
|
||||
ref="modulesContainer"
|
||||
class="wp-modules-frame"
|
||||
>
|
||||
<div
|
||||
class="wp-modules"
|
||||
@@ -4848,6 +4947,7 @@ 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"
|
||||
@save="saveEditedModule"
|
||||
@@ -5700,6 +5800,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 +5882,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,27 @@ 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"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Constraint v2 dispatcher branch ────────────────────────────────────
|
||||
@@ -583,6 +622,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(),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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 +16,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 +26,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. */
|
||||
@@ -141,7 +147,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: [
|
||||
@@ -422,11 +431,17 @@ 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"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -444,6 +459,7 @@ function cancel() {
|
||||
:module="draft"
|
||||
:is-modified="instanceModified"
|
||||
:sibling-modules="siblingModules"
|
||||
:chain-modules="chainModules"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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: () => [],
|
||||
...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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -84,11 +85,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 +135,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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,7 +7,10 @@ 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", "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
|
||||
@@ -46,6 +49,8 @@ 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,8 @@ export const KIND_TITLE: Record<string, string> = {
|
||||
|
||||
export type InstanceFieldKey =
|
||||
| "variable_binding" | "enabled_options" | "option_weights" | "category_filter"
|
||||
| "exclude_null"
|
||||
| "pick_min" | "pick_max" | "pick_separator" | "pick_independent"
|
||||
| "mode" | "pinned_option_id" | "locked_seed" | "internal" | "values_overrides"
|
||||
| "template_override"
|
||||
| "disabled_rule_ids"
|
||||
@@ -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,7 +46,10 @@ 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", "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"],
|
||||
@@ -70,5 +77,9 @@ 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",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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)); }
|
||||
|
||||
@@ -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,7 +31,7 @@ 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[];
|
||||
@@ -108,6 +109,7 @@ function onSpaClick(): void {
|
||||
:sibling-vars="siblingVars"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<div class="wp-cbm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-cbm__foot">
|
||||
@@ -146,6 +148,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 +162,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 +170,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;
|
||||
|
||||
@@ -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<{
|
||||
@@ -86,7 +87,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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,15 @@ 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[];
|
||||
}>(),
|
||||
{ isDrifted: false, isModified: false, siblingModules: () => [] },
|
||||
);
|
||||
@@ -67,9 +92,51 @@ interface WildcardPayload {
|
||||
options?: Array<{ value?: 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 +146,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 +184,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
|
||||
@@ -210,6 +293,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 +621,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 +661,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 +688,7 @@ function onSpaClick(): void {
|
||||
:target-subs="targetSubs"
|
||||
:source-name="sourceName"
|
||||
:target-name="targetName"
|
||||
:stranded="hasDangling"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<ExceptionsSection
|
||||
@@ -318,9 +700,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 +749,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 +774,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;
|
||||
@@ -434,4 +844,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,111 @@
|
||||
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.text()).toBe("colour");
|
||||
});
|
||||
|
||||
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,282 @@
|
||||
<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 }
|
||||
|
||||
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 });
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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)"
|
||||
>{{ c.name }}</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)"
|
||||
>{{ c.name }}</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 { 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__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>
|
||||
|
||||
@@ -355,6 +355,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, {
|
||||
|
||||
@@ -33,8 +33,14 @@ const props = withDefaults(
|
||||
targetSubs: readonly string[];
|
||||
sourceName?: string;
|
||||
targetName?: 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: "", stranded: false },
|
||||
);
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
@@ -128,7 +134,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 +179,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;
|
||||
@@ -302,8 +313,16 @@ onBeforeUnmount(() => {
|
||||
</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">
|
||||
@@ -331,6 +350,10 @@ onBeforeUnmount(() => {
|
||||
<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">
|
||||
<!-- 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. -->
|
||||
<div
|
||||
:class="cellClasses(s, t)"
|
||||
:data-test="`mx-cell-${s}-${t}`"
|
||||
@@ -350,6 +373,10 @@ onBeforeUnmount(() => {
|
||||
</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 +417,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 {
|
||||
@@ -523,6 +571,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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,14 @@
|
||||
* Section order matches all shipped v2 modals: Header → Identity →
|
||||
* Rules (kind-specific) → Runtime → Footer.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import type { ModuleEntry } from "../../../../widgets/_shared";
|
||||
import type { PairingBadge } from "../../../../extension/constraint-pairs";
|
||||
import type { ModuleRow } from "../../../../manager/api/types";
|
||||
import {
|
||||
buildWildcardRefData,
|
||||
collectLibraryWildcardRefs,
|
||||
} from "../../../../manager/utils/library-suggestions";
|
||||
import IdentitySection from "./sections/IdentitySection.vue";
|
||||
import RulesSection from "./sections/RulesSection.vue";
|
||||
import RuntimeSection from "./sections/RuntimeSection.vue";
|
||||
@@ -25,10 +31,73 @@ const props = withDefaults(
|
||||
/** True when draft has unsaved instance edits. Gates "Save to library"
|
||||
* visibility — pushing an unmodified payload back is a no-op. */
|
||||
isModified?: boolean;
|
||||
/** Vars produced upstream of this Context node — forwarded as the
|
||||
* `$var` autocomplete list for the rule override fields. */
|
||||
upstreamVars?: string[];
|
||||
/** Vars produced by other modules in the SAME Context node. */
|
||||
siblingVars?: string[];
|
||||
/** Via-nested constraint-pair badges for this derivation when it acts
|
||||
* as a constraint carrier (a rule ACTION value hosts a nested `@{uuid}`
|
||||
* that resolves to a downstream constraint's target). Keyed by the
|
||||
* engine branch key (`${rule_id}:${bi}` / `${rule_id}:else`). Forwarded
|
||||
* verbatim to RulesSection, which renders the inline `↪#N` badge. */
|
||||
viaOptionPairs?: Map<string, readonly PairingBadge[]>;
|
||||
}>(),
|
||||
{ isDrifted: false, isModified: false },
|
||||
{
|
||||
isDrifted: false,
|
||||
isModified: false,
|
||||
upstreamVars: () => [],
|
||||
siblingVars: () => [],
|
||||
viaOptionPairs: () => new Map(),
|
||||
},
|
||||
);
|
||||
|
||||
// ── Library catalog → `@{}` ref-data ───────────────────────────────
|
||||
//
|
||||
// Bug parity: the rule ACTION-value override fields reuse the wildcard
|
||||
// `@{}` nested-ref machinery (autocomplete + chips + step-2 sub-cat
|
||||
// picker), and the read-only summary chips `@{uuid}` refs. Both need the
|
||||
// per-wildcard ref-data the SPA derivation editor builds from
|
||||
// `moduleStore.catalog`. The canvas has no Pinia store, so we fetch the
|
||||
// library ONCE when the modal mounts (same `/wp/api/modules` source the
|
||||
// ModulePickerModal reads) and build the SAME maps via the shared
|
||||
// `buildWildcardRefData`. The `@{}` source is the LIBRARY (by identity),
|
||||
// NOT this Context node's chain — a chain sibling would be a `$var`.
|
||||
const catalog = ref<ModuleRow[]>([]);
|
||||
|
||||
const refData = computed(() => buildWildcardRefData(catalog.value));
|
||||
const uuidToName = computed(() => refData.value.uuidToName);
|
||||
// Library wildcard uuids for `@{}` autocomplete, excluding this module's own
|
||||
// id (a wildcard never nests itself) — sorted by display name to mirror the
|
||||
// SPA's `collectLibraryWildcardRefs`.
|
||||
const refSuggestions = computed(() =>
|
||||
collectLibraryWildcardRefs({ catalog: catalog.value }, props.module.id, refData.value.uuidToName),
|
||||
);
|
||||
|
||||
/** `$var` suggestion list for the override fields — upstream + sibling
|
||||
* producer vars, deduped + alpha-sorted (mirrors the combine modal). */
|
||||
const varSuggestions = computed<string[]>(() => {
|
||||
const set = new Set<string>();
|
||||
for (const n of props.upstreamVars) if (n) set.add(n);
|
||||
for (const n of props.siblingVars) if (n) set.add(n);
|
||||
return [...set].sort();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
// Fire-and-forget: until this resolves the ref-data maps stay empty, so
|
||||
// autocomplete simply shows nothing + summary chips fall back to cached
|
||||
// names — no crash, no blocking. A failed fetch leaves the catalog empty.
|
||||
try {
|
||||
if (typeof fetch !== "function") return;
|
||||
const res = await fetch("/wp/api/modules", { credentials: "same-origin" });
|
||||
if (!res.ok) return;
|
||||
const json = (await res.json()) as { items?: ModuleRow[] };
|
||||
if (Array.isArray(json.items)) catalog.value = json.items;
|
||||
} catch {
|
||||
// Non-fatal — leave catalog empty (editor still works without @{} data).
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update": [patch: Partial<ModuleEntry>];
|
||||
"save": [];
|
||||
@@ -78,7 +147,20 @@ function onSpaClick(): void {
|
||||
</header>
|
||||
|
||||
<IdentitySection :module="module" @update="onUpdate" />
|
||||
<RulesSection :module="module" @update="onUpdate" />
|
||||
<RulesSection
|
||||
:module="module"
|
||||
:var-suggestions="varSuggestions"
|
||||
:ref-suggestions="refSuggestions"
|
||||
:uuid-to-name="uuidToName"
|
||||
:uuid-to-sub-categories="refData.uuidToSubCategories"
|
||||
:uuid-to-has-null="refData.uuidToHasNull"
|
||||
:uuid-to-options-count="refData.uuidToOptionsCount"
|
||||
:uuid-to-option-tag-sets="refData.uuidToOptionTagSets"
|
||||
:uuid-to-tag-groups="refData.uuidToTagGroups"
|
||||
:via-option-pairs="viaOptionPairs"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<div class="wp-dvm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-dvm__foot">
|
||||
@@ -117,6 +199,7 @@ function onSpaClick(): void {
|
||||
<button type="button" class="wp-dvm__btn" data-test="dvm-cancel" @click="emit('cancel')">Cancel</button>
|
||||
<button type="button" class="wp-dvm__btn wp-dvm__btn--primary" data-test="dvm-save" @click="emit('save')">Save</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -130,13 +213,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-dvm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-dvm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* RuleValueChips — read-only chip renderer for a derivation rule's
|
||||
* action.value / condition.value strings inside the canvas instance modal
|
||||
* summary. Tokenizes the value and renders `@{uuid}` nested-wildcard refs +
|
||||
* `$var` references as CHIPS, brace blocks / escapes as colored spans — so a
|
||||
* value like `@{ad00acd5#mood:warm}` reads as a `@mood` chip with a funnel
|
||||
* mark instead of leaking raw hex.
|
||||
*
|
||||
* Chip markup + CSS are copied verbatim from the wildcard editor's OptionRow
|
||||
* (`editors/wildcard/sections/OptionRow.vue`) so the canvas reads identically
|
||||
* to the option-value chips. Names resolve from the modal-supplied
|
||||
* `uuidToName` map (the live library catalog) — no preview-resolver dep on
|
||||
* this surface. A missing display value renders the placeholder text.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { splitRefFilter, tokenizeRich, type RichToken } from "../../../../../widgets/richTokenize";
|
||||
import { parse, readsAs } from "@/manager/parsing/subcatFilter";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value?: string;
|
||||
/** UUID → display name, from the modal's catalog fetch. Empty until the
|
||||
* fetch resolves — chips fall back to the token's cached `#name`. */
|
||||
uuidToName?: Map<string, string>;
|
||||
/** Shown when `value` is empty (mirrors the old `|| "?"` placeholder). */
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{ value: "", uuidToName: () => new Map(), placeholder: "?" },
|
||||
);
|
||||
|
||||
const tokens = computed<RichToken[]>(() => tokenizeRich(props.value ?? ""));
|
||||
const isEmpty = computed(() => (props.value ?? "").length === 0);
|
||||
|
||||
/** Human label for a ref chip — `@name` from the live map, else the cached
|
||||
* `#name` (stripping a leaked `!null`), else the raw token. Mirrors
|
||||
* OptionRow.refDisplay's fallback chain. */
|
||||
function refDisplay(tok: RichToken): string {
|
||||
const uuid = tok.meta?.uuid;
|
||||
if (!uuid) return tok.raw;
|
||||
const live = props.uuidToName.get(uuid);
|
||||
if (live && live.trim()) return `@${live.trim()}`;
|
||||
const cached = typeof tok.meta?.name === "string" ? tok.meta.name : "";
|
||||
if (cached.trim()) return `@${cached.split("!")[0].trim() || cached.trim()}`;
|
||||
return tok.raw;
|
||||
}
|
||||
|
||||
/** Ref uuid absent from the live map → broken reference. Empty map (pre-fetch)
|
||||
* is "unknown, not broken" so chips don't flash red while loading. */
|
||||
function refIsUnresolved(tok: RichToken): boolean {
|
||||
const uuid = tok.meta?.uuid;
|
||||
if (!uuid) return false;
|
||||
if (props.uuidToName.size === 0) return false;
|
||||
return !props.uuidToName.has(uuid);
|
||||
}
|
||||
|
||||
interface RefFilterInfo { hasExpr: boolean; excludeNull: boolean; isFiltered: boolean; title: string }
|
||||
const _refFilterMemo = new WeakMap<RichToken, RefFilterInfo>();
|
||||
/** Funnel (sub-cat expression) + ban (null excluded) marks + hover title.
|
||||
* Copied from OptionRow.refFilter. */
|
||||
function refFilter(tok: RichToken): RefFilterInfo {
|
||||
const cached = _refFilterMemo.get(tok);
|
||||
if (cached) return cached;
|
||||
const subs = tok.meta?.sub_categories;
|
||||
let expr = "";
|
||||
let excludeNull = false;
|
||||
if (Array.isArray(subs) && subs.length > 0) {
|
||||
({ expr, excludeNull } = splitRefFilter(subs.join(",")));
|
||||
} else {
|
||||
const name = typeof tok.meta?.name === "string" ? tok.meta.name : "";
|
||||
const bang = name.indexOf("!");
|
||||
if (bang >= 0) excludeNull = name.slice(bang + 1) === "null";
|
||||
}
|
||||
let reads = "";
|
||||
if (expr) {
|
||||
try { reads = readsAs(parse(expr)); } catch { reads = expr; }
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (reads) parts.push(reads);
|
||||
if (excludeNull) parts.push("null excluded");
|
||||
const info: RefFilterInfo = {
|
||||
hasExpr: expr.length > 0,
|
||||
excludeNull,
|
||||
isFiltered: expr.length > 0 || excludeNull,
|
||||
title: parts.join(" · "),
|
||||
};
|
||||
_refFilterMemo.set(tok, info);
|
||||
return info;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="rvc">
|
||||
<span v-if="isEmpty" class="rvc__placeholder">{{ placeholder }}</span>
|
||||
<template v-for="(tok, idx) in tokens" v-else :key="idx">
|
||||
<span
|
||||
v-if="tok.kind === 'ref'"
|
||||
class="rvc__tok rvc__tok--ref"
|
||||
:class="{
|
||||
'rvc__tok--filtered': refFilter(tok).isFiltered,
|
||||
'rvc__tok--unresolved': refIsUnresolved(tok),
|
||||
}"
|
||||
:data-uuid="tok.meta?.uuid"
|
||||
:title="refIsUnresolved(tok)
|
||||
? `Reference ${tok.meta?.uuid} not in library`
|
||||
: (refFilter(tok).title || undefined)"
|
||||
>
|
||||
<span class="rvc__tok-icon" aria-hidden="true">{{ refIsUnresolved(tok) ? '?' : '✦' }}</span>
|
||||
<span class="rvc__tok-label">{{ refDisplay(tok) }}</span>
|
||||
<span
|
||||
v-if="refFilter(tok).isFiltered"
|
||||
class="rvc__tok-filter"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i v-if="refFilter(tok).hasExpr" class="pi pi-filter rvc__tok-funnel"></i>
|
||||
<i v-if="refFilter(tok).excludeNull" class="pi pi-ban rvc__tok-nonull"></i>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="tok.kind === 'var'"
|
||||
class="rvc__tok rvc__tok--var"
|
||||
>
|
||||
<span class="rvc__tok-icon" aria-hidden="true">⌘</span>
|
||||
<span class="rvc__tok-label">{{ tok.raw }}</span>
|
||||
</span>
|
||||
<span v-else-if="tok.kind === 'dp-brace'" class="rvc__tok rvc__tok--brace">{{ tok.raw }}</span>
|
||||
<span v-else-if="tok.kind === 'dp-multi'" class="rvc__tok rvc__tok--multi">{{ tok.raw }}</span>
|
||||
<span v-else-if="tok.kind === 'escape'" class="rvc__tok rvc__tok--escape">{{ tok.raw }}</span>
|
||||
<template v-else>{{ tok.raw }}</template>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Inline-syntax chips — palette + icon prefix copied from the wildcard
|
||||
* OptionRow (`editors/wildcard/sections/OptionRow.vue`) so an `@nestedName`
|
||||
* reads the same in the derivation summary as in an option value: purple ✦
|
||||
* for refs, green ⌘ for vars, amber funnel/ban filter marks. */
|
||||
.rvc {
|
||||
display: inline;
|
||||
}
|
||||
.rvc__placeholder {
|
||||
color: var(--wp-text);
|
||||
background: color-mix(in srgb, var(--wp-text-dim, var(--wp-text3)) 12%, transparent);
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.rvc__tok {
|
||||
margin: 0 1px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
.rvc__tok--ref,
|
||||
.rvc__tok--var {
|
||||
border-radius: 3px;
|
||||
padding: 0 5px;
|
||||
gap: 3px;
|
||||
border: 1px solid;
|
||||
font: 10px/1.4 var(--wp-font-mono);
|
||||
}
|
||||
.rvc__tok-icon { font-size: 8px; opacity: 0.75; }
|
||||
.rvc__tok-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: 2px;
|
||||
color: var(--wp-status-modified, #fbbf24);
|
||||
}
|
||||
.rvc__tok-funnel { font-size: 8px; line-height: 1; }
|
||||
.rvc__tok-nonull { font-size: 8px; line-height: 1; opacity: 0.85; }
|
||||
.rvc__tok--ref {
|
||||
background: color-mix(in srgb, var(--wp-kind-wildcard, #a855f7) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--wp-kind-wildcard, #a855f7) 50%, transparent);
|
||||
color: var(--wp-kind-wildcard);
|
||||
}
|
||||
.rvc__tok--ref.rvc__tok--unresolved {
|
||||
background: color-mix(in srgb, var(--wp-danger, #ef4444) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--wp-danger, #ef4444) 50%, transparent);
|
||||
color: var(--wp-danger, #ef4444);
|
||||
cursor: help;
|
||||
}
|
||||
.rvc__tok--var {
|
||||
background: color-mix(in srgb, var(--wp-success, #22c55e) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--wp-success, #22c55e) 50%, transparent);
|
||||
color: var(--wp-success);
|
||||
}
|
||||
.rvc__tok--brace {
|
||||
color: var(--wp-warn, #fcd34d);
|
||||
font-weight: 600;
|
||||
}
|
||||
.rvc__tok--multi {
|
||||
color: var(--wp-rt-token-good, #4ad4c4);
|
||||
font-weight: 500;
|
||||
}
|
||||
.rvc__tok--escape {
|
||||
color: var(--wp-text-muted, var(--wp-text2));
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -8,10 +8,28 @@
|
||||
// / edit-mode UI here.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import RulesSection from "./RulesSection.vue";
|
||||
import RichTextInput from "../../../../../manager/components/RichTextInput.vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
|
||||
// The override fields are RichTextInput hosts loaded via defineAsyncComponent
|
||||
// (lazy chunk — keeps the bundle split). `flushPromises()` resolves the async
|
||||
// wrapper so `findAllComponents(RichTextInput)` matches the real instance, and
|
||||
// driving the field is `vm.$emit("update:modelValue", …)` rather than the
|
||||
// old `<input>` value-set + "input" event.
|
||||
async function expandAndFindRti(
|
||||
w: ReturnType<typeof mount>,
|
||||
ruleId: string,
|
||||
testId: string,
|
||||
) {
|
||||
await w.find(`[data-test="rule-head-${ruleId}"]`).trigger("click");
|
||||
await flushPromises();
|
||||
return w
|
||||
.findAllComponents(RichTextInput)
|
||||
.find((c) => c.attributes("data-test") === testId);
|
||||
}
|
||||
|
||||
interface DerivationBranch {
|
||||
condition: { var: string; op: string; value: string };
|
||||
action: { target_var: string; mode: string; value: string };
|
||||
@@ -192,19 +210,21 @@ describe("derivation RulesSection (tier-D accordion + branch table)", () => {
|
||||
props: { module: makeModule([multiRule()]) },
|
||||
});
|
||||
await w.find('[data-test="rule-head-r1"]').trigger("click");
|
||||
expect(w.find('[data-test="action-override-r1-0"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="action-override-r1-1"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="action-override-r1-else"]').exists()).toBe(true);
|
||||
await flushPromises();
|
||||
const rtis = w.findAllComponents(RichTextInput);
|
||||
const has = (t: string) => rtis.some((c) => c.attributes("data-test") === t);
|
||||
expect(has("action-override-r1-0")).toBe(true);
|
||||
expect(has("action-override-r1-1")).toBe(true);
|
||||
expect(has("action-override-r1-else")).toBe(true);
|
||||
});
|
||||
|
||||
it("typing into IF action override emits action_value_overrides patch", async () => {
|
||||
const w = mount(RulesSection, {
|
||||
props: { module: makeModule([multiRule()]) },
|
||||
});
|
||||
await w.find('[data-test="rule-head-r1"]').trigger("click");
|
||||
const input = w.find<HTMLInputElement>('[data-test="action-override-r1-0"]');
|
||||
input.element.value = "fiery";
|
||||
await input.trigger("input");
|
||||
const input = await expandAndFindRti(w, "r1", "action-override-r1-0");
|
||||
input!.vm.$emit("update:modelValue", "fiery");
|
||||
await w.vm.$nextTick();
|
||||
const patch = lastPatch(w);
|
||||
expect(patch.instance?.action_value_overrides).toEqual({
|
||||
r1: { "0": "fiery" },
|
||||
@@ -215,10 +235,9 @@ describe("derivation RulesSection (tier-D accordion + branch table)", () => {
|
||||
const w = mount(RulesSection, {
|
||||
props: { module: makeModule([multiRule()]) },
|
||||
});
|
||||
await w.find('[data-test="rule-head-r1"]').trigger("click");
|
||||
const input = w.find<HTMLInputElement>('[data-test="action-override-r1-else"]');
|
||||
input.element.value = "blank";
|
||||
await input.trigger("input");
|
||||
const input = await expandAndFindRti(w, "r1", "action-override-r1-else");
|
||||
input!.vm.$emit("update:modelValue", "blank");
|
||||
await w.vm.$nextTick();
|
||||
expect(lastPatch(w).instance?.action_value_overrides).toEqual({
|
||||
r1: { else: "blank" },
|
||||
});
|
||||
@@ -232,10 +251,9 @@ describe("derivation RulesSection (tier-D accordion + branch table)", () => {
|
||||
}),
|
||||
},
|
||||
});
|
||||
await w.find('[data-test="rule-head-r1"]').trigger("click");
|
||||
const input = w.find<HTMLInputElement>('[data-test="action-override-r1-0"]');
|
||||
input.element.value = "";
|
||||
await input.trigger("input");
|
||||
const input = await expandAndFindRti(w, "r1", "action-override-r1-0");
|
||||
input!.vm.$emit("update:modelValue", "");
|
||||
await w.vm.$nextTick();
|
||||
expect(lastPatch(w).instance?.action_value_overrides).toBeNull();
|
||||
});
|
||||
|
||||
@@ -246,19 +264,21 @@ describe("derivation RulesSection (tier-D accordion + branch table)", () => {
|
||||
props: { module: makeModule([multiRule()]) },
|
||||
});
|
||||
await w.find('[data-test="rule-head-r1"]').trigger("click");
|
||||
expect(w.find('[data-test="cond-override-r1-0"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="cond-override-r1-1"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="cond-override-r1-else"]').exists()).toBe(false);
|
||||
await flushPromises();
|
||||
const rtis = w.findAllComponents(RichTextInput);
|
||||
const has = (t: string) => rtis.some((c) => c.attributes("data-test") === t);
|
||||
expect(has("cond-override-r1-0")).toBe(true);
|
||||
expect(has("cond-override-r1-1")).toBe(true);
|
||||
expect(has("cond-override-r1-else")).toBe(false);
|
||||
});
|
||||
|
||||
it("typing into IF condition override emits condition_value_overrides patch", async () => {
|
||||
const w = mount(RulesSection, {
|
||||
props: { module: makeModule([multiRule()]) },
|
||||
});
|
||||
await w.find('[data-test="rule-head-r1"]').trigger("click");
|
||||
const input = w.find<HTMLInputElement>('[data-test="cond-override-r1-0"]');
|
||||
input.element.value = "purple";
|
||||
await input.trigger("input");
|
||||
const input = await expandAndFindRti(w, "r1", "cond-override-r1-0");
|
||||
input!.vm.$emit("update:modelValue", "purple");
|
||||
await w.vm.$nextTick();
|
||||
expect(lastPatch(w).instance?.condition_value_overrides).toEqual({
|
||||
r1: { "0": "purple" },
|
||||
});
|
||||
@@ -350,3 +370,280 @@ describe("derivation RulesSection (tier-D accordion + branch table)", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── @{} nested-ref parity (chips in summary + RichTextInput overrides) ──
|
||||
//
|
||||
// The canvas derivation modal must reach full parity with the SPA
|
||||
// DerivationRuleCard editor: `@{uuid}` refs render as CHIPS in the
|
||||
// read-only rule summary, and the ACTION / ELSE-action override fields
|
||||
// become RichTextInputs with the full wildcard `@{}` machinery
|
||||
// (allowNestedRefs + the six ref-data maps + $var suggestions). The
|
||||
// CONDITION override stays var-only (engine compares condition.value
|
||||
// raw — no `@{}` carrier resolution there). Mirrors the prop sets in
|
||||
// src/manager/components/DerivationRuleCard.vue.
|
||||
|
||||
const REF_DATA = {
|
||||
varSuggestions: ["mood", "age"],
|
||||
refSuggestions: ["aabbccdd"],
|
||||
uuidToName: new Map([["aabbccdd", "color"]]),
|
||||
uuidToSubCategories: new Map([["aabbccdd", ["warm", "cool"]]]),
|
||||
uuidToHasNull: new Map([["aabbccdd", false]]),
|
||||
uuidToOptionsCount: new Map([["aabbccdd", 3]]),
|
||||
uuidToOptionTagSets: new Map([["aabbccdd", [["warm"], ["cool"]]]]),
|
||||
uuidToTagGroups: new Map([["aabbccdd", {}]]),
|
||||
};
|
||||
|
||||
/** A rule whose IF action value carries a nested `@{uuid}` ref so the
|
||||
* summary has something to chipify. */
|
||||
function refRule(): DerivationRule {
|
||||
return {
|
||||
id: "r1",
|
||||
branches: [{
|
||||
condition: { var: "color", op: "equals", value: "red" },
|
||||
action: { target_var: "mood", mode: "replace", value: "@{aabbccdd#color:warm}" },
|
||||
}],
|
||||
else: { action: { target_var: "mood", mode: "replace", value: "@{aabbccdd}" } },
|
||||
};
|
||||
}
|
||||
|
||||
describe("derivation RulesSection — @{} chip + autocomplete parity", () => {
|
||||
it("summary renders an @{} ref as a chip (not raw @{uuid} text)", () => {
|
||||
const w = mount(RulesSection, {
|
||||
props: { module: makeModule([refRule()]), ...REF_DATA },
|
||||
});
|
||||
const summary = w.find('[data-test="rule-summary-r1"]');
|
||||
// The chip carries the uuid as a data attribute (same as OptionRow).
|
||||
const chip = summary.find('[data-uuid="aabbccdd"]');
|
||||
expect(chip.exists()).toBe(true);
|
||||
// Resolved name is shown, raw hex is NOT printed verbatim.
|
||||
expect(summary.text()).toContain("color");
|
||||
expect(summary.text()).not.toContain("@{aabbccdd");
|
||||
});
|
||||
|
||||
it("action override + ELSE action override mount RichTextInput with allowNestedRefs + ref-data", async () => {
|
||||
const w = mount(RulesSection, {
|
||||
props: { module: makeModule([refRule()]), ...REF_DATA },
|
||||
});
|
||||
await w.find('[data-test="rule-head-r1"]').trigger("click");
|
||||
await flushPromises();
|
||||
const rtis = w.findAllComponents(RichTextInput);
|
||||
const byTest = (t: string) => rtis.find((c) => c.attributes("data-test") === t);
|
||||
|
||||
const actVal = byTest("action-override-r1-0");
|
||||
const elseVal = byTest("action-override-r1-else");
|
||||
expect(actVal).toBeDefined();
|
||||
expect(elseVal).toBeDefined();
|
||||
|
||||
for (const rti of [actVal!, elseVal!]) {
|
||||
expect(rti.props("surface")).toBe("derivation");
|
||||
expect(rti.props("allowNestedRefs")).toBe(true);
|
||||
expect(rti.props("refSuggestions")).toEqual(["aabbccdd"]);
|
||||
expect(rti.props("varSuggestions")).toEqual(["mood", "age"]);
|
||||
expect(rti.props("uuidToName")).toBeInstanceOf(Map);
|
||||
expect(rti.props("uuidToSubCategories")).toBeInstanceOf(Map);
|
||||
expect(rti.props("uuidToHasNull")).toBeInstanceOf(Map);
|
||||
expect(rti.props("uuidToOptionsCount")).toBeInstanceOf(Map);
|
||||
expect(rti.props("uuidToOptionTagSets")).toBeInstanceOf(Map);
|
||||
expect(rti.props("uuidToTagGroups")).toBeInstanceOf(Map);
|
||||
}
|
||||
});
|
||||
|
||||
it("condition override mounts RichTextInput WITHOUT allowNestedRefs (raw compare) but WITH var-suggestions", async () => {
|
||||
const w = mount(RulesSection, {
|
||||
props: { module: makeModule([refRule()]), ...REF_DATA },
|
||||
});
|
||||
await w.find('[data-test="rule-head-r1"]').trigger("click");
|
||||
await flushPromises();
|
||||
const rtis = w.findAllComponents(RichTextInput);
|
||||
const condVal = rtis.find((c) => c.attributes("data-test") === "cond-override-r1-0");
|
||||
expect(condVal).toBeDefined();
|
||||
expect(condVal!.props("surface")).toBe("derivation");
|
||||
// condition.value is compared RAW → no @{} carrier machinery.
|
||||
expect(condVal!.props("allowNestedRefs")).toBeFalsy();
|
||||
// …but $var autocomplete stays available (parity with the SPA field).
|
||||
expect(condVal!.props("varSuggestions")).toEqual(["mood", "age"]);
|
||||
// No ref-data passed to the condition input (empty default Map).
|
||||
expect((condVal!.props("refSuggestions") as string[] | undefined) ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it("typing into the action-override RichTextInput emits action_value_overrides patch", async () => {
|
||||
const w = mount(RulesSection, {
|
||||
props: { module: makeModule([refRule()]), ...REF_DATA },
|
||||
});
|
||||
const actVal = await expandAndFindRti(w, "r1", "action-override-r1-0");
|
||||
actVal!.vm.$emit("update:modelValue", "fiery");
|
||||
await w.vm.$nextTick();
|
||||
const updates = w.emitted("update")! as unknown[][];
|
||||
const patch = updates[updates.length - 1][0] as Partial<ModuleEntry>;
|
||||
expect(patch.instance?.action_value_overrides).toEqual({ r1: { "0": "fiery" } });
|
||||
});
|
||||
|
||||
it("clearing the action-override RichTextInput back to empty collapses the override to null", async () => {
|
||||
const w = mount(RulesSection, {
|
||||
props: {
|
||||
module: makeModule([refRule()], { action_value_overrides: { r1: { "0": "fiery" } } }),
|
||||
...REF_DATA,
|
||||
},
|
||||
});
|
||||
const actVal = await expandAndFindRti(w, "r1", "action-override-r1-0");
|
||||
actVal!.vm.$emit("update:modelValue", "");
|
||||
await w.vm.$nextTick();
|
||||
const updates = w.emitted("update")! as unknown[][];
|
||||
const patch = updates[updates.length - 1][0] as Partial<ModuleEntry>;
|
||||
expect(patch.instance?.action_value_overrides).toBeNull();
|
||||
});
|
||||
|
||||
// Filter-pop-up seam: picking an `@` ref whose target declares
|
||||
// sub_categories must open the SubcategoryFilterPicker from inside the
|
||||
// canvas action-override input. Driven via RichTextInput's test seams
|
||||
// (jsdom can't fake the real keyboard path). This exercises the
|
||||
// picker-open code path — real-browser z-index/positioning over the
|
||||
// litegraph modal still needs a human check (see report).
|
||||
it("action-override RichTextInput opens SubcategoryFilterPicker on @-pick with sub-cats", async () => {
|
||||
const w = mount(RulesSection, {
|
||||
props: { module: makeModule([refRule()]), ...REF_DATA },
|
||||
attachTo: document.body,
|
||||
});
|
||||
const actVal = await expandAndFindRti(w, "r1", "action-override-r1-0");
|
||||
const vm = actVal!.vm as unknown as {
|
||||
__triggerAutocompleteForTest: (t: "@" | "$") => void;
|
||||
__applyAutocompleteForTest: (label: string) => void;
|
||||
};
|
||||
vm.__triggerAutocompleteForTest("@");
|
||||
vm.__applyAutocompleteForTest("aabbccdd");
|
||||
await w.vm.$nextTick();
|
||||
expect(document.querySelector('[data-test="subcat-picker"]')).not.toBeNull();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Inline ↪#N constraint-pair badge in the rule summary ────────────
|
||||
//
|
||||
// When this derivation is the CARRIER of a downstream constraint (one of
|
||||
// its rule ACTION values hosts a nested `@{uuid}` ref the constraint
|
||||
// reaches its target through), the summary must surface a cyan `↪#N`
|
||||
// PairBadge inline — RIGHT AFTER the value chips — exactly like OptionRow
|
||||
// renders `↪#N` for a wildcard option's `@{}` ref. The badges arrive via a
|
||||
// `viaOptionPairs` map keyed by the engine branch key (`${rule_id}:${bi}` /
|
||||
// `${rule_id}:else`) — the SAME key `computePairingsFull` stamps as the
|
||||
// carrier option_id for a derivation occurrence, and the SAME map
|
||||
// ContextWidget builds for the wildcard option path. Reuses PairBadge.vue
|
||||
// (variant="option" → `↪#N`); no bespoke styling.
|
||||
import PairBadge from "../../../PairBadge.vue";
|
||||
import type { PairingBadge } from "../../../../../extension/constraint-pairs";
|
||||
|
||||
function viaPair(number: number, over: Partial<PairingBadge> = {}): PairingBadge {
|
||||
return {
|
||||
number,
|
||||
targetUuid: "ffeeddcc",
|
||||
colorIndex: 4,
|
||||
isOrphan: false,
|
||||
via: {
|
||||
carrierRowKey: "dv012345",
|
||||
carrierName: "tint",
|
||||
optionIds: ["r1:0"],
|
||||
routeChain: ["ffeeddcc"],
|
||||
},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("derivation RulesSection — inline ↪#N pair badge", () => {
|
||||
it("renders a PairBadge (↪#N) in the IF rule-head summary when the IF branch carries a via-pair", () => {
|
||||
const w = mount(RulesSection, {
|
||||
props: {
|
||||
module: makeModule([refRule()]),
|
||||
...REF_DATA,
|
||||
viaOptionPairs: new Map([["r1:0", [viaPair(1)]]]),
|
||||
},
|
||||
});
|
||||
const summary = w.find('[data-test="rule-summary-r1"]');
|
||||
const badge = summary.findComponent(PairBadge);
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.props("variant")).toBe("option");
|
||||
expect(summary.text()).toContain("↪#1");
|
||||
});
|
||||
|
||||
it("does NOT render a PairBadge in the summary when no via-pairs are supplied", () => {
|
||||
const w = mount(RulesSection, {
|
||||
props: { module: makeModule([refRule()]), ...REF_DATA },
|
||||
});
|
||||
const summary = w.find('[data-test="rule-summary-r1"]');
|
||||
expect(summary.findComponent(PairBadge).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("renders the ↪#N badge inline AFTER the action value chips (mockup placement)", () => {
|
||||
const w = mount(RulesSection, {
|
||||
props: {
|
||||
module: makeModule([refRule()]),
|
||||
...REF_DATA,
|
||||
viaOptionPairs: new Map([["r1:0", [viaPair(1)]]]),
|
||||
},
|
||||
});
|
||||
const summary = w.find('[data-test="rule-summary-r1"]');
|
||||
// The badge must read AFTER the action value in the rendered text so it
|
||||
// reads "→ $mood = @color ↪#1" — not the other way round. The IF action
|
||||
// value resolves to "@color" (REF_DATA maps aabbccdd → color).
|
||||
const text = summary.text();
|
||||
expect(text).toContain("color");
|
||||
expect(text).toContain("↪#1");
|
||||
expect(text.indexOf("↪#1")).toBeGreaterThan(text.indexOf("color"));
|
||||
});
|
||||
|
||||
it("renders ↪#N badges per-branch in the expanded IF/ELIF/ELSE summary rows", async () => {
|
||||
const w = mount(RulesSection, {
|
||||
props: {
|
||||
module: makeModule([multiRule()]),
|
||||
...REF_DATA,
|
||||
viaOptionPairs: new Map<string, PairingBadge[]>([
|
||||
["r1:0", [viaPair(1)]],
|
||||
["r1:1", [viaPair(2)]],
|
||||
["r1:else", [viaPair(3)]],
|
||||
]),
|
||||
},
|
||||
});
|
||||
await w.find('[data-test="rule-head-r1"]').trigger("click");
|
||||
const ifRow = w.find('[data-test="branch-row-r1-0"]');
|
||||
const elifRow = w.find('[data-test="branch-row-r1-1"]');
|
||||
const elseRow = w.find('[data-test="branch-row-r1-else"]');
|
||||
expect(ifRow.findComponent(PairBadge).exists()).toBe(true);
|
||||
expect(ifRow.text()).toContain("↪#1");
|
||||
expect(elifRow.findComponent(PairBadge).exists()).toBe(true);
|
||||
expect(elifRow.text()).toContain("↪#2");
|
||||
expect(elseRow.findComponent(PairBadge).exists()).toBe(true);
|
||||
expect(elseRow.text()).toContain("↪#3");
|
||||
});
|
||||
|
||||
it("renders multiple ↪#N badges when several constraints route through one branch", () => {
|
||||
const w = mount(RulesSection, {
|
||||
props: {
|
||||
module: makeModule([refRule()]),
|
||||
...REF_DATA,
|
||||
viaOptionPairs: new Map([["r1:0", [viaPair(1), viaPair(2, { colorIndex: 5 })]]]),
|
||||
},
|
||||
});
|
||||
const summary = w.find('[data-test="rule-summary-r1"]');
|
||||
expect(summary.findAllComponents(PairBadge)).toHaveLength(2);
|
||||
expect(summary.text()).toContain("↪#1");
|
||||
expect(summary.text()).toContain("↪#2");
|
||||
});
|
||||
});
|
||||
|
||||
import DerivationInstanceModal from "../DerivationInstanceModal.vue";
|
||||
|
||||
describe("DerivationInstanceModal — forwards viaOptionPairs to RulesSection", () => {
|
||||
it("passes its viaOptionPairs prop through to the RulesSection", () => {
|
||||
const pairs = new Map([["r1:0", [viaPair(1)]]]);
|
||||
const w = mount(DerivationInstanceModal, {
|
||||
props: {
|
||||
module: makeModule([refRule()]),
|
||||
viaOptionPairs: pairs,
|
||||
},
|
||||
});
|
||||
const rules = w.findComponent(RulesSection);
|
||||
expect(rules.exists()).toBe(true);
|
||||
// Vue rewraps the prop through reactivity, so identity (toBe) can't hold;
|
||||
// structural equality confirms the same map content reached RulesSection.
|
||||
expect(rules.props("viaOptionPairs")).toEqual(pairs);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,10 +24,23 @@
|
||||
* - `condition_value_overrides` — same shape, but for IF + ELIF
|
||||
* conditions only (ELSE has no condition).
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, defineAsyncComponent, ref } from "vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
import type { PairingBadge } from "../../../../../extension/constraint-pairs";
|
||||
import { patchInstance } from "../../instance/patch";
|
||||
import { varColorClass } from "../../../../shared/var-color";
|
||||
import RuleValueChips from "./RuleValueChips.vue";
|
||||
import PairBadge from "../../../PairBadge.vue";
|
||||
|
||||
// Async-import the rich-text editor so its chunk stays split + is only
|
||||
// pulled in when a derivation instance modal expands a rule. RichTextInput
|
||||
// is ALREADY a lazy chunk in the extension (combine's TemplateSection imports
|
||||
// the same module) — Vite dedupes, so reusing the identical specifier adds
|
||||
// ~0 to the bundle. Bug parity: this brings `@{}` chips + autocomplete + the
|
||||
// sub-cat picker to the canvas override fields, matching the SPA editor.
|
||||
const RichTextInput = defineAsyncComponent(
|
||||
() => import("../../../../../manager/components/RichTextInput.vue"),
|
||||
);
|
||||
|
||||
interface DerivationCondition { var?: string; op?: string; value?: string }
|
||||
interface DerivationAction { target_var?: string; mode?: string; value?: string }
|
||||
@@ -38,7 +51,46 @@ interface DerivationRule {
|
||||
else?: { action?: DerivationAction };
|
||||
}
|
||||
|
||||
const props = defineProps<{ module: ModuleEntry }>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
module: ModuleEntry;
|
||||
/** `$var` autocomplete list for the override RichTextInputs —
|
||||
* upstream + sibling producer vars, forwarded by the modal. */
|
||||
varSuggestions?: string[];
|
||||
/** Library WILDCARD uuids for the `@{}` autocomplete (ACTION /
|
||||
* ELSE-action inputs only — `condition.value` is compared raw). */
|
||||
refSuggestions?: string[];
|
||||
/** The six per-wildcard maps `buildWildcardRefData` returns — used
|
||||
* both to chipify `@{uuid}` in the read-only summary and to feed the
|
||||
* ACTION-value RichTextInputs' nested-ref autocomplete + step-2
|
||||
* sub-cat picker. Empty until the modal's catalog fetch resolves. */
|
||||
uuidToName?: Map<string, string>;
|
||||
uuidToSubCategories?: Map<string, string[]>;
|
||||
uuidToHasNull?: Map<string, boolean>;
|
||||
uuidToOptionsCount?: Map<string, number>;
|
||||
uuidToOptionTagSets?: Map<string, string[][]>;
|
||||
uuidToTagGroups?: Map<string, Record<string, string[]>>;
|
||||
/** Via-nested constraint pairs that reach a downstream target through a
|
||||
* nested `@{uuid}` ref hosted in this derivation's rule ACTION values.
|
||||
* Keyed by the engine branch key (`${rule_id}:${bi}` / `${rule_id}:else`)
|
||||
* — the SAME carrier-occurrence key `computePairingsFull` stamps. Drives
|
||||
* the inline `↪#N` PairBadge after the value chips in each branch's
|
||||
* summary row, mirroring the wildcard editor's per-option badge. Empty
|
||||
* when this derivation isn't a constraint carrier. */
|
||||
viaOptionPairs?: Map<string, readonly PairingBadge[]>;
|
||||
}>(),
|
||||
{
|
||||
varSuggestions: () => [],
|
||||
refSuggestions: () => [],
|
||||
uuidToName: () => new Map(),
|
||||
uuidToSubCategories: () => new Map(),
|
||||
uuidToHasNull: () => new Map(),
|
||||
uuidToOptionsCount: () => new Map(),
|
||||
uuidToOptionTagSets: () => new Map(),
|
||||
uuidToTagGroups: () => new Map(),
|
||||
viaOptionPairs: () => new Map(),
|
||||
},
|
||||
);
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
const rules = computed<DerivationRule[]>(() => {
|
||||
@@ -78,6 +130,14 @@ function branchKey(ruleId: string, branchIdx: number | "else"): string {
|
||||
return `${ruleId}:${branchIdx}`;
|
||||
}
|
||||
|
||||
/** Via-nested constraint-pair badges for one branch occurrence, looked up
|
||||
* by its engine branch key. Empty array when this derivation isn't a
|
||||
* carrier for that branch — keeps the template `v-for` inert without a
|
||||
* surrounding `v-if`. */
|
||||
function pairBadgesFor(ruleId: string, branchIdx: number | "else"): readonly PairingBadge[] {
|
||||
return props.viaOptionPairs.get(branchKey(ruleId, branchIdx)) ?? [];
|
||||
}
|
||||
|
||||
function isBranchEnabled(ruleId: string, branchIdx: number | "else"): boolean {
|
||||
return !disabledBranchKeys.value.has(branchKey(ruleId, branchIdx));
|
||||
}
|
||||
@@ -138,13 +198,14 @@ function setOverride(
|
||||
emit("update", patchInstance(props.module, field, collapsed));
|
||||
}
|
||||
|
||||
function onActionOverrideInput(ruleId: string, key: string, ev: Event): void {
|
||||
const value = (ev.target as HTMLInputElement).value;
|
||||
// RichTextInput emits the resolved string directly (not a DOM Event), so
|
||||
// these handlers take the value as-is. `setOverride` already collapses an
|
||||
// empty string to a dropped entry / null map.
|
||||
function onActionOverrideInput(ruleId: string, key: string, value: string): void {
|
||||
setOverride("action_value_overrides", ruleId, key, value);
|
||||
}
|
||||
|
||||
function onCondOverrideInput(ruleId: string, key: string, ev: Event): void {
|
||||
const value = (ev.target as HTMLInputElement).value;
|
||||
function onCondOverrideInput(ruleId: string, key: string, value: string): void {
|
||||
setOverride("condition_value_overrides", ruleId, key, value);
|
||||
}
|
||||
|
||||
@@ -371,14 +432,24 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
<span
|
||||
v-if="opUsesValue(rule.branches[0].condition?.op)"
|
||||
class="rule-tok-val"
|
||||
>{{ rule.branches[0].condition?.value || "?" }}</span>
|
||||
><RuleValueChips :value="rule.branches[0].condition?.value" :uuid-to-name="uuidToName" /></span>
|
||||
<span class="rule-tok-arrow">→</span>
|
||||
<span
|
||||
v-if="rule.branches[0].action?.target_var"
|
||||
:class="['rule-tok-var', varColorClass(rule.branches[0].action.target_var)]"
|
||||
>${{ rule.branches[0].action.target_var }}</span>
|
||||
<span class="rule-tok-op">{{ modeLabel(rule.branches[0].action?.mode) }}</span>
|
||||
<span class="rule-tok-val">{{ rule.branches[0].action?.value || "?" }}</span>
|
||||
<span class="rule-tok-val"><RuleValueChips :value="rule.branches[0].action?.value" :uuid-to-name="uuidToName" /></span>
|
||||
<!-- Inline ↪#N constraint-pair badge — rendered when this
|
||||
derivation is a constraint carrier through the IF branch's
|
||||
nested `@{uuid}` ref. Mirrors OptionRow's per-option badge
|
||||
so the summary reads "@{tint} ↪#1". -->
|
||||
<PairBadge
|
||||
v-for="p in pairBadgesFor(rule.id, 0)"
|
||||
:key="`${p.number}-${p.targetUuid}`"
|
||||
:pair="p"
|
||||
variant="option"
|
||||
/>
|
||||
</template>
|
||||
</span>
|
||||
|
||||
@@ -456,38 +527,64 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
<span
|
||||
v-if="opUsesValue(branch.condition?.op)"
|
||||
class="rule-tok-val"
|
||||
>{{ branch.condition?.value || "?" }}</span>
|
||||
><RuleValueChips :value="branch.condition?.value" :uuid-to-name="uuidToName" /></span>
|
||||
<span class="rule-tok-arrow">→</span>
|
||||
<span
|
||||
v-if="branch.action?.target_var"
|
||||
:class="['rule-tok-var', varColorClass(branch.action.target_var)]"
|
||||
>${{ branch.action.target_var }}</span>
|
||||
<span class="rule-tok-op">{{ modeLabel(branch.action?.mode) }}</span>
|
||||
<span class="rule-tok-val">{{ branch.action?.value || "?" }}</span>
|
||||
<span class="rule-tok-val"><RuleValueChips :value="branch.action?.value" :uuid-to-name="uuidToName" /></span>
|
||||
<PairBadge
|
||||
v-for="p in pairBadgesFor(rule.id, bi)"
|
||||
:key="`${p.number}-${p.targetUuid}`"
|
||||
:pair="p"
|
||||
variant="option"
|
||||
/>
|
||||
</span>
|
||||
<span class="branch-cell branch-cell--cond-override">
|
||||
<input
|
||||
<!-- condition.value override — RichTextInput on the
|
||||
`derivation` surface gives `$var` chips/autocomplete, but
|
||||
NO `@{}` machinery: the engine compares condition.value
|
||||
RAW (never resolves refs/vars there). Matches the SPA
|
||||
DerivationRuleCard condition field. -->
|
||||
<RichTextInput
|
||||
v-if="opUsesValue(branch.condition?.op)"
|
||||
type="text"
|
||||
surface="derivation"
|
||||
:var-suggestions="varSuggestions"
|
||||
:uuid-to-name="uuidToName"
|
||||
:model-value="getOverride('condition_value_overrides', rule.id, String(bi))"
|
||||
:placeholder="branch.condition?.value || ''"
|
||||
class="branch-override-input"
|
||||
:class="{ 'branch-override-input--mod': getOverride('condition_value_overrides', rule.id, String(bi)) !== '' }"
|
||||
:value="getOverride('condition_value_overrides', rule.id, String(bi))"
|
||||
:placeholder="branch.condition?.value || ''"
|
||||
:data-test="`cond-override-${rule.id}-${bi}`"
|
||||
:aria-label="`Condition value override for rule ${rule.id} branch ${bi}`"
|
||||
@input="(ev) => onCondOverrideInput(rule.id, String(bi), ev)"
|
||||
@update:model-value="(v: string) => onCondOverrideInput(rule.id, String(bi), v)"
|
||||
/>
|
||||
</span>
|
||||
<span class="branch-cell branch-cell--action-override">
|
||||
<input
|
||||
type="text"
|
||||
<!-- action.value override — full `@{}` carrier machinery
|
||||
(allow-nested-refs + the six ref-data maps): the engine
|
||||
resolves `@{}` here post-Layer-A. Matches the SPA
|
||||
DerivationRuleCard action field. -->
|
||||
<RichTextInput
|
||||
surface="derivation"
|
||||
allow-nested-refs
|
||||
:var-suggestions="varSuggestions"
|
||||
:ref-suggestions="refSuggestions"
|
||||
:uuid-to-name="uuidToName"
|
||||
:uuid-to-sub-categories="uuidToSubCategories"
|
||||
:uuid-to-has-null="uuidToHasNull"
|
||||
:uuid-to-options-count="uuidToOptionsCount"
|
||||
:uuid-to-option-tag-sets="uuidToOptionTagSets"
|
||||
:uuid-to-tag-groups="uuidToTagGroups"
|
||||
:model-value="getOverride('action_value_overrides', rule.id, String(bi))"
|
||||
:placeholder="branch.action?.value || ''"
|
||||
class="branch-override-input"
|
||||
:class="{ 'branch-override-input--mod': getOverride('action_value_overrides', rule.id, String(bi)) !== '' }"
|
||||
:value="getOverride('action_value_overrides', rule.id, String(bi))"
|
||||
:placeholder="branch.action?.value || ''"
|
||||
:data-test="`action-override-${rule.id}-${bi}`"
|
||||
:aria-label="`Action value override for rule ${rule.id} branch ${bi}`"
|
||||
@input="(ev) => onActionOverrideInput(rule.id, String(bi), ev)"
|
||||
@update:model-value="(v: string) => onActionOverrideInput(rule.id, String(bi), v)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -520,20 +617,37 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
:class="['rule-tok-var', varColorClass(rule.else.action.target_var)]"
|
||||
>${{ rule.else.action.target_var }}</span>
|
||||
<span class="rule-tok-op">{{ modeLabel(rule.else.action?.mode) }}</span>
|
||||
<span class="rule-tok-val">{{ rule.else.action?.value || "?" }}</span>
|
||||
<span class="rule-tok-val"><RuleValueChips :value="rule.else.action?.value" :uuid-to-name="uuidToName" /></span>
|
||||
<PairBadge
|
||||
v-for="p in pairBadgesFor(rule.id, 'else')"
|
||||
:key="`${p.number}-${p.targetUuid}`"
|
||||
:pair="p"
|
||||
variant="option"
|
||||
/>
|
||||
</span>
|
||||
<!-- ELSE has no condition → no condition override cell -->
|
||||
<span class="branch-cell branch-cell--cond-override"></span>
|
||||
<span class="branch-cell branch-cell--action-override">
|
||||
<input
|
||||
type="text"
|
||||
<!-- ELSE action.value override — full `@{}` carrier machinery,
|
||||
same as the IF/ELIF action field. -->
|
||||
<RichTextInput
|
||||
surface="derivation"
|
||||
allow-nested-refs
|
||||
:var-suggestions="varSuggestions"
|
||||
:ref-suggestions="refSuggestions"
|
||||
:uuid-to-name="uuidToName"
|
||||
:uuid-to-sub-categories="uuidToSubCategories"
|
||||
:uuid-to-has-null="uuidToHasNull"
|
||||
:uuid-to-options-count="uuidToOptionsCount"
|
||||
:uuid-to-option-tag-sets="uuidToOptionTagSets"
|
||||
:uuid-to-tag-groups="uuidToTagGroups"
|
||||
:model-value="getOverride('action_value_overrides', rule.id, 'else')"
|
||||
:placeholder="rule.else.action?.value || ''"
|
||||
class="branch-override-input"
|
||||
:class="{ 'branch-override-input--mod': getOverride('action_value_overrides', rule.id, 'else') !== '' }"
|
||||
:value="getOverride('action_value_overrides', rule.id, 'else')"
|
||||
:placeholder="rule.else.action?.value || ''"
|
||||
:data-test="`action-override-${rule.id}-else`"
|
||||
:aria-label="`ELSE action value override for rule ${rule.id}`"
|
||||
@input="(ev) => onActionOverrideInput(rule.id, 'else', ev)"
|
||||
@update:model-value="(v: string) => onActionOverrideInput(rule.id, 'else', v)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -579,6 +693,9 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
/* Long rule lists scroll in place so identity / footer stay visible. */
|
||||
max-height: 44vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Rule card */
|
||||
@@ -752,30 +869,29 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
}
|
||||
.branch-toggle .pi { font-size: 8px; }
|
||||
|
||||
/* The override fields are now RichTextInput hosts (atomic-chip editors)
|
||||
* rather than bare `<input>`s — RichTextInput supplies its own `.wp-rt`
|
||||
* border / background / padding / mono font. We only own the grid-cell
|
||||
* sizing here + the `--mod` accent (same minimal approach combine's
|
||||
* TemplateSection uses with `.wp-tpl__input--mod`). The `:deep()` rules
|
||||
* tighten the embedded host to the compact 10px branch-table scale so the
|
||||
* canvas override field doesn't tower over the summary row. */
|
||||
.branch-override-input {
|
||||
width: 100%;
|
||||
background: var(--wp-bg2);
|
||||
border: 1px dashed var(--wp-border);
|
||||
border-radius: 3px;
|
||||
padding: 3px 6px;
|
||||
font: 10px var(--wp-font-mono);
|
||||
color: var(--wp-text);
|
||||
min-width: 0;
|
||||
}
|
||||
.branch-override-input:focus {
|
||||
border-color: var(--wp-accent);
|
||||
outline: none;
|
||||
.branch-override-input :deep(.wp-rt) {
|
||||
font: 10px var(--wp-font-mono);
|
||||
border-style: dashed;
|
||||
}
|
||||
.branch-override-input--mod {
|
||||
.branch-override-input :deep(.wp-rt__host) {
|
||||
padding: 3px 6px;
|
||||
}
|
||||
.branch-override-input--mod :deep(.wp-rt) {
|
||||
border-style: solid;
|
||||
border-color: var(--wp-accent);
|
||||
color: var(--wp-accent-text, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--wp-accent) 6%, var(--wp-bg2));
|
||||
}
|
||||
.branch-override-input::placeholder {
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Token coloring (re-used from prior list rendering) */
|
||||
.rule-tok-var { font-weight: 600; }
|
||||
|
||||
@@ -66,6 +66,7 @@ function onSpaClick(): void {
|
||||
|
||||
<IdentitySection :module="module" @update="onUpdate" />
|
||||
<ValuesSection :module="module" @update="onUpdate" />
|
||||
<div class="wp-fvm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-fvm__foot">
|
||||
@@ -105,6 +106,7 @@ function onSpaClick(): void {
|
||||
<button type="button" class="wp-fvm__btn" data-test="fvm-cancel" @click="emit('cancel')">Cancel</button>
|
||||
<button type="button" class="wp-fvm__btn wp-fvm__btn--primary" data-test="fvm-save" @click="emit('save')">Save</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -118,13 +120,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-fvm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-fvm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -48,6 +48,25 @@ describe("ValueRow", () => {
|
||||
expect(w.emitted("update")?.[0]).toEqual(["v1", { value: "50mm" }]);
|
||||
});
|
||||
|
||||
it("strips comma + punctuation from the name on input (value names must be identifiers)", async () => {
|
||||
// The name becomes a `$var`; only identifier chars allowed. The VALUE
|
||||
// field (below) is free-form and must NOT be sanitized.
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
|
||||
const input = w.find<HTMLInputElement>('[data-test="row-name"]');
|
||||
input.element.value = "cam,era;lens!";
|
||||
await input.trigger("input");
|
||||
expect(w.emitted("update")?.[0]).toEqual(["v1", { name: "cameralens" }]);
|
||||
expect(input.element.value).toBe("cameralens");
|
||||
});
|
||||
|
||||
it("does NOT strip punctuation from the value field (values are free-form)", async () => {
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
|
||||
const input = w.find<HTMLInputElement>('[data-test="row-value"]');
|
||||
input.element.value = "85mm, f/1.8!";
|
||||
await input.trigger("input");
|
||||
expect(w.emitted("update")?.[0]).toEqual(["v1", { value: "85mm, f/1.8!" }]);
|
||||
});
|
||||
|
||||
it("library row with no override is plain (no --mod, no reset button)", () => {
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
|
||||
expect(w.find('[data-test="row-name-wrap"]').classes()).not.toContain("row__name-wrap--mod");
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type LibraryRow,
|
||||
} from "../defaults";
|
||||
import { tokenize, type PreviewToken } from "../../_shared/preview-tokens";
|
||||
import { stripNonIdentifierChars } from "../../../../../manager/utils/slug";
|
||||
|
||||
const props = defineProps<{
|
||||
row: DraftRow;
|
||||
@@ -53,7 +54,12 @@ function onToggle(): void {
|
||||
emit("toggle", props.row.id);
|
||||
}
|
||||
function onNameInput(ev: Event): void {
|
||||
emit("update", props.row.id, { name: (ev.target as HTMLInputElement).value });
|
||||
const el = ev.target as HTMLInputElement;
|
||||
// The name becomes a `$var` — strip non-identifier chars on keystroke +
|
||||
// force the DOM. The value field stays free-form (onValueInput unchanged).
|
||||
const name = stripNonIdentifierChars(el.value);
|
||||
if (el.value !== name) el.value = name;
|
||||
emit("update", props.row.id, { name });
|
||||
}
|
||||
function onValueInput(ev: Event): void {
|
||||
emit("update", props.row.id, { value: (ev.target as HTMLInputElement).value });
|
||||
|
||||
@@ -183,9 +183,14 @@ const addedCount = computed(() => draftRows.value.filter((r) => r.libraryId ===
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
/* Long value lists scroll in place; the header row stays pinned. */
|
||||
max-height: 42vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.vals__head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 22px 180px 1fr 28px 28px;
|
||||
gap: 10px;
|
||||
|
||||
@@ -29,10 +29,10 @@ describe("pruneStaleInstanceRefs — wildcard", () => {
|
||||
expect(result.warnings.length).toBe(1);
|
||||
});
|
||||
|
||||
it("drops stale category_filter values", () => {
|
||||
const inst = { category_filter: ["cat1", "ghost"] };
|
||||
it("drops stale category_filter tokens from the expression", () => {
|
||||
const inst = { category_filter: "cat1 or ghost" };
|
||||
const result = pruneStaleInstanceRefs(inst, newPayload, "wildcard");
|
||||
expect(result.instance?.category_filter).toEqual(["cat1"]);
|
||||
expect(result.instance?.category_filter).toBe("cat1");
|
||||
expect(result.warnings.length).toBe(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { removeSubcatFromExpr } from "@/manager/cascade/subcatExprCascade";
|
||||
import { parse as parseSubcatExpr, type Ast } from "@/manager/parsing/subcatFilter";
|
||||
|
||||
import type { ModuleEntry } from "../../../../widgets/_shared";
|
||||
import type { ModuleKind } from "../_shell";
|
||||
import { encodeKey } from "./keys";
|
||||
|
||||
type Instance = NonNullable<ModuleEntry["instance"]>;
|
||||
|
||||
/** Distinct sub-category tags referenced by a parsed filter expression. */
|
||||
function exprTags(ast: Ast | null): string[] {
|
||||
if (!ast) return [];
|
||||
if ("tag" in ast) return [ast.tag];
|
||||
if (ast.op === "not") return exprTags(ast.x);
|
||||
return ast.kids.flatMap(exprTags);
|
||||
}
|
||||
|
||||
export interface PruneResult {
|
||||
instance: Instance;
|
||||
warnings: string[];
|
||||
@@ -55,14 +66,16 @@ export function pruneStaleInstanceRefs(
|
||||
out.pinned_option_id = null;
|
||||
out.mode = "random";
|
||||
}
|
||||
if (Array.isArray(out.category_filter)) {
|
||||
const before = out.category_filter;
|
||||
const after = before.filter((c) => subCats.has(c));
|
||||
if (after.length !== before.length) {
|
||||
for (const dropped of before.filter((c) => !subCats.has(c))) {
|
||||
if (typeof out.category_filter === "string" && out.category_filter.trim()) {
|
||||
const stale = [...new Set(exprTags(parseSubcatExpr(out.category_filter)))]
|
||||
.filter((c) => !subCats.has(c));
|
||||
if (stale.length > 0) {
|
||||
let expr = out.category_filter;
|
||||
for (const dropped of stale) {
|
||||
warnings.push(`category_filter: dropped stale category '${dropped}'`);
|
||||
expr = removeSubcatFromExpr(expr, dropped);
|
||||
}
|
||||
out.category_filter = after.length > 0 ? after : null;
|
||||
out.category_filter = expr || null;
|
||||
}
|
||||
}
|
||||
} else if (kind === "derivation") {
|
||||
|
||||
@@ -14,10 +14,10 @@ describe("DisabledRulesSection", () => {
|
||||
const wrapper = renderSection(DisabledRulesSection, {
|
||||
library: lib, modelValue: null,
|
||||
});
|
||||
const checks = wrapper.findAll('input[data-test^="dr-cb-"]');
|
||||
const checks = wrapper.findAll('[data-test^="dr-cb-"]');
|
||||
expect(checks).toHaveLength(3);
|
||||
checks.forEach((c) => {
|
||||
expect((c.element as HTMLInputElement).checked).toBe(false);
|
||||
expect(c.attributes("aria-checked")).toBe("false");
|
||||
});
|
||||
expect(wrapper.find('[data-test="dr-reset"]').exists()).toBe(false);
|
||||
});
|
||||
@@ -26,9 +26,9 @@ describe("DisabledRulesSection", () => {
|
||||
const wrapper = renderSection(DisabledRulesSection, {
|
||||
library: lib, modelValue: ["r1", "r3"],
|
||||
});
|
||||
expect((wrapper.find('input[data-test="dr-cb-r1"]').element as HTMLInputElement).checked).toBe(true);
|
||||
expect((wrapper.find('input[data-test="dr-cb-r2"]').element as HTMLInputElement).checked).toBe(false);
|
||||
expect((wrapper.find('input[data-test="dr-cb-r3"]').element as HTMLInputElement).checked).toBe(true);
|
||||
expect(wrapper.find('[data-test="dr-cb-r1"]').attributes("aria-checked")).toBe("true");
|
||||
expect(wrapper.find('[data-test="dr-cb-r2"]').attributes("aria-checked")).toBe("false");
|
||||
expect(wrapper.find('[data-test="dr-cb-r3"]').attributes("aria-checked")).toBe("true");
|
||||
expect(wrapper.find('[data-test="dr-reset"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -36,9 +36,8 @@ describe("DisabledRulesSection", () => {
|
||||
const wrapper = renderSection(DisabledRulesSection, {
|
||||
library: lib, modelValue: null,
|
||||
});
|
||||
const cb = wrapper.find('input[data-test="dr-cb-r2"]');
|
||||
(cb.element as HTMLInputElement).checked = true;
|
||||
await cb.trigger("change");
|
||||
const cb = wrapper.find('[data-test="dr-cb-r2"]');
|
||||
await cb.trigger("click");
|
||||
const next = getEmittedUpdate<string[] | null>(wrapper);
|
||||
expect(next).toEqual(["r2"]);
|
||||
});
|
||||
@@ -47,9 +46,8 @@ describe("DisabledRulesSection", () => {
|
||||
const wrapper = renderSection(DisabledRulesSection, {
|
||||
library: lib, modelValue: ["r1"],
|
||||
});
|
||||
const cb = wrapper.find('input[data-test="dr-cb-r3"]');
|
||||
(cb.element as HTMLInputElement).checked = true;
|
||||
await cb.trigger("change");
|
||||
const cb = wrapper.find('[data-test="dr-cb-r3"]');
|
||||
await cb.trigger("click");
|
||||
const next = getEmittedUpdate<string[] | null>(wrapper);
|
||||
expect(next).toContain("r1");
|
||||
expect(next).toContain("r3");
|
||||
@@ -60,9 +58,8 @@ describe("DisabledRulesSection", () => {
|
||||
const wrapper = renderSection(DisabledRulesSection, {
|
||||
library: lib, modelValue: ["r1"],
|
||||
});
|
||||
const cb = wrapper.find('input[data-test="dr-cb-r1"]');
|
||||
(cb.element as HTMLInputElement).checked = false;
|
||||
await cb.trigger("change");
|
||||
const cb = wrapper.find('[data-test="dr-cb-r1"]');
|
||||
await cb.trigger("click");
|
||||
const next = getEmittedUpdate<string[] | null>(wrapper);
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import WpCheck from "../../../../shared/WpCheck.vue";
|
||||
|
||||
interface Rule {
|
||||
id: string;
|
||||
@@ -32,14 +33,13 @@ function emitList(ids: string[]): void {
|
||||
emit("update:modelValue", ids);
|
||||
}
|
||||
|
||||
function onToggle(id: string, ev: Event): void {
|
||||
const checked = (ev.target as HTMLInputElement).checked;
|
||||
function onToggle(id: string, next: boolean): void {
|
||||
const current = new Set(props.modelValue ?? []);
|
||||
if (checked) current.add(id);
|
||||
if (next) current.add(id);
|
||||
else current.delete(id);
|
||||
// Preserve library order in emitted array.
|
||||
const next = props.library.map((r) => r.id).filter((rid) => current.has(rid));
|
||||
emitList(next);
|
||||
const ordered = props.library.map((r) => r.id).filter((rid) => current.has(rid));
|
||||
emitList(ordered);
|
||||
}
|
||||
|
||||
function ruleLabel(rule: Rule): string {
|
||||
@@ -71,20 +71,20 @@ function onClickReset(): void {
|
||||
</button>
|
||||
</div>
|
||||
<div class="wp-instance-section-body wp-instance-section-body-col">
|
||||
<label
|
||||
<div
|
||||
v-for="rule in library"
|
||||
:key="rule.id"
|
||||
class="wp-instance-row"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
<WpCheck
|
||||
:data-test="`dr-cb-${rule.id}`"
|
||||
:checked="isDisabled(rule.id)"
|
||||
@change="(ev) => onToggle(rule.id, ev)"
|
||||
:model-value="isDisabled(rule.id)"
|
||||
:aria-label="`disable rule ${rule.id}`"
|
||||
@update:model-value="(v) => onToggle(rule.id, v)"
|
||||
/>
|
||||
<span class="wp-instance-row-id">{{ rule.id }}</span>
|
||||
<span class="wp-instance-row-value">{{ ruleLabel(rule) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -14,8 +14,8 @@ function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
var_binding: "outfit",
|
||||
sub_categories: ["warm", "cool"],
|
||||
options: [
|
||||
{ id: "o1", value: "red", weight: 1, sub_category: "warm" },
|
||||
{ id: "o2", value: "blue", weight: 1, sub_category: "cool" },
|
||||
{ id: "o1", value: "red", weight: 1, sub_categories: ["warm"] },
|
||||
{ id: "o2", value: "blue", weight: 1, sub_categories: ["cool"] },
|
||||
],
|
||||
},
|
||||
instance: {},
|
||||
|
||||
@@ -41,6 +41,16 @@ const emit = defineEmits<{
|
||||
// the older gate of `isLibraryTracked && isModified` is gone.
|
||||
const canSaveToLibrary = computed(() => Boolean(props.module.payload));
|
||||
|
||||
/** SP2a: when the instance picks more than one option the var resolves to a
|
||||
* list, so the header subtitle drops the singular "per pick" framing. */
|
||||
const isMultiPick = computed(() => {
|
||||
const inst = props.module.instance ?? {};
|
||||
const lo = typeof inst.pick_min === "number" ? inst.pick_min : 1;
|
||||
const hi = typeof inst.pick_max === "number" ? inst.pick_max : lo;
|
||||
const loC = Math.max(0, Math.min(lo, hi));
|
||||
return !(loC === 1 && Math.max(loC, hi) === 1);
|
||||
});
|
||||
|
||||
function spaUrl(): string {
|
||||
// SPA base is `/wp/` (see `src/manager/router/index.ts:44`),
|
||||
// so wildcard editor lives at `/wp/wildcards/<id>/edit`. The
|
||||
@@ -67,7 +77,11 @@ function onSpaClick(): void {
|
||||
<span class="wp-wcm__name" data-test="wcm-name">{{ module.meta?.name ?? module.type }}</span>
|
||||
<span class="wp-wcm__chip" data-test="wcm-chip">wildcard</span>
|
||||
</div>
|
||||
<div class="wp-wcm__sub">Library entry · weighted options resolved per pick</div>
|
||||
<div class="wp-wcm__sub" data-test="wcm-sub">
|
||||
{{ isMultiPick
|
||||
? "Library entry · resolves a list (multi-pick)"
|
||||
: "Library entry · weighted options resolved per pick" }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -87,6 +101,7 @@ function onSpaClick(): void {
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<PoolSection :module="module" :via-option-pairs="viaOptionPairs" @update="onUpdate" />
|
||||
<div class="wp-wcm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-wcm__foot">
|
||||
@@ -136,6 +151,7 @@ function onSpaClick(): void {
|
||||
Save
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -149,7 +165,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);
|
||||
@@ -157,6 +173,12 @@ function onSpaClick(): void {
|
||||
/* Head styling lives in src/components/context/editors/_modal-head.css
|
||||
* (imported once by ContextWidget). Adopted from InjectorBindingModal
|
||||
* so every per-instance edit modal reads as one design family. */
|
||||
.wp-wcm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-wcm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+138
-27
@@ -1,23 +1,67 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import OptionRow from "./OptionRow.vue";
|
||||
import { _resetForTests, _setForTests } from "../../../../../extension/preview-resolver";
|
||||
import OptionRow from "../sections/OptionRow.vue";
|
||||
import { _resetForTests, _setForTests } from "@/extension/preview-resolver";
|
||||
|
||||
const baseOption = { id: "o1", value: "red", weight: 1, sub_category: "warm" };
|
||||
const baseOption = { id: "o1", value: "red", weight: 1, sub_categories: ["warm"] };
|
||||
const allOptions = [
|
||||
baseOption,
|
||||
{ id: "o2", value: "blue", weight: 1, sub_category: "cool" },
|
||||
{ id: "o2", value: "blue", weight: 1, sub_categories: ["cool"] },
|
||||
];
|
||||
|
||||
describe("OptionRow", () => {
|
||||
beforeEach(() => _resetForTests());
|
||||
|
||||
it("renders option name + category label", () => {
|
||||
it("renders option name + a category chip per sub-category", () => {
|
||||
const w = mount(OptionRow, {
|
||||
props: { option: baseOption, allOptions, instance: {} },
|
||||
});
|
||||
expect(w.find('[data-test="opt-name"]').text()).toBe("red");
|
||||
expect(w.find('[data-test="opt-cat"]').text().toLowerCase()).toBe("warm");
|
||||
const chip = w.find('[data-test="opt-cat-warm"]');
|
||||
expect(chip.exists()).toBe(true);
|
||||
expect(chip.text().toLowerCase()).toBe("warm");
|
||||
});
|
||||
|
||||
it("renders multiple category chips for a multi-tag option", () => {
|
||||
const opt = { id: "m", value: "lynx", weight: 1, sub_categories: ["feline", "warm"] };
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
expect(w.find('[data-test="opt-cat-feline"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="opt-cat-warm"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("decomposes a {N$$…} block into scaffolding + an inner ref chip (SP2b #6)", () => {
|
||||
_setForTests("ad00acd5", { name: "mood", varBinding: "mood" });
|
||||
const opt = { id: "b1", value: "{2$$, $$@{ad00acd5}|warm}", weight: 1, sub_categories: [] };
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
// The inner @{uuid} arm renders as a resolved ref chip, not raw uuid text.
|
||||
const ref = w.find(".opt__tok--ref");
|
||||
expect(ref.exists()).toBe(true);
|
||||
expect(ref.text()).toContain("@mood");
|
||||
expect(w.find(".opt__tok--multi").exists()).toBe(true); // scaffolding coloured
|
||||
expect(w.find('[data-test="opt-name"]').text()).not.toContain("ad00acd5");
|
||||
});
|
||||
|
||||
it("shows the null-excluded ban mark on an inner ref carrying !null (SP2b #1)", () => {
|
||||
_setForTests("ad00acd5", { name: "mood", varBinding: "mood" });
|
||||
const opt = { id: "b3", value: "{3$$, $$@{ad00acd5#mood!null}}", weight: 1, sub_categories: [] };
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
const ref = w.find(".opt__tok--ref");
|
||||
expect(ref.exists()).toBe(true);
|
||||
// The leaked `!null` is detected → ban (null-excluded) indicator shows.
|
||||
expect(ref.find(".opt__tok-filter").exists()).toBe(true);
|
||||
expect(ref.find(".opt__tok-nonull").exists()).toBe(true);
|
||||
// Label is the clean name, not `@mood!null`.
|
||||
expect(ref.text()).toContain("@mood");
|
||||
expect(ref.text()).not.toContain("!null");
|
||||
});
|
||||
|
||||
it("decomposes a plain {a|@{uuid}} alternation into an inner ref chip (SP2b #6)", () => {
|
||||
_setForTests("ad00acd5", { name: "hair", varBinding: "hair" });
|
||||
const opt = { id: "b2", value: "{warm|@{ad00acd5}}", weight: 1, sub_categories: [] };
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
expect(w.find(".opt__tok--ref").exists()).toBe(true);
|
||||
expect(w.find(".opt__tok--brace").exists()).toBe(true);
|
||||
expect(w.find('[data-test="opt-name"]').text()).not.toContain("ad00acd5");
|
||||
});
|
||||
|
||||
it("checkbox is checked when enabled_options is null (library default)", () => {
|
||||
@@ -103,15 +147,15 @@ describe("OptionRow", () => {
|
||||
|
||||
it("category-filtered row gets opt--filtered modifier class", () => {
|
||||
const w = mount(OptionRow, {
|
||||
props: { option: baseOption, allOptions, instance: { category_filter: ["cool"] } },
|
||||
props: { option: baseOption, allOptions, instance: { category_filter: "cool" } },
|
||||
});
|
||||
// baseOption.sub_category = "warm", filter only allows "cool"
|
||||
// baseOption.sub_categories = ["warm"], filter only allows "cool"
|
||||
expect(w.classes()).toContain("opt--filtered");
|
||||
});
|
||||
|
||||
it("category-filtered row does NOT emit toggle on checkbox click", async () => {
|
||||
const w = mount(OptionRow, {
|
||||
props: { option: baseOption, allOptions, instance: { category_filter: ["cool"] } },
|
||||
props: { option: baseOption, allOptions, instance: { category_filter: "cool" } },
|
||||
});
|
||||
await w.find('[data-test="opt-check"]').trigger("click");
|
||||
expect(w.emitted("toggle")).toBeUndefined();
|
||||
@@ -119,7 +163,7 @@ describe("OptionRow", () => {
|
||||
|
||||
it("category-filtered row exposes aria-disabled=true on checkbox", () => {
|
||||
const w = mount(OptionRow, {
|
||||
props: { option: baseOption, allOptions, instance: { category_filter: ["cool"] } },
|
||||
props: { option: baseOption, allOptions, instance: { category_filter: "cool" } },
|
||||
});
|
||||
expect(w.find('[data-test="opt-check"]').attributes("aria-disabled")).toBe("true");
|
||||
});
|
||||
@@ -142,9 +186,6 @@ describe("OptionRow", () => {
|
||||
});
|
||||
|
||||
it("down spinner clamps weight to floor 0.01 (no zero/negative weights)", async () => {
|
||||
// Weight 0 / negative never picks (probability normalises away),
|
||||
// so the editor floors at 0.01. To disable an option entirely,
|
||||
// use the per-row toggle — engine respects that.
|
||||
const w = mount(OptionRow, {
|
||||
props: { option: baseOption, allOptions, instance: { option_weights: { o1: 0 } } },
|
||||
});
|
||||
@@ -163,40 +204,60 @@ describe("OptionRow", () => {
|
||||
});
|
||||
|
||||
it("renders @{uuid} ref token with raw uuid form when name not yet cached", () => {
|
||||
const opt = { id: "o9", value: "city @{a361dbdc} dusk", weight: 1, sub_category: "warm" };
|
||||
const opt = { id: "o9", value: "city @{a361dbdc} dusk", weight: 1, sub_categories: ["warm"] };
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
const ref = w.find('[data-test="opt-name"] .opt__tok--ref');
|
||||
expect(ref.exists()).toBe(true);
|
||||
expect(ref.attributes("data-uuid")).toBe("a361dbdc");
|
||||
// Until preview-resolver lands the lookup, raw form is the fallback.
|
||||
// Read the identity-text span so the chip icon prefix doesn't bleed in.
|
||||
expect(ref.find(".opt__tok-label").text()).toBe("@{a361dbdc}");
|
||||
});
|
||||
|
||||
it("renders @{uuid} ref as @<varBinding> once resolver caches the entry", () => {
|
||||
_setForTests("a361dbdc", { name: "subject", varBinding: "subject_name" });
|
||||
const opt = { id: "o9", value: "city @{a361dbdc} dusk", weight: 1, sub_category: "warm" };
|
||||
const opt = { id: "o9", value: "city @{a361dbdc} dusk", weight: 1, sub_categories: ["warm"] };
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
const ref = w.find('[data-test="opt-name"] .opt__tok--ref');
|
||||
expect(ref.find(".opt__tok-label").text()).toBe("@subject_name");
|
||||
});
|
||||
|
||||
it("renders a v2 boolean ref filter as funnel + ban, not inline !null text", () => {
|
||||
_setForTests("a361dbdc", { name: "mood", varBinding: "mood" });
|
||||
const opt = {
|
||||
id: "o9",
|
||||
value: "wolf @{a361dbdc:warm or intense!null}",
|
||||
weight: 1,
|
||||
sub_categories: ["canine"],
|
||||
};
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
const ref = w.find('[data-test="opt-name"] .opt__tok--ref');
|
||||
expect(ref.exists()).toBe(true);
|
||||
// The raw expression + `!null` marker must NOT appear inline anymore.
|
||||
expect(ref.text()).not.toContain("!null");
|
||||
expect(ref.text()).not.toContain("warm or intense");
|
||||
// Compact indicators instead — funnel (expression) + ban (exclude-null),
|
||||
// matching the SPA RefChip grammar.
|
||||
expect(ref.find(".opt__tok-funnel").exists()).toBe(true);
|
||||
expect(ref.find(".opt__tok-nonull").exists()).toBe(true);
|
||||
// Full normalized expression lives in the hover title, `!null` peeled.
|
||||
expect(ref.attributes("title")).toContain("warm or intense");
|
||||
expect(ref.attributes("title")).not.toContain("!null");
|
||||
});
|
||||
|
||||
it("renders {a|b|c} brace block as a brace token (warn colour)", () => {
|
||||
const opt = { id: "o9", value: "color {a|b|c}", weight: 1, sub_category: "warm" };
|
||||
const opt = { id: "o9", value: "color {a|b|c}", weight: 1, sub_categories: ["warm"] };
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
expect(w.find('[data-test="opt-name"] .opt__tok--brace').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("renders {N$$,$$a|b|c} multi-pick block as a multi token (distinct from brace)", () => {
|
||||
const opt = { id: "o10", value: "{2$$,$$silver|gold|pearl}", weight: 1, sub_category: "warm" };
|
||||
const opt = { id: "o10", value: "{2$$,$$silver|gold|pearl}", weight: 1, sub_categories: ["warm"] };
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
expect(w.find('[data-test="opt-name"] .opt__tok--multi').exists()).toBe(true);
|
||||
// Brace-class should NOT match — multi gets its own visual treatment.
|
||||
expect(w.find('[data-test="opt-name"] .opt__tok--brace').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("renders $varname as a var token", () => {
|
||||
const opt = { id: "o9", value: "uses $style here", weight: 1, sub_category: "warm" };
|
||||
const opt = { id: "o9", value: "uses $style here", weight: 1, sub_categories: ["warm"] };
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
expect(w.find('[data-test="opt-name"] .opt__tok--var .opt__tok-label').text()).toBe("$style");
|
||||
});
|
||||
@@ -206,7 +267,6 @@ describe("OptionRow", () => {
|
||||
props: { option: baseOption, allOptions, instance: { option_weights: { o1: 1.8 } } },
|
||||
});
|
||||
const input = w.find<HTMLInputElement>('[data-test="opt-weight"]');
|
||||
// baseOption.weight = 1 — matching library default should drop override
|
||||
input.element.value = "1";
|
||||
await input.trigger("input");
|
||||
expect(w.emitted("weight")?.[0]).toEqual(["o1", null]);
|
||||
@@ -225,12 +285,27 @@ describe("OptionRow", () => {
|
||||
props: { option: baseOption, allOptions, instance: { option_weights: { o1: 1.1 } } },
|
||||
});
|
||||
await w.find('[data-test="opt-weight-down"]').trigger("click");
|
||||
// 1.1 - 0.1 = 1.0 = library default → cleared
|
||||
expect(w.emitted("weight")?.[0]).toEqual(["o1", null]);
|
||||
});
|
||||
|
||||
it("colors a category chip by its tag_groups axis when provided", () => {
|
||||
const opt = { id: "m", value: "lynx", weight: 1, sub_categories: ["feline", "warm"] };
|
||||
const w = mount(OptionRow, {
|
||||
props: {
|
||||
option: opt,
|
||||
allOptions: [opt],
|
||||
instance: {},
|
||||
tagGroups: { family: ["feline"], temp: ["warm"] },
|
||||
},
|
||||
});
|
||||
// Two distinct axes → the two chips carry different axis style hooks.
|
||||
const feline = w.find('[data-test="opt-cat-feline"]');
|
||||
const warm = w.find('[data-test="opt-cat-warm"]');
|
||||
expect(feline.attributes("style")).not.toBe(warm.attributes("style"));
|
||||
});
|
||||
|
||||
describe("null option row", () => {
|
||||
const nullOpt = { id: "n1", value: "", weight: 1, is_null: true } as typeof baseOption & { is_null: boolean };
|
||||
const nullOpt = { id: "n1", value: "", weight: 1, is_null: true, sub_categories: [] };
|
||||
|
||||
it("renders the pi-ban chip in place of tokens", () => {
|
||||
const w = mount(OptionRow, {
|
||||
@@ -238,15 +313,14 @@ describe("OptionRow", () => {
|
||||
});
|
||||
expect(w.find(".opt__null-chip").exists()).toBe(true);
|
||||
expect(w.find(".opt__null-chip .pi-ban").exists()).toBe(true);
|
||||
// Label literal "null" inside the chip.
|
||||
expect(w.find(".opt__null-chip").text()).toMatch(/null/i);
|
||||
});
|
||||
|
||||
it("sub-category cell is empty", () => {
|
||||
it("category cell renders no sub-category chips", () => {
|
||||
const w = mount(OptionRow, {
|
||||
props: { option: nullOpt, allOptions: [nullOpt], instance: {} },
|
||||
});
|
||||
expect(w.find('[data-test="opt-cat"]').text()).toBe("");
|
||||
expect(w.findAll('[data-test^="opt-cat-"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("weight + check still render", () => {
|
||||
@@ -256,5 +330,42 @@ describe("OptionRow", () => {
|
||||
expect(w.find('[data-test="opt-weight"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="opt-check"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("null row is disabled (opt--off) when exclude_null is set", () => {
|
||||
const w = mount(OptionRow, {
|
||||
props: { option: nullOpt, allOptions: [nullOpt], instance: { exclude_null: true } },
|
||||
});
|
||||
expect(w.classes()).toContain("opt--off");
|
||||
});
|
||||
|
||||
it("null row is disabled + non-toggleable when multiActive (SP2a)", () => {
|
||||
const w = mount(OptionRow, {
|
||||
props: { option: nullOpt, allOptions: [nullOpt], instance: {}, multiActive: true },
|
||||
});
|
||||
expect(w.classes()).toContain("opt--off");
|
||||
expect(w.find('[data-test="opt-check"]').attributes("aria-disabled")).toBe("true");
|
||||
// The checkbox must not READ as checked — the bug was the glyph + tick
|
||||
// bound to `enabled` alone, leaving a filled blue box on the disabled
|
||||
// null row in multi-pick.
|
||||
expect(w.find('[data-test="opt-check"]').classes()).not.toContain("opt__check--on");
|
||||
expect(w.find(".opt__check-tick").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("null row stays interactive when not multiActive", () => {
|
||||
const w = mount(OptionRow, {
|
||||
props: { option: nullOpt, allOptions: [nullOpt], instance: {} },
|
||||
});
|
||||
expect(w.find('[data-test="opt-check"]').attributes("aria-disabled")).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
it("real option row is unaffected by multiActive", () => {
|
||||
const w = mount(OptionRow, {
|
||||
props: { option: baseOption, allOptions, instance: {}, multiActive: true },
|
||||
});
|
||||
expect(w.find('[data-test="opt-check"]').attributes("aria-disabled")).toBe("false");
|
||||
// Not over-gated: a real (enabled, non-null) option keeps its checked
|
||||
// glyph even while a sibling null row is locked out of the multi pool.
|
||||
expect(w.find('[data-test="opt-check"]').classes()).toContain("opt__check--on");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import PoolSection from "../sections/PoolSection.vue";
|
||||
import type { ModuleEntry } from "@/widgets/_shared";
|
||||
|
||||
const module = {
|
||||
payload: {
|
||||
options: [
|
||||
{ id: "a", value: "cat", weight: 1, sub_categories: ["feline", "warm"] },
|
||||
{ id: "b", value: "wolf", weight: 1, sub_categories: ["canine", "cold"] },
|
||||
],
|
||||
sub_categories: ["feline", "canine", "warm", "cold"],
|
||||
tag_groups: { family: ["feline", "canine"], temp: ["warm", "cold"] },
|
||||
},
|
||||
instance: {},
|
||||
} as unknown as ModuleEntry;
|
||||
|
||||
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
id: "ab12cd34",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
meta: { name: "outfit" },
|
||||
entries: [],
|
||||
payload: {
|
||||
var_binding: "outfit",
|
||||
sub_categories: ["feline", "canine", "warm", "cold"],
|
||||
tag_groups: { family: ["feline", "canine"], temp: ["warm", "cold"] },
|
||||
options: [
|
||||
{ id: "a", value: "cat", weight: 1, sub_categories: ["feline", "warm"] },
|
||||
{ id: "b", value: "wolf", weight: 1, sub_categories: ["canine", "cold"] },
|
||||
{ id: "c", value: "owl", weight: 1, sub_categories: ["warm"] },
|
||||
],
|
||||
},
|
||||
instance: {},
|
||||
...overrides,
|
||||
} as unknown as ModuleEntry;
|
||||
}
|
||||
|
||||
function lastPatch(w: ReturnType<typeof mount>): Partial<ModuleEntry> {
|
||||
const updates = w.emitted("update")!;
|
||||
return updates[updates.length - 1][0] as Partial<ModuleEntry>;
|
||||
}
|
||||
|
||||
describe("PoolSection pick-count (SP2a)", () => {
|
||||
it("min stepper emits a pick_min update", async () => {
|
||||
const w = mount(PoolSection, { props: { module: makeModule() } });
|
||||
const min = w.get('[data-test="pool-pick-min"]');
|
||||
(min.element as HTMLInputElement).value = "2";
|
||||
await min.trigger("input");
|
||||
expect(lastPatch(w).instance?.pick_min).toBe(2);
|
||||
});
|
||||
|
||||
it("max stepper emits a pick_max update", async () => {
|
||||
const w = mount(PoolSection, { props: { module: makeModule() } });
|
||||
const max = w.get('[data-test="pool-pick-max"]');
|
||||
(max.element as HTMLInputElement).value = "3";
|
||||
await max.trigger("input");
|
||||
expect(lastPatch(w).instance?.pick_max).toBe(3);
|
||||
});
|
||||
|
||||
it("'Allow repeats' toggle hides for single-pick, shows for multi + emits pick_independent (SP2c)", async () => {
|
||||
// Single-pick (default 1..1): toggle irrelevant → hidden.
|
||||
const single = mount(PoolSection, { props: { module: makeModule() } });
|
||||
expect(single.find('[data-test="pool-allow-repeats"]').exists()).toBe(false);
|
||||
// Multi-pick (1..3): toggle shows; checking it emits pick_independent=true.
|
||||
const w = mount(PoolSection, {
|
||||
props: { module: makeModule({ instance: { pick_min: 1, pick_max: 3 } } as Partial<ModuleEntry>) },
|
||||
});
|
||||
const toggle = w.find('[data-test="pool-allow-repeats"]');
|
||||
expect(toggle.exists()).toBe(true);
|
||||
await toggle.find(".wp-check").trigger("click"); // shared Checkbox toggles on box-click
|
||||
expect(lastPatch(w).instance?.pick_independent).toBe(true);
|
||||
});
|
||||
|
||||
it("clamps max UP to min when min is set above max (SP2a — no inverted range)", async () => {
|
||||
const w = mount(PoolSection, {
|
||||
props: { module: makeModule({ instance: { pick_min: 1, pick_max: 2 } } as Partial<ModuleEntry>) },
|
||||
});
|
||||
const min = w.get('[data-test="pool-pick-min"]');
|
||||
(min.element as HTMLInputElement).value = "5";
|
||||
await min.trigger("input");
|
||||
const inst = lastPatch(w).instance;
|
||||
expect(inst?.pick_min).toBe(5);
|
||||
expect(inst?.pick_max).toBe(5);
|
||||
});
|
||||
|
||||
it("clamps min DOWN to max when max is set below min (SP2a — no inverted range)", async () => {
|
||||
const w = mount(PoolSection, {
|
||||
props: { module: makeModule({ instance: { pick_min: 3, pick_max: 4 } } as Partial<ModuleEntry>) },
|
||||
});
|
||||
const max = w.get('[data-test="pool-pick-max"]');
|
||||
(max.element as HTMLInputElement).value = "1";
|
||||
await max.trigger("input");
|
||||
const inst = lastPatch(w).instance;
|
||||
expect(inst?.pick_max).toBe(1);
|
||||
expect(inst?.pick_min).toBe(1);
|
||||
});
|
||||
|
||||
it("min up-arrow button bumps pick_min by 1 (SP2a)", async () => {
|
||||
const w = mount(PoolSection, {
|
||||
props: { module: makeModule({ instance: { pick_min: 1, pick_max: 3 } } as Partial<ModuleEntry>) },
|
||||
});
|
||||
await w.get('[data-test="pool-pick-min-up"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.pick_min).toBe(2);
|
||||
});
|
||||
|
||||
it("enabled count excludes the null option in multi-pick (SP2a)", () => {
|
||||
const m = {
|
||||
id: "x", type: "wildcard", enabled: true, meta: { name: "w" }, entries: [],
|
||||
payload: {
|
||||
var_binding: "w", sub_categories: [], tag_groups: {},
|
||||
options: [
|
||||
{ id: "a", value: "cat", weight: 1, sub_categories: [] },
|
||||
{ id: "n", value: "", weight: 1, is_null: true, sub_categories: [] },
|
||||
],
|
||||
},
|
||||
instance: { pick_min: 1, pick_max: 3 },
|
||||
} as unknown as ModuleEntry;
|
||||
const w = mount(PoolSection, { props: { module: m } });
|
||||
// Only "a" counts — null is dropped from the multi pool (was wrongly 2 of 2).
|
||||
expect(w.find('[data-test="pool-summary"]').text()).toMatch(/1\s*of\s*2/);
|
||||
});
|
||||
|
||||
it("separator input appears only when max > 1", () => {
|
||||
const single = mount(PoolSection, { props: { module: makeModule() } });
|
||||
expect(single.find('[data-test="pool-pick-sep"]').exists()).toBe(false);
|
||||
const multi = mount(PoolSection, {
|
||||
props: { module: makeModule({ instance: { pick_min: 1, pick_max: 3 } } as Partial<ModuleEntry>) },
|
||||
});
|
||||
expect(multi.find('[data-test="pool-pick-sep"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("disables exclude_null when multi-pick is active", () => {
|
||||
const m = {
|
||||
id: "x", type: "wildcard", enabled: true, meta: { name: "w" }, entries: [],
|
||||
payload: {
|
||||
var_binding: "w", sub_categories: [], tag_groups: {},
|
||||
options: [
|
||||
{ id: "a", value: "cat", weight: 1, sub_categories: [] },
|
||||
{ id: "n", value: "", weight: 1, is_null: true, sub_categories: [] },
|
||||
],
|
||||
},
|
||||
instance: { pick_min: 0, pick_max: 2 },
|
||||
} as unknown as ModuleEntry;
|
||||
const w = mount(PoolSection, { props: { module: m } });
|
||||
// Inline wp-check span; locked state surfaces as aria-disabled.
|
||||
const cb = w.get('[data-test="pool-exclude-null"] .wp-check');
|
||||
expect(cb.attributes("aria-disabled")).toBe("true");
|
||||
});
|
||||
|
||||
it("pool summary surfaces the pick range when multi-pick (SP2a)", () => {
|
||||
const w = mount(PoolSection, {
|
||||
props: { module: makeModule({ instance: { pick_min: 1, pick_max: 3 } } as Partial<ModuleEntry>) },
|
||||
});
|
||||
const picks = w.find('[data-test="pool-summary-picks"]');
|
||||
expect(picks.exists()).toBe(true);
|
||||
expect(picks.text()).toBe("1–3");
|
||||
});
|
||||
|
||||
it("pool summary omits the pick range in single-pick mode", () => {
|
||||
const w = mount(PoolSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="pool-summary-picks"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PoolSection grouped quick pills", () => {
|
||||
it("ticking family=feline + temp=warm builds 'feline and warm'", async () => {
|
||||
const w = mount(PoolSection, { props: { module } });
|
||||
await w.get('[data-test="cat-chip-feline"]').trigger("click");
|
||||
await w.get('[data-test="cat-chip-warm"]').trigger("click");
|
||||
const updates = w.emitted("update")!;
|
||||
const patch = updates[updates.length - 1]![0] as Partial<ModuleEntry>;
|
||||
expect(patch.instance?.category_filter).toBe("feline and warm");
|
||||
});
|
||||
|
||||
it("shows an Advanced toggle that reveals the expression editor", async () => {
|
||||
const w = mount(PoolSection, { props: { module } });
|
||||
await w.get('[data-test="pool-advanced"]').trigger("click");
|
||||
expect(w.find('[data-test="expr-input"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("advanced palette inserts a sub-category token at the cursor (#7)", async () => {
|
||||
const w = mount(PoolSection, { props: { module } });
|
||||
await w.get('[data-test="pool-advanced"]').trigger("click");
|
||||
await w.get('[data-test="pool-ins-feline"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.category_filter).toBe("feline");
|
||||
});
|
||||
|
||||
it("ticking two tags in the same group ORs them inside parens", async () => {
|
||||
const w = mount(PoolSection, { props: { module } });
|
||||
await w.get('[data-test="cat-chip-feline"]').trigger("click");
|
||||
await w.get('[data-test="cat-chip-canine"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.category_filter).toBe("(feline or canine)");
|
||||
});
|
||||
|
||||
it("multi-term group AND'd with a single-term group keeps parens only on the multi", async () => {
|
||||
const w = mount(PoolSection, { props: { module } });
|
||||
await w.get('[data-test="cat-chip-feline"]').trigger("click");
|
||||
await w.get('[data-test="cat-chip-canine"]').trigger("click");
|
||||
await w.get('[data-test="cat-chip-warm"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.category_filter).toBe("(feline or canine) and warm");
|
||||
});
|
||||
|
||||
it("unticking the last tag clears the filter to empty string", async () => {
|
||||
const w = mount(PoolSection, {
|
||||
props: { module: makeModule({ instance: { category_filter: "feline" } }) },
|
||||
});
|
||||
await w.get('[data-test="cat-chip-feline"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.category_filter).toBe("");
|
||||
});
|
||||
|
||||
it("renders pills grouped under their axis headings", () => {
|
||||
const w = mount(PoolSection, { props: { module } });
|
||||
const groups = w.findAll('[data-test^="pool-group-"]');
|
||||
// family + temp = two axis clusters.
|
||||
expect(groups.length).toBe(2);
|
||||
});
|
||||
|
||||
it("places ungrouped tags into an 'other' cluster", () => {
|
||||
const w = mount(PoolSection, {
|
||||
props: {
|
||||
module: {
|
||||
payload: {
|
||||
options: [{ id: "a", value: "x", weight: 1, sub_categories: ["loose"] }],
|
||||
sub_categories: ["feline", "loose"],
|
||||
tag_groups: { family: ["feline"] },
|
||||
},
|
||||
instance: {},
|
||||
} as unknown as ModuleEntry,
|
||||
},
|
||||
});
|
||||
expect(w.find('[data-test="pool-group-other"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="cat-chip-loose"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PoolSection advanced expression editor", () => {
|
||||
it("prefills the expression input from the existing category_filter", async () => {
|
||||
const w = mount(PoolSection, {
|
||||
props: { module: makeModule({ instance: { category_filter: "feline and warm" } }) },
|
||||
});
|
||||
await w.get('[data-test="pool-advanced"]').trigger("click");
|
||||
const input = w.get<HTMLInputElement>('[data-test="expr-input"]').element;
|
||||
expect(input.value).toBe("feline and warm");
|
||||
});
|
||||
|
||||
it("emits category_filter when a valid expression is typed", async () => {
|
||||
const w = mount(PoolSection, { props: { module: makeModule() } });
|
||||
await w.get('[data-test="pool-advanced"]').trigger("click");
|
||||
const input = w.get<HTMLInputElement>('[data-test="expr-input"]');
|
||||
input.element.value = "not canine";
|
||||
await input.trigger("input");
|
||||
expect(lastPatch(w).instance?.category_filter).toBe("not canine");
|
||||
});
|
||||
|
||||
it("does NOT emit while the expression is invalid", async () => {
|
||||
const w = mount(PoolSection, { props: { module: makeModule() } });
|
||||
await w.get('[data-test="pool-advanced"]').trigger("click");
|
||||
const input = w.get<HTMLInputElement>('[data-test="expr-input"]');
|
||||
input.element.value = "feline and";
|
||||
await input.trigger("input");
|
||||
expect(w.emitted("update")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("flags an unknown sub-category as invalid", async () => {
|
||||
const w = mount(PoolSection, { props: { module: makeModule() } });
|
||||
await w.get('[data-test="pool-advanced"]').trigger("click");
|
||||
const input = w.get<HTMLInputElement>('[data-test="expr-input"]');
|
||||
input.element.value = "zebra";
|
||||
await input.trigger("input");
|
||||
expect(w.find('[data-test="expr-error"]').exists()).toBe(true);
|
||||
expect(w.emitted("update")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows a 'reads as' normalized preview for a valid expression", async () => {
|
||||
const w = mount(PoolSection, {
|
||||
props: { module: makeModule({ instance: { category_filter: "feline,warm" } }) },
|
||||
});
|
||||
await w.get('[data-test="pool-advanced"]').trigger("click");
|
||||
// Comma is OR sugar — reads-as normalizes it to the word form.
|
||||
expect(w.get('[data-test="expr-reads-as"]').text()).toContain("feline or warm");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PoolSection exclude-null toggle", () => {
|
||||
// The toggle only renders when the wildcard has a null option (#5), so
|
||||
// append one to the fixture for the toggle/reflect cases.
|
||||
function withNullOption(m: ModuleEntry): ModuleEntry {
|
||||
(m.payload as { options: Array<Record<string, unknown>> }).options.push({
|
||||
id: "n", value: "", weight: 1, is_null: true,
|
||||
});
|
||||
return m;
|
||||
}
|
||||
|
||||
it("emits exclude_null=true when toggled on", async () => {
|
||||
const w = mount(PoolSection, { props: { module: withNullOption(makeModule()) } });
|
||||
// Clicking the shared Checkbox box toggles exclude_null (#4).
|
||||
await w.get('[data-test="pool-exclude-null"] .wp-check').trigger("click");
|
||||
expect(lastPatch(w).instance?.exclude_null).toBe(true);
|
||||
});
|
||||
|
||||
it("reflects an existing exclude_null instance value", () => {
|
||||
const w = mount(PoolSection, {
|
||||
props: { module: withNullOption(makeModule({ instance: { exclude_null: true } })) },
|
||||
});
|
||||
// "On" reads off aria-checked on the wp-check span.
|
||||
const cb = w.get('[data-test="pool-exclude-null"] .wp-check');
|
||||
expect(cb.attributes("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("hides the toggle entirely when the wildcard has no null option (#5)", () => {
|
||||
const w = mount(PoolSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="pool-exclude-null"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("toggling the null option row drives exclude_null, not enabled_options (#3)", async () => {
|
||||
const w = mount(PoolSection, { props: { module: withNullOption(makeModule()) } });
|
||||
// The appended null option renders as the last row; unchecking its box is
|
||||
// the same control as "Exclude null", so it must flip exclude_null.
|
||||
const checks = w.findAll('[data-test="opt-check"]');
|
||||
await checks[checks.length - 1].trigger("click");
|
||||
const patch = lastPatch(w);
|
||||
expect(patch.instance?.exclude_null).toBe(true);
|
||||
expect(patch.instance?.enabled_options).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PoolSection option rows", () => {
|
||||
it("renders one OptionRow per payload.options entry", () => {
|
||||
const w = mount(PoolSection, { props: { module: makeModule() } });
|
||||
const rows = w.findAllComponents({ name: "OptionRow" });
|
||||
expect(rows).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("OptionRow toggle event emits enabled_options patch", async () => {
|
||||
const w = mount(PoolSection, { props: { module: makeModule() } });
|
||||
const firstRow = w.findComponent({ name: "OptionRow" });
|
||||
firstRow.vm.$emit("toggle", "a");
|
||||
await w.vm.$nextTick();
|
||||
// a was enabled by default → toggle disables it → enabled_options = ["b", "c"]
|
||||
expect(lastPatch(w).instance?.enabled_options).toEqual(["b", "c"]);
|
||||
});
|
||||
|
||||
it("toggling all options back on emits enabled_options=null", async () => {
|
||||
const w = mount(PoolSection, {
|
||||
props: { module: makeModule({ instance: { enabled_options: ["a", "b"] } }) },
|
||||
});
|
||||
const rows = w.findAllComponents({ name: "OptionRow" });
|
||||
rows[2].vm.$emit("toggle", "c");
|
||||
await w.vm.$nextTick();
|
||||
expect(lastPatch(w).instance?.enabled_options).toBeNull();
|
||||
});
|
||||
|
||||
it("OptionRow weight event emits option_weights patch", async () => {
|
||||
const w = mount(PoolSection, { props: { module: makeModule() } });
|
||||
const firstRow = w.findComponent({ name: "OptionRow" });
|
||||
firstRow.vm.$emit("weight", "a", 2.5);
|
||||
await w.vm.$nextTick();
|
||||
expect(lastPatch(w).instance?.option_weights).toEqual({ a: 2.5 });
|
||||
});
|
||||
|
||||
it("weight = null deletes the entry from option_weights map", async () => {
|
||||
const w = mount(PoolSection, {
|
||||
props: { module: makeModule({ instance: { option_weights: { a: 2.5 } } }) },
|
||||
});
|
||||
const firstRow = w.findComponent({ name: "OptionRow" });
|
||||
firstRow.vm.$emit("weight", "a", null);
|
||||
await w.vm.$nextTick();
|
||||
expect(lastPatch(w).instance?.option_weights).toEqual({});
|
||||
});
|
||||
|
||||
it("passes tag_groups down to each OptionRow for axis colouring", () => {
|
||||
const w = mount(PoolSection, { props: { module: makeModule() } });
|
||||
const firstRow = w.findComponent({ name: "OptionRow" });
|
||||
expect(firstRow.props("tagGroups")).toEqual({
|
||||
family: ["feline", "canine"],
|
||||
temp: ["warm", "cold"],
|
||||
});
|
||||
});
|
||||
|
||||
it("summary shows enabled count", () => {
|
||||
const w = mount(PoolSection, {
|
||||
props: { module: makeModule({ instance: { enabled_options: ["a", "b"] } }) },
|
||||
});
|
||||
expect(w.find('[data-test="pool-summary"]').text()).toContain("2 of 3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
effectiveWeight,
|
||||
isEnabled,
|
||||
probabilityFor,
|
||||
type InstanceLike,
|
||||
type WildcardOption,
|
||||
} from "../probability";
|
||||
|
||||
const opt = (sc: string[], extra: Partial<WildcardOption> = {}): WildcardOption => ({
|
||||
id: "o",
|
||||
weight: 1,
|
||||
sub_categories: sc,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe("isEnabled multi-tag", () => {
|
||||
it("matches the boolean category_filter expression (any/and/not)", () => {
|
||||
expect(isEnabled(opt(["warm"]), { category_filter: "warm or cold" })).toBe(true);
|
||||
expect(isEnabled(opt(["warm"]), { category_filter: "warm and cold" })).toBe(false);
|
||||
expect(isEnabled(opt(["warm"]), { category_filter: "" })).toBe(true);
|
||||
expect(isEnabled(opt([]), { category_filter: "not cold" })).toBe(true);
|
||||
});
|
||||
|
||||
it("null option governed by exclude_null", () => {
|
||||
const nul = { id: "n", value: "", weight: 1, is_null: true, sub_categories: [] };
|
||||
expect(isEnabled(nul, { category_filter: "warm", exclude_null: false })).toBe(true);
|
||||
expect(isEnabled(nul, { exclude_null: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("null option ignores enabled_options — only exclude_null gates it (#3)", () => {
|
||||
const nul = { id: "n", value: "", weight: 1, is_null: true, sub_categories: [] };
|
||||
// enabled_options excludes the null id, but the null slot is orthogonal:
|
||||
// it stays enabled while exclude_null is false, and only drops when set.
|
||||
expect(isEnabled(nul, { enabled_options: ["other"], exclude_null: false })).toBe(true);
|
||||
expect(isEnabled(nul, { enabled_options: ["other"], exclude_null: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("drops the null option entirely in multi-pick, ignoring exclude_null (SP2a)", () => {
|
||||
const nul = { id: "n", value: "", weight: 1, is_null: true, sub_categories: [] };
|
||||
// single-pick: governed by exclude_null
|
||||
expect(isEnabled(nul, { exclude_null: false })).toBe(true);
|
||||
// multi-pick (3rd arg true): always dropped — the engine excludes it,
|
||||
// "maybe nothing" is pick_min=0.
|
||||
expect(isEnabled(nul, { exclude_null: false }, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("drops the null option from the probability denominator in multi-pick (SP2a)", () => {
|
||||
const a = opt([], { id: "a" });
|
||||
const nul = { id: "n", value: "", weight: 1, is_null: true, sub_categories: [] };
|
||||
const opts = [nul, a];
|
||||
// single-pick: null + a split the per-draw weight 50/50
|
||||
expect(probabilityFor(a, opts, {})).toBeCloseTo(0.5, 2);
|
||||
// multi-pick: null excluded → a takes 100% of the denominator, null is 0
|
||||
expect(probabilityFor(a, opts, {}, true)).toBeCloseTo(1.0, 2);
|
||||
expect(probabilityFor(nul, opts, {}, true)).toBe(0);
|
||||
});
|
||||
|
||||
it("matches a multi-tag option against an AND across axes", () => {
|
||||
// Option carries both tags → satisfies "feline and warm".
|
||||
expect(isEnabled(opt(["feline", "warm"]), { category_filter: "feline and warm" })).toBe(true);
|
||||
// Option missing one of the AND'd tags → excluded.
|
||||
expect(isEnabled(opt(["feline", "cold"]), { category_filter: "feline and warm" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when category_filter is absent (library default = no filter)", () => {
|
||||
expect(isEnabled(opt(["warm"]), {})).toBe(true);
|
||||
expect(isEnabled(opt([]), {})).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when category_filter is null", () => {
|
||||
expect(isEnabled(opt(["neutral"]), { category_filter: null })).toBe(true);
|
||||
});
|
||||
|
||||
it("combines with enabled_options (intersection)", () => {
|
||||
expect(
|
||||
isEnabled(opt(["warm"], { id: "o1" }), {
|
||||
enabled_options: ["o2"],
|
||||
category_filter: "warm",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isEnabled(opt(["warm"], { id: "o1" }), {
|
||||
enabled_options: ["o1"],
|
||||
category_filter: "warm",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when option id is NOT in enabled_options array", () => {
|
||||
expect(isEnabled(opt(["warm"], { id: "o3" }), { enabled_options: ["o1", "o2"] })).toBe(false);
|
||||
});
|
||||
|
||||
it("null option stays in the pool under a category_filter (exclude_null governs it)", () => {
|
||||
const nul = { id: "n", value: "", weight: 1, is_null: true, sub_categories: [] };
|
||||
// A sub-cat filter doesn't strip the null slot — only exclude_null does.
|
||||
expect(isEnabled(nul, { category_filter: "warm and cold" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveWeight", () => {
|
||||
it("returns library default when no override", () => {
|
||||
expect(effectiveWeight(opt([], { weight: 2.5 }), {})).toBe(2.5);
|
||||
});
|
||||
|
||||
it("returns instance override when present", () => {
|
||||
expect(effectiveWeight(opt([], { id: "o1", weight: 1 }), { option_weights: { o1: 1.8 } })).toBe(1.8);
|
||||
});
|
||||
|
||||
it("falls back to 1.0 when neither library weight nor override is set", () => {
|
||||
expect(effectiveWeight({ id: "o1", sub_categories: [] }, {})).toBe(1.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("probabilityFor", () => {
|
||||
const opts: WildcardOption[] = [
|
||||
{ id: "a", weight: 1, sub_categories: [] },
|
||||
{ id: "b", weight: 1, sub_categories: [] },
|
||||
{ id: "c", weight: 1, sub_categories: [] },
|
||||
];
|
||||
|
||||
it("uniform weights, all enabled → equal probability", () => {
|
||||
expect(probabilityFor(opts[0], opts, {})).toBeCloseTo(0.333, 2);
|
||||
});
|
||||
|
||||
it("disabled option → 0", () => {
|
||||
expect(probabilityFor(opts[2], opts, { enabled_options: ["a", "b"] })).toBe(0);
|
||||
});
|
||||
|
||||
it("skewed weight gets higher probability", () => {
|
||||
const inst: InstanceLike = { option_weights: { a: 2 } };
|
||||
// total enabled weight = 2 + 1 + 1 = 4. a gets 2/4 = 0.5.
|
||||
expect(probabilityFor(opts[0], opts, inst)).toBeCloseTo(0.5, 2);
|
||||
expect(probabilityFor(opts[1], opts, inst)).toBeCloseTo(0.25, 2);
|
||||
});
|
||||
|
||||
it("single option enabled → probability 1.0 (effectively pinned)", () => {
|
||||
expect(probabilityFor(opts[0], opts, { enabled_options: ["a"] })).toBe(1.0);
|
||||
});
|
||||
|
||||
it("all disabled → all probabilities 0", () => {
|
||||
expect(probabilityFor(opts[0], opts, { enabled_options: [] })).toBe(0);
|
||||
});
|
||||
|
||||
it("category filter excludes options from totals", () => {
|
||||
const catOpts: WildcardOption[] = [
|
||||
{ id: "a", weight: 1, sub_categories: ["warm"] },
|
||||
{ id: "b", weight: 1, sub_categories: ["warm"] },
|
||||
{ id: "c", weight: 1, sub_categories: ["cool"] },
|
||||
];
|
||||
// category_filter: "warm" → only a and b count. each gets 1/2 = 0.5.
|
||||
expect(probabilityFor(catOpts[0], catOpts, { category_filter: "warm" })).toBeCloseTo(0.5, 2);
|
||||
expect(probabilityFor(catOpts[2], catOpts, { category_filter: "warm" })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { effectiveWeight, isEnabled, probabilityFor } from "./probability";
|
||||
|
||||
interface Opt { id: string; weight?: number; sub_category?: string }
|
||||
type Instance = {
|
||||
enabled_options?: string[] | null;
|
||||
option_weights?: Record<string, number> | null;
|
||||
category_filter?: string[] | null;
|
||||
};
|
||||
|
||||
describe("probability helpers", () => {
|
||||
describe("isEnabled", () => {
|
||||
it("returns true when enabled_options is null (library default = all enabled)", () => {
|
||||
expect(isEnabled({ id: "o1" }, {})).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when option id is in enabled_options array", () => {
|
||||
expect(isEnabled({ id: "o1" }, { enabled_options: ["o1", "o2"] })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when option id is NOT in enabled_options array", () => {
|
||||
expect(isEnabled({ id: "o3" }, { enabled_options: ["o1", "o2"] })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when sub_category is filtered out", () => {
|
||||
expect(isEnabled({ id: "o1", sub_category: "neutral" }, { category_filter: ["warm", "cool"] })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when sub_category is in category_filter", () => {
|
||||
expect(isEnabled({ id: "o1", sub_category: "warm" }, { category_filter: ["warm", "cool"] })).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores category_filter when it is null", () => {
|
||||
expect(isEnabled({ id: "o1", sub_category: "neutral" }, { category_filter: null })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveWeight", () => {
|
||||
it("returns library default when no override", () => {
|
||||
expect(effectiveWeight({ id: "o1", weight: 2.5 }, {})).toBe(2.5);
|
||||
});
|
||||
|
||||
it("returns instance override when present", () => {
|
||||
expect(effectiveWeight({ id: "o1", weight: 1 }, { option_weights: { o1: 1.8 } })).toBe(1.8);
|
||||
});
|
||||
|
||||
it("falls back to 1.0 when neither library weight nor override is set", () => {
|
||||
expect(effectiveWeight({ id: "o1" }, {})).toBe(1.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("probabilityFor", () => {
|
||||
const opts: Opt[] = [
|
||||
{ id: "a", weight: 1 },
|
||||
{ id: "b", weight: 1 },
|
||||
{ id: "c", weight: 1 },
|
||||
];
|
||||
|
||||
it("uniform weights, all enabled → equal probability", () => {
|
||||
expect(probabilityFor(opts[0], opts, {})).toBeCloseTo(0.333, 2);
|
||||
});
|
||||
|
||||
it("disabled option → 0", () => {
|
||||
expect(probabilityFor(opts[2], opts, { enabled_options: ["a", "b"] })).toBe(0);
|
||||
});
|
||||
|
||||
it("skewed weight gets higher probability", () => {
|
||||
const inst: Instance = { option_weights: { a: 2 } };
|
||||
// total enabled weight = 2 + 1 + 1 = 4. a gets 2/4 = 0.5.
|
||||
expect(probabilityFor(opts[0], opts, inst)).toBeCloseTo(0.5, 2);
|
||||
expect(probabilityFor(opts[1], opts, inst)).toBeCloseTo(0.25, 2);
|
||||
});
|
||||
|
||||
it("single option enabled → probability 1.0 (effectively pinned)", () => {
|
||||
expect(probabilityFor(opts[0], opts, { enabled_options: ["a"] })).toBe(1.0);
|
||||
});
|
||||
|
||||
it("all disabled → all probabilities 0", () => {
|
||||
expect(probabilityFor(opts[0], opts, { enabled_options: [] })).toBe(0);
|
||||
});
|
||||
|
||||
it("category filter excludes options from totals", () => {
|
||||
const catOpts: Opt[] = [
|
||||
{ id: "a", weight: 1, sub_category: "warm" },
|
||||
{ id: "b", weight: 1, sub_category: "warm" },
|
||||
{ id: "c", weight: 1, sub_category: "cool" },
|
||||
];
|
||||
// category_filter: ["warm"] → only a and b count. each gets 1/2 = 0.5.
|
||||
expect(probabilityFor(catOpts[0], catOpts, { category_filter: ["warm"] })).toBeCloseTo(0.5, 2);
|
||||
expect(probabilityFor(catOpts[2], catOpts, { category_filter: ["warm"] })).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,15 +6,24 @@
|
||||
* Mirrors the engine's pick semantics in `engine/modules/wildcard_handler.py`:
|
||||
* - `enabled_options` null → all options eligible (library default)
|
||||
* - `enabled_options` array → only listed ids eligible
|
||||
* - `category_filter` array → restrict to options whose sub_category is in the list
|
||||
* - `category_filter` boolean expression → restrict to options whose
|
||||
* `sub_categories` satisfy the parsed expression (OR/AND/NOT over tags).
|
||||
* Empty/null/absent = no filter. See the multi-tag boolean-filter design
|
||||
* `docs/superpowers/specs/2026-06-06-wildcard-multi-subcategory-boolean-filter-design.md`.
|
||||
* - `option_weights[id]` overrides library `option.weight`, else fall back to 1.0
|
||||
*
|
||||
* See: docs/superpowers/specs/2026-05-08-instance-overrides-v2-design.md §8.3
|
||||
*/
|
||||
import { matches, parse } from "@/manager/parsing/subcatFilter";
|
||||
|
||||
export interface WildcardOption {
|
||||
id: string;
|
||||
weight?: number;
|
||||
sub_category?: string;
|
||||
/** Sub-category tags this option belongs to. Multi-tag (v2): an option
|
||||
* can sit on several axes at once (e.g. `["feline", "warm"]`). Empty
|
||||
* array = untagged. The boolean `category_filter` matches against this
|
||||
* set via the shared parser. */
|
||||
sub_categories: string[];
|
||||
/** Marks the single optional "null option" per wildcard — picks
|
||||
* resolve to empty string. See spec
|
||||
* `docs/superpowers/specs/2026-05-24-null-wildcard-option-design.md`. */
|
||||
@@ -24,23 +33,35 @@ export interface WildcardOption {
|
||||
export interface InstanceLike {
|
||||
enabled_options?: string[] | null;
|
||||
option_weights?: Record<string, number> | null;
|
||||
category_filter?: string[] | null;
|
||||
/** Boolean sub-category expression (e.g. `"feline and warm"`, `"not lynx"`).
|
||||
* Empty/null/absent = no filter. Parsed + matched via the shared parser. */
|
||||
category_filter?: string | null;
|
||||
/** When true the null option is dropped from the pool. The sub-category
|
||||
* `category_filter` never strips the null slot — only this toggle does
|
||||
* (the null option carries no tags, so it's orthogonal to the filter). */
|
||||
exclude_null?: boolean;
|
||||
}
|
||||
|
||||
export function isEnabled(option: WildcardOption, instance: InstanceLike): boolean {
|
||||
export function isEnabled(
|
||||
option: WildcardOption,
|
||||
instance: InstanceLike,
|
||||
multiActive = false,
|
||||
): boolean {
|
||||
// Null option intentionally has no sub-categories. It's an orthogonal
|
||||
// "no-output" slot governed SOLELY by `exclude_null` — neither
|
||||
// `enabled_options` nor `category_filter` apply. Checked first so the
|
||||
// per-row checkbox and the "Exclude null" toggle read the same flag and
|
||||
// stay linked (#3). SP2a: multi-pick ALWAYS drops the null option from the
|
||||
// pool (the engine excludes it; "maybe nothing" is pick_min=0), so it is
|
||||
// never enabled in multi mode regardless of exclude_null — keeps the
|
||||
// enabled count + per-option probability honest.
|
||||
if (option.is_null) {
|
||||
return !multiActive && !instance.exclude_null;
|
||||
}
|
||||
if (Array.isArray(instance.enabled_options) && !instance.enabled_options.includes(option.id)) {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(instance.category_filter) && instance.category_filter.length > 0) {
|
||||
// Null option intentionally has no sub_category. Category filters
|
||||
// are about narrowing the sub-cat-bearing pool — the null option is
|
||||
// an orthogonal "no-output" slot and stays in the pool regardless.
|
||||
// User can still toggle it off via `enabled_options` if desired.
|
||||
if (option.is_null) return true;
|
||||
if (!option.sub_category) return false;
|
||||
if (!instance.category_filter.includes(option.sub_category)) return false;
|
||||
}
|
||||
return true;
|
||||
return matches(parse(instance.category_filter ?? ""), new Set(option.sub_categories ?? []));
|
||||
}
|
||||
|
||||
export function effectiveWeight(option: WildcardOption, instance: InstanceLike): number {
|
||||
@@ -53,10 +74,11 @@ export function probabilityFor(
|
||||
option: WildcardOption,
|
||||
allOptions: readonly WildcardOption[],
|
||||
instance: InstanceLike,
|
||||
multiActive = false,
|
||||
): number {
|
||||
if (!isEnabled(option, instance)) return 0;
|
||||
if (!isEnabled(option, instance, multiActive)) return 0;
|
||||
const totalEnabledWeight = allOptions
|
||||
.filter((o) => isEnabled(o, instance))
|
||||
.filter((o) => isEnabled(o, instance, multiActive))
|
||||
.reduce((sum, o) => sum + effectiveWeight(o, instance), 0);
|
||||
if (totalEnabledWeight === 0) return 0;
|
||||
return effectiveWeight(option, instance) / totalEnabledWeight;
|
||||
|
||||
@@ -70,6 +70,21 @@ describe("IdentitySection", () => {
|
||||
expect(patch.instance?.variable_binding).toBeNull();
|
||||
});
|
||||
|
||||
it("strips comma + punctuation from the binding on input (var names must be identifiers)", async () => {
|
||||
// The binding becomes a `$var`; a comma is a settle delimiter and other
|
||||
// punctuation can't appear in an identifier. Sanitize on keystroke so a
|
||||
// junk char can never enter the produced-var name. DOM reflects it too
|
||||
// (controlled input — force-sync so it can't diverge from the model).
|
||||
const w = mount(IdentitySection, { props: { module: makeModule() } });
|
||||
const input = w.find<HTMLInputElement>('[data-test="id-binding"]');
|
||||
input.element.value = "out,fit;top!";
|
||||
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("outfittop");
|
||||
expect(input.element.value).toBe("outfittop");
|
||||
});
|
||||
|
||||
it("renders $-prefix label on binding input", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="id-binding-prefix"]').text()).toBe("$");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user