|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #20731 — `ObjectStackProtocolImplementation.getPackagedViewBase`, against a |
| 5 | + * REAL `SchemaRegistry`, and the read it exists to feed. The view twin of |
| 6 | + * `protocol-packaged-dashboard-base.test.ts`. |
| 7 | + * |
| 8 | + * ## What was measured |
| 9 | + * |
| 10 | + * An org overlay on the showcase's packaged view `showcase_task.in_progress` |
| 11 | + * (ADR-0126 Regime O — a packaged view is overlay-editable) changed its label |
| 12 | + * and was published. An `en` reader was served the edit; a `zh-CN` reader, |
| 13 | + * admin or member, on the `/meta` item and list reads, was served `进行中` — |
| 14 | + * the catalog's translation of the label the package shipped. Reproduced here |
| 15 | + * one layer down, on the tree before this change: the protocol's item and list |
| 16 | + * reads both return the overlay's label, and `translateView` answered `进行中` |
| 17 | + * over it in `zh-CN` even when handed the packaged view, because it took no |
| 18 | + * base at all. |
| 19 | + * |
| 20 | + * The rule that fixes it is ADR-0029 D9.2a's — an explicit override beats a |
| 21 | + * packaged default, decided by comparing against the PACKAGED declaration — |
| 22 | + * and that comparison is only as good as the body this accessor returns. Hence |
| 23 | + * a real registry, registered the way the boot registers a `defineView` |
| 24 | + * container: the container under the bare object key, and each view it |
| 25 | + * expands to (`expandViewContainer`) under its qualified `<object>.<viewKey>` |
| 26 | + * name, which is the identity the base is looked up by. |
| 27 | + */ |
| 28 | + |
| 29 | +import { describe, it, expect } from 'vitest'; |
| 30 | +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; |
| 31 | +import { translateView, type TranslationBundle } from '@objectstack/spec/system'; |
| 32 | +import { expandViewContainer } from '@objectstack/spec/ui'; |
| 33 | +import { SchemaRegistry } from './registry.js'; |
| 34 | +import { assertEngineFindOnePredicate } from './engine-findone-predicate.js'; |
| 35 | + |
| 36 | +const PKG = 'com.example.showcase'; |
| 37 | +const OBJ = 'showcase_task'; |
| 38 | +const VIEW = 'showcase_task.in_progress'; |
| 39 | +const ORG = 'org_acme'; |
| 40 | + |
| 41 | +const SHIPPED_LABEL = 'In Progress'; |
| 42 | +const EDITED_LABEL = 'In Progress (edited)'; |
| 43 | + |
| 44 | +const listView = (object: string, label: string) => ({ |
| 45 | + label, |
| 46 | + type: 'grid' as const, |
| 47 | + data: { provider: 'object' as const, object }, |
| 48 | + columns: [{ field: 'title' }], |
| 49 | +}); |
| 50 | + |
| 51 | +/** The `defineView` container as the code package ships it. */ |
| 52 | +const taskContainer = () => ({ |
| 53 | + listViews: { |
| 54 | + in_progress: listView(OBJ, SHIPPED_LABEL), |
| 55 | + urgent: listView(OBJ, 'Urgent'), |
| 56 | + }, |
| 57 | +}); |
| 58 | + |
| 59 | +/** |
| 60 | + * The catalog: `zh-CN` translates the shipped labels, keyed by the BARE view |
| 61 | + * key under its object; `en` repeats the shipped label, the way a package that |
| 62 | + * extracts its source locale ships it. |
| 63 | + */ |
| 64 | +const BUNDLE: TranslationBundle = { |
| 65 | + en: { objects: { [OBJ]: { _views: { in_progress: { label: SHIPPED_LABEL }, urgent: { label: 'Urgent' } } } } } as any, |
| 66 | + 'zh-CN': { objects: { [OBJ]: { _views: { in_progress: { label: '进行中' }, urgent: { label: '紧急' } } } } } as any, |
| 67 | +}; |
| 68 | + |
| 69 | +const matchesWhere = (row: Record<string, unknown>, where: Record<string, unknown>) => |
| 70 | + Object.entries(where ?? {}).every(([k, v]) => { |
| 71 | + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); |
| 72 | + if (v === null) return row[k] === null || row[k] === undefined; |
| 73 | + return row[k] === v; |
| 74 | + }); |
| 75 | + |
| 76 | +/** |
| 77 | + * A real registry and the real protocol over a READ-ONLY in-memory |
| 78 | + * `sys_metadata`: the overlay row is seeded exactly as a published org overlay |
| 79 | + * stores it, so no write verb is doubled here at all. |
| 80 | + */ |
| 81 | +function makeSession() { |
| 82 | + const registry = new SchemaRegistry({ multiTenant: false }); |
| 83 | + registry.logLevel = 'silent'; |
| 84 | + const rows: Record<string, unknown>[] = []; |
| 85 | + const engine: any = { |
| 86 | + registry, |
| 87 | + async findOne(table: string, o: { where: Record<string, unknown> }) { |
| 88 | + // The real engine's #4419 predicate: a `findOne` naming no record is refused. |
| 89 | + assertEngineFindOnePredicate(table, o); |
| 90 | + if (table !== 'sys_metadata') return null; |
| 91 | + return rows.find((r) => matchesWhere(r, o.where)) ?? null; |
| 92 | + }, |
| 93 | + async find(table: string, o: { where: Record<string, unknown>; limit?: number }) { |
| 94 | + if (table !== 'sys_metadata') return []; |
| 95 | + const matched = rows.filter((r) => matchesWhere(r, o.where)); |
| 96 | + // The caller's bound, applied after the filter, by presence. |
| 97 | + return typeof o?.limit === 'number' ? matched.slice(0, o.limit) : matched; |
| 98 | + }, |
| 99 | + }; |
| 100 | + const protocol: any = new ObjectStackProtocolImplementation(engine, undefined, 'env_test'); |
| 101 | + return { protocol, registry, rows }; |
| 102 | +} |
| 103 | + |
| 104 | +/** Register a container the way the boot does: the bare object key, then every expanded view. */ |
| 105 | +function registerContainer(registry: SchemaRegistry, object: string, container: Record<string, unknown>, pkg: string) { |
| 106 | + registry.registerItem('view', { name: object, ...container }, 'name', pkg); |
| 107 | + for (const item of expandViewContainer(object, container)) { |
| 108 | + registry.registerItem('view', item, 'name', pkg); |
| 109 | + } |
| 110 | +} |
| 111 | + |
| 112 | +/** The packaged view item, as the boot registered it — the overlay's starting point. */ |
| 113 | +const packagedItem = () => { |
| 114 | + const item = expandViewContainer(OBJ, taskContainer()).find((v) => v.name === VIEW); |
| 115 | + if (!item) throw new Error(`fixture missing ${VIEW}`); |
| 116 | + return item; |
| 117 | +}; |
| 118 | + |
| 119 | +/** The same view as the org's published overlay stores it — its label edited. */ |
| 120 | +const overlayBody = () => ({ ...packagedItem(), label: EDITED_LABEL }); |
| 121 | + |
| 122 | +/** The published org overlay row, in the identity the write stores. */ |
| 123 | +function seedOrgOverlay(rows: Record<string, unknown>[]) { |
| 124 | + rows.push({ |
| 125 | + id: 'r_1', |
| 126 | + type: 'view', |
| 127 | + name: VIEW, |
| 128 | + package_id: null, |
| 129 | + organization_id: ORG, |
| 130 | + state: 'active', |
| 131 | + metadata: JSON.stringify(overlayBody()), |
| 132 | + }); |
| 133 | +} |
| 134 | + |
| 135 | +describe('#20731 getPackagedViewBase — the packaged view, never an overlay', () => { |
| 136 | + it('answers the view item the code package shipped, by its qualified name', () => { |
| 137 | + const s = makeSession(); |
| 138 | + registerContainer(s.registry, OBJ, taskContainer(), PKG); |
| 139 | + const base = s.protocol.getPackagedViewBase(VIEW); |
| 140 | + expect(base?.name).toBe(VIEW); |
| 141 | + expect(base?.object).toBe(OBJ); |
| 142 | + expect(base?.label).toBe(SHIPPED_LABEL); |
| 143 | + expect(base?._packageId).toBe(PKG); |
| 144 | + }); |
| 145 | + |
| 146 | + it('is immune to an overlay hydrated under the plain registry key', () => { |
| 147 | + // A published overlay is hydrated into the registry's PLAIN key. The |
| 148 | + // accessor must still answer the packaged body — a base that returned |
| 149 | + // the overlay would compare the edit equal to itself and hand the |
| 150 | + // catalog straight back. |
| 151 | + const s = makeSession(); |
| 152 | + registerContainer(s.registry, OBJ, taskContainer(), PKG); |
| 153 | + s.registry.registerItem('view', overlayBody(), 'name'); |
| 154 | + |
| 155 | + // The trap is live: the plain registry read answers the overlay… |
| 156 | + expect((s.registry.getItem('view', VIEW) as any)?.label).toBe(EDITED_LABEL); |
| 157 | + // …and the packaged base does not move. |
| 158 | + expect(s.protocol.getPackagedViewBase(VIEW)?.label).toBe(SHIPPED_LABEL); |
| 159 | + }); |
| 160 | + |
| 161 | + it('the KEY — matched by the qualified <object>.<viewKey>, never by the bare catalog key another object shares', () => { |
| 162 | + // The catalog addresses a view by its bare key UNDER its object, so the |
| 163 | + // bare key alone is not an identity: `showcase_project` ships an |
| 164 | + // `in_progress` view too. The base must be this object's view. |
| 165 | + const s = makeSession(); |
| 166 | + registerContainer(s.registry, OBJ, taskContainer(), PKG); |
| 167 | + registerContainer(s.registry, 'showcase_project', { listViews: { in_progress: listView('showcase_project', 'Active Projects') } }, PKG); |
| 168 | + |
| 169 | + expect(s.protocol.getPackagedViewBase(VIEW)?.label).toBe(SHIPPED_LABEL); |
| 170 | + expect(s.protocol.getPackagedViewBase('showcase_project.in_progress')?.label).toBe('Active Projects'); |
| 171 | + // The bare key selects nothing — no view is registered under it. |
| 172 | + expect(s.protocol.getPackagedViewBase('in_progress')).toBeUndefined(); |
| 173 | + }); |
| 174 | + |
| 175 | + it('is undefined for a view no code package ships, an unknown name, and an empty name', () => { |
| 176 | + // "No packaged baseline" is a real answer: the translator reads it as |
| 177 | + // "infer nothing" and keeps the catalog, as before this change. |
| 178 | + const s = makeSession(); |
| 179 | + s.registry.registerItem('view', { ...packagedItem(), name: 'showcase_task.tenant_view' }, 'name'); |
| 180 | + expect(s.protocol.getPackagedViewBase('showcase_task.tenant_view')).toBeUndefined(); |
| 181 | + expect(s.protocol.getPackagedViewBase('showcase_task.no_such_view')).toBeUndefined(); |
| 182 | + expect(s.protocol.getPackagedViewBase('')).toBeUndefined(); |
| 183 | + }); |
| 184 | + |
| 185 | + it('is undefined when the host registry cannot answer', () => { |
| 186 | + const partial: any = new ObjectStackProtocolImplementation({ registry: {} } as any, undefined, 'env_test'); |
| 187 | + expect(partial.getPackagedViewBase(VIEW)).toBeUndefined(); |
| 188 | + const none: any = new ObjectStackProtocolImplementation({} as any, undefined, 'env_test'); |
| 189 | + expect(none.getPackagedViewBase(VIEW)).toBeUndefined(); |
| 190 | + }); |
| 191 | +}); |
| 192 | + |
| 193 | +describe('#20731 the published org overlay, through the protocol reads and the translation', () => { |
| 194 | + it('the item and list reads serve the overlay — one identity for the write and both reads', async () => { |
| 195 | + const s = makeSession(); |
| 196 | + registerContainer(s.registry, OBJ, taskContainer(), PKG); |
| 197 | + seedOrgOverlay(s.rows); |
| 198 | + const served = await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG }); |
| 199 | + expect(served.item.label).toBe(EDITED_LABEL); |
| 200 | + // The code artifact's envelope rides on the overlay body; it names the |
| 201 | + // registering package, not a second identity the overlay missed. |
| 202 | + expect(served.item._packageId).toBe(PKG); |
| 203 | + |
| 204 | + const list = await s.protocol.getMetaItems({ type: 'view', organizationId: ORG }); |
| 205 | + const listed = (list.items as any[]).find((v) => v?.name === VIEW); |
| 206 | + expect(listed?.label).toBe(EDITED_LABEL); |
| 207 | + }); |
| 208 | + |
| 209 | + it('the translation serves the edit once it is handed the packaged base — zh-CN and en, item and list', async () => { |
| 210 | + const s = makeSession(); |
| 211 | + registerContainer(s.registry, OBJ, taskContainer(), PKG); |
| 212 | + seedOrgOverlay(s.rows); |
| 213 | + const item = (await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG })).item; |
| 214 | + const listed = ((await s.protocol.getMetaItems({ type: 'view', organizationId: ORG })).items as any[]) |
| 215 | + .find((v) => v?.name === VIEW); |
| 216 | + |
| 217 | + for (const served of [item, listed]) { |
| 218 | + const packagedBase = s.protocol.getPackagedViewBase(served.name); |
| 219 | + for (const locale of ['zh-CN', 'en']) { |
| 220 | + expect(translateView(served, BUNDLE, { locale, packagedBase }).label, locale).toBe(EDITED_LABEL); |
| 221 | + } |
| 222 | + } |
| 223 | + }); |
| 224 | + |
| 225 | + it('an unedited view stays translated, handed its own base', async () => { |
| 226 | + const s = makeSession(); |
| 227 | + registerContainer(s.registry, OBJ, taskContainer(), PKG); |
| 228 | + seedOrgOverlay(s.rows); |
| 229 | + const urgent = (await s.protocol.getMetaItem({ type: 'view', name: 'showcase_task.urgent', organizationId: ORG })).item; |
| 230 | + const packagedBase = s.protocol.getPackagedViewBase('showcase_task.urgent'); |
| 231 | + expect(packagedBase?.label).toBe('Urgent'); |
| 232 | + expect(translateView(urgent, BUNDLE, { locale: 'zh-CN', packagedBase }).label).toBe('紧急'); |
| 233 | + }); |
| 234 | + |
| 235 | + it('CONTROL — without the base, the catalog replaces the edit (the measured defect)', async () => { |
| 236 | + const s = makeSession(); |
| 237 | + registerContainer(s.registry, OBJ, taskContainer(), PKG); |
| 238 | + seedOrgOverlay(s.rows); |
| 239 | + const served = (await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG })).item; |
| 240 | + expect(translateView(served, BUNDLE, { locale: 'zh-CN' }).label).toBe('进行中'); |
| 241 | + expect(translateView(served, BUNDLE, { locale: 'en' }).label).toBe(SHIPPED_LABEL); |
| 242 | + }); |
| 243 | + |
| 244 | + it('after reset (no overlay row) the shipped label is served and translated again', async () => { |
| 245 | + const s = makeSession(); |
| 246 | + registerContainer(s.registry, OBJ, taskContainer(), PKG); |
| 247 | + seedOrgOverlay(s.rows); |
| 248 | + const edited = (await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG })).item; |
| 249 | + const packagedBase = s.protocol.getPackagedViewBase(VIEW); |
| 250 | + expect(translateView(edited, BUNDLE, { locale: 'zh-CN', packagedBase }).label).toBe(EDITED_LABEL); |
| 251 | + |
| 252 | + s.rows.length = 0; |
| 253 | + const served = (await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG })).item; |
| 254 | + expect(served.label).toBe(SHIPPED_LABEL); |
| 255 | + expect(translateView(served, BUNDLE, { locale: 'zh-CN', packagedBase }).label).toBe('进行中'); |
| 256 | + }); |
| 257 | +}); |
0 commit comments