import { validateOfficeSnapshot, makeEmptyOfficeSnapshot, } from '../src/features/astraOffice/schema'; import { validateLayout, migrateLayout, } from '../src/features/astraOffice/view/layoutSchema'; import { presentOfficeSnapshot, normalizeAgentId } from '../src/features/astraOffice/presenter'; describe('OfficeSnapshot schema', () => { test('makeEmptyOfficeSnapshot returns idle / null active', () => { const s = makeEmptyOfficeSnapshot(); expect(s.phase).toBe('idle'); expect(s.activeAgentId).toBeNull(); expect(s.roster).toEqual([]); expect(s.activity).toEqual([]); expect(s.newBubbles).toEqual([]); }); test('validateOfficeSnapshot rejects null / non-object', () => { expect(validateOfficeSnapshot(null)).toBeNull(); expect(validateOfficeSnapshot(undefined)).toBeNull(); expect(validateOfficeSnapshot('idle')).toBeNull(); expect(validateOfficeSnapshot(42)).toBeNull(); }); test('validateOfficeSnapshot fills defaults for sparse input', () => { const s = validateOfficeSnapshot({}); expect(s).not.toBeNull(); expect(s!.phase).toBe('idle'); expect(s!.activeAgentId).toBeNull(); expect(s!.roster).toEqual([]); expect(typeof s!.updatedAt).toBe('number'); }); test('validateOfficeSnapshot rejects invalid enums quietly', () => { const s = validateOfficeSnapshot({ phase: 'totally_made_up_phase', activeAgentId: 'ceo', roster: [{ agentId: 'ceo', agentName: 'CEO', roleCategory: 'BOGUS', status: 'NOT_REAL', lastActivityAt: 100 }], }); expect(s!.phase).toBe('idle'); // fell back expect(s!.roster[0].roleCategory).toBe('support'); // fell back expect(s!.roster[0].status).toBe('idle'); // fell back }); test('validateOfficeSnapshot keeps valid roster + bubbles', () => { const s = validateOfficeSnapshot({ phase: 'executing', activeAgentId: 'developer', roster: [ { agentId: 'developer', agentName: '개발', roleCategory: 'developer', status: 'executing', lastActivityAt: 100 }, { agentId: 'inspector', agentName: '감리', roleCategory: 'inspector', status: 'idle', lastActivityAt: 90 }, ], newBubbles: [ { agentId: 'developer', text: '코드 들어간다', type: 'event' }, { agentId: 'inspector', text: 'should_be_dropped', type: 'wrong_type' }, // type 잘못 → 'status' 로 폴백 ], }); expect(s!.phase).toBe('executing'); expect(s!.activeAgentId).toBe('developer'); expect(s!.roster).toHaveLength(2); expect(s!.newBubbles).toHaveLength(2); expect(s!.newBubbles[1].type).toBe('status'); // fell back }); test('validateOfficeSnapshot drops malformed roster entries', () => { const s = validateOfficeSnapshot({ phase: 'idle', roster: [ { agentId: 'ok', roleCategory: 'ceo', status: 'idle', lastActivityAt: 0 }, { /* no agentId */ roleCategory: 'ceo' }, null, 'string', ], }); expect(s!.roster).toHaveLength(1); expect(s!.roster[0].agentId).toBe('ok'); }); }); describe('Layout schema', () => { test('validateLayout rejects non-v2 raw', () => { expect(validateLayout(null)).toBeNull(); expect(validateLayout({ cells: [] })).toBeNull(); // empty cells, no schema marker → null expect(validateLayout({ cells: [{ roleKey: 'x', deskX: 0, deskY: 0 }] })).toBeNull(); // missing v2 markers }); test('validateLayout accepts explicit schema:2', () => { const v = validateLayout({ schema: 2, cells: [ { roleKey: 'ceo', agentKey: 'ceo', label: 'CEO', charRow: 0, deskSprite: 'desk-boss', face: 'R', boss: true, deskX: 100, deskY: 50, deskW: 136, seatX: 130, seatY: 80, deskRot: 0, deskZ: 0, charRot: 0, charZ: 0, noChar: false, }, ], objs: [{ id: 'obj_0', name: 'plant', x: 10, y: 20, w: 32, rot: 0, z: 0 }], }); expect(v).not.toBeNull(); expect(v!.cells).toHaveLength(1); expect(v!.cells[0].deskSprite).toBe('desk-boss'); expect(v!.objs[0].name).toBe('plant'); }); test('validateLayout normalizes invalid face to R', () => { const v = validateLayout({ schema: 2, cells: [{ roleKey: 'x', deskSprite: 'desk-main', face: 'XYZ', charRow: 0, deskX: 0, deskY: 0, deskW: 100, seatX: 0, seatY: 0, deskRot: 0, deskZ: 0, charRot: 0, charZ: 0, }], }); expect(v!.cells[0].face).toBe('R'); }); test('validateLayout detects v2 via field presence (no explicit schema)', () => { const v = validateLayout({ cells: [{ roleKey: 'a', deskSprite: 'desk-main', charRow: 2, deskX: 0, deskY: 0, deskW: 100, seatX: 0, seatY: 0, }], }); expect(v).not.toBeNull(); expect(v!.cells[0].charRow).toBe(2); }); test('migrateLayout upgrades v1 (coord-only) cells', () => { const v1 = { cells: [{ roleKey: 'ceo', deskX: 100, deskY: 50, deskW: 136, seatX: 130, seatY: 80, deskRot: 0, deskZ: 0, charRot: 0, charZ: 0 }], objs: [], }; const v = migrateLayout(v1); expect(v).not.toBeNull(); expect(v!.schema).toBe(2); expect(v!.cells[0].agentKey).toBe('ceo'); // fallback: roleKey == agentKey expect(v!.cells[0].deskSprite).toBe('desk-main'); expect(v!.cells[0].charRow).toBe(0); }); }); describe('presenter', () => { test('normalizeAgentId resolves aliases', () => { expect(normalizeAgentId('writer')).toBe('writer'); expect(normalizeAgentId('Editor')).toBe('designer'); expect(normalizeAgentId('Secretary')).toBe('support'); expect(normalizeAgentId('business')).toBe('inspector'); expect(normalizeAgentId(undefined)).toBeNull(); }); test('presentOfficeSnapshot builds single-agent roster from old AgentWorkState', () => { const snap = presentOfficeSnapshot({ activeState: { agentId: 'inspector', agentName: '감리', status: 'reviewing', currentStep: '라운드 2/3', currentTask: '타입 정합성 검수', recentLogs: ['타입 누락 발견'], updatedAt: 1000, } as any, }); expect(snap.phase).toBe('reviewing'); expect(snap.activeAgentId).toBe('inspector'); expect(snap.roster).toHaveLength(1); expect(snap.roster[0].roleCategory).toBe('inspector'); expect(snap.roster[0].lastLog).toBe('타입 누락 발견'); expect(snap.task?.goal).toBe('타입 정합성 검수'); }); test('presentOfficeSnapshot returns empty when no input', () => { const snap = presentOfficeSnapshot({}); expect(snap.phase).toBe('idle'); expect(snap.activeAgentId).toBeNull(); expect(snap.roster).toEqual([]); }); test('presentOfficeSnapshot maps need_clarification to awaiting-approval phase', () => { const snap = presentOfficeSnapshot({ activeState: { agentId: 'ceo', agentName: 'CEO', status: 'need_clarification', needUserInput: ['배포 대상 환경은?'], updatedAt: 0, } as any, }); expect(snap.phase).toBe('awaiting-approval'); expect(snap.awaiting?.kind).toBe('clarification'); expect(snap.awaiting?.questions).toEqual(['배포 대상 환경은?']); }); test('presentOfficeSnapshot with roster places active agent in working state and others idle', () => { const snap = presentOfficeSnapshot({ activeState: { agentId: 'developer', agentName: '개발', status: 'executing', currentStep: '함수 추출', recentLogs: ['파일 수정 완료'], updatedAt: 1000, } as any, roster: [ { agentId: 'ceo', agentName: 'CEO', roleCategory: 'ceo' }, { agentId: 'developer', agentName: '개발', roleCategory: 'developer' }, { agentId: 'inspector', agentName: '감리', roleCategory: 'inspector' }, ], }); expect(snap.roster).toHaveLength(3); const active = snap.roster.find((a) => a.agentId === 'developer'); const idle = snap.roster.find((a) => a.agentId === 'ceo'); expect(active?.status).toBe('executing'); expect(active?.lastLog).toBe('파일 수정 완료'); expect(idle?.status).toBe('idle'); expect(idle?.lastLog).toBeUndefined(); }); test('presentOfficeSnapshot folds recentActivity into snapshot.activity (ring buffer)', () => { const activity = Array.from({ length: 40 }, (_, i) => ({ ts: i * 100, agentId: 'developer', text: `step ${i}`, })); const snap = presentOfficeSnapshot({ recentActivity: activity }); // presenter 가 32개로 잘라야 함. expect(snap.activity).toHaveLength(32); expect(snap.activity[0].text).toBe('step 8'); // 처음 8개 dropped expect(snap.activity[31].text).toBe('step 39'); }); });