v2.2.15: Astra Office Refactor & Multi-Service Integration
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
_buildEventBody,
|
||||
_addMinutesIso,
|
||||
_addDaysDate,
|
||||
} from '../src/features/calendar/calendarApi';
|
||||
import { _parseCalEventAttrs } from '../src/agent';
|
||||
|
||||
describe('_addMinutesIso', () => {
|
||||
test('adds minutes to local ISO without timezone, preserves no-tz format', () => {
|
||||
const out = _addMinutesIso('2026-05-21T14:00', 60);
|
||||
expect(out).toBe('2026-05-21T15:00:00');
|
||||
});
|
||||
|
||||
test('handles ISO with seconds', () => {
|
||||
const out = _addMinutesIso('2026-05-21T14:30:00', 30);
|
||||
expect(out).toBe('2026-05-21T15:00:00');
|
||||
});
|
||||
|
||||
test('handles UTC marker Z', () => {
|
||||
const out = _addMinutesIso('2026-05-21T14:00:00Z', 60);
|
||||
expect(out).toBe('2026-05-21T15:00:00.000Z');
|
||||
});
|
||||
|
||||
test('returns null on malformed input', () => {
|
||||
expect(_addMinutesIso('not-a-date', 30)).toBeNull();
|
||||
expect(_addMinutesIso('', 30)).toBeNull();
|
||||
});
|
||||
|
||||
test('handles day-rollover correctly', () => {
|
||||
const out = _addMinutesIso('2026-05-21T23:30', 60);
|
||||
expect(out).toBe('2026-05-22T00:30:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_addDaysDate', () => {
|
||||
test('adds days to YYYY-MM-DD', () => {
|
||||
expect(_addDaysDate('2026-05-21', 1)).toBe('2026-05-22');
|
||||
expect(_addDaysDate('2026-12-31', 1)).toBe('2027-01-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_buildEventBody', () => {
|
||||
test('rejects empty title or start', () => {
|
||||
const r1 = _buildEventBody({ title: '', start: '2026-05-21T14:00' }, 60);
|
||||
expect(r1.ok).toBe(false);
|
||||
const r2 = _buildEventBody({ title: 'x', start: '' }, 60);
|
||||
expect(r2.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('builds basic timed event with default duration', () => {
|
||||
const r = _buildEventBody({ title: '회의', start: '2026-05-21T14:00' }, 60);
|
||||
if (!r.ok) throw new Error('expected ok');
|
||||
expect(r.event.summary).toBe('회의');
|
||||
expect(r.event.start.dateTime).toBe('2026-05-21T14:00');
|
||||
expect(r.event.end.dateTime).toBe('2026-05-21T15:00:00');
|
||||
// 로컬 timezone 자동 포함 — Intl 결과 (값은 시스템 의존이라 존재만 확인)
|
||||
expect(r.event.start.timeZone).toBeTruthy();
|
||||
expect(r.event.reminders.overrides).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('respects explicit duration', () => {
|
||||
const r = _buildEventBody({
|
||||
title: '미팅', start: '2026-05-21T14:00', durationMinutes: 90,
|
||||
}, 60);
|
||||
if (!r.ok) throw new Error('expected ok');
|
||||
expect(r.event.end.dateTime).toBe('2026-05-21T15:30:00');
|
||||
});
|
||||
|
||||
test('respects explicit end over duration', () => {
|
||||
const r = _buildEventBody({
|
||||
title: '미팅', start: '2026-05-21T14:00', end: '2026-05-21T16:00', durationMinutes: 30,
|
||||
}, 60);
|
||||
if (!r.ok) throw new Error('expected ok');
|
||||
expect(r.event.end.dateTime).toBe('2026-05-21T16:00');
|
||||
});
|
||||
|
||||
test('builds all-day event with exclusive end (+1 day)', () => {
|
||||
const r = _buildEventBody({
|
||||
title: '생일', start: '2026-06-15', allDay: true,
|
||||
}, 60);
|
||||
if (!r.ok) throw new Error('expected ok');
|
||||
expect(r.event.start.date).toBe('2026-06-15');
|
||||
expect(r.event.end.date).toBe('2026-06-16');
|
||||
expect(r.event.start.dateTime).toBeUndefined();
|
||||
});
|
||||
|
||||
test('omits timeZone when input has explicit offset', () => {
|
||||
const r = _buildEventBody({
|
||||
title: '회의', start: '2026-05-21T14:00:00+09:00',
|
||||
}, 60);
|
||||
if (!r.ok) throw new Error('expected ok');
|
||||
expect(r.event.start.timeZone).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_parseCalEventAttrs', () => {
|
||||
test('parses double-quoted attrs', () => {
|
||||
const a = _parseCalEventAttrs(' title="팀 미팅" start="2026-05-21T14:00" duration="60" ');
|
||||
expect(a.title).toBe('팀 미팅');
|
||||
expect(a.start).toBe('2026-05-21T14:00');
|
||||
expect(a.duration).toBe(60);
|
||||
});
|
||||
|
||||
test('parses single-quoted + bare attrs', () => {
|
||||
const a = _parseCalEventAttrs(`title='간단' start=2026-05-21T14:00 duration=30`);
|
||||
expect(a.title).toBe('간단');
|
||||
expect(a.start).toBe('2026-05-21T14:00');
|
||||
expect(a.duration).toBe(30);
|
||||
});
|
||||
|
||||
test('parses all_day variants', () => {
|
||||
const a1 = _parseCalEventAttrs('title="x" start="2026-05-21" all_day="true"');
|
||||
expect(a1.allDay).toBe(true);
|
||||
const a2 = _parseCalEventAttrs('title="x" start="2026-05-21" allday="1"');
|
||||
expect(a2.allDay).toBe(true);
|
||||
const a3 = _parseCalEventAttrs('title="x" start="2026-05-21" all-day="yes"');
|
||||
expect(a3.allDay).toBe(true);
|
||||
const a4 = _parseCalEventAttrs('title="x" start="2026-05-21" all_day="false"');
|
||||
expect(a4.allDay).toBe(false);
|
||||
});
|
||||
|
||||
test('ignores invalid duration value', () => {
|
||||
const a = _parseCalEventAttrs('title="x" start="t" duration="abc"');
|
||||
expect(a.duration).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns empty when attrs are missing', () => {
|
||||
expect(_parseCalEventAttrs('')).toEqual({});
|
||||
expect(_parseCalEventAttrs(' ')).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { parseIcs, selectUpcoming } from '../src/features/calendar/icsParser';
|
||||
|
||||
describe('parseIcs', () => {
|
||||
test('returns empty array on invalid / empty input', () => {
|
||||
expect(parseIcs('')).toEqual([]);
|
||||
expect(parseIcs(null as any)).toEqual([]);
|
||||
expect(parseIcs(undefined as any)).toEqual([]);
|
||||
expect(parseIcs('random text no VEVENT')).toEqual([]);
|
||||
});
|
||||
|
||||
test('parses single timed event', () => {
|
||||
const ics = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:팀 미팅',
|
||||
'DTSTART:20260520T130000Z',
|
||||
'DTEND:20260520T140000Z',
|
||||
'LOCATION:회의실 A',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n');
|
||||
const events = parseIcs(ics);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].summary).toBe('팀 미팅');
|
||||
expect(events[0].location).toBe('회의실 A');
|
||||
expect(events[0].allDay).toBe(false);
|
||||
// 2026-05-20 13:00 UTC
|
||||
expect(events[0].start.getUTCFullYear()).toBe(2026);
|
||||
expect(events[0].start.getUTCMonth()).toBe(4);
|
||||
expect(events[0].start.getUTCDate()).toBe(20);
|
||||
expect(events[0].start.getUTCHours()).toBe(13);
|
||||
expect(events[0].end?.getUTCHours()).toBe(14);
|
||||
});
|
||||
|
||||
test('parses all-day event via VALUE=DATE', () => {
|
||||
const ics = [
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:생일',
|
||||
'DTSTART;VALUE=DATE:20260615',
|
||||
'DTEND;VALUE=DATE:20260616',
|
||||
'END:VEVENT',
|
||||
].join('\n');
|
||||
const events = parseIcs(ics);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].allDay).toBe(true);
|
||||
expect(events[0].start.getFullYear()).toBe(2026);
|
||||
expect(events[0].start.getMonth()).toBe(5);
|
||||
expect(events[0].start.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
test('unfolds line continuations (RFC 5545)', () => {
|
||||
// ICS 75자 wrap — 다음 줄이 공백/탭으로 시작하면 같은 필드의 연속.
|
||||
const ics = [
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:긴 제목 첫 부분',
|
||||
' 두번째 줄 이어짐',
|
||||
'DTSTART:20260520T130000Z',
|
||||
'END:VEVENT',
|
||||
].join('\r\n');
|
||||
const events = parseIcs(ics);
|
||||
expect(events[0].summary).toBe('긴 제목 첫 부분두번째 줄 이어짐');
|
||||
});
|
||||
|
||||
test('unescapes ICS escape sequences in summary / location', () => {
|
||||
const ics = [
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:1\\, 2\\, 3 — 회의\\nFollow-up',
|
||||
'LOCATION:Zoom\\, 본관 3층',
|
||||
'DTSTART:20260520T130000Z',
|
||||
'END:VEVENT',
|
||||
].join('\n');
|
||||
const events = parseIcs(ics);
|
||||
expect(events[0].summary).toBe('1, 2, 3 — 회의 Follow-up');
|
||||
expect(events[0].location).toBe('Zoom, 본관 3층');
|
||||
});
|
||||
|
||||
test('parses multiple VEVENTs and gives default summary when missing', () => {
|
||||
const ics = [
|
||||
'BEGIN:VEVENT',
|
||||
'DTSTART:20260520T130000Z',
|
||||
'END:VEVENT',
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:두번째',
|
||||
'DTSTART:20260521T130000Z',
|
||||
'END:VEVENT',
|
||||
].join('\n');
|
||||
const events = parseIcs(ics);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].summary).toBe('(제목 없음)');
|
||||
expect(events[1].summary).toBe('두번째');
|
||||
});
|
||||
|
||||
test('drops VEVENT without DTSTART', () => {
|
||||
const ics = [
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:DTSTART 누락',
|
||||
'END:VEVENT',
|
||||
].join('\n');
|
||||
expect(parseIcs(ics)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectUpcoming', () => {
|
||||
const now = new Date('2026-05-20T12:00:00Z');
|
||||
const mkEv = (offsetMin: number, label = 'evt'): any => ({
|
||||
start: new Date(now.getTime() + offsetMin * 60 * 1000),
|
||||
end: undefined, summary: label, location: '', description: '', allDay: false,
|
||||
});
|
||||
|
||||
test('filters past events older than 1 hour', () => {
|
||||
const events = [
|
||||
mkEv(-120, 'old'), // 2 hours ago — dropped
|
||||
mkEv(-30, 'recent'), // 30 min ago — kept (within 1h window)
|
||||
mkEv(60, 'soon'), // 1 hour future — kept
|
||||
];
|
||||
const upcoming = selectUpcoming(events, 14, now);
|
||||
expect(upcoming.map((e) => e.summary)).toEqual(['recent', 'soon']);
|
||||
});
|
||||
|
||||
test('filters events beyond cutoff days', () => {
|
||||
const events = [
|
||||
mkEv(60, 'soon'),
|
||||
mkEv(60 * 24 * 15, 'too-far'), // 15 days — beyond 14-day cutoff
|
||||
];
|
||||
const upcoming = selectUpcoming(events, 14, now);
|
||||
expect(upcoming.map((e) => e.summary)).toEqual(['soon']);
|
||||
});
|
||||
|
||||
test('sorts upcoming events by start time', () => {
|
||||
const events = [mkEv(180, 'c'), mkEv(60, 'a'), mkEv(120, 'b')];
|
||||
const upcoming = selectUpcoming(events, 14, now);
|
||||
expect(upcoming.map((e) => e.summary)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
PIPELINE_TEMPLATES,
|
||||
getPipelineTemplate,
|
||||
SCOPE_PRESETS,
|
||||
} from '../src/features/company/pipelineTemplates';
|
||||
|
||||
describe('Pipeline templates registry', () => {
|
||||
test('exposes plan-only / dev-only / full-product-dev in expected order', () => {
|
||||
const ids = PIPELINE_TEMPLATES.map((t) => t.templateId);
|
||||
expect(ids).toEqual(['plan-only', 'dev-only', 'full-product-dev']);
|
||||
});
|
||||
|
||||
test('getPipelineTemplate returns each by id', () => {
|
||||
expect(getPipelineTemplate('plan-only')?.suggestedPipelineId).toBe('plan-only');
|
||||
expect(getPipelineTemplate('dev-only')?.suggestedPipelineId).toBe('dev-only');
|
||||
expect(getPipelineTemplate('full-product-dev')?.suggestedPipelineId).toBe('product-dev');
|
||||
expect(getPipelineTemplate('does-not-exist')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('SCOPE_PRESETS keys are 1:1 with template ids', () => {
|
||||
const presetIds = SCOPE_PRESETS.map((p) => p.templateId);
|
||||
for (const id of presetIds) {
|
||||
expect(getPipelineTemplate(id)).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEV_ONLY template stage shape', () => {
|
||||
test('has 10 stages and ends at dev-impl', () => {
|
||||
const tpl = getPipelineTemplate('dev-only');
|
||||
expect(tpl).toBeDefined();
|
||||
expect(tpl!.stages).toHaveLength(10);
|
||||
const last = tpl!.stages[tpl!.stages.length - 1];
|
||||
expect(last.id).toBe('dev-impl');
|
||||
});
|
||||
|
||||
test('does NOT include qa / deploy stages', () => {
|
||||
const tpl = getPipelineTemplate('dev-only')!;
|
||||
const stageIds = tpl.stages.map((s) => s.id);
|
||||
expect(stageIds).not.toContain('qa');
|
||||
expect(stageIds).not.toContain('deploy');
|
||||
});
|
||||
|
||||
test('keeps planner → design-review chain intact', () => {
|
||||
const tpl = getPipelineTemplate('dev-only')!;
|
||||
const stageIds = tpl.stages.map((s) => s.id);
|
||||
expect(stageIds).toEqual(expect.arrayContaining([
|
||||
'plan-discuss', 'plan-draft', 'plan-final', 'dev-design', 'design-review', 'dev-impl',
|
||||
]));
|
||||
});
|
||||
|
||||
test('shares stage objects with FULL_PRODUCT_DEV (read-only template safety)', () => {
|
||||
// Slice(0, 10) — full 의 stages 배열에서 자른 references.
|
||||
// 사용자가 dev-only template 을 stamp 하면 _normalizePipeline 이 deep-copy 하므로
|
||||
// 본 template 의 stage 객체 자체는 절대 mutate 되지 않아야 함.
|
||||
const dev = getPipelineTemplate('dev-only')!;
|
||||
const full = getPipelineTemplate('full-product-dev')!;
|
||||
expect(dev.stages[0]).toBe(full.stages[0]); // shared reference (intentional)
|
||||
expect(dev.stages.length).toBeLessThan(full.stages.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PLAN_ONLY template (existing) sanity', () => {
|
||||
test('still has 3 stages ending at plan-doc', () => {
|
||||
const tpl = getPipelineTemplate('plan-only')!;
|
||||
expect(tpl.stages).toHaveLength(3);
|
||||
expect(tpl.stages[tpl.stages.length - 1].id).toBe('plan-doc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
parseTsvBody,
|
||||
valuesToMarkdownTable,
|
||||
} from '../src/features/sheets/sheetsApi';
|
||||
import { _parseSheetAttrs } from '../src/agent';
|
||||
|
||||
describe('parseTsvBody', () => {
|
||||
test('returns [] for empty / whitespace input', () => {
|
||||
expect(parseTsvBody('')).toEqual([]);
|
||||
expect(parseTsvBody(' ')).toEqual([]);
|
||||
expect(parseTsvBody('\n\n')).toEqual([]);
|
||||
});
|
||||
|
||||
test('parses tab-separated rows', () => {
|
||||
const body = '이름\t나이\t직책\n민지\t29\t디자이너\n준호\t31\t개발자';
|
||||
expect(parseTsvBody(body)).toEqual([
|
||||
['이름', '나이', '직책'],
|
||||
['민지', '29', '디자이너'],
|
||||
['준호', '31', '개발자'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('falls back to pipe-separated when no tab present', () => {
|
||||
const body = '이름 | 나이\n민지 | 29\n준호 | 31';
|
||||
expect(parseTsvBody(body)).toEqual([
|
||||
['이름', '나이'],
|
||||
['민지', '29'],
|
||||
['준호', '31'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('strips leading and trailing blank lines (LLM artifact)', () => {
|
||||
const body = '\n\n이름\t나이\n민지\t29\n\n';
|
||||
expect(parseTsvBody(body)).toEqual([
|
||||
['이름', '나이'],
|
||||
['민지', '29'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('preserves empty cells when tabs are present', () => {
|
||||
const body = 'A\t\tC';
|
||||
expect(parseTsvBody(body)).toEqual([['A', '', 'C']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('valuesToMarkdownTable', () => {
|
||||
test('empty → placeholder', () => {
|
||||
expect(valuesToMarkdownTable([])).toBe('_(empty)_');
|
||||
});
|
||||
|
||||
test('renders header + separator + rows', () => {
|
||||
const out = valuesToMarkdownTable([
|
||||
['이름', '나이'],
|
||||
['민지', 29],
|
||||
['준호', 31],
|
||||
]);
|
||||
const lines = out.split('\n');
|
||||
expect(lines[0]).toBe('| 이름 | 나이 |');
|
||||
expect(lines[1]).toBe('|---|---|');
|
||||
expect(lines[2]).toBe('| 민지 | 29 |');
|
||||
expect(lines[3]).toBe('| 준호 | 31 |');
|
||||
});
|
||||
|
||||
test('truncates beyond maxRows + adds note', () => {
|
||||
const big: any[][] = [['col']];
|
||||
for (let i = 0; i < 60; i++) big.push([`row${i}`]);
|
||||
const out = valuesToMarkdownTable(big, 10);
|
||||
expect(out).toContain('| col |');
|
||||
expect(out).toContain('| row0 |');
|
||||
expect(out).toContain('| row8 |'); // 10 rows total = header + 9 data
|
||||
expect(out).not.toContain('| row9 |');
|
||||
expect(out).toContain('51 more rows truncated');
|
||||
});
|
||||
|
||||
test('escapes pipe characters inside cell values', () => {
|
||||
const out = valuesToMarkdownTable([
|
||||
['a|b', 'c'],
|
||||
['d', 'e|f'],
|
||||
]);
|
||||
expect(out).toContain('| a\\|b | c |');
|
||||
expect(out).toContain('| d | e\\|f |');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_parseSheetAttrs', () => {
|
||||
test('parses spreadsheet_id + range with double quotes', () => {
|
||||
const a = _parseSheetAttrs(' spreadsheet_id="1abc" range="Sheet1!A1:D20" ');
|
||||
expect(a.spreadsheetId).toBe('1abc');
|
||||
expect(a.range).toBe('Sheet1!A1:D20');
|
||||
});
|
||||
|
||||
test('accepts camelCase alias spreadsheetId', () => {
|
||||
const a = _parseSheetAttrs('spreadsheetId="1xyz" range="A:B"');
|
||||
expect(a.spreadsheetId).toBe('1xyz');
|
||||
});
|
||||
|
||||
test('accepts sheet_id alias', () => {
|
||||
const a = _parseSheetAttrs(`sheet_id='1qrs' range='Tab1'`);
|
||||
expect(a.spreadsheetId).toBe('1qrs');
|
||||
expect(a.range).toBe('Tab1');
|
||||
});
|
||||
|
||||
test('parses bare (unquoted) values', () => {
|
||||
const a = _parseSheetAttrs('spreadsheet_id=1simple range=Sheet1!A1');
|
||||
expect(a.spreadsheetId).toBe('1simple');
|
||||
expect(a.range).toBe('Sheet1!A1');
|
||||
});
|
||||
|
||||
test('returns empty for missing attrs', () => {
|
||||
expect(_parseSheetAttrs('')).toEqual({});
|
||||
expect(_parseSheetAttrs('foo="bar"')).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
parseTaskStore,
|
||||
renderTaskStore,
|
||||
addTask,
|
||||
updateTask,
|
||||
completeTask,
|
||||
summarizeActiveTasks,
|
||||
TaskStore,
|
||||
} from '../src/features/tasks/taskStore';
|
||||
import { _parseTaskAttrs } from '../src/agent';
|
||||
|
||||
describe('parseTaskStore', () => {
|
||||
test('returns empty store on empty markdown', () => {
|
||||
const s = parseTaskStore('');
|
||||
expect(s).toEqual({ active: [], done: [] });
|
||||
});
|
||||
|
||||
test('parses round-tripped output', () => {
|
||||
const original: TaskStore = {
|
||||
active: [
|
||||
{ id: 't_001', title: '광고주 자료', owner: '@me', due: '2026-05-24T18:00', status: 'in_progress', notes: '자료 대기' },
|
||||
{ id: 't_002', title: '디자인 리뷰', owner: '@planner', due: '', status: 'open', notes: '' },
|
||||
],
|
||||
done: [
|
||||
{ id: 't_000', title: '셋업', owner: '@me', due: '', status: 'done', notes: '', completedAt: '2026-05-20T10:00' },
|
||||
],
|
||||
};
|
||||
const md = renderTaskStore(original);
|
||||
const parsed = parseTaskStore(md);
|
||||
expect(parsed.active).toHaveLength(2);
|
||||
expect(parsed.active[0].id).toBe('t_001');
|
||||
expect(parsed.active[0].status).toBe('in_progress');
|
||||
expect(parsed.done).toHaveLength(1);
|
||||
expect(parsed.done[0].id).toBe('t_000');
|
||||
expect(parsed.done[0].completedAt).toBe('2026-05-20T10:00');
|
||||
});
|
||||
|
||||
test('normalizes status variants', () => {
|
||||
const md = `# Tasks
|
||||
## Active
|
||||
| ID | Title | Owner | Due | Status | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| t_001 | a | @me | | In Progress | |
|
||||
| t_002 | b | @me | | INPROGRESS | |
|
||||
| t_003 | c | @me | | blocked | |
|
||||
| t_004 | d | @me | | unknown_status | |`;
|
||||
const s = parseTaskStore(md);
|
||||
expect(s.active.map((t) => t.status)).toEqual(['in_progress', 'in_progress', 'blocked', 'open']);
|
||||
});
|
||||
|
||||
test('ignores malformed rows', () => {
|
||||
const md = `# Tasks
|
||||
## Active
|
||||
| ID | Title | Owner | Due | Status | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| t_001 | ok | @me | | open | |
|
||||
| no_t_prefix | bad |
|
||||
| just text not a row
|
||||
| t_003 | ok2 | @me | | open | |`;
|
||||
const s = parseTaskStore(md);
|
||||
expect(s.active.map((t) => t.id)).toEqual(['t_001', 't_003']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTask', () => {
|
||||
test('assigns incremental t_NNN ids based on max across active + done', () => {
|
||||
const store: TaskStore = {
|
||||
active: [{ id: 't_003', title: 'a', owner: '', due: '', status: 'open', notes: '' }],
|
||||
done: [{ id: 't_010', title: 'b', owner: '', due: '', status: 'done', notes: '', completedAt: '' }],
|
||||
};
|
||||
const t = addTask(store, { title: '신규' });
|
||||
expect(t.id).toBe('t_011');
|
||||
});
|
||||
|
||||
test('starts at t_001 for empty store', () => {
|
||||
const store: TaskStore = { active: [], done: [] };
|
||||
const t = addTask(store, { title: '첫 task' });
|
||||
expect(t.id).toBe('t_001');
|
||||
});
|
||||
|
||||
test('trims input fields and defaults status to open', () => {
|
||||
const store: TaskStore = { active: [], done: [] };
|
||||
const t = addTask(store, { title: ' 광고주 ', owner: ' @me ', notes: '' });
|
||||
expect(t.title).toBe('광고주');
|
||||
expect(t.owner).toBe('@me');
|
||||
expect(t.status).toBe('open');
|
||||
expect(t.notes).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTask', () => {
|
||||
test('patches only provided fields, preserves id', () => {
|
||||
const store: TaskStore = {
|
||||
active: [{ id: 't_001', title: 'a', owner: '@me', due: '', status: 'open', notes: '' }],
|
||||
done: [],
|
||||
};
|
||||
const r = updateTask(store, 't_001', { status: 'in_progress', notes: '시작' });
|
||||
expect(r?.title).toBe('a');
|
||||
expect(r?.status).toBe('in_progress');
|
||||
expect(r?.notes).toBe('시작');
|
||||
});
|
||||
|
||||
test('returns null for unknown id', () => {
|
||||
const store: TaskStore = { active: [], done: [] };
|
||||
expect(updateTask(store, 't_999', { notes: 'x' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeTask', () => {
|
||||
test('moves active task to done with completedAt', () => {
|
||||
const store: TaskStore = {
|
||||
active: [{ id: 't_001', title: 'a', owner: '', due: '', status: 'open', notes: '' }],
|
||||
done: [],
|
||||
};
|
||||
const r = completeTask(store, 't_001', '2026-05-21T15:00');
|
||||
expect(r?.status).toBe('done');
|
||||
expect(store.active).toHaveLength(0);
|
||||
expect(store.done).toHaveLength(1);
|
||||
expect(store.done[0].completedAt).toBe('2026-05-21T15:00');
|
||||
});
|
||||
|
||||
test('returns null when already done or unknown', () => {
|
||||
const store: TaskStore = { active: [], done: [] };
|
||||
expect(completeTask(store, 't_001')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeActiveTasks', () => {
|
||||
test('returns empty string when no active', () => {
|
||||
expect(summarizeActiveTasks({ active: [], done: [] })).toBe('');
|
||||
});
|
||||
|
||||
test('sorts due-set tasks before unset, asc by due', () => {
|
||||
const store: TaskStore = {
|
||||
active: [
|
||||
{ id: 't_001', title: '늦은', owner: '', due: '2026-06-01T10:00', status: 'open', notes: '' },
|
||||
{ id: 't_002', title: '미정', owner: '', due: '', status: 'open', notes: '' },
|
||||
{ id: 't_003', title: '빠른', owner: '', due: '2026-05-21T10:00', status: 'open', notes: '' },
|
||||
],
|
||||
done: [],
|
||||
};
|
||||
const summary = summarizeActiveTasks(store);
|
||||
const lines = summary.split('\n');
|
||||
// 빠른 → 늦은 → 미정 순
|
||||
expect(lines[0]).toContain('빠른');
|
||||
expect(lines[1]).toContain('늦은');
|
||||
expect(lines[2]).toContain('미정');
|
||||
});
|
||||
|
||||
test('truncates beyond max', () => {
|
||||
const active = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `t_${String(i).padStart(3, '0')}`,
|
||||
title: `task ${i}`,
|
||||
owner: '', due: '', status: 'open' as const, notes: '',
|
||||
}));
|
||||
const summary = summarizeActiveTasks({ active, done: [] }, 5);
|
||||
expect(summary).toContain('task 0');
|
||||
expect(summary).toContain('task 4');
|
||||
expect(summary).not.toContain('task 5');
|
||||
expect(summary).toContain('15 more');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_parseTaskAttrs', () => {
|
||||
test('parses standard task attrs', () => {
|
||||
const a = _parseTaskAttrs('id="t_001" title="광고주 자료" owner="@me" due="2026-05-24T18:00"');
|
||||
expect(a.id).toBe('t_001');
|
||||
expect(a.title).toBe('광고주 자료');
|
||||
expect(a.owner).toBe('@me');
|
||||
expect(a.due).toBe('2026-05-24T18:00');
|
||||
});
|
||||
|
||||
test('normalizes status variants', () => {
|
||||
expect(_parseTaskAttrs('status="In Progress"').status).toBe('in_progress');
|
||||
expect(_parseTaskAttrs('status="INPROGRESS"').status).toBe('in_progress');
|
||||
expect(_parseTaskAttrs('status="blocked"').status).toBe('blocked');
|
||||
expect(_parseTaskAttrs('status="done"').status).toBe('done');
|
||||
expect(_parseTaskAttrs('status="garbage"').status).toBe('open');
|
||||
});
|
||||
|
||||
test('returns empty object when nothing parseable', () => {
|
||||
expect(_parseTaskAttrs('')).toEqual({});
|
||||
expect(_parseTaskAttrs('garbage')).toEqual({});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user