|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#24534] `navItemVisibleDataNeedsDataSource` is EXPORTED, and the export IS |
| 5 | + * the check `NavigationItemSchema` runs, so a hand-written mirror of the |
| 6 | + * navigation item attaches the spec's own rule by identifier instead of |
| 7 | + * restating it. The rule itself (what is refused, where, and with which |
| 8 | + * words) is pinned in `app-nav-visible-data-needs-data-source.test.ts`; this |
| 9 | + * file pins only the export, on the same four legs as |
| 10 | + * `app-nav-target-exclusivity-export.test.ts`, plus the export's input |
| 11 | + * contract: |
| 12 | + * |
| 13 | + * 1. PARITY over the check's matrix (every refusing branch and every |
| 14 | + * accepting path) between the export called directly with a collecting |
| 15 | + * ctx and `NavigationItemSchema`'s own parse. The matrix authors `visible` |
| 16 | + * as the `{ dialect, source }` envelope, the one spelling whose parsed |
| 17 | + * value equals what was written, so the two runners read the same input. |
| 18 | + * 2. MOUNT: the union `NavigationItemSchema` lazily resolves to carries |
| 19 | + * exactly one `custom` check, and that check's issue vector over the |
| 20 | + * matrix equals the export's. |
| 21 | + * 3. ATTACHMENT BY IDENTIFIER: the module declares the export exactly once |
| 22 | + * and chains it by name exactly once, read from the source, so the door |
| 23 | + * cannot be running an inline copy that merely agrees on this matrix. |
| 24 | + * 4. BARREL identity: `./index` (what `@objectstack/spec/ui` ships) exports |
| 25 | + * the very same function object, with the `(value, ctx)` arity. |
| 26 | + * 5. BOTH SPELLINGS: the export accepts `visible` as authored (the bare |
| 27 | + * string a mirror whose own `visible` stays the authored string hands it) |
| 28 | + * and as parsed (the envelope the spec's door hands it), and answers the |
| 29 | + * same on both. The card's own entry as written draws the door's identical |
| 30 | + * issue from a direct call, and so does its parsed `visible`. Over the leg-1 |
| 31 | + * matrix rewritten with bare strings, the direct call and the door agree |
| 32 | + * issue for issue. The same bare string with a `dataSource` draws nothing, |
| 33 | + * and neither does a boolean or absent `visible`. So a mirror attaches the |
| 34 | + * export as it is, by identifier. |
| 35 | + * |
| 36 | + * What it does NOT prove: reference identity between the export and the |
| 37 | + * check object the schema holds. zod 4 wraps the function handed to |
| 38 | + * `superRefine` in a closure and keeps no handle to it; legs 2 + 3 are the |
| 39 | + * substitute, as in the two sibling export pins. |
| 40 | + * |
| 41 | + * The fixtures are SHAPE-VALID on purpose and the parse leg throws otherwise, |
| 42 | + * so "both refuse" can never be true for the wrong reason. |
| 43 | + */ |
| 44 | + |
| 45 | +import { describe, it, expect } from 'vitest'; |
| 46 | +import type { z } from 'zod'; |
| 47 | +import fs from 'node:fs'; |
| 48 | +import path from 'node:path'; |
| 49 | +import { fileURLToPath } from 'node:url'; |
| 50 | +import { NavigationItemSchema, navItemVisibleDataNeedsDataSource } from './app.zod'; |
| 51 | +import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; |
| 52 | +import * as ui from './index'; |
| 53 | + |
| 54 | +const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 55 | + |
| 56 | +// --------------------------------------------------------------------------- |
| 57 | +// Harness |
| 58 | +// --------------------------------------------------------------------------- |
| 59 | + |
| 60 | +interface IssueSig { |
| 61 | + code: string; |
| 62 | + path: string; |
| 63 | + message: string; |
| 64 | +} |
| 65 | + |
| 66 | +interface RawIssueLike { |
| 67 | + code?: string; |
| 68 | + path?: readonly PropertyKey[]; |
| 69 | + message?: string; |
| 70 | +} |
| 71 | + |
| 72 | +const sig = (i: RawIssueLike): IssueSig => ({ |
| 73 | + code: String(i.code), |
| 74 | + path: (i.path ?? []).map(String).join('.'), |
| 75 | + message: String(i.message), |
| 76 | +}); |
| 77 | + |
| 78 | +const sorted = (issues: IssueSig[]): IssueSig[] => |
| 79 | + [...issues].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); |
| 80 | + |
| 81 | +/** Leg (a): call the export directly with a collecting ctx. */ |
| 82 | +function runExport(value: unknown): IssueSig[] { |
| 83 | + const issues: RawIssueLike[] = []; |
| 84 | + const ctx = { |
| 85 | + value, |
| 86 | + issues, |
| 87 | + addIssue: (issue: string | RawIssueLike) => { |
| 88 | + issues.push(typeof issue === 'string' ? { code: 'custom', message: issue } : { code: 'custom', ...issue }); |
| 89 | + }, |
| 90 | + }; |
| 91 | + navItemVisibleDataNeedsDataSource(value, ctx as unknown as z.RefinementCtx); |
| 92 | + return sorted(issues.map(sig)); |
| 93 | +} |
| 94 | + |
| 95 | +interface ZodCheckLike { |
| 96 | + _zod: { |
| 97 | + def: { check: string }; |
| 98 | + check: (payload: { value: unknown; issues: RawIssueLike[] }) => unknown; |
| 99 | + }; |
| 100 | +} |
| 101 | + |
| 102 | +/** Leg (b): run ONE of the schema's own check objects in isolation. */ |
| 103 | +function runCheckObject(check: ZodCheckLike, value: unknown): IssueSig[] { |
| 104 | + const payload = { value, issues: [] as RawIssueLike[] }; |
| 105 | + check._zod.check(payload); |
| 106 | + return sorted(payload.issues.map(sig)); |
| 107 | +} |
| 108 | + |
| 109 | +/** Leg (c): the door's full parse, restricted to object-level (`custom`) issues. |
| 110 | + * Throws when the fixture is not shape-valid — see the header. */ |
| 111 | +function runParse(value: unknown): IssueSig[] { |
| 112 | + const r = NavigationItemSchema.safeParse(value); |
| 113 | + if (r.success) return []; |
| 114 | + const foreign = r.error.issues.filter((i) => i.code !== 'custom'); |
| 115 | + if (foreign.length > 0) { |
| 116 | + throw new Error(`fixture is not shape-valid — the object-level check never ran: ${JSON.stringify(foreign)}`); |
| 117 | + } |
| 118 | + return sorted(r.error.issues.map((i) => sig(i as RawIssueLike))); |
| 119 | +} |
| 120 | + |
| 121 | +/** |
| 122 | + * The checks held by the discriminated union `NavigationItemSchema` lazily |
| 123 | + * resolves to — the one place this module chains the refinement (its |
| 124 | + * branches carry their own checks; those are not this export's). |
| 125 | + */ |
| 126 | +function unionChecksOf(schema: unknown): ZodCheckLike[] { |
| 127 | + const lazyDef = (schema as { _zod: { def: { type: string; getter: () => unknown } } })._zod.def; |
| 128 | + expect(lazyDef.type).toBe('lazy'); |
| 129 | + const union = lazyDef.getter() as { _zod: { def: { type: string; checks?: ZodCheckLike[] } } }; |
| 130 | + expect(union._zod.def.type).toBe('union'); |
| 131 | + return union._zod.def.checks ?? []; |
| 132 | +} |
| 133 | + |
| 134 | +/** One issue vector per runner over the matrix — the equality's key. */ |
| 135 | +const vectorOf = (run: (value: unknown) => IssueSig[], values: unknown[]): string => |
| 136 | + JSON.stringify(values.map(run)); |
| 137 | + |
| 138 | +// --------------------------------------------------------------------------- |
| 139 | +// Fixture matrix — every refusing branch, plus every accepting path |
| 140 | +// --------------------------------------------------------------------------- |
| 141 | + |
| 142 | +interface Fixture { |
| 143 | + label: string; |
| 144 | + value: Record<string, unknown>; |
| 145 | + /** The paths the check is expected to refuse at; `[]` is an accepting path. */ |
| 146 | + refusesAt: string[]; |
| 147 | +} |
| 148 | + |
| 149 | +/** The card's entry, as written there. */ |
| 150 | +const CARD_ENTRY = { id: 'nav_envs', type: 'object', objectName: 'sys_environment', visible: 'data.total == 1' } as const; |
| 151 | +const API_BINDING = { provider: 'api', read: { url: '/api/v1/cloud/home' }, path: 'data.environments' } as const; |
| 152 | +const cel = (source: string) => ({ dialect: 'cel', source }) as const; |
| 153 | + |
| 154 | +const fixtures: Fixture[] = [ |
| 155 | + // Refusals — the one rule, on the branches that spread the base. |
| 156 | + { label: 'the card\'s entry, `visible` as the envelope', value: { ...CARD_ENTRY, visible: cel('data.total == 1') }, refusesAt: ['visible'] }, |
| 157 | + { label: 'a `dashboard` entry', value: { id: 'nav_ops', type: 'dashboard', dashboardName: 'ops', visible: cel('!data.has_more') }, refusesAt: ['visible'] }, |
| 158 | + { label: 'a `url` entry reading the bare root', value: { id: 'nav_cloud', type: 'url', url: '/apps/cloud', visible: cel('data') }, refusesAt: ['visible'] }, |
| 159 | + { label: 'a `group` entry', value: { id: 'nav_group', type: 'group', children: [], visible: cel('has(data.total)') }, refusesAt: ['visible'] }, |
| 160 | + // Accepting paths — the control the card names, and the reader's edges. |
| 161 | + { label: 'the card\'s entry WITH a `dataSource` (the control)', value: { ...CARD_ENTRY, visible: cel('data.total == 1'), dataSource: API_BINDING }, refusesAt: [] }, |
| 162 | + { label: 'an ambient root only (`current_user`)', value: { ...CARD_ENTRY, visible: cel('\'org_admin\' in current_user.positions') }, refusesAt: [] }, |
| 163 | + { label: 'a member named `data`, not the root', value: { ...CARD_ENTRY, visible: cel('features.data == true') }, refusesAt: [] }, |
| 164 | + { label: 'no `visible` at all', value: { id: 'nav_envs', type: 'object', objectName: 'sys_environment' }, refusesAt: [] }, |
| 165 | +]; |
| 166 | + |
| 167 | +const matrix = fixtures.map((f) => f.value); |
| 168 | +const refusing = fixtures.filter((f) => f.refusesAt.length > 0); |
| 169 | +const accepting = fixtures.filter((f) => f.refusesAt.length === 0); |
| 170 | + |
| 171 | +// --------------------------------------------------------------------------- |
| 172 | +// Leg 1 — parity on every fixture between the export and the door |
| 173 | +// --------------------------------------------------------------------------- |
| 174 | + |
| 175 | +describe('navItemVisibleDataNeedsDataSource — parity between the export and NavigationItemSchema', () => { |
| 176 | + it.each(fixtures)('$label — the direct call refuses at exactly the declared paths', ({ value, refusesAt }) => { |
| 177 | + const direct = runExport(value); |
| 178 | + expect(direct.map((i) => i.path).sort()).toEqual([...refusesAt].sort()); |
| 179 | + for (const issue of direct) expect(issue.code).toBe('custom'); |
| 180 | + }); |
| 181 | + |
| 182 | + it.each(fixtures)('$label — the NavigationItemSchema parse and the direct call agree issue for issue', ({ value }) => { |
| 183 | + expect(runParse(value)).toEqual(runExport(value)); |
| 184 | + }); |
| 185 | + |
| 186 | + it('the matrix has both kinds of path, so agreement is not vacuous', () => { |
| 187 | + expect(refusing.length).toBeGreaterThanOrEqual(4); |
| 188 | + expect(accepting.length).toBeGreaterThanOrEqual(4); |
| 189 | + }); |
| 190 | +}); |
| 191 | + |
| 192 | +// --------------------------------------------------------------------------- |
| 193 | +// Leg 2 — the mount: the union carries the check |
| 194 | +// --------------------------------------------------------------------------- |
| 195 | + |
| 196 | +describe('the mount — the union NavigationItemSchema resolves to carries the check', () => { |
| 197 | + it('carries exactly one `custom` check', () => { |
| 198 | + expect(unionChecksOf(NavigationItemSchema).map((c) => c._zod.def.check)).toEqual(['custom']); |
| 199 | + }); |
| 200 | + |
| 201 | + it('that check is behaviourally identical to the export over the whole matrix', () => { |
| 202 | + const [check] = unionChecksOf(NavigationItemSchema); |
| 203 | + expect(vectorOf((v) => runCheckObject(check, v), matrix)).toBe(vectorOf(runExport, matrix)); |
| 204 | + // …and the export is not a no-op on its own matrix (an all-empty vector |
| 205 | + // would match any dead check). |
| 206 | + expect(vectorOf(runExport, matrix)).not.toBe(vectorOf(() => [], matrix)); |
| 207 | + }); |
| 208 | +}); |
| 209 | + |
| 210 | +// --------------------------------------------------------------------------- |
| 211 | +// Leg 3 — attached by identifier, in the module that declares the schema |
| 212 | +// --------------------------------------------------------------------------- |
| 213 | + |
| 214 | +describe('app.zod.ts attaches the export BY IDENTIFIER — no inline copy', () => { |
| 215 | + const src = fs.readFileSync(path.join(HERE, 'app.zod.ts'), 'utf8'); |
| 216 | + // An attachment is a CODE line carrying `.superRefine(name)`; docblock |
| 217 | + // (` * `) and `//` lines are excluded, so prose naming the same spelling is |
| 218 | + // not counted. |
| 219 | + const attachments = (name: string): number => |
| 220 | + src.split(/\r?\n/).filter((line) => !/^[ \t]*(\*|\/\/)/.test(line) && line.includes(`.superRefine(${name})`)).length; |
| 221 | + // A NAME is only a sound key for that count if the module declares it exactly |
| 222 | + // once — a second, shadowing binding would satisfy the count while the door |
| 223 | + // chains a different function object. |
| 224 | + const declarations = (name: string): number => |
| 225 | + src.match(new RegExp(`^\\s*(export )?function ${name}\\b`, 'gm'))?.length ?? 0; |
| 226 | + |
| 227 | + it('declares `export function navItemVisibleDataNeedsDataSource(` exactly once', () => { |
| 228 | + expect(src).toContain('export function navItemVisibleDataNeedsDataSource('); |
| 229 | + expect(declarations('navItemVisibleDataNeedsDataSource')).toBe(1); |
| 230 | + }); |
| 231 | + |
| 232 | + it('chains it exactly once — on the union, by name', () => { |
| 233 | + expect(attachments('navItemVisibleDataNeedsDataSource')).toBe(1); |
| 234 | + }); |
| 235 | +}); |
| 236 | + |
| 237 | +// --------------------------------------------------------------------------- |
| 238 | +// Leg 4 — the barrel ships the same function object |
| 239 | +// --------------------------------------------------------------------------- |
| 240 | + |
| 241 | +describe('`./index` (the `@objectstack/spec/ui` surface) exports the same function object', () => { |
| 242 | + it('navItemVisibleDataNeedsDataSource — reference identity, and the `(value, ctx)` arity', () => { |
| 243 | + expect((ui as Record<string, unknown>).navItemVisibleDataNeedsDataSource).toBe(navItemVisibleDataNeedsDataSource); |
| 244 | + expect(typeof navItemVisibleDataNeedsDataSource).toBe('function'); |
| 245 | + expect(navItemVisibleDataNeedsDataSource.length).toBe(2); |
| 246 | + }); |
| 247 | +}); |
| 248 | + |
| 249 | +// --------------------------------------------------------------------------- |
| 250 | +// Leg 5 — both spellings: the export reads `visible` as authored and as parsed |
| 251 | +// --------------------------------------------------------------------------- |
| 252 | + |
| 253 | +/** The leg-1 matrix with each envelope `visible` written back as the bare string an author writes. */ |
| 254 | +const authoredFixtures: Fixture[] = fixtures |
| 255 | + .filter((f) => f.value.visible !== undefined) |
| 256 | + .map((f) => ({ |
| 257 | + ...f, |
| 258 | + label: `${f.label}, \`visible\` written as the bare string`, |
| 259 | + value: { ...f.value, visible: (f.value.visible as { source: string }).source }, |
| 260 | + })); |
| 261 | + |
| 262 | +describe('both spellings — the export reads `visible` as authored and as parsed, and answers the same', () => { |
| 263 | + const parsedVisible = EvaluatedExpressionInputSchema.parse(CARD_ENTRY.visible); |
| 264 | + |
| 265 | + it('the spec normalises the bare string to the envelope the check reads', () => { |
| 266 | + expect(parsedVisible).toEqual(cel('data.total == 1')); |
| 267 | + }); |
| 268 | + |
| 269 | + it('the door refuses the card\'s entry as written, with one `custom` issue at `visible`', () => { |
| 270 | + const door = runParse(CARD_ENTRY); |
| 271 | + expect(door).toHaveLength(1); |
| 272 | + expect(door[0]).toMatchObject({ code: 'custom', path: 'visible' }); |
| 273 | + }); |
| 274 | + |
| 275 | + it('the export raises the identical issue on that entry\'s parsed `visible`', () => { |
| 276 | + expect(runExport({ ...CARD_ENTRY, visible: parsedVisible })).toEqual(runParse(CARD_ENTRY)); |
| 277 | + }); |
| 278 | + |
| 279 | + it('the export raises the door\'s identical issue (code, path and message) on the card\'s entry AS WRITTEN', () => { |
| 280 | + const direct = runExport(CARD_ENTRY); |
| 281 | + expect(direct).toHaveLength(1); |
| 282 | + expect(direct).toEqual(runParse(CARD_ENTRY)); |
| 283 | + }); |
| 284 | + |
| 285 | + it.each(authoredFixtures)('$label — the direct call refuses at exactly the declared paths, issue for issue with the door', ({ value, refusesAt }) => { |
| 286 | + const direct = runExport(value); |
| 287 | + expect(direct.map((i) => i.path).sort()).toEqual([...refusesAt].sort()); |
| 288 | + expect(runParse(value)).toEqual(direct); |
| 289 | + }); |
| 290 | + |
| 291 | + it('the authored matrix has both kinds of path, so agreement is not vacuous', () => { |
| 292 | + expect(authoredFixtures.filter((f) => f.refusesAt.length > 0).length).toBeGreaterThanOrEqual(4); |
| 293 | + expect(authoredFixtures.filter((f) => f.refusesAt.length === 0).length).toBeGreaterThanOrEqual(3); |
| 294 | + }); |
| 295 | + |
| 296 | + it('the control — the same bare string with a `dataSource` — draws nothing, from the door or the export', () => { |
| 297 | + const withSource = { ...CARD_ENTRY, dataSource: API_BINDING }; |
| 298 | + expect(NavigationItemSchema.safeParse(withSource).success).toBe(true); |
| 299 | + expect(runExport(withSource)).toEqual([]); |
| 300 | + expect(runExport({ ...withSource, visible: parsedVisible })).toEqual([]); |
| 301 | + }); |
| 302 | + |
| 303 | + it('a boolean or absent `visible` judges nothing', () => { |
| 304 | + expect(runExport({ ...CARD_ENTRY, visible: true })).toEqual([]); |
| 305 | + expect(runExport({ ...CARD_ENTRY, visible: false })).toEqual([]); |
| 306 | + expect(runExport({ id: CARD_ENTRY.id, type: CARD_ENTRY.type, objectName: CARD_ENTRY.objectName })).toEqual([]); |
| 307 | + }); |
| 308 | +}); |
0 commit comments