Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/lib/mirror/blueprintsView.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
103 changes: 103 additions & 0 deletions src/lib/mirror/blueprintsView.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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) };
}
3 changes: 0 additions & 3 deletions src/lib/tunnel/storePayloads.fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,6 @@
"id": "default",
"name": "Default",
"desc": "greenfield seed",
"category": "greenfield",
"mode": "create",
"stageCount": 0,
"hasTeam": true,
Expand All @@ -190,7 +189,6 @@
"id": "migrate",
"name": "Migrate",
"desc": "restructure repos",
"category": "transform",
"stageCount": 0,
"hasTeam": false
}
Expand Down Expand Up @@ -551,7 +549,6 @@
"id": "migrate",
"name": "Migrate",
"desc": "restructure repos",
"category": "transform",
"stageCount": 0,
"hasTeam": false
}
Expand Down
93 changes: 92 additions & 1 deletion src/lib/tunnel/storePayloads.fixtures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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<string, string> = {
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).
//
Expand Down Expand Up @@ -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<string, (o: Raw) => Raw> = {
glance: decodeGlance,
security: decodeSecurity,
Expand All @@ -415,6 +479,7 @@ const DECODERS: Record<string, (o: Raw) => Raw> = {
skills: decodeSkills,
automations: decodeAutomations,
mcp: decodeMcp,
blueprints: decodeBlueprints,
};

// ── Coverage + vocabulary guards ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading