|
| 1 | +// Open-Closed proof — the executable definition of done for the typed-node |
| 2 | +// refactor. Registering a brand-new attr subtype (and field subtype) must cost |
| 3 | +// ONLY a new class + a registration line: ZERO edits to any central file (no |
| 4 | +// DataType union arm, no convertToDataType case, no attr-schema subtype-set, no |
| 5 | +// AttrValue union arm, no ATTR_DATA_TYPE / FIELD_DATA_TYPE map entry). |
| 6 | +// |
| 7 | +// The registration mechanism (post-Task-4): attr value classes self-register via |
| 8 | +// the dependency-free leaf `src/attr-class-map.ts` (`registerAttrClass(subType, |
| 9 | +// ctor)`), which the parser/serializer/setAttr read back through. A new subtype |
| 10 | +// therefore needs (a) one `registerAttrClass(...)` line so the value behavior |
| 11 | +// resolves to the new class, and (b) one `registry.register({...})` def so the |
| 12 | +// loader can find the subtype's TypeDefinition + factory. Both lines live here |
| 13 | +// (or in a consumer's provider) — never in a central metadata file. |
| 14 | +// |
| 15 | +// HONEST surface notes (see the per-test comments + the trailer at end): |
| 16 | +// * Attr value class via the `attr.<subType>` child-node form: TRUE zero |
| 17 | +// central edits — the subtype is fused into the wrapper key and resolved |
| 18 | +// straight from the registry factory, so `validateValue`/`coerce` dispatch |
| 19 | +// to the new class with no central typing involved. |
| 20 | +// * Field subtype: registering a `field.<subType>` def is likewise zero edits |
| 21 | +// to FIELD_SUBTYPES or the co-located FIELD_DATA_TYPE map — the def carries |
| 22 | +// its own `dataType`, applied via `node.setDataType(...)` in the factory. |
| 23 | +// * The ONE central typing surface is `AttrSchema.valueType: AttrSubType`: to |
| 24 | +// DECLARE `@fizz` on an owner's schema (so the loader's attr-schema pass |
| 25 | +// runs `FizzAttr.validateValue` during load), the closed `AttrSubType` union |
| 26 | +// would need `"fizz"` admitted. That is a one-token edit to a closed-set |
| 27 | +// constant — documented honestly below rather than papered over. |
| 28 | + |
| 29 | +import { describe, it, expect } from "bun:test"; |
| 30 | +import { TypeId, TypeRegistry, type AttrSchema } from "../src/registry.js"; |
| 31 | +import { registerCoreTypes } from "../src/core-types.js"; |
| 32 | +import { MetaAttr, type ValueError } from "../src/meta/meta-attr.js"; |
| 33 | +import { MetaField } from "../src/meta/meta-field.js"; |
| 34 | +import { registerAttrClass } from "../src/attr-class-map.js"; |
| 35 | +import { MetaDataLoader } from "../src/loader/meta-data-loader.js"; |
| 36 | +import { InMemorySource } from "../src/loader/meta-data-source.js"; |
| 37 | +import { canonicalSerialize } from "../src/serializer-json.js"; |
| 38 | +import { TYPE_ATTR, TYPE_FIELD, TYPE_OBJECT, type AttrSubType } from "../src/constants.js"; |
| 39 | +import { DATA_TYPE_STRING, type DataType } from "../src/data-type.js"; |
| 40 | +import type { AttrValue } from "../src/meta/meta-data.js"; |
| 41 | + |
| 42 | +// =========================================================================== |
| 43 | +// The ENTIRE cost of a new value-shaped attr subtype: one class. |
| 44 | +// FizzAttr overrides only what differs from the base MetaAttr — its DataType, |
| 45 | +// how it coerces a raw value, and how it validates a stored value. A 'fizz' |
| 46 | +// value must be the literal string "fizz" or "buzz". |
| 47 | +// =========================================================================== |
| 48 | +const ATTR_SUBTYPE_FIZZ = "fizz"; // throwaway test subtype — OK as a literal |
| 49 | +const FIELD_SUBTYPE_FIZZ = "fizz"; |
| 50 | + |
| 51 | +class FizzAttr extends MetaAttr { |
| 52 | + override get dataType(): DataType { |
| 53 | + return DATA_TYPE_STRING; |
| 54 | + } |
| 55 | + override coerce(raw: unknown): AttrValue { |
| 56 | + return String(raw); |
| 57 | + } |
| 58 | + override validateValue(value: AttrValue): ValueError[] { |
| 59 | + return value === "fizz" || value === "buzz" |
| 60 | + ? [] |
| 61 | + : [{ message: `attribute '@${this.name}' must be 'fizz' or 'buzz'` }]; |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +// The ENTIRE cost of a new field subtype: one class. FizzField inherits all |
| 66 | +// MetaField behavior; its DataType comes from the registered def below (applied |
| 67 | +// via setDataType in the factory), so it need not even override dataType. |
| 68 | +class FizzField extends MetaField {} |
| 69 | + |
| 70 | +// --- registration line #1: the attr-class-map self-registration seam -------- |
| 71 | +// In a real provider this lives at module load inside the FizzAttr file (the |
| 72 | +// same `registerAttrClass(ATTR_SUBTYPE_X, XAttr)` call meta-attr-filter.ts etc. |
| 73 | +// make). Running it here (idempotent — Map.set) wires `@fizz` value behavior to |
| 74 | +// FizzAttr for the inline `@fizz` materialization path (setAttr → attrClassFor). |
| 75 | +registerAttrClass(ATTR_SUBTYPE_FIZZ, FizzAttr); |
| 76 | + |
| 77 | +/** A fresh registry with the core types plus the two throwaway fizz defs. The |
| 78 | + * two `registry.register` calls are the ONLY registration surface — no central |
| 79 | + * file is touched. */ |
| 80 | +function registryWithFizz(extraFieldAttrs: AttrSchema[] = []): TypeRegistry { |
| 81 | + const registry = new TypeRegistry(); |
| 82 | + registerCoreTypes(registry); |
| 83 | + |
| 84 | + // --- registration line #2: the attr.fizz TypeDefinition ------------------ |
| 85 | + // The factory builds a FizzAttr; the def carries its own dataType. No edit to |
| 86 | + // ATTR_SUBTYPES, BASE_ATTR_DATA_TYPE, data-converter, or AttrValue. |
| 87 | + registry.register({ |
| 88 | + typeId: new TypeId(TYPE_ATTR, ATTR_SUBTYPE_FIZZ), |
| 89 | + description: "throwaway fizz attr", |
| 90 | + factory: (id, name) => new FizzAttr(id, name), |
| 91 | + childRules: [], |
| 92 | + attributes: [], |
| 93 | + dataType: DATA_TYPE_STRING, |
| 94 | + }); |
| 95 | + |
| 96 | + // --- registration line #3: the field.fizz TypeDefinition ----------------- |
| 97 | + // The factory builds a FizzField; the def carries dataType, applied via |
| 98 | + // node.setDataType(...) inside the registry's def() factory. No edit to |
| 99 | + // FIELD_SUBTYPES or the co-located FIELD_DATA_TYPE map. |
| 100 | + registry.register({ |
| 101 | + typeId: new TypeId(TYPE_FIELD, FIELD_SUBTYPE_FIZZ), |
| 102 | + description: "throwaway fizz field", |
| 103 | + factory: (id, name) => { |
| 104 | + const node = new FizzField(id, name); |
| 105 | + node.setDataType(DATA_TYPE_STRING); |
| 106 | + return node; |
| 107 | + }, |
| 108 | + childRules: [], |
| 109 | + attributes: extraFieldAttrs, |
| 110 | + dataType: DATA_TYPE_STRING, |
| 111 | + }); |
| 112 | + |
| 113 | + return registry; |
| 114 | +} |
| 115 | + |
| 116 | +describe("Open-Closed proof: a new subtype costs one class + one registration line", () => { |
| 117 | + it("loads, coerces, validates, and canonically serializes attr.fizz + field.fizz (TRUE zero central edits)", async () => { |
| 118 | + const registry = registryWithFizz(); |
| 119 | + |
| 120 | + // `attr.fizz` is authored as a child node: { "attr.fizz": { name, value } }. |
| 121 | + // This is the genuinely zero-central-edit path — the subtype is fused into |
| 122 | + // the wrapper key and resolved straight from the registry factory, so the |
| 123 | + // value coerces + validates via FizzAttr without any AttrSchema typing. |
| 124 | + const doc = JSON.stringify({ |
| 125 | + "metadata.root": { |
| 126 | + package: "acme", |
| 127 | + children: [ |
| 128 | + { |
| 129 | + "object.entity": { |
| 130 | + name: "Demo", |
| 131 | + children: [ |
| 132 | + { |
| 133 | + "field.fizz": { |
| 134 | + name: "label", |
| 135 | + children: [{ "attr.fizz": { name: "fizz", value: "buzz" } }], |
| 136 | + }, |
| 137 | + }, |
| 138 | + { "identity.primary": { "@fields": ["label"] } }, |
| 139 | + ], |
| 140 | + }, |
| 141 | + }, |
| 142 | + ], |
| 143 | + }, |
| 144 | + }); |
| 145 | + |
| 146 | + const loader = new MetaDataLoader({ registry, freeze: false }); |
| 147 | + const { root, errors } = await loader.load([ |
| 148 | + new InMemorySource(doc, { id: "demo.json", format: "json" }), |
| 149 | + ]); |
| 150 | + expect(errors).toEqual([]); |
| 151 | + |
| 152 | + // The field materialized as the new FizzField with the registered DataType. |
| 153 | + const obj = root.ownChildByTypeAndName(TYPE_OBJECT, "Demo")!; |
| 154 | + const field = obj.ownChildByTypeAndName(TYPE_FIELD, "label")! as FizzField; |
| 155 | + expect(field).toBeInstanceOf(FizzField); |
| 156 | + expect(field.subType).toBe(FIELD_SUBTYPE_FIZZ); |
| 157 | + expect(field.dataType).toBe(DATA_TYPE_STRING); |
| 158 | + |
| 159 | + // The @fizz attr materialized as a FizzAttr instance, coerced via the class. |
| 160 | + const fizzAttr = field.ownMetaAttr("fizz")!; |
| 161 | + expect(fizzAttr).toBeInstanceOf(FizzAttr); |
| 162 | + expect(fizzAttr.value).toBe("buzz"); |
| 163 | + |
| 164 | + // The class's own validateValue is exercised end-to-end. |
| 165 | + expect(fizzAttr.validateValue("buzz")).toEqual([]); |
| 166 | + expect(fizzAttr.validateValue("fizz")).toEqual([]); |
| 167 | + expect(fizzAttr.validateValue("nope").length).toBeGreaterThan(0); |
| 168 | + |
| 169 | + // The class's own coerce stringifies a non-string raw value. |
| 170 | + expect(fizzAttr.coerce(42)).toBe("42"); |
| 171 | + |
| 172 | + // Canonical serialize emits @fizz inline (D5) and the field.fizz wrapper — |
| 173 | + // the serializer is fully type-agnostic, reading type/subType + the value |
| 174 | + // map off the node, so a new subtype round-trips with no serializer edit. |
| 175 | + const json = canonicalSerialize(root); |
| 176 | + expect(json).toContain('"@fizz": "buzz"'); |
| 177 | + expect(json).toContain('"field.fizz"'); |
| 178 | + }); |
| 179 | + |
| 180 | + it("rejects an invalid fizz value via the loader's attr-schema pass (one declared @fizz on the field schema)", async () => { |
| 181 | + // To make the loader's attr-schema pass invoke FizzAttr.validateValue during |
| 182 | + // load (rather than calling it directly), the owner field's schema must |
| 183 | + // DECLARE @fizz with valueType: "fizz". Functionally this is one AttrSchema |
| 184 | + // entry. The one honest CENTRAL typing surface: `AttrSchema.valueType` is |
| 185 | + // typed `AttrSubType` (a closed union of known attr subtypes), so a |
| 186 | + // production provider would add "fizz" to the ATTR_SUBTYPES const for this |
| 187 | + // line to typecheck. We localize that single narrowing here as |
| 188 | + // `ATTR_SUBTYPE_FIZZ as AttrSubType` — equivalent to a one-token edit to a |
| 189 | + // closed-set constant, NOT a structural central-file change. |
| 190 | + const fizzSchema: AttrSchema = { |
| 191 | + name: "fizz", |
| 192 | + valueType: ATTR_SUBTYPE_FIZZ as AttrSubType, |
| 193 | + required: false, |
| 194 | + description: "fizz or buzz", |
| 195 | + }; |
| 196 | + const registry = registryWithFizz([fizzSchema]); |
| 197 | + |
| 198 | + // Inline @fizz on a field whose schema declares it → the parser resolves the |
| 199 | + // declared valueType "fizz", materializes a FizzAttr, and the attr-schema |
| 200 | + // pass runs FizzAttr.validateValue, surfacing ERR_BAD_ATTR_VALUE for "nope". |
| 201 | + const doc = JSON.stringify({ |
| 202 | + "metadata.root": { |
| 203 | + package: "acme", |
| 204 | + children: [ |
| 205 | + { |
| 206 | + "object.entity": { |
| 207 | + name: "Demo", |
| 208 | + children: [ |
| 209 | + { "field.fizz": { name: "label", "@fizz": "nope" } }, |
| 210 | + { "identity.primary": { "@fields": ["label"] } }, |
| 211 | + ], |
| 212 | + }, |
| 213 | + }, |
| 214 | + ], |
| 215 | + }, |
| 216 | + }); |
| 217 | + |
| 218 | + const loader = new MetaDataLoader({ registry, freeze: false }); |
| 219 | + const { errors } = await loader.load([ |
| 220 | + new InMemorySource(doc, { id: "demo.json", format: "json" }), |
| 221 | + ]); |
| 222 | + expect( |
| 223 | + errors.some((e) => (e as { code?: string }).code === "ERR_BAD_ATTR_VALUE"), |
| 224 | + ).toBe(true); |
| 225 | + }); |
| 226 | + |
| 227 | + it("accepts a valid declared @fizz value through the same pipeline", async () => { |
| 228 | + const fizzSchema: AttrSchema = { |
| 229 | + name: "fizz", |
| 230 | + valueType: ATTR_SUBTYPE_FIZZ as AttrSubType, |
| 231 | + required: false, |
| 232 | + description: "fizz or buzz", |
| 233 | + }; |
| 234 | + const registry = registryWithFizz([fizzSchema]); |
| 235 | + const doc = JSON.stringify({ |
| 236 | + "metadata.root": { |
| 237 | + package: "acme", |
| 238 | + children: [ |
| 239 | + { |
| 240 | + "object.entity": { |
| 241 | + name: "Demo", |
| 242 | + children: [ |
| 243 | + { "field.fizz": { name: "label", "@fizz": "fizz" } }, |
| 244 | + { "identity.primary": { "@fields": ["label"] } }, |
| 245 | + ], |
| 246 | + }, |
| 247 | + }, |
| 248 | + ], |
| 249 | + }, |
| 250 | + }); |
| 251 | + |
| 252 | + const loader = new MetaDataLoader({ registry, freeze: false }); |
| 253 | + const { root, errors } = await loader.load([ |
| 254 | + new InMemorySource(doc, { id: "demo.json", format: "json" }), |
| 255 | + ]); |
| 256 | + expect(errors).toEqual([]); |
| 257 | + |
| 258 | + const obj = root.ownChildByTypeAndName(TYPE_OBJECT, "Demo")!; |
| 259 | + const field = obj.ownChildByTypeAndName(TYPE_FIELD, "label")! as FizzField; |
| 260 | + const fizzAttr = field.ownMetaAttr("fizz")!; |
| 261 | + expect(fizzAttr).toBeInstanceOf(FizzAttr); |
| 262 | + expect(fizzAttr.value).toBe("fizz"); |
| 263 | + }); |
| 264 | +}); |
| 265 | + |
| 266 | +// =========================================================================== |
| 267 | +// Documented invariant — the property this refactor exists to create. |
| 268 | +// |
| 269 | +// Registering attr.fizz + field.fizz above required editing ZERO central files: |
| 270 | +// * NO arm added to the DataType union (data-type.ts). |
| 271 | +// * NO case added to convertToDataType (data-converter.ts). |
| 272 | +// * NO subtype-set edited in attr-schema-validate.ts (value-shape knowledge |
| 273 | +// lives on FizzAttr.validateValue). |
| 274 | +// * NO arm added to the AttrValue union (meta-data.ts). |
| 275 | +// * NO entry added to BASE_ATTR_DATA_TYPE (meta-attr.ts) or FIELD_DATA_TYPE |
| 276 | +// (meta-field.ts) — each def carries its own dataType. |
| 277 | +// * NO entry hard-coded in core-types.ts — the attr-class-map self-registration |
| 278 | +// seam (registerAttrClass) wires value behavior; the registry defs supply the |
| 279 | +// factories. |
| 280 | +// |
| 281 | +// The single HONEST central typing surface is `AttrSchema.valueType: AttrSubType` |
| 282 | +// (a closed union): to DECLARE a new attr on an owner's schema typesafely, a |
| 283 | +// production provider adds the subtype string to the ATTR_SUBTYPES const — a |
| 284 | +// one-token edit to a closed-set constant, not a structural change. The attr's |
| 285 | +// VALUE BEHAVIOR (dataType/coerce/validate) and the field subtype are fully |
| 286 | +// described by their class + registration. THAT is Open-Closed. |
| 287 | +// =========================================================================== |
0 commit comments