@nanobpm/urban 0.57.0 → 0.59.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/dist/context/conformance/index.d.ts +66 -1
- package/dist/context/conformance/index.js +215 -1
- package/dist/context/conformance/records.conformance.js +52 -0
- package/dist/context/schema/index.d.ts +135 -1
- package/dist/context/schema/index.js +330 -1
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/package.json +1 -1
- package/src/context/conformance/index.ts +285 -2
- package/src/context/conformance/records.conformance.ts +68 -0
- package/src/context/schema/index.ts +443 -2
- package/src/context/schema/schema.test.ts +251 -0
- package/src/runtime/browser/runtime.browser.js +39 -3
- package/src/runtime/browser/runtime.browser.test.ts +43 -1
- package/src/runtime/core/modules/pages.test.ts +5 -3
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
- package/dist/context/conformance/scaffold.conformance.js +0 -10
- package/src/context/conformance/scaffold.conformance.ts +0 -11
- /package/dist/context/conformance/{scaffold.conformance.d.ts → records.conformance.d.ts} +0 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// Unit tests for the memory-record schema (slice S2). Run via
|
|
2
|
+
// `npm run test --workspace @nanobpm/urban` (node --test, src/**/*.test.ts).
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
validateMemoryRecord,
|
|
8
|
+
isMemoryRecord,
|
|
9
|
+
assertMemoryRecord,
|
|
10
|
+
MemoryRecordValidationError,
|
|
11
|
+
checkInvariants,
|
|
12
|
+
isAuthoritative,
|
|
13
|
+
SCOPE_LADDER,
|
|
14
|
+
MEMORY_MODES,
|
|
15
|
+
PROVENANCES,
|
|
16
|
+
AUTHORITIES,
|
|
17
|
+
MEMORY_RECORD_SCHEMA_VERSION,
|
|
18
|
+
type MemoryRecord,
|
|
19
|
+
type ValidationResult,
|
|
20
|
+
} from "./index.ts";
|
|
21
|
+
|
|
22
|
+
function base(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
23
|
+
return {
|
|
24
|
+
schemaVersion: MEMORY_RECORD_SCHEMA_VERSION,
|
|
25
|
+
id: "rec-1",
|
|
26
|
+
scope: "repo",
|
|
27
|
+
mode: "empirical",
|
|
28
|
+
provenance: "human",
|
|
29
|
+
authority: "authoritative",
|
|
30
|
+
statement: "the tests pass",
|
|
31
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("accepts a well-formed record and narrows it", () => {
|
|
37
|
+
const result = validateMemoryRecord(base());
|
|
38
|
+
assert.equal(result.ok, true);
|
|
39
|
+
if (result.ok) {
|
|
40
|
+
const rec: MemoryRecord = result.record;
|
|
41
|
+
assert.equal(rec.id, "rec-1");
|
|
42
|
+
assert.equal(rec.schemaVersion, MEMORY_RECORD_SCHEMA_VERSION);
|
|
43
|
+
assert.equal(isAuthoritative(rec), true);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("accepts optional fields when well-typed and drops absent ones", () => {
|
|
48
|
+
const result = validateMemoryRecord(base({ provenance: "measured", scopeRef: "elem-1", subject: "latency", evidence: ["run-1"], supersedes: "rec-0" }));
|
|
49
|
+
assert.equal(result.ok, true);
|
|
50
|
+
if (result.ok) {
|
|
51
|
+
assert.equal(result.record.scopeRef, "elem-1");
|
|
52
|
+
assert.deepEqual(result.record.evidence, ["run-1"]);
|
|
53
|
+
assert.equal("subject" in result.record, true);
|
|
54
|
+
}
|
|
55
|
+
const bare = validateMemoryRecord(base());
|
|
56
|
+
if (bare.ok) {
|
|
57
|
+
assert.equal("scopeRef" in bare.record, false);
|
|
58
|
+
assert.equal("evidence" in bare.record, false);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("rejects a non-object with a clear error", () => {
|
|
63
|
+
const result = validateMemoryRecord(42);
|
|
64
|
+
assert.equal(result.ok, false);
|
|
65
|
+
if (!result.ok) {
|
|
66
|
+
assert.equal(result.errors[0]?.code, "not-an-object");
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("never throws on hostile inputs: throwing getters and exotic prototypes are rejected, not read", () => {
|
|
71
|
+
// A throwing getter must not be invoked — it is rejected as not-an-object.
|
|
72
|
+
const withThrowingGetter = {
|
|
73
|
+
get id(): string {
|
|
74
|
+
throw new Error("hostile getter must never be read");
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
let hostile: ValidationResult | undefined;
|
|
78
|
+
assert.doesNotThrow(() => {
|
|
79
|
+
hostile = validateMemoryRecord(withThrowingGetter);
|
|
80
|
+
});
|
|
81
|
+
assert.equal(hostile?.ok, false);
|
|
82
|
+
if (hostile && !hostile.ok) {
|
|
83
|
+
assert.equal(hostile.errors[0]?.code, "not-an-object");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A non-plain prototype (class instance) is likewise rejected up front.
|
|
87
|
+
class Exotic {
|
|
88
|
+
id = "";
|
|
89
|
+
}
|
|
90
|
+
const proto = validateMemoryRecord(new Exotic());
|
|
91
|
+
assert.equal(proto.ok, false);
|
|
92
|
+
if (!proto.ok) {
|
|
93
|
+
assert.equal(proto.errors[0]?.code, "not-an-object");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A null-prototype object with only data properties is still a plain object.
|
|
97
|
+
const nullProto: Record<string, unknown> = Object.create(null);
|
|
98
|
+
nullProto.schemaVersion = MEMORY_RECORD_SCHEMA_VERSION;
|
|
99
|
+
assert.doesNotThrow(() => validateMemoryRecord(nullProto));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("reports every problem, not just the first", () => {
|
|
103
|
+
const result = validateMemoryRecord({ schemaVersion: 9, scope: "galaxy", mode: "x" });
|
|
104
|
+
assert.equal(result.ok, false);
|
|
105
|
+
if (!result.ok) {
|
|
106
|
+
assert.ok(result.errors.length >= 3);
|
|
107
|
+
const codes = new Set(result.errors.map((e) => e.code));
|
|
108
|
+
assert.ok(codes.has("unsupported-schema-version"));
|
|
109
|
+
assert.ok(codes.has("invalid-vocabulary"));
|
|
110
|
+
assert.ok(codes.has("missing-field"));
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("distinguishes missing, wrong-typed, and unsupported schemaVersion", () => {
|
|
115
|
+
const missing = validateMemoryRecord(base({ schemaVersion: undefined }));
|
|
116
|
+
assert.equal(missing.ok, false);
|
|
117
|
+
if (!missing.ok) {
|
|
118
|
+
assert.equal(missing.errors.find((e) => e.path === "schemaVersion")?.code, "missing-field");
|
|
119
|
+
}
|
|
120
|
+
const wrongType = validateMemoryRecord(base({ schemaVersion: "1" }));
|
|
121
|
+
assert.equal(wrongType.ok, false);
|
|
122
|
+
if (!wrongType.ok) {
|
|
123
|
+
assert.equal(wrongType.errors.find((e) => e.path === "schemaVersion")?.code, "wrong-type");
|
|
124
|
+
}
|
|
125
|
+
const unsupported = validateMemoryRecord(base({ schemaVersion: 9 }));
|
|
126
|
+
assert.equal(unsupported.ok, false);
|
|
127
|
+
if (!unsupported.ok) {
|
|
128
|
+
assert.equal(unsupported.errors.find((e) => e.path === "schemaVersion")?.code, "unsupported-schema-version");
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("rejects drifted vocabulary on each controlled field", () => {
|
|
133
|
+
assert.equal(validateMemoryRecord(base({ scope: "galaxy" })).ok, false);
|
|
134
|
+
assert.equal(validateMemoryRecord(base({ mode: "prescriptive" })).ok, false);
|
|
135
|
+
assert.equal(validateMemoryRecord(base({ provenance: "robot" })).ok, false);
|
|
136
|
+
assert.equal(validateMemoryRecord(base({ authority: "supreme" })).ok, false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("distinguishes wrong-type from drifted vocabulary on controlled fields", () => {
|
|
140
|
+
for (const field of ["scope", "mode", "provenance", "authority"]) {
|
|
141
|
+
const wrongType = validateMemoryRecord(base({ [field]: 42 }));
|
|
142
|
+
assert.equal(wrongType.ok, false);
|
|
143
|
+
if (!wrongType.ok) {
|
|
144
|
+
const err = wrongType.errors.find((e) => e.path === field);
|
|
145
|
+
assert.equal(err?.code, "wrong-type", `${field}: a non-string must report wrong-type, not invalid-vocabulary`);
|
|
146
|
+
}
|
|
147
|
+
const drifted = validateMemoryRecord(base({ [field]: "not-in-ladder" }));
|
|
148
|
+
assert.equal(drifted.ok, false);
|
|
149
|
+
if (!drifted.ok) {
|
|
150
|
+
const err = drifted.errors.find((e) => e.path === field);
|
|
151
|
+
assert.equal(err?.code, "invalid-vocabulary", `${field}: a drifted string must report invalid-vocabulary`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("distinguishes empty-string from wrong-type on optional non-empty-string fields", () => {
|
|
157
|
+
for (const field of ["scopeRef", "supersedes"]) {
|
|
158
|
+
const empty = validateMemoryRecord(base({ [field]: "" }));
|
|
159
|
+
assert.equal(empty.ok, false);
|
|
160
|
+
if (!empty.ok) {
|
|
161
|
+
const err = empty.errors.find((e) => e.path === field);
|
|
162
|
+
assert.equal(err?.code, "empty-string", `${field}: an empty string is a valid type and must report empty-string, not wrong-type`);
|
|
163
|
+
}
|
|
164
|
+
const wrongType = validateMemoryRecord(base({ [field]: 42 }));
|
|
165
|
+
assert.equal(wrongType.ok, false);
|
|
166
|
+
if (!wrongType.ok) {
|
|
167
|
+
const err = wrongType.errors.find((e) => e.path === field);
|
|
168
|
+
assert.equal(err?.code, "wrong-type", `${field}: a non-string must report wrong-type`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("rejects non-ISO timestamps but accepts offset zones", () => {
|
|
174
|
+
assert.equal(validateMemoryRecord(base({ createdAt: "yesterday" })).ok, false);
|
|
175
|
+
assert.equal(validateMemoryRecord(base({ createdAt: "2026-01-01" })).ok, false);
|
|
176
|
+
assert.equal(validateMemoryRecord(base({ createdAt: "2026-01-01T12:00:00+13:00" })).ok, true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// --- The hypothesis-never-fix invariant, at every scope -------------------
|
|
180
|
+
|
|
181
|
+
test("INVARIANT: agent-retro can never be authoritative, at any scope", () => {
|
|
182
|
+
for (const scope of SCOPE_LADDER) {
|
|
183
|
+
for (const mode of MEMORY_MODES) {
|
|
184
|
+
const result = validateMemoryRecord(base({ scope, mode, provenance: "agent-retro", authority: "authoritative" }));
|
|
185
|
+
assert.equal(result.ok, false, `agent-retro/authoritative must be rejected at ${scope}/${mode}`);
|
|
186
|
+
if (!result.ok) {
|
|
187
|
+
assert.ok(result.errors.some((e) => e.code === "invariant-violation"));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("agent-retro as a hypothesis is accepted at every scope and mode", () => {
|
|
194
|
+
for (const scope of SCOPE_LADDER) {
|
|
195
|
+
for (const mode of MEMORY_MODES) {
|
|
196
|
+
assert.equal(validateMemoryRecord(base({ scope, mode, provenance: "agent-retro", authority: "hypothesis" })).ok, true);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("measured/instance must be empirical + authoritative", () => {
|
|
202
|
+
assert.equal(validateMemoryRecord(base({ provenance: "measured", authority: "hypothesis" })).ok, false);
|
|
203
|
+
assert.equal(validateMemoryRecord(base({ provenance: "measured", mode: "normative", authority: "authoritative" })).ok, false);
|
|
204
|
+
assert.equal(validateMemoryRecord(base({ provenance: "instance", authority: "hypothesis" })).ok, false);
|
|
205
|
+
assert.equal(validateMemoryRecord(base({ provenance: "instance", mode: "normative", authority: "authoritative" })).ok, false);
|
|
206
|
+
assert.equal(validateMemoryRecord(base({ provenance: "measured", mode: "empirical", authority: "authoritative" })).ok, true);
|
|
207
|
+
assert.equal(validateMemoryRecord(base({ provenance: "instance", mode: "empirical", authority: "authoritative" })).ok, true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("only human/agent-retro may author a normative record", () => {
|
|
211
|
+
assert.equal(validateMemoryRecord(base({ mode: "normative", provenance: "human", authority: "hypothesis" })).ok, true);
|
|
212
|
+
assert.equal(validateMemoryRecord(base({ mode: "normative", provenance: "agent-retro", authority: "hypothesis" })).ok, true);
|
|
213
|
+
assert.equal(validateMemoryRecord(base({ mode: "normative", provenance: "measured", authority: "authoritative" })).ok, false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("checkInvariants: the only route to authoritative excludes agent-retro", () => {
|
|
217
|
+
for (const provenance of PROVENANCES) {
|
|
218
|
+
for (const mode of MEMORY_MODES) {
|
|
219
|
+
const errs = checkInvariants({ provenance, mode, authority: "authoritative" });
|
|
220
|
+
if (provenance === "agent-retro") {
|
|
221
|
+
assert.ok(errs.length > 0, "agent-retro must never validate as authoritative");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("assertMemoryRecord throws MemoryRecordValidationError carrying structured errors", () => {
|
|
228
|
+
assert.throws(
|
|
229
|
+
() => assertMemoryRecord(base({ provenance: "agent-retro", authority: "authoritative" })),
|
|
230
|
+
(err: unknown) => {
|
|
231
|
+
assert.ok(err instanceof MemoryRecordValidationError);
|
|
232
|
+
assert.ok(err.errors.length > 0);
|
|
233
|
+
return true;
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
const ok = assertMemoryRecord(base());
|
|
237
|
+
assert.equal(ok.id, "rec-1");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("isMemoryRecord is a usable type guard", () => {
|
|
241
|
+
const v: unknown = base();
|
|
242
|
+
assert.equal(isMemoryRecord(v), true);
|
|
243
|
+
assert.equal(isMemoryRecord(42), false);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("vocabulary arrays are the expected controlled sets", () => {
|
|
247
|
+
assert.deepEqual([...SCOPE_LADDER], ["element", "instance", "epic", "repo", "corpus"]);
|
|
248
|
+
assert.deepEqual([...MEMORY_MODES], ["normative", "empirical"]);
|
|
249
|
+
assert.deepEqual([...PROVENANCES], ["human", "agent-retro", "measured", "instance"]);
|
|
250
|
+
assert.deepEqual([...AUTHORITIES], ["hypothesis", "authoritative"]);
|
|
251
|
+
});
|
|
@@ -321,6 +321,36 @@ function interpTemplate(tpl, row) {
|
|
|
321
321
|
})
|
|
322
322
|
: tpl;
|
|
323
323
|
}
|
|
324
|
+
|
|
325
|
+
// Format a single-field cell value per a column's opt-in `col.format` (issue
|
|
326
|
+
// #327). This runs in the browser, so it is the ONLY place that can render a
|
|
327
|
+
// stored UTC ISO timestamp in the VIEWER'S local timezone — the server never
|
|
328
|
+
// knows the viewer's zone, so a server-formatted string would always be wrong
|
|
329
|
+
// for someone. Currently the sole format is "datetime": a parseable ISO/date
|
|
330
|
+
// value → "11:42am Aug 19" (h:mm + lowercase am/pm, then short-month day), both
|
|
331
|
+
// parts in local time. Defensive by construction: an empty value, an unparseable
|
|
332
|
+
// date, or any unknown format returns the raw text verbatim — a mis-set format
|
|
333
|
+
// never blanks a cell or throws, matching how an unrecognised link.kind degrades
|
|
334
|
+
// to plain text. Display-only: the untransformed rawText still drives the badge
|
|
335
|
+
// gate/tooltip and any sort/groupBy (those key off the real field).
|
|
336
|
+
/** @param {any} format
|
|
337
|
+
* @param {string} raw
|
|
338
|
+
* @returns {string} */
|
|
339
|
+
function fmtCellValue(format, raw) {
|
|
340
|
+
if (format !== "datetime" || raw === "") return raw;
|
|
341
|
+
const d = new Date(raw);
|
|
342
|
+
if (Number.isNaN(d.getTime())) return raw;
|
|
343
|
+
// Present in a stable en-US format ("11:42am Aug 19") regardless of the
|
|
344
|
+
// viewer's locale ordering (e.g. en-GB would emit "19 Aug"), while the ABSENCE
|
|
345
|
+
// of a `timeZone` option keeps both parts in the viewer's LOCAL zone. hour12
|
|
346
|
+
// forces am/pm; the replace lowercases "AM"/"a.m."/… to "am"/"pm" and drops the
|
|
347
|
+
// separating space so the output is exactly "11:42am".
|
|
348
|
+
const time = d
|
|
349
|
+
.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true })
|
|
350
|
+
.replace(/\s*([AaPp])\.?\s*[Mm]\.?$/, (_m, p) => p.toLowerCase() + "m");
|
|
351
|
+
const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
352
|
+
return time + " " + date;
|
|
353
|
+
}
|
|
324
354
|
/** @param {any} node */
|
|
325
355
|
function renderText(node) {
|
|
326
356
|
const v = node.props.variant;
|
|
@@ -641,9 +671,15 @@ function gridCell(col, row, role) {
|
|
|
641
671
|
// field-derived defaults. Kept separate from the (possibly templated) display
|
|
642
672
|
// text so a template never re-lights or blanks a badge.
|
|
643
673
|
const rawText = row[col.field] == null ? "" : String(row[col.field]);
|
|
674
|
+
// Per-column display format (issue #327): an opt-in `col.format` transforms the
|
|
675
|
+
// single-field value for display only (e.g. "datetime" → the viewer's local
|
|
676
|
+
// time). rawText above stays untransformed so the badge gate/tooltip and any
|
|
677
|
+
// sort/groupBy still key off the real field. A col.template composes its own
|
|
678
|
+
// {{field}} tokens and therefore wins over the formatted field text below.
|
|
679
|
+
const fieldText = col.format ? fmtCellValue(col.format, rawText) : rawText;
|
|
644
680
|
// Visible text: a col.template (interpolated from the row) wins over the
|
|
645
|
-
// raw field value, else the field value is shown verbatim.
|
|
646
|
-
const text = typeof col.template === "string" ? interpTemplate(col.template, row) :
|
|
681
|
+
// formatted/raw field value, else the field value is shown verbatim.
|
|
682
|
+
const text = typeof col.template === "string" ? interpTemplate(col.template, row) : fieldText;
|
|
647
683
|
// 1. badge — a compact status indicator. When the row's field value is
|
|
648
684
|
// non-empty (truthy after trimming) render a small circular badge (e.g. a
|
|
649
685
|
// red "1" dot flagging an incident) whose tooltip is the full field text;
|
|
@@ -2176,4 +2212,4 @@ function boot() {
|
|
|
2176
2212
|
// renderer functions / RENDERERS registry can be imported directly.
|
|
2177
2213
|
if (typeof document !== "undefined" && document.getElementById("page")) boot();
|
|
2178
2214
|
|
|
2179
|
-
export { RENDERERS, renderText, renderButton, renderProse, renderNav };
|
|
2215
|
+
export { RENDERERS, renderText, renderButton, renderProse, renderNav, fmtCellValue, gridCell };
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { test } from "node:test";
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
9
|
import { PAGE_NODE_TYPES } from "@nanobpm/nano-app-schema";
|
|
10
|
-
import { RENDERERS, renderText } from "./runtime.browser.js";
|
|
10
|
+
import { RENDERERS, renderText, fmtCellValue, gridCell } from "./runtime.browser.js";
|
|
11
11
|
|
|
12
12
|
// ── Minimal fake DOM ────────────────────────────────────────────────────────
|
|
13
13
|
// Just enough of the Element/Document surface that el()/renderText touch:
|
|
@@ -116,3 +116,45 @@ test("#291: renderText degrades to empty text (never throws) on a missing text p
|
|
|
116
116
|
assert.equal(node.tagName, "P");
|
|
117
117
|
assert.equal(node.textContent, "");
|
|
118
118
|
});
|
|
119
|
+
|
|
120
|
+
test('#327: fmtCellValue "datetime" renders an ISO timestamp in the viewer\'s local time as "h:mmam Mon D"', () => {
|
|
121
|
+
const iso = "2026-08-18T23:42:47.788Z";
|
|
122
|
+
const out = fmtCellValue("datetime", iso);
|
|
123
|
+
// Shape: h:mm + lowercase am/pm (no separating space), then short-month day.
|
|
124
|
+
assert.match(out, /^\d{1,2}:\d{2}(am|pm) [A-Z][a-z]{2} \d{1,2}$/, `unexpected shape: ${out}`);
|
|
125
|
+
// Local, not UTC: the hour/minute/am-pm/day track the runner's local zone, so
|
|
126
|
+
// the assertion is timezone-independent (derives the expectation from the same
|
|
127
|
+
// Date in local time) yet still proves the value isn't rendered as raw UTC.
|
|
128
|
+
const d = new Date(iso);
|
|
129
|
+
let h = d.getHours() % 12;
|
|
130
|
+
if (h === 0) h = 12;
|
|
131
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
132
|
+
const ampm = d.getHours() < 12 ? "am" : "pm";
|
|
133
|
+
assert.ok(out.startsWith(`${h}:${mm}${ampm} `), `local time prefix wrong: ${out}`);
|
|
134
|
+
assert.ok(out.endsWith(` ${d.getDate()}`), `local day suffix wrong: ${out}`);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("#327: fmtCellValue passes through empty, unparseable, and unknown-format values verbatim (never blank/throw)", () => {
|
|
138
|
+
assert.equal(fmtCellValue("datetime", ""), "");
|
|
139
|
+
assert.equal(fmtCellValue("datetime", "not-a-date"), "not-a-date");
|
|
140
|
+
// An unknown format is a no-op — a mis-set column shows its raw value, not blank.
|
|
141
|
+
assert.equal(fmtCellValue("nope", "2026-08-18T23:42:47.788Z"), "2026-08-18T23:42:47.788Z");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("#327: gridCell applies col.format to the cell text, leaving an unformatted column verbatim", (t) => {
|
|
145
|
+
t.after(installFakeDom());
|
|
146
|
+
const iso = "2026-08-18T23:42:47.788Z";
|
|
147
|
+
const formatted = gridCell(
|
|
148
|
+
{ field: "updated_at", header: "Updated", format: "datetime" },
|
|
149
|
+
{ updated_at: iso },
|
|
150
|
+
"secondary",
|
|
151
|
+
);
|
|
152
|
+
assert.match(
|
|
153
|
+
formatted.textContent,
|
|
154
|
+
/^\d{1,2}:\d{2}(am|pm) [A-Z][a-z]{2} \d{1,2}$/,
|
|
155
|
+
`cell not local-formatted: ${formatted.textContent}`,
|
|
156
|
+
);
|
|
157
|
+
// No `format` → the field value renders exactly as stored (backward compatible).
|
|
158
|
+
const plain = gridCell({ field: "updated_at", header: "Updated" }, { updated_at: iso }, "secondary");
|
|
159
|
+
assert.equal(plain.textContent, iso);
|
|
160
|
+
});
|
|
@@ -378,10 +378,12 @@ test("renderer interpolates a column's per-cell template into DOM text (#214)",
|
|
|
378
378
|
// resolving to prototype cruft — guard the own-property gate explicitly.
|
|
379
379
|
assert.match(js, /Object\.prototype\.hasOwnProperty\.call\(row, key\) \? row\[key\] : null/);
|
|
380
380
|
assert.match(js, /return v == null \? "" : String\(v\);/);
|
|
381
|
-
// gridCell keeps the raw single-field value separate (drives the badge gate)
|
|
382
|
-
//
|
|
381
|
+
// gridCell keeps the raw single-field value separate (drives the badge gate),
|
|
382
|
+
// applies an opt-in col.format to the field text (#327), and lets a string
|
|
383
|
+
// template win for the rendered text over that formatted/raw field value.
|
|
383
384
|
assert.match(js, /const rawText = row\[col\.field\] == null \? "" : String\(row\[col\.field\]\);/);
|
|
384
|
-
assert.match(js, /const
|
|
385
|
+
assert.match(js, /const fieldText = col\.format \? fmtCellValue\(col\.format, rawText\) : rawText;/);
|
|
386
|
+
assert.match(js, /const text = typeof col\.template === "string" \? interpTemplate\(col\.template, row\) : fieldText;/);
|
|
385
387
|
});
|
|
386
388
|
|
|
387
389
|
test("renderer renders a column's subtitle as a muted second line, never an href (#257)", async () => {
|