@nanobpm/urban 0.52.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -20
- package/dist/runtime/core/events.d.ts +135 -0
- package/dist/runtime/core/events.js +329 -0
- package/dist/runtime/core/extensions.d.ts +131 -0
- package/dist/runtime/core/extensions.js +137 -0
- package/dist/runtime/core/glob.d.ts +7 -6
- package/dist/runtime/core/glob.js +16 -14
- package/dist/runtime/core/host.d.ts +4 -1
- package/dist/runtime/core/modules/agent.d.ts +17 -0
- package/dist/runtime/core/modules/agent.js +63 -0
- package/dist/runtime/core/modules/deploy.d.ts +8 -5
- package/dist/runtime/core/modules/deploy.js +62 -16
- package/dist/runtime/core/modules/pages.js +397 -20
- package/dist/runtime/core/modules/surfaces.js +8 -0
- package/dist/runtime/core/phase.d.ts +168 -0
- package/dist/runtime/core/phase.js +367 -0
- package/dist/runtime/core/runtime.d.ts +17 -0
- package/dist/runtime/core/runtime.js +30 -0
- package/dist/runtime/index.d.ts +6 -0
- package/dist/runtime/index.js +7 -0
- package/dist/toolkit/derivers/system-brief.d.ts +62 -0
- package/dist/toolkit/derivers/system-brief.js +156 -0
- package/dist/toolkit/gen.d.ts +5 -5
- package/dist/toolkit/gen.js +20 -14
- package/dist/toolkit/index.d.ts +5 -0
- package/dist/toolkit/index.js +3 -0
- package/dist/toolkit/models.d.ts +4 -3
- package/dist/toolkit/models.js +4 -3
- package/package.json +1 -1
- package/src/runtime/core/events.test.ts +382 -0
- package/src/runtime/core/events.ts +370 -0
- package/src/runtime/core/extensions.test.ts +378 -0
- package/src/runtime/core/extensions.ts +276 -0
- package/src/runtime/core/glob.ts +15 -12
- package/src/runtime/core/host.ts +4 -1
- package/src/runtime/core/modules/agent.test.ts +104 -0
- package/src/runtime/core/modules/agent.ts +76 -0
- package/src/runtime/core/modules/deploy.test.ts +84 -16
- package/src/runtime/core/modules/deploy.ts +59 -16
- package/src/runtime/core/modules/pages.test.ts +309 -5
- package/src/runtime/core/modules/pages.ts +398 -20
- package/src/runtime/core/modules/surfaces.ts +9 -0
- package/src/runtime/core/phase.test.ts +350 -0
- package/src/runtime/core/phase.ts +526 -0
- package/src/runtime/core/runtime.extensions.test.ts +151 -0
- package/src/runtime/core/runtime.ts +49 -0
- package/src/runtime/devserver.test.ts +3 -0
- package/src/runtime/index.ts +57 -0
- package/src/toolkit/derivers/system-brief.test.ts +150 -0
- package/src/toolkit/derivers/system-brief.ts +210 -0
- package/src/toolkit/gen.test.ts +28 -4
- package/src/toolkit/gen.ts +20 -13
- package/src/toolkit/index.ts +14 -0
- package/src/toolkit/models.ts +4 -3
|
@@ -344,6 +344,14 @@ test("renderer wires a column's badge to a tone-classed pill shown only when pre
|
|
|
344
344
|
assert.match(js, /class:\s*"pc-badge pc-badge-"\s*\+\s*tone/);
|
|
345
345
|
assert.match(js, /title:\s*rawText/);
|
|
346
346
|
assert.match(js, /"aria-label":\s*rawText/);
|
|
347
|
+
// Even a blank badge cell stamps data-label, upholding the "every td carries
|
|
348
|
+
// data-label" invariant so empty and non-empty badge cells stay consistent
|
|
349
|
+
// (the mobile pc-mcell-chip:empty rule still collapses it — attributes don't
|
|
350
|
+
// defeat :empty).
|
|
351
|
+
assert.match(
|
|
352
|
+
js,
|
|
353
|
+
/rawText\.trim\(\)\s*===\s*""\s*\)\s*return el\("td",\s*\{ class: "pc-mcell pc-mcell-chip", "data-label": mlabel \}\)/,
|
|
354
|
+
);
|
|
347
355
|
});
|
|
348
356
|
|
|
349
357
|
test("renderer interpolates a column's per-cell template into DOM text (#214)", async () => {
|
|
@@ -386,7 +394,7 @@ test("renderer renders a column's subtitle as a muted second line, never an href
|
|
|
386
394
|
assert.match(js, /class: "pc-cell-sub"/);
|
|
387
395
|
// Absent subtitle (empty) and no truncation collapses back to the single inline
|
|
388
396
|
// node, so existing grids render exactly as before (backward compatible).
|
|
389
|
-
assert.match(js, /if \(subText === "" && !truncate\) return el\("td",
|
|
397
|
+
assert.match(js, /if \(subText === "" && !truncate\) return el\("td", td, primary\);/);
|
|
390
398
|
});
|
|
391
399
|
|
|
392
400
|
test("renderer truncates a column to one line with an ellipsis + full-text tooltip (#257)", async () => {
|
|
@@ -515,11 +523,15 @@ test("renderer wires a column's page link to an in-app scoped hash route", async
|
|
|
515
523
|
const res = await dispatch("GET", "/app/runtime.js");
|
|
516
524
|
const js = res.body ?? "";
|
|
517
525
|
// link: { kind: "page", page, keyField } → an in-app #/<page>/<key> hash link
|
|
518
|
-
// (no new tab, no server hit). The
|
|
519
|
-
//
|
|
526
|
+
// (no new tab, no server hit). The route is built by the shared pageHashHref
|
|
527
|
+
// helper: the page id is safe-id-validated, the key is trimmed + URL-encoded
|
|
528
|
+
// into the param tail, and a blank/keyless cell stays text.
|
|
520
529
|
assert.match(js, /col\.link && col\.link\.kind === "page" && col\.link\.page && col\.link\.keyField/);
|
|
521
|
-
assert.match(js, /
|
|
522
|
-
|
|
530
|
+
assert.match(js, /const href = pageHashHref\(col\.link\.page, row\[col\.link\.keyField\]\)/);
|
|
531
|
+
// pageHashHref is the single source of truth: safe-id guard + encoded route.
|
|
532
|
+
assert.match(js, /function pageHashHref\(page, key\)/);
|
|
533
|
+
assert.match(js, /if \(!safePageId\(page\) \|\| keyStr === ""\) return ""/);
|
|
534
|
+
assert.match(js, /"#\/" \+ encodeURIComponent\(page\) \+ "\/" \+ encodeURIComponent\(keyStr\)/);
|
|
523
535
|
// In-app: NOT a new-tab link (no target/rel on this anchor).
|
|
524
536
|
assert.match(js, /el\("a", \{ class: "pc-link", href \}, text\)/);
|
|
525
537
|
});
|
|
@@ -781,6 +793,14 @@ test("the renderer routes between pages by hash and tears down grid polls", asyn
|
|
|
781
793
|
assert.match(js, /location\.hash/);
|
|
782
794
|
assert.match(js, /window\.addEventListener\("hashchange", renderPage\)/);
|
|
783
795
|
assert.match(js, /async function renderPage\(\)/);
|
|
796
|
+
// renderPage() fires from both hashchange and the matchMedia viewport handler,
|
|
797
|
+
// so overlapping async renders must not apply out of order: each captures a
|
|
798
|
+
// monotonic token and bails after its await if a newer render superseded it
|
|
799
|
+
// (guarding both the success path and the stale error render).
|
|
800
|
+
assert.match(js, /let renderSeq = 0/);
|
|
801
|
+
assert.match(js, /const mySeq = \+\+renderSeq/);
|
|
802
|
+
assert.match(js, /const doc = await getJSON\([\s\S]*?\);\s*if \(mySeq !== renderSeq\) return;/);
|
|
803
|
+
assert.match(js, /catch \(e\) \{\s*if \(mySeq !== renderSeq\) return;/);
|
|
784
804
|
// Navigation runs every registered disposer first, so a switched-away grid's
|
|
785
805
|
// poll interval + pc:refresh listener are removed rather than leaking forever.
|
|
786
806
|
assert.match(js, /const disposers = \[\]/);
|
|
@@ -1039,3 +1059,287 @@ test("rowLimit respects an explicit 0 and falls back for non-finite values", asy
|
|
|
1039
1059
|
await run(undefined);
|
|
1040
1060
|
assert.deepEqual(limits, ["0", "200", "200"]);
|
|
1041
1061
|
});
|
|
1062
|
+
|
|
1063
|
+
test("the shell ships a mobile @media block that flips a dataGrid into a card list (#268)", async () => {
|
|
1064
|
+
const res = await dispatch("GET", "/");
|
|
1065
|
+
const html = res.body ?? "";
|
|
1066
|
+
// Tier 0 — zero-declaration responsive default: a single (max-width) breakpoint
|
|
1067
|
+
// reflows every app for a phone. The dense dataGrid — the one primitive CSS
|
|
1068
|
+
// alone can't reflow — flips from a wide <table> to a stacked card list purely
|
|
1069
|
+
// from the per-cell data-label the renderer stamps on (no JS branching).
|
|
1070
|
+
const media = (html.match(/@media \(max-width:640px\) \{[\s\S]*?\n\}/) ?? [""])[0];
|
|
1071
|
+
assert.ok(media, "a (max-width) media block must be present");
|
|
1072
|
+
// thead hidden, rows/cells become blocks (the card flip).
|
|
1073
|
+
assert.match(media, /table\.pc-grid colgroup, table\.pc-grid thead \{ display:none; \}/);
|
|
1074
|
+
assert.match(media, /table\.pc-grid, table\.pc-grid tbody, table\.pc-grid tr, table\.pc-grid td \{ display:block[^}]*\}/);
|
|
1075
|
+
// The column header prefixes each value as a label:value line via data-label.
|
|
1076
|
+
assert.match(media, /td\[data-label\]::before \{ content:attr\(data-label\)/);
|
|
1077
|
+
// The derived title leads the card with no caption; a badge column is a chip.
|
|
1078
|
+
assert.match(media, /td\.pc-mcell-primary::before \{ content:none; \}/);
|
|
1079
|
+
assert.match(media, /td\.pc-mcell-chip::before \{ content:none; \}/);
|
|
1080
|
+
// An empty badge cell (blank field value) carries the chip class but no content;
|
|
1081
|
+
// it must collapse on mobile so it doesn't leave a stray blank flex row in the card.
|
|
1082
|
+
assert.match(media, /td\.pc-mcell-chip:empty \{ display:none; \}/);
|
|
1083
|
+
// Low-value (hidden) columns drop off the card until the row's More toggle opens them.
|
|
1084
|
+
assert.match(media, /td\.pc-mcell-hidden \{ display:none; \}/);
|
|
1085
|
+
assert.match(media, /tr\.pc-open td\.pc-mcell-hidden \{ display:flex; \}/);
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
test("the shell mobile block collapses the nav and stacks buttons/form actions (#268)", async () => {
|
|
1089
|
+
const res = await dispatch("GET", "/");
|
|
1090
|
+
const html = res.body ?? "";
|
|
1091
|
+
const media = (html.match(/@media \(max-width:640px\) \{[\s\S]*?\n\}/) ?? [""])[0];
|
|
1092
|
+
// nav → scrollable bottom-ish bar (bar) / inline stack (rail); buttons and
|
|
1093
|
+
// modal/form actions go full-width single column; text reflows on its own.
|
|
1094
|
+
assert.match(media, /\.pc-bar \{ flex-wrap:nowrap; overflow-x:auto; \}/);
|
|
1095
|
+
assert.match(media, /\.pc-rail \.pc-nav-items \{ flex-direction:row/);
|
|
1096
|
+
assert.match(media, /\.pc-buttonrow \.pc-btn, \.pc-card \.pc-btn \{ width:100%; \}/);
|
|
1097
|
+
assert.match(media, /\.pc-modal-actions \{ flex-direction:column; \}/);
|
|
1098
|
+
// Row actions stack full-width under the card body.
|
|
1099
|
+
assert.match(media, /td\.pc-row-actions \{ display:flex/);
|
|
1100
|
+
// The per-row "More" toggle is base-hidden (outside the media block) so a grid
|
|
1101
|
+
// with hidden-column hints renders unchanged on desktop, shown only on mobile.
|
|
1102
|
+
assert.match(html, /\.pc-mcard-toggle \{ display:none; \}/);
|
|
1103
|
+
assert.match(media, /\.pc-mcard-toggle \{ display:block/);
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
test("the mobile breakpoint has one source of truth shared by CSS and matchMedia (#268)", async () => {
|
|
1107
|
+
// AGENTS.md: no drift surfaces. The breakpoint must appear identically in the
|
|
1108
|
+
// shell CSS @media and the runtime's matchMedia — both interpolated from the
|
|
1109
|
+
// single MOBILE_MAX_WIDTH constant — so "narrow" can never diverge between the
|
|
1110
|
+
// pure-CSS card flip and the Tier-2 JS mode-switch.
|
|
1111
|
+
const shell = (await dispatch("GET", "/")).body ?? "";
|
|
1112
|
+
const js = (await dispatch("GET", "/app/runtime.js")).body ?? "";
|
|
1113
|
+
assert.match(shell, /@media \(max-width:640px\)/);
|
|
1114
|
+
assert.match(js, /window\.matchMedia\("\(max-width:640px\)"\)/);
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
test("the renderer classifies grid columns into mobile card regions by convention (#268)", async () => {
|
|
1118
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
1119
|
+
const js = res.body ?? "";
|
|
1120
|
+
// Tier 0 heuristic (classifyColumns): the first non-badge column is the card
|
|
1121
|
+
// title (primary), a badge column is a status chip, the rest are secondary
|
|
1122
|
+
// label:value rows. Tier 1 refines it — mobile.priority ("primary"|"secondary"
|
|
1123
|
+
// |"hidden") wins, and an explicit "primary" suppresses the auto title pick.
|
|
1124
|
+
assert.match(js, /function classifyColumns\(cols\)/);
|
|
1125
|
+
assert.match(js, /const explicitPrimary = list\.some\(\(c\) => c && c\.mobile && c\.mobile\.priority === "primary"\);/);
|
|
1126
|
+
assert.match(js, /if \(pr === "primary"\) return "primary";/);
|
|
1127
|
+
assert.match(js, /if \(pr === "hidden"\) return "hidden";/);
|
|
1128
|
+
assert.match(js, /if \(col && col\.badge\) return "chip";/);
|
|
1129
|
+
assert.match(js, /if \(!titleTaken\) \{ titleTaken = true; return "primary"; \}/);
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
test("the renderer stamps every grid cell with a data-label + mobile role class (#268)", async () => {
|
|
1133
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
1134
|
+
const js = res.body ?? "";
|
|
1135
|
+
// Every td carries data-label (the mobile.label override or the column header)
|
|
1136
|
+
// and a pc-mcell-<role> class so the card flip is pure CSS. The role is derived
|
|
1137
|
+
// per column (classifyColumns) and threaded through gridCell into cellTd.
|
|
1138
|
+
assert.match(js, /col\.mobile && typeof col\.mobile\.label === "string"\s*\n?\s*\? col\.mobile\.label/);
|
|
1139
|
+
assert.match(js, /const mob = \{ class: "pc-mcell pc-mcell-" \+ mrole, "data-label": mlabel \};/);
|
|
1140
|
+
assert.match(js, /function gridCell\(col, row, role\)/);
|
|
1141
|
+
assert.match(js, /const cells = cols\.map\(\(c, i\) => gridCell\(c, row, roles\[i\]\)\);/);
|
|
1142
|
+
assert.match(js, /return cellTd\(primary, text, subText, truncate, mob\);/);
|
|
1143
|
+
// cellTd applies the mobile attrs to whichever td shape it returns.
|
|
1144
|
+
assert.match(js, /function cellTd\(primary, primaryText, subText, truncate, tdAttrs\)/);
|
|
1145
|
+
assert.match(js, /const td = tdAttrs \|\| \{\};/);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
test("the renderer adds a per-row More toggle only when a column is mobile-hidden (#268)", async () => {
|
|
1149
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
1150
|
+
const js = res.body ?? "";
|
|
1151
|
+
// Tier 1: mobile:{priority:"hidden"} columns are revealed behind a per-row
|
|
1152
|
+
// "More" toggle. The toggle cell is base-hidden and shown only inside the
|
|
1153
|
+
// mobile @media, so desktop is unchanged; it appears only when the grid
|
|
1154
|
+
// actually has a hidden column. The cell is built by the shared mobileMoreCell
|
|
1155
|
+
// helper and appended to the row (both the top-level grid and child grids use it).
|
|
1156
|
+
assert.match(js, /const hasHidden = roles\.indexOf\("hidden"\) >= 0;/);
|
|
1157
|
+
assert.match(js, /if \(hasHidden\) tr\.append\(mobileMoreCell\(tr\)\);/);
|
|
1158
|
+
assert.match(js, /function mobileMoreCell\(tr\)/);
|
|
1159
|
+
assert.match(js, /class: "pc-mcard-toggle"/);
|
|
1160
|
+
assert.match(js, /const open = tr\.classList\.toggle\("pc-open"\);/);
|
|
1161
|
+
assert.match(js, /moreBtn\.textContent = open \? "Less" : "More";/);
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
test("the per-row More toggle exposes its reveal state via aria-expanded (#268)", async () => {
|
|
1165
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
1166
|
+
const js = res.body ?? "";
|
|
1167
|
+
// The toggle is a button that shows/hides card content, so assistive tech needs
|
|
1168
|
+
// aria-expanded: it starts collapsed ("false") and is updated on every toggle.
|
|
1169
|
+
assert.match(js, /"aria-expanded": "false"/);
|
|
1170
|
+
assert.match(js, /moreBtn\.setAttribute\("aria-expanded", String\(open\)\);/);
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
test("child grids reuse the same per-row More toggle for hidden columns (#268)", async () => {
|
|
1174
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
1175
|
+
const js = res.body ?? "";
|
|
1176
|
+
// Child grids classify columns into the same mobile roles (including "hidden"),
|
|
1177
|
+
// so — like the top-level grid — they must render the shared More toggle, or a
|
|
1178
|
+
// mobile:{priority:"hidden"} child column would be permanently unreachable on a
|
|
1179
|
+
// narrow viewport. Derived from the same mobileMoreCell helper (no drift).
|
|
1180
|
+
assert.match(js, /const chasHidden = croles\.indexOf\("hidden"\) >= 0;/);
|
|
1181
|
+
assert.match(js, /if \(chasHidden\) ctr\.append\(mobileMoreCell\(ctr\)\);/);
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
test("the renderer honours an optional Tier-2 page-level mobile layout variant (#268)", async () => {
|
|
1185
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
1186
|
+
const js = res.body ?? "";
|
|
1187
|
+
// Tier 2 escape hatch: a page may declare a distinct `mobile` node list used
|
|
1188
|
+
// ONLY on a narrow viewport (matchMedia), with the default nodes everywhere
|
|
1189
|
+
// else and a fallback for anything malformed. The page re-renders when the
|
|
1190
|
+
// viewport crosses the breakpoint so the variant swaps in/out on rotation.
|
|
1191
|
+
assert.match(js, /function pickNodes\(doc\)/);
|
|
1192
|
+
assert.match(js, /if \(isNarrow\(\) && doc && doc\.mobile && Array\.isArray\(doc\.mobile\.nodes\)\) return doc\.mobile\.nodes;/);
|
|
1193
|
+
assert.match(js, /const nodes = pickNodes\(doc\);/);
|
|
1194
|
+
assert.match(js, /MOBILE_MQ\.addEventListener\("change", renderPage\);/);
|
|
1195
|
+
// …but degrade gracefully on older Safari/iOS (< 14) where MediaQueryList
|
|
1196
|
+
// lacks addEventListener, else the Tier-2 variant would never swap on rotation.
|
|
1197
|
+
assert.match(js, /typeof MOBILE_MQ\.addEventListener === "function"/);
|
|
1198
|
+
assert.match(js, /else if \(typeof MOBILE_MQ\.addListener === "function"\) MOBILE_MQ\.addListener\(renderPage\);/);
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
// --- Pipeline / stepper cell renderer (issue #265) ---------------------------
|
|
1202
|
+
// These guard the runtime shape the way the other column-mode tests do: the
|
|
1203
|
+
// renderer JS is asserted for the pipeline logic, and the shell CSS for the
|
|
1204
|
+
// .pc-pipe* chrome. Together they lock the six rendering requirements so the
|
|
1205
|
+
// primitive can't silently regress.
|
|
1206
|
+
|
|
1207
|
+
test("renderer gates the pipeline cell on kind:\"pipeline\" and leaves other modes untouched", async () => {
|
|
1208
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
1209
|
+
const js = res.body ?? "";
|
|
1210
|
+
// A dataGrid column declaring kind:"pipeline" is dispatched to pipelineCell;
|
|
1211
|
+
// the gate is a pure discriminator so absent it, every existing column mode
|
|
1212
|
+
// (badge/linkField/link/template/truncate) renders exactly as before.
|
|
1213
|
+
assert.match(js, /if\s*\(col\.kind === "pipeline"\) return pipelineCell\(col, row, text, subText, truncate, mob\)/);
|
|
1214
|
+
assert.match(js, /function pipelineCell\(col, row, text, subText, truncate, tdAttrs\)/);
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
test("pipeline orders stages upstream-filled / active-lit / downstream-ghosted from activeField", async () => {
|
|
1218
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
1219
|
+
const js = res.body ?? "";
|
|
1220
|
+
// The active stage is located by matching activeField's value against
|
|
1221
|
+
// stages[].key; stages before it are done/filled, the match is lit (active),
|
|
1222
|
+
// and in-path stages after it are ghosted/upcoming. The lookup breaks on the
|
|
1223
|
+
// first key match so a repeated key resolves deterministically to the first stage.
|
|
1224
|
+
assert.match(js, /const activeKey = col\.activeField != null/);
|
|
1225
|
+
// The row value is trimmed so a whitespace-padded key still matches its stage.
|
|
1226
|
+
assert.match(js, /\? String\(row\[col\.activeField\]\)\.trim\(\) : ""/);
|
|
1227
|
+
assert.match(js, /if \(String\(stages\[i\]\.key\) === activeKey\) \{\s*activeIdx = i;\s*break;\s*\}/);
|
|
1228
|
+
assert.match(js, /i < activeIdx\) \{\s*cls = "pc-pipe-done"; word = "completed";/);
|
|
1229
|
+
assert.match(js, /cls = "pc-pipe-upcoming"; word = "upcoming";/);
|
|
1230
|
+
// The connector fills up to the active stage, but a not-in-path predecessor
|
|
1231
|
+
// breaks the fill so a skipped stage is never implied to be on the path.
|
|
1232
|
+
assert.match(js, /const filled =\s*activeIdx >= 0 && i <= activeIdx && !skipped && !skip\.has\(String\(stages\[i - 1\]\.key\)\)/);
|
|
1233
|
+
assert.match(js, /class: "pc-pipe-conn" \+ filled/);
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
test("pipeline reads the not-in-path set per-row from a field, falling back to static config", async () => {
|
|
1237
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
1238
|
+
const js = res.body ?? "";
|
|
1239
|
+
// Skipped stages are dashed (.pc-pipe-skip). The set comes from notInPathField
|
|
1240
|
+
// on the row when given, else the static col.notInPath — via toStageSet, which
|
|
1241
|
+
// accepts an array or a comma/space-separated string, trimming + dropping
|
|
1242
|
+
// empties on BOTH paths so whitespace-padded array keys still match.
|
|
1243
|
+
assert.match(js, /col\.notInPathField != null && row\[col\.notInPathField\] != null/);
|
|
1244
|
+
assert.match(js, /toStageSet\(row\[col\.notInPathField\]\)/);
|
|
1245
|
+
assert.match(js, /toStageSet\(col\.notInPath\)/);
|
|
1246
|
+
assert.match(js, /cls = "pc-pipe-skip"; word = "skipped";/);
|
|
1247
|
+
assert.match(js, /function toStageSet\(v\)/);
|
|
1248
|
+
assert.match(js, /Array\.isArray\(v\)\) return new Set\(v\.map\(\(s\) => String\(s\)\.trim\(\)\)\.filter\(\(s\) => s !== ""\)\)/);
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
test("pipeline keeps the active stage current even if its key is also in the not-in-path set (active wins over skip)", async () => {
|
|
1252
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
1253
|
+
const js = res.body ?? "";
|
|
1254
|
+
// Precedence guard: the skipped predicate excludes the active index so a stage
|
|
1255
|
+
// that is both the active stage AND in the not-in-path set still renders as the
|
|
1256
|
+
// current step (active styling + aria-current), never as dashed/skipped. The
|
|
1257
|
+
// skip branch is checked before i === activeIdx, so without this exclusion the
|
|
1258
|
+
// active stage would silently lose aria-current="step".
|
|
1259
|
+
assert.match(js, /const skipped = i !== activeIdx && skip\.has\(String\(s\.key\)\);/);
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
test("pipeline renders failure distinctly from success on the active stage (stateField)", async () => {
|
|
1263
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
1264
|
+
const js = res.body ?? "";
|
|
1265
|
+
// stateField drives the active stage's terminal treatment: ok → success ✓,
|
|
1266
|
+
// failed → ✕, blocked → ⊘ — each a distinct class + glyph so success and
|
|
1267
|
+
// failure are unmistakably different.
|
|
1268
|
+
assert.match(js, /stRaw === "ok" \|\| stRaw === "failed" \|\| stRaw === "blocked" \? stRaw : "active"/);
|
|
1269
|
+
assert.match(js, /state === "failed"\) \{ cls = "pc-pipe-active pc-pipe-failed"; word = "failed"; glyph = "\\u2715";/);
|
|
1270
|
+
assert.match(js, /state === "ok"\) \{ cls = "pc-pipe-active pc-pipe-ok"; word = "done"; glyph = "\\u2713";/);
|
|
1271
|
+
assert.match(js, /state === "blocked"\) \{ cls = "pc-pipe-active pc-pipe-blocked"; word = "blocked"; glyph = "\\u2298";/);
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
test("pipeline renders an active-stage badge from badgeField reusing .pc-badge tone classes", async () => {
|
|
1275
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
1276
|
+
const js = res.body ?? "";
|
|
1277
|
+
// The badge sits on the active stage, only when badgeField is non-empty, and
|
|
1278
|
+
// reuses the existing .pc-badge tone classes (no new badge styles): warn for
|
|
1279
|
+
// attention/escalation, danger for blocked/failure.
|
|
1280
|
+
assert.match(js, /const badgeText = col\.badgeField != null && row\[col\.badgeField\] != null/);
|
|
1281
|
+
assert.match(js, /const badgeTone = state === "failed" \|\| state === "blocked" \? "danger" : "warn"/);
|
|
1282
|
+
assert.match(js, /current && badgeText\.trim\(\) !== ""/);
|
|
1283
|
+
assert.match(js, /class: "pc-badge pc-badge-" \+ badgeTone, title: badgeText, "aria-label": badgeText/);
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
test("pipeline builds a locus link via the shared pageHashHref route builder, only when the key is non-empty", async () => {
|
|
1287
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
1288
|
+
const js = res.body ?? "";
|
|
1289
|
+
// The locus stage becomes a #/<page>/<key> in-app link built by the SAME
|
|
1290
|
+
// pageHashHref helper the grid's page-link cell uses (single source of truth):
|
|
1291
|
+
// safePageId guard + encodeURIComponent, a blank key yields "" so no link, and
|
|
1292
|
+
// the anchor reuses .pc-link — no duplicated href construction.
|
|
1293
|
+
assert.match(js, /function pageHashHref\(page, key\)/);
|
|
1294
|
+
assert.match(js, /return "#\/" \+ encodeURIComponent\(page\) \+ "\/" \+ encodeURIComponent\(keyStr\)/);
|
|
1295
|
+
// The grid page-link cell was refactored onto the shared builder too.
|
|
1296
|
+
assert.match(js, /const href = pageHashHref\(col\.link\.page, row\[col\.link\.keyField\]\)/);
|
|
1297
|
+
// The locus link uses it and the shared .pc-link class; blank key → "" → plain.
|
|
1298
|
+
assert.match(js, /pageHashHref\(locus\.link\.page, locusKey\)/);
|
|
1299
|
+
assert.match(js, /el\("a", \{ class: "pc-link pc-pipe-label", href: locusHref \}, label\)/);
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
test("pipeline exposes step semantics to assistive tech (role/list + aria-current)", async () => {
|
|
1303
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
1304
|
+
const js = res.body ?? "";
|
|
1305
|
+
// The track is a labelled list; each stage is a listitem with an aria-label
|
|
1306
|
+
// stating its label + status word; the current stage is marked aria-current.
|
|
1307
|
+
assert.match(js, /class: "pc-pipe",\s*role: "list",\s*"aria-label": "Progress"/);
|
|
1308
|
+
assert.match(js, /role: "listitem", "aria-label": label \+ " \\u2014 " \+ word/);
|
|
1309
|
+
assert.match(js, /if \(current\) attrs\["aria-current"\] = "step"/);
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
test("pipeline degrades to plain cell text on missing/unknown config (never throws)", async () => {
|
|
1313
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
1314
|
+
const js = res.body ?? "";
|
|
1315
|
+
// No stages array → the cell falls back to plain text via cellTd, matching how
|
|
1316
|
+
// an unknown link.kind falls back today. Null/undefined entries are filtered so
|
|
1317
|
+
// a schema hole never throws on s.key.
|
|
1318
|
+
assert.match(js, /const stages = Array\.isArray\(col\.stages\) \? col\.stages\.filter\(\(s\) => s != null\) : \[\]/);
|
|
1319
|
+
assert.match(js, /if \(stages\.length === 0\) return cellTd\(text, text, subText, truncate, tdAttrs\)/);
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
test("pipeline cell inherits the mobile card attrs so it labels/classifies under the card flip (#268)", async () => {
|
|
1323
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
1324
|
+
const js = res.body ?? "";
|
|
1325
|
+
// The pipeline <td> merges gridCell's mobile card attrs (data-label + role
|
|
1326
|
+
// class) alongside its own pc-pipe-cell class, so a pipeline column is not left
|
|
1327
|
+
// out of the CSS-only card flip. Single source of truth: the same `mob` object
|
|
1328
|
+
// every other cell path receives.
|
|
1329
|
+
assert.match(js, /const cellAttrs = \{ \.\.\.\(tdAttrs \|\| \{\}\), class: \(tdAttrs && tdAttrs\.class \? tdAttrs\.class \+ " " : ""\) \+ "pc-pipe-cell" \}/);
|
|
1330
|
+
assert.match(js, /return el\("td", cellAttrs, track\)/);
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
test("shell CSS carries the .pc-pipe* track chrome reusing --nano-* tokens", async () => {
|
|
1334
|
+
const res = await dispatch("GET", "/");
|
|
1335
|
+
const html = res.body ?? "";
|
|
1336
|
+
// Structural track/stage styles only (badges/links reuse .pc-badge and .pc-link);
|
|
1337
|
+
// all colours resolve through the shared token vocabulary so themes work.
|
|
1338
|
+
assert.match(html, /\.pc-pipe \{ display:flex;/);
|
|
1339
|
+
assert.match(html, /\.pc-pipe-done \{[^}]*border-color:var\(--nano-ok\)/);
|
|
1340
|
+
assert.match(html, /\.pc-pipe-active \{[^}]*border-color:var\(--nano-accent\)/);
|
|
1341
|
+
assert.match(html, /\.pc-pipe-active\.pc-pipe-failed \{[^}]*border-color:var\(--nano-danger\)/);
|
|
1342
|
+
assert.match(html, /\.pc-pipe-active\.pc-pipe-ok \{[^}]*border-color:var\(--nano-ok\)/);
|
|
1343
|
+
assert.match(html, /\.pc-pipe-skip \{[^}]*border-style:dashed/);
|
|
1344
|
+
assert.match(html, /\.pc-pipe-upcoming \{[^}]*var\(--nano-text-faint\)/);
|
|
1345
|
+
});
|