@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.
@@ -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) : rawText;
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
- // and lets a string template win for the rendered text.
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 text = typeof col\.template === "string" \? interpTemplate\(col\.template, row\) : rawText;/);
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 () => {