diff --git a/src/lib/mirror/blueprintsView.test.ts b/src/lib/mirror/blueprintsView.test.ts new file mode 100644 index 0000000..3a22534 --- /dev/null +++ b/src/lib/mirror/blueprintsView.test.ts @@ -0,0 +1,91 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { selectBlueprintsView, selectOrgPersonas, blueprintTeamToOrgInput } from './blueprintsView'; +import { buildOrgScene } from '../graph'; + +const payload = () => ({ + active: 'default', + library: [ + { + id: 'default', name: 'Default', desc: 'greenfield', mode: 'create', tags: ['app'], uses: 4, + stageCount: 6, hasTeam: true, + uiKit: { id: 'react', version: '1.0', themeId: 'midnight' }, + soundKit: { id: 'bsc/signal', version: '1.0.0' }, + h: 262, origin: 'built-in', updatedAt: '2026-07-20T09:00:00.000Z', + }, + { id: 'api', name: 'API', desc: '', stageCount: 4, hasTeam: false }, + ], + activeTeam: { + positions: [ + { nodeId: 'dir', kind: 'agent', personaId: 'director' }, + { nodeId: 'w1', kind: 'agent', personaId: 'worker' }, + { nodeId: 'w2', kind: 'agent', personaId: 'worker' }, + ], + relationships: [ + { id: 'r1', archetype: 'manages', from: 'dir', to: 'w1' }, + { id: 'r2', archetype: 'manages', from: 'dir', to: 'w2' }, + ], + }, +}); + +describe('selectBlueprintsView', () => { + it('parses the library + active team, including soundKit/h/origin/updatedAt (#236)', () => { + const m = selectBlueprintsView(payload())!; + assert.equal(m.library.length, 2); + const card = m.library[0]; + assert.equal(card.hasTeam, true); + assert.equal(card.uiKit?.themeId, 'midnight'); + assert.deepEqual(card.soundKit, { id: 'bsc/signal', version: '1.0.0' }); + assert.equal(card.h, 262); + assert.equal(card.origin, 'built-in'); + assert.equal(card.updatedAt, '2026-07-20T09:00:00.000Z'); + assert.equal(m.activeTeam?.positions.length, 3); + }); + + it('a card without soundKit/h/origin/updatedAt yields undefined for each, not a fallback', () => { + const m = selectBlueprintsView(payload())!; + const card = m.library[1]; + assert.equal(card.soundKit, undefined); + assert.equal(card.h, undefined); + assert.equal(card.origin, undefined); + assert.equal(card.updatedAt, undefined); + }); + + it('returns undefined for missing / malformed payloads', () => { + assert.equal(selectBlueprintsView(undefined), undefined); + assert.equal(selectBlueprintsView({}), undefined); + }); + + it('treats an empty team as null', () => { + const m = selectBlueprintsView({ active: 'x', library: [], activeTeam: { positions: [] } })!; + assert.equal(m.activeTeam, null); + }); +}); + +describe('blueprintTeamToOrgInput', () => { + it('maps positions/relationships and resolves referenced personas', () => { + const m = selectBlueprintsView(payload())!; + const personas = selectOrgPersonas({ + personas: [ + { id: 'director', name: 'Director', role: 'director' }, + { id: 'worker', name: 'Worker', role: 'worker' }, + { id: 'unused', name: 'Nope', role: 'worker' }, + ], + }); + const input = blueprintTeamToOrgInput(m.activeTeam!, personas); + assert.equal(input.positions.length, 3); + assert.equal(input.personas.length, 2); // only referenced (director + worker) + // Worker personas stack: the two worker positions collapse into one pool node. + const scene = buildOrgScene(input); + const poolNode = scene.nodes.find((n) => (n.stackCount ?? 0) >= 2); + assert.ok(poolNode, 'the two worker positions pool into one stacked card'); + }); + + it('tolerates no personas (renders without pooling)', () => { + const m = selectBlueprintsView(payload())!; + const input = blueprintTeamToOrgInput(m.activeTeam!); + assert.equal(input.personas.length, 0); + const scene = buildOrgScene(input); + assert.equal(scene.nodes.length, 3); // no pooling → all three positions + }); +}); diff --git a/src/lib/mirror/blueprintsView.ts b/src/lib/mirror/blueprintsView.ts new file mode 100644 index 0000000..0b1caed --- /dev/null +++ b/src/lib/mirror/blueprintsView.ts @@ -0,0 +1,103 @@ +/** + * View-model selector for the `blueprints` mirror domain (#236). + * + * `src/lib/pages/blueprintsPage.ts` also parses this domain, but it is orphaned — unreachable + * from any `app/` route (CLAUDE.md's Known Issues & Tech Debt) — and out of this session's + * write scope (bsc-scope #1297 grants only src/lib/mirror/**, src/lib/types.ts, and + * src/lib/tunnel/*). This module is the up-to-date contract, verified directly against + * base-studio-code `develop`'s `storeProjections.ts` `BlueprintCard`/`buildBlueprintsPayload`: + * it reads `soundKit`/`h`/`origin`/`updatedAt`, which `blueprintsPage.ts`'s `selectBlueprints` + * does not (the #236 gap). Whichever surface reconnects the domain next (#233's Studio/Teams + * segment) should import from here. + * + * `category` was read by `blueprintsPage.ts` but no longer exists on the real desktop + * `BlueprintCard` (confirmed live against `develop` — the desktop builder never emits it) — + * deliberately not carried over here. + * + * Team-graph parsing (positions/relationships) is NOT duplicated: a blueprint's embedded team + * is structurally identical to an `org` library team, so this reuses `parseTeamGraph` from + * orgPage.ts (#235), the same way `blueprintsPage.ts` does. + */ +import { + parseTeamGraph, selectOrgPersonas, teamToOrgInput, type TeamGraphVM, type PersonaRefVM, +} from '../pages/orgPage'; + +export { + selectOrgPersonas, + /** @see teamToOrgInput — the blueprint-flavoured name, kept for parity with blueprintsPage.ts's call sites. */ + teamToOrgInput as blueprintTeamToOrgInput, +}; +export type { PersonaRefVM }; + +// ── wire shape (mirrors base-studio-code's `BlueprintCard`, storeProjections.ts) ─────────────── + +export interface BlueprintCardVM { + id: string; + name: string; + desc: string; + icon?: string; + mode?: string; + tags: string[]; + uses?: number; + stageCount: number; + hasTeam: boolean; + uiKit?: { id: string; version: string; themeId?: string }; + /** Per-blueprint sound-kit pin (base-studio-code#3372). Mirrors `uiKit`'s shape. */ + soundKit?: { id: string; version: string }; + /** Card accent hue (oklch degrees), when the blueprint carries one. */ + h?: number; + /** `built-in \| local \| forked \| imported` — packaged vs user-authored/imported grouping key. */ + origin?: string; + /** ISO-8601 last-modified timestamp, for recency sort. */ + updatedAt?: string; +} + +/** The ACTIVE blueprint's embedded team — structurally identical to an `org` library team. */ +export type BlueprintTeamVM = TeamGraphVM; + +export interface BlueprintsModel { + active: string; + library: BlueprintCardVM[]; + activeTeam: BlueprintTeamVM | null; +} + +function isObj(v: unknown): v is Record { + return typeof v === 'object' && v !== null; +} +const str = (v: unknown, d = ''): string => (typeof v === 'string' ? v : d); +const optStr = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined); +const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined); +const strArr = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []); + +function parseCard(b: unknown): BlueprintCardVM | null { + if (!isObj(b) || typeof b.id !== 'string') return null; + return { + id: b.id, + name: str(b.name, b.id), + desc: str(b.desc), + icon: optStr(b.icon), + mode: optStr(b.mode), + tags: strArr(b.tags), + uses: num(b.uses), + stageCount: num(b.stageCount) ?? 0, + hasTeam: b.hasTeam === true, + uiKit: isObj(b.uiKit) && typeof b.uiKit.id === 'string' + ? { id: b.uiKit.id, version: str(b.uiKit.version), themeId: optStr(b.uiKit.themeId) } + : undefined, + soundKit: isObj(b.soundKit) && typeof b.soundKit.id === 'string' + ? { id: b.soundKit.id, version: str(b.soundKit.version) } + : undefined, + h: num(b.h), + origin: optStr(b.origin), + updatedAt: optStr(b.updatedAt), + }; +} + +/** Parse the mirrored `blueprints` projection, or `undefined` when missing / malformed. */ +export function selectBlueprintsView(data: unknown): BlueprintsModel | undefined { + if (!isObj(data) || !Array.isArray(data.library)) return undefined; + const library = data.library + .map(parseCard) + .filter((c): c is BlueprintCardVM => c !== null); + return { active: str(data.active), library, activeTeam: parseTeamGraph(data.activeTeam) }; +} diff --git a/src/lib/tunnel/storePayloads.fixtures.json b/src/lib/tunnel/storePayloads.fixtures.json index 9c4be6f..d9a340c 100644 --- a/src/lib/tunnel/storePayloads.fixtures.json +++ b/src/lib/tunnel/storePayloads.fixtures.json @@ -172,7 +172,6 @@ "id": "default", "name": "Default", "desc": "greenfield seed", - "category": "greenfield", "mode": "create", "stageCount": 0, "hasTeam": true, @@ -190,7 +189,6 @@ "id": "migrate", "name": "Migrate", "desc": "restructure repos", - "category": "transform", "stageCount": 0, "hasTeam": false } @@ -551,7 +549,6 @@ "id": "migrate", "name": "Migrate", "desc": "restructure repos", - "category": "transform", "stageCount": 0, "hasTeam": false } diff --git a/src/lib/tunnel/storePayloads.fixtures.test.ts b/src/lib/tunnel/storePayloads.fixtures.test.ts index 87974d0..c48aff1 100644 --- a/src/lib/tunnel/storePayloads.fixtures.test.ts +++ b/src/lib/tunnel/storePayloads.fixtures.test.ts @@ -46,6 +46,7 @@ import { selectMcpView } from '../mirror/mcpView'; import { buildGlanceScene, buildOrgScene } from '../graph'; import { selectSecurityView } from '../mirror/securityView'; import { parseAlertsPayload, alertTarget } from '../alerts/model'; +import { selectBlueprintsView } from '../mirror/blueprintsView'; const fx = JSON.parse( readFileSync('src/lib/tunnel/storePayloads.fixtures.json', 'utf8'), @@ -72,7 +73,6 @@ const UNPROJECTED_DOMAINS = new Set(['plan']); * coverage guard, so a newly-published desktop domain cannot slip in unnoticed. */ const PENDING_DOMAINS: Record = { - blueprints: '#236 — soundKit/origin/updatedAt unread; only the ACTIVE team crosses', // #241 C5 (global composes scoping) is FIXED. What remains is all desktop-side: kits ship // unpared (C1), `group` is not projected (C3), and `libraryRefs` is not projected (C4). // @@ -406,6 +406,70 @@ function decodeMcp(o: Raw): Raw { }; } +/** `blueprints.library[]` — one blueprint card. #236: soundKit/h/origin/updatedAt now read. */ +function decodeBlueprintCard(o: Raw): Raw { + const out: Raw = { + id: str(o, 'id'), name: str(o, 'name'), desc: str(o, 'desc'), + stageCount: num(o, 'stageCount'), hasTeam: bool(o, 'hasTeam'), + }; + copyOptStr(o, out, 'icon'); + copyOptStr(o, out, 'mode'); + copyOptStr(o, out, 'origin'); + copyOptStr(o, out, 'updatedAt'); + copyOptNum(o, out, 'h'); + copyOptNum(o, out, 'uses'); + if ('tags' in o) out.tags = strArr(o, 'tags'); + if ('uiKit' in o) { + const k = o.uiKit as Raw; + const kit: Raw = { id: str(k, 'id'), version: str(k, 'version') }; + copyOptStr(k, kit, 'themeId'); + out.uiKit = kit; + } + if ('soundKit' in o) { + const k = o.soundKit as Raw; + out.soundKit = { id: str(k, 'id'), version: str(k, 'version') }; + } + return out; +} + +/** `blueprints.activeTeam` — positions + relationships only (no id/name/blurb, unlike an `org` library team). */ +function decodeBlueprintTeam(o: Raw): Raw { + return { + positions: arr(o, 'positions').map(decodeOrgPosition), + relationships: arr(o, 'relationships').map((r) => ({ + id: str(r, 'id'), archetype: str(r, 'archetype'), from: str(r, 'from'), to: str(r, 'to'), + })), + }; +} + +/** + * `blueprints` — the library + the ACTIVE blueprint's embedded team (#236). + * + * `h`/`origin`/`updatedAt` are read (mirroring the real desktop `BlueprintCard`, verified live + * against base-studio-code `develop`'s `storeProjections.ts`), but this canonical fixture's + * `PROJECTION_INPUTS` blueprint does not set them — the same fixture-input gap documented for + * `components` above (C1) — so Layer B cannot assert their survival here. The gap is closed by + * `mirror/blueprintsView.test.ts`'s hand-written payloads, the same pattern `designPage.test.ts` + * uses for `themes.base`. + * + * `category`: the orphaned `blueprintsPage.ts` (out of this session's write scope — #1297) still + * reads it, but the real desktop `BlueprintCard` no longer has it — confirmed live against + * `develop`, where the builder never emits it. Not read here; the stale field has been dropped + * from this fixture (both the `default` and `migrate` cards) to match. + */ +function decodeBlueprints(o: Raw): Raw { + const activeTeam = o.activeTeam; + assert.ok( + activeTeam === null || (typeof activeTeam === 'object' && !Array.isArray(activeTeam)), + 'field "activeTeam" must be an object or null', + ); + return { + active: str(o, 'active'), + library: arr(o, 'library').map(decodeBlueprintCard), + activeTeam: activeTeam === null ? null : decodeBlueprintTeam(activeTeam as Raw), + }; +} + const DECODERS: Record Raw> = { glance: decodeGlance, security: decodeSecurity, @@ -415,6 +479,7 @@ const DECODERS: Record Raw> = { skills: decodeSkills, automations: decodeAutomations, mcp: decodeMcp, + blueprints: decodeBlueprints, }; // ── Coverage + vocabulary guards ──────────────────────────────────────────────────────────── @@ -464,6 +529,10 @@ test('Layer A: the glance_l0 variant round-trips (nullable drill / drillFleet)', assert.deepEqual(decodeGlance(fx.variants.glance_l0), fx.variants.glance_l0); }); +test('Layer A: the blueprints_no_team variant round-trips (nullable activeTeam)', () => { + assert.deepEqual(decodeBlueprints(fx.variants.blueprints_no_team), fx.variants.blueprints_no_team); +}); + // ── Variant pinning — optionality and nullability cannot quietly become required ───────────── test('variants pin the nullable fields', () => { @@ -704,6 +773,28 @@ test('Layer B: mcp — both install states resolve, neither falls back to unknow assert.equal(view.builtins.length, 0, 'built-ins now ride — update the mcp Layer B expectation (#240)'); }); +test('Layer B: blueprints — soundKit and the active team are consumed (#236)', () => { + const model = selectBlueprintsView(fx.domains.blueprints); + assert.ok(model, 'selectBlueprintsView returned undefined for the canonical payload'); + + const [defaultCard, migrateCard] = model.library; + assert.equal(defaultCard.name, 'Default'); + // Before #236, soundKit was silently dropped — the exact same shape of bug as #237/#238. + assert.deepEqual(defaultCard.soundKit, { id: 'bsc/signal', version: '1.0.0' }); + assert.equal(defaultCard.uiKit?.themeId, 'soft'); + assert.equal(migrateCard.soundKit, undefined, 'a card without soundKit must not fall back to one'); + + assert.ok(model.activeTeam, 'the active team did not survive the parse'); + assert.equal(model.activeTeam!.positions.length, 1); +}); + +test('Layer B: blueprints — the no-team variant parses as null, not an empty team', () => { + const model = selectBlueprintsView(fx.variants.blueprints_no_team)!; + assert.equal(model.activeTeam, null); + assert.equal(model.library.length, 1); + assert.equal(model.library[0].hasTeam, false); +}); + // ── The invariant that gives Layer B its teeth ─────────────────────────────────────────────── test('the fixture states the no-fallback-values invariant', () => {