feat: add listInitiatives sort-order tests and qualityReview persistence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,18 @@
|
||||
/**
|
||||
* Integration tests for initiative tRPC router — qualityReview field.
|
||||
* Integration tests for initiative tRPC router — qualityReview field and listInitiatives sort.
|
||||
*
|
||||
* Verifies that updateInitiativeConfig accepts and persists qualityReview.
|
||||
* Verifies that updateInitiativeConfig accepts and persists qualityReview,
|
||||
* and that listInitiatives returns initiatives in priority-sorted order.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { router, publicProcedure, createCallerFactory } from '../trpc.js';
|
||||
import { initiativeProcedures } from './initiative.js';
|
||||
import type { TRPCContext } from '../context.js';
|
||||
import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js';
|
||||
import { DrizzleInitiativeRepository } from '../../db/repositories/drizzle/index.js';
|
||||
import { DrizzleInitiativeRepository, DrizzlePhaseRepository } from '../../db/repositories/drizzle/index.js';
|
||||
import { initiatives as initiativesTable } from '../../db/schema.js';
|
||||
|
||||
// =============================================================================
|
||||
// Mock ensureProjectClone — prevents actual git cloning
|
||||
@@ -61,6 +64,99 @@ async function setup() {
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
// =============================================================================
|
||||
// listInitiatives sort order tests
|
||||
// =============================================================================
|
||||
|
||||
describe('listInitiatives — sort order', () => {
|
||||
async function setupWithPhaseRepo() {
|
||||
const db = createTestDatabase();
|
||||
const initiativeRepo = new DrizzleInitiativeRepository(db);
|
||||
const phaseRepo = new DrizzlePhaseRepository(db);
|
||||
const ctx: TRPCContext = {
|
||||
eventBus: createMockEventBus(),
|
||||
serverStartedAt: null,
|
||||
processCount: 0,
|
||||
initiativeRepository: initiativeRepo,
|
||||
phaseRepository: phaseRepo,
|
||||
};
|
||||
const caller = createCaller(ctx);
|
||||
return { caller, initiativeRepo, phaseRepo, db };
|
||||
}
|
||||
|
||||
async function setupNoPhaseRepo() {
|
||||
const db = createTestDatabase();
|
||||
const initiativeRepo = new DrizzleInitiativeRepository(db);
|
||||
const ctx: TRPCContext = {
|
||||
eventBus: createMockEventBus(),
|
||||
serverStartedAt: null,
|
||||
processCount: 0,
|
||||
initiativeRepository: initiativeRepo,
|
||||
};
|
||||
const caller = createCaller(ctx);
|
||||
return { caller, initiativeRepo, db };
|
||||
}
|
||||
|
||||
it('executing appears before idle/planning (state priority 0 < 1)', async () => {
|
||||
const { caller, initiativeRepo, phaseRepo } = await setupWithPhaseRepo();
|
||||
const executingInit = await initiativeRepo.create({ name: 'Executing' });
|
||||
const planningInit = await initiativeRepo.create({ name: 'Planning' });
|
||||
await phaseRepo.create({ initiativeId: executingInit.id, name: 'Phase 1', status: 'in_progress' });
|
||||
// planningInit has no phases → derives 'idle' (priority 1 via fallback)
|
||||
// executingInit has in_progress phase → derives 'executing' (priority 0)
|
||||
const result = await caller.listInitiatives({});
|
||||
expect(result[0].id).toBe(executingInit.id);
|
||||
});
|
||||
|
||||
it('ready appears before blocked (priority 1 < 2)', async () => {
|
||||
const { caller, initiativeRepo, phaseRepo } = await setupWithPhaseRepo();
|
||||
const blockedInit = await initiativeRepo.create({ name: 'Blocked' });
|
||||
const readyInit = await initiativeRepo.create({ name: 'Ready' });
|
||||
await phaseRepo.create({ initiativeId: blockedInit.id, name: 'Blocked Phase', status: 'blocked' });
|
||||
await phaseRepo.create({ initiativeId: readyInit.id, name: 'Ready Phase', status: 'approved' });
|
||||
const result = await caller.listInitiatives({});
|
||||
expect(result[0].id).toBe(readyInit.id);
|
||||
});
|
||||
|
||||
it('same state — most recently updated comes first', async () => {
|
||||
const { caller, initiativeRepo, db } = await setupNoPhaseRepo();
|
||||
const older = await initiativeRepo.create({ name: 'Older' });
|
||||
const newer = await initiativeRepo.create({ name: 'Newer' });
|
||||
// Set different updatedAt values via raw DB (SQLite stores timestamps at second precision)
|
||||
const olderDate = new Date('2024-01-01T00:00:00.000Z');
|
||||
const newerDate = new Date('2024-01-02T00:00:00.000Z');
|
||||
await db.update(initiativesTable).set({ updatedAt: olderDate }).where(eq(initiativesTable.id, older.id));
|
||||
await db.update(initiativesTable).set({ updatedAt: newerDate }).where(eq(initiativesTable.id, newer.id));
|
||||
const result = await caller.listInitiatives({});
|
||||
expect(result[0].id).toBe(newer.id);
|
||||
});
|
||||
|
||||
it('same state and updatedAt — lexicographically smaller id appears first', async () => {
|
||||
const { caller, initiativeRepo, db } = await setupNoPhaseRepo();
|
||||
const init1 = await initiativeRepo.create({ name: 'Init A' });
|
||||
const init2 = await initiativeRepo.create({ name: 'Init B' });
|
||||
const fixedDate = new Date('2024-01-01T00:00:00.000Z');
|
||||
await db.update(initiativesTable).set({ updatedAt: fixedDate }).where(eq(initiativesTable.id, init1.id));
|
||||
await db.update(initiativesTable).set({ updatedAt: fixedDate }).where(eq(initiativesTable.id, init2.id));
|
||||
const result = await caller.listInitiatives({});
|
||||
const expectedFirst = init1.id < init2.id ? init1.id : init2.id;
|
||||
expect(result[0].id).toBe(expectedFirst);
|
||||
});
|
||||
|
||||
it('empty list returns []', async () => {
|
||||
const { caller } = await setupNoPhaseRepo();
|
||||
const result = await caller.listInitiatives({});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('single item returns array of length 1', async () => {
|
||||
const { caller, initiativeRepo } = await setupNoPhaseRepo();
|
||||
await initiativeRepo.create({ name: 'Only' });
|
||||
const result = await caller.listInitiatives({});
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateInitiativeConfig — qualityReview', () => {
|
||||
it('sets qualityReview to true', async () => {
|
||||
const { caller, initiative } = await setup();
|
||||
|
||||
@@ -11,6 +11,36 @@ import { buildRefinePrompt, buildConflictResolutionPrompt, buildConflictResoluti
|
||||
import type { PageForSerialization } from '../../agent/content-serializer.js';
|
||||
import { ensureProjectClone } from '../../git/project-clones.js';
|
||||
|
||||
const ACTIVITY_STATE_PRIORITY: Record<string, number> = {
|
||||
executing: 0,
|
||||
pending_review: 0,
|
||||
discussing: 0,
|
||||
detailing: 0,
|
||||
refining: 0,
|
||||
resolving_conflict: 0,
|
||||
ready: 1,
|
||||
planning: 1,
|
||||
blocked: 2,
|
||||
complete: 3,
|
||||
archived: 4,
|
||||
};
|
||||
|
||||
function activityPriority(state: string): number {
|
||||
return ACTIVITY_STATE_PRIORITY[state] ?? 1;
|
||||
}
|
||||
|
||||
function sortInitiatives<T extends { activity: { state: string }; updatedAt: string | Date; id: string }>(enriched: T[]): T[] {
|
||||
return enriched.sort((a, b) => {
|
||||
const pa = activityPriority(a.activity.state);
|
||||
const pb = activityPriority(b.activity.state);
|
||||
if (pa !== pb) return pa - pb;
|
||||
const ta = new Date(a.updatedAt).getTime();
|
||||
const tb = new Date(b.updatedAt).getTime();
|
||||
if (tb !== ta) return tb - ta;
|
||||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
return {
|
||||
createInitiative: publicProcedure
|
||||
@@ -156,17 +186,18 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
|
||||
if (ctx.phaseRepository) {
|
||||
const phaseRepo = ctx.phaseRepository;
|
||||
return Promise.all(initiatives.map(async (init) => {
|
||||
const enriched = await Promise.all(initiatives.map(async (init) => {
|
||||
const phases = await phaseRepo.findByInitiativeId(init.id);
|
||||
return { ...init, ...addProjects(init), activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) };
|
||||
}));
|
||||
return sortInitiatives(enriched);
|
||||
}
|
||||
|
||||
return initiatives.map(init => ({
|
||||
return sortInitiatives(initiatives.map(init => ({
|
||||
...init,
|
||||
...addProjects(init),
|
||||
activity: deriveInitiativeActivity(init, [], activeArchitectAgents),
|
||||
}));
|
||||
})));
|
||||
}),
|
||||
|
||||
getInitiative: publicProcedure
|
||||
|
||||
Reference in New Issue
Block a user