This commit is contained in:
283
packages/server/tests/local/home.test.ts
Normal file
283
packages/server/tests/local/home.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Home Cockpit route tests (P2 — verify + J08).
|
||||
*
|
||||
* Covers the P2 additions to GET /api/home/briefing:
|
||||
* - personalizeGreeting(): user name spliced into the first greeting clause
|
||||
* (B8 / PRD §12.1 "greeting with user name") — unit + via inject.
|
||||
* - applyPriorityRanking(): recency + bounded pending-item boost
|
||||
* (PRD §12.1 "ranked by recency and priority") — unit.
|
||||
* - needsReviewCount (J08/D6): personal-mind frames with metadata status
|
||||
* 'unreviewed' are counted; the count matches what the Memory Center
|
||||
* "Needs review" view lists.
|
||||
*
|
||||
* Scaffolding follows memory-center.test.ts: Fastify inject + MindDB(':memory:')
|
||||
* + plain-object decorators. localConfig is intentionally absent → the
|
||||
* buildWorkspaceState call inside the briefing loop throws and is absorbed by
|
||||
* the route's per-workspace try/catch (pendingCount degrades to 0), and
|
||||
* emitAuditEvent stays a safe no-op.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import { MindDB, IdentityLayer } from '@waggle/core';
|
||||
import {
|
||||
homeRoutes,
|
||||
personalizeGreeting,
|
||||
applyPriorityRanking,
|
||||
type RecentWorkspaceCard,
|
||||
} from '../../src/local/routes/home.js';
|
||||
import { memoryCenterRoutes } from '../../src/local/routes/memory-center.js';
|
||||
import {
|
||||
buildUpcomingSchedules,
|
||||
type CronScheduleLike,
|
||||
} from '../../src/local/routes/workspace-context.js';
|
||||
|
||||
interface TestWorkspace {
|
||||
id: string;
|
||||
name: string;
|
||||
group: string;
|
||||
created: string;
|
||||
status?: string;
|
||||
teamId?: string;
|
||||
}
|
||||
|
||||
function createTestServer(
|
||||
db: MindDB,
|
||||
workspaces: TestWorkspace[] = [],
|
||||
cronSchedules: CronScheduleLike[] = [],
|
||||
) {
|
||||
const server = Fastify({ logger: false });
|
||||
server.decorate('multiMind', {
|
||||
personal: db,
|
||||
getFrameStore: () => undefined,
|
||||
search: () => [],
|
||||
workspace: undefined,
|
||||
setWorkspace: () => {},
|
||||
});
|
||||
server.decorate('agentState', {
|
||||
getWorkspaceMindDb: () => undefined,
|
||||
activateWorkspaceMind: () => undefined,
|
||||
listWorkspaces: () => [],
|
||||
});
|
||||
server.decorate('workspaceManager', {
|
||||
list: () => workspaces,
|
||||
get: (id: string) => workspaces.find((w) => w.id === id),
|
||||
});
|
||||
server.decorate('cronStore', {
|
||||
list: () => cronSchedules,
|
||||
getExecutionHistory: () => [],
|
||||
});
|
||||
// localConfig intentionally absent — see file header.
|
||||
server.register(homeRoutes);
|
||||
server.register(memoryCenterRoutes);
|
||||
return server;
|
||||
}
|
||||
|
||||
const card = (id: string, pendingCount: number, rankTs: number): { card: RecentWorkspaceCard; rankTs: number } => ({
|
||||
rankTs,
|
||||
card: { id, name: id, group: 'Personal', lastActive: new Date(rankTs).toISOString(), pendingCount },
|
||||
});
|
||||
|
||||
describe('personalizeGreeting (P2 — B8 name in greeting)', () => {
|
||||
it('splices the name before the first sentence break', () => {
|
||||
expect(personalizeGreeting("Good morning. Here's your day", 'Marko'))
|
||||
.toBe("Good morning, Marko. Here's your day");
|
||||
});
|
||||
|
||||
it('handles the em-dash fresh-state greeting', () => {
|
||||
expect(personalizeGreeting('Welcome — anything you discuss here will be remembered.', 'Marko'))
|
||||
.toBe('Welcome, Marko — anything you discuss here will be remembered.');
|
||||
});
|
||||
|
||||
it('passes through without a name', () => {
|
||||
expect(personalizeGreeting("Good morning. Here's your day"))
|
||||
.toBe("Good morning. Here's your day");
|
||||
});
|
||||
|
||||
it('passes through greetings with no sentence break', () => {
|
||||
expect(personalizeGreeting('Hello there', 'Marko')).toBe('Hello there');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPriorityRanking (P2 — recency + pending boost)', () => {
|
||||
const HOUR = 3_600_000;
|
||||
const T0 = Date.parse('2026-06-11T12:00:00Z');
|
||||
|
||||
it('pending items boost an older workspace past a fresher empty one within the cap window', () => {
|
||||
const fresh = card('fresh', 0, T0);
|
||||
const blocked = card('blocked', 3, T0 - 2 * HOUR); // 3 pending → +3h ≥ 2h gap
|
||||
const ranked = applyPriorityRanking([fresh, blocked]);
|
||||
expect(ranked.map((c) => c.id)).toEqual(['blocked', 'fresh']);
|
||||
});
|
||||
|
||||
it('the boost is capped — a stale workspace cannot leapfrog on pending count alone', () => {
|
||||
const fresh = card('fresh', 0, T0);
|
||||
const stale = card('stale', 50, T0 - 24 * HOUR); // cap 5 → +5h < 24h gap
|
||||
const ranked = applyPriorityRanking([fresh, stale]);
|
||||
expect(ranked.map((c) => c.id)).toEqual(['fresh', 'stale']);
|
||||
});
|
||||
|
||||
it('slices to the display cap of 6', () => {
|
||||
const cards = Array.from({ length: 8 }, (_, i) => card(`w${i}`, 0, T0 - i * HOUR));
|
||||
expect(applyPriorityRanking(cards)).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/home/briefing (P2 — J08 needsReviewCount + greeting)', () => {
|
||||
let db: MindDB;
|
||||
let server: ReturnType<typeof Fastify>;
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
function boot(workspaces: TestWorkspace[] = []) {
|
||||
db = new MindDB(':memory:');
|
||||
server = createTestServer(db, workspaces);
|
||||
}
|
||||
|
||||
it('empty mind → needsReviewCount 0, isFirstRun true', async () => {
|
||||
boot();
|
||||
const res = await server.inject({ method: 'GET', url: '/api/home/briefing' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const briefing = res.json();
|
||||
expect(briefing.isFirstRun).toBe(true);
|
||||
expect(briefing.needsReviewCount).toBe(0);
|
||||
});
|
||||
|
||||
it('counts only personal-mind memories with status unreviewed', async () => {
|
||||
boot();
|
||||
// Seed via the same routes the product uses: create (status active) then
|
||||
// flip one to unreviewed — the C33 harvest lifecycle state.
|
||||
const m1 = await server.inject({
|
||||
method: 'POST', url: '/api/memory',
|
||||
payload: { content: 'Imported fact awaiting review.' },
|
||||
});
|
||||
expect(m1.statusCode).toBe(200);
|
||||
const m2 = await server.inject({
|
||||
method: 'POST', url: '/api/memory',
|
||||
payload: { content: 'Already-reviewed fact.' },
|
||||
});
|
||||
expect(m2.statusCode).toBe(200);
|
||||
|
||||
const patch = await server.inject({
|
||||
method: 'PATCH', url: `/api/memory/${m1.json().id}`,
|
||||
payload: { status: 'unreviewed' },
|
||||
});
|
||||
expect(patch.statusCode).toBe(200);
|
||||
|
||||
const res = await server.inject({ method: 'GET', url: '/api/home/briefing' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().needsReviewCount).toBe(1);
|
||||
|
||||
// The count must agree with what the deep-linked "Needs review" view shows
|
||||
// — same query the Memory Center fires (status filter + limit=200).
|
||||
const list = await server.inject({ method: 'GET', url: '/api/memory?status=unreviewed&limit=200' });
|
||||
expect(list.json().count).toBe(1);
|
||||
});
|
||||
|
||||
it('briefing slices recent workspaces to 6, newest first (route-level ranking wiring)', async () => {
|
||||
const T0 = Date.parse('2026-06-11T12:00:00Z');
|
||||
const workspaces = Array.from({ length: 8 }, (_, i) => ({
|
||||
id: `w${i}`,
|
||||
name: `Workspace ${i}`,
|
||||
group: 'Personal',
|
||||
// w0 oldest … w7 newest
|
||||
created: new Date(T0 + i * 3_600_000).toISOString(),
|
||||
}));
|
||||
boot(workspaces);
|
||||
const res = await server.inject({ method: 'GET', url: '/api/home/briefing' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const cards = res.json().recentWorkspaces;
|
||||
expect(cards).toHaveLength(6);
|
||||
expect(cards.map((c: { id: string }) => c.id)).toEqual(['w7', 'w6', 'w5', 'w4', 'w3', 'w2']);
|
||||
});
|
||||
|
||||
it('greets by name when identity is seeded (B8)', async () => {
|
||||
boot([{ id: 'w1', name: 'Alpha', group: 'Personal', created: new Date().toISOString() }]);
|
||||
new IdentityLayer(db).create({
|
||||
name: 'Marko', role: 'Founder', department: 'Egzakta',
|
||||
personality: '', capabilities: '', system_prompt: '',
|
||||
});
|
||||
const res = await server.inject({ method: 'GET', url: '/api/home/briefing' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const briefing = res.json();
|
||||
expect(briefing.userName).toBe('Marko');
|
||||
expect(briefing.greeting).toContain(', Marko');
|
||||
});
|
||||
|
||||
it('greeting stays name-free when no identity exists', async () => {
|
||||
boot([{ id: 'w1', name: 'Alpha', group: 'Personal', created: new Date().toISOString() }]);
|
||||
const res = await server.inject({ method: 'GET', url: '/api/home/briefing' });
|
||||
expect(res.json().userName).toBeUndefined();
|
||||
expect(res.json().greeting).not.toContain(',');
|
||||
});
|
||||
});
|
||||
|
||||
describe('upNext schedule aggregation (global jobs dedup + future-only)', () => {
|
||||
let db: MindDB;
|
||||
let server: ReturnType<typeof Fastify>;
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const FUTURE = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
const PAST = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
|
||||
const globalSchedule = (id: number, name: string, nextRunAt: string): CronScheduleLike => ({
|
||||
id,
|
||||
name,
|
||||
cron_expr: '30 3 * * *',
|
||||
enabled: 1,
|
||||
workspace_id: null,
|
||||
next_run_at: nextRunAt,
|
||||
} as CronScheduleLike);
|
||||
|
||||
const threeWorkspaces: TestWorkspace[] = ['a', 'b', 'c'].map((id, i) => ({
|
||||
id,
|
||||
name: `Workspace ${id}`,
|
||||
group: 'Personal',
|
||||
created: new Date(Date.parse('2026-06-11T12:00:00Z') + i * 3_600_000).toISOString(),
|
||||
}));
|
||||
|
||||
it('a global schedule surfaces once across multiple workspaces, not once per workspace', async () => {
|
||||
db = new MindDB(':memory:');
|
||||
server = createTestServer(db, threeWorkspaces, [
|
||||
globalSchedule(1, 'Memory compaction', FUTURE),
|
||||
]);
|
||||
const res = await server.inject({ method: 'GET', url: '/api/home/briefing' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const labels = (res.json().upNext as Array<{ label: string }>).map((u) => u.label);
|
||||
const compactions = labels.filter((l) => l.startsWith('Memory compaction'));
|
||||
expect(compactions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('past-due schedules never display as upcoming', async () => {
|
||||
db = new MindDB(':memory:');
|
||||
server = createTestServer(db, threeWorkspaces, [
|
||||
globalSchedule(1, 'Memory compaction', PAST),
|
||||
globalSchedule(2, 'Harvest sync', FUTURE),
|
||||
]);
|
||||
const res = await server.inject({ method: 'GET', url: '/api/home/briefing' });
|
||||
const labels = (res.json().upNext as Array<{ label: string }>).map((u) => u.label);
|
||||
expect(labels.some((l) => l.startsWith('Memory compaction'))).toBe(false);
|
||||
expect(labels.filter((l) => l.startsWith('Harvest sync'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('buildUpcomingSchedules unit: filters past, keeps future, scopes by workspace', () => {
|
||||
const out = buildUpcomingSchedules(
|
||||
[
|
||||
globalSchedule(1, 'Past job', PAST),
|
||||
globalSchedule(2, 'Future job', FUTURE),
|
||||
{ ...globalSchedule(3, 'Other ws job', FUTURE), workspace_id: 'other' } as CronScheduleLike,
|
||||
],
|
||||
'mine',
|
||||
);
|
||||
expect(out.some((l) => l.startsWith('Past job'))).toBe(false);
|
||||
expect(out.some((l) => l.startsWith('Future job'))).toBe(true);
|
||||
expect(out.some((l) => l.startsWith('Other ws job'))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user