v2.2.15: Astra Office Refactor & Multi-Service Integration

This commit is contained in:
g1nation
2026-05-16 22:07:06 +09:00
parent 9dcc98ad33
commit 9ca95ab997
46 changed files with 5648 additions and 1299 deletions
+131
View File
@@ -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({});
});
});
+134
View File
@@ -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']);
});
});
+241
View File
@@ -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');
});
});
+69
View File
@@ -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');
});
});
+113
View File
@@ -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({});
});
});
+185
View File
@@ -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({});
});
});