/** * Unit tests for tournament orchestrator. * @module openfinclaw/tournament/orchestrator.test */ import { DatabaseSync } from "node:sqlite"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TournamentDb, ensureTournamentSchema } from "./db.js"; import { type OrchestratorDeps, type SpawnResult, TournamentOrchestrator } from "./orchestrator.js"; function createMockDeps(db: TournamentDb, overrides?: Partial): OrchestratorDeps { return { db, selectTicker: vi.fn().mockResolvedValue("AAPL"), spawnSubagent: vi.fn().mockResolvedValue({ status: "accepted", runId: "run-1" } as SpawnResult), enqueueSystemEvent: vi.fn(), requestHeartbeatNow: vi.fn(), waitForResults: vi.fn().mockResolvedValue([ { round_id: expect.any(String), agent_name: "bull", thesis: "Bullish EMA crossover", entry_price: 150, exit_price: 165, stop_loss: 145, position_pct: 0.25, confidence: 85, sharpe: 1.5, max_drawdown: -0.08, total_return: 0.12, raw_result: null, created_at: new Date().toISOString(), }, { round_id: expect.any(String), agent_name: "bear", thesis: "RSI overbought signals", entry_price: 150, exit_price: 135, stop_loss: 155, position_pct: 0.2, confidence: 70, sharpe: 0.8, max_drawdown: -0.12, total_return: -0.05, raw_result: null, created_at: new Date().toISOString(), }, { round_id: expect.any(String), agent_name: "contrarian", thesis: "Sentiment reversal", entry_price: 148, exit_price: 160, stop_loss: 142, position_pct: 0.15, confidence: 60, sharpe: 1.2, max_drawdown: -0.1, total_return: 0.08, raw_result: null, created_at: new Date().toISOString(), }, ]), logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, config: { maxAgents: 3, subagentTimeoutSeconds: 600, alertAfterSkips: 3, sessionKey: "agent:main:test", }, ...overrides, }; } describe("TournamentOrchestrator", () => { let sqliteDb: DatabaseSync; let tdb: TournamentDb; beforeEach(() => { sqliteDb = new DatabaseSync(":memory:"); sqliteDb.exec("PRAGMA journal_mode = WAL"); ensureTournamentSchema(sqliteDb); tdb = new TournamentDb(sqliteDb); }); afterEach(() => { sqliteDb.close(); vi.restoreAllMocks(); }); it("runs happy path: 3 agents succeed, round completed", async () => { const deps = createMockDeps(tdb); const orch = new TournamentOrchestrator(deps); await orch.runDailyTournament(); expect(deps.selectTicker).toHaveBeenCalledOnce(); expect(deps.spawnSubagent).toHaveBeenCalledTimes(3); expect(deps.enqueueSystemEvent).toHaveBeenCalledOnce(); expect(deps.requestHeartbeatNow).toHaveBeenCalledOnce(); const today = new Date().toISOString().slice(0, 10); const roundId = `round-${today.replace(/-/g, "")}`; const round = tdb.getRound(roundId); expect(round?.status).toBe("completed"); }); it("skips idempotently when round already completed", async () => { const today = new Date().toISOString().slice(0, 10); const roundId = `round-${today.replace(/-/g, "")}`; tdb.createRound({ id: roundId, date: today, ticker: "AAPL" }); tdb.updateRoundStatus(roundId, "completed"); const deps = createMockDeps(tdb); const orch = new TournamentOrchestrator(deps); await orch.runDailyTournament(); expect(deps.selectTicker).not.toHaveBeenCalled(); expect(deps.spawnSubagent).not.toHaveBeenCalled(); }); it("completes with 2/3 agents when one fails to spawn", async () => { let callCount = 0; const deps = createMockDeps(tdb, { spawnSubagent: vi.fn().mockImplementation(() => { callCount++; if (callCount === 2) return Promise.resolve({ status: "error", error: "slot full" } as SpawnResult); return Promise.resolve({ status: "accepted", runId: `run-${callCount}` } as SpawnResult); }), waitForResults: vi.fn().mockResolvedValue([ { round_id: "any", agent_name: "bull", thesis: "test", confidence: 80, entry_price: null, exit_price: null, stop_loss: null, position_pct: null, sharpe: 1.0, max_drawdown: null, total_return: null, raw_result: null, created_at: new Date().toISOString(), }, { round_id: "any", agent_name: "contrarian", thesis: "test", confidence: 70, entry_price: null, exit_price: null, stop_loss: null, position_pct: null, sharpe: 0.9, max_drawdown: null, total_return: null, raw_result: null, created_at: new Date().toISOString(), }, ]), }); const orch = new TournamentOrchestrator(deps); await orch.runDailyTournament(); expect(deps.enqueueSystemEvent).toHaveBeenCalledOnce(); const today = new Date().toISOString().slice(0, 10); const roundId = `round-${today.replace(/-/g, "")}`; expect(tdb.getRound(roundId)?.status).toBe("completed"); }); it("marks round as skipped when <2 agents succeed", async () => { const deps = createMockDeps(tdb, { spawnSubagent: vi .fn() .mockResolvedValue({ status: "forbidden", error: "slots full" } as SpawnResult), }); const orch = new TournamentOrchestrator(deps); await orch.runDailyTournament(); const today = new Date().toISOString().slice(0, 10); const roundId = `round-${today.replace(/-/g, "")}`; const round = tdb.getRound(roundId); expect(round?.status).toBe("skipped"); expect(round?.skip_reason).toContain("0/3"); }); it("sends alert after 3 consecutive skips", async () => { // Create 2 prior skipped rounds tdb.createRound({ id: "round-20260330", date: "2026-03-30", ticker: "A" }); tdb.updateRoundStatus("round-20260330", "skipped"); tdb.createRound({ id: "round-20260329", date: "2026-03-29", ticker: "B" }); tdb.updateRoundStatus("round-20260329", "skipped"); // This round will also be skipped (all spawns fail) const deps = createMockDeps(tdb, { spawnSubagent: vi .fn() .mockResolvedValue({ status: "forbidden", error: "no slots" } as SpawnResult), }); const orch = new TournamentOrchestrator(deps); await orch.runDailyTournament(); // Should have 2 calls: 1 for skip alert (consecutive failures) // The round result message is NOT sent because it was skipped const calls = (deps.enqueueSystemEvent as ReturnType).mock.calls; expect(calls.length).toBeGreaterThanOrEqual(1); const alertCall = calls.find((c: unknown[]) => String(c[0]).includes("连续")); expect(alertCall).toBeTruthy(); }); it("skips when ticker selection fails", async () => { const deps = createMockDeps(tdb, { selectTicker: vi.fn().mockRejectedValue(new Error("DataHub offline")), }); const orch = new TournamentOrchestrator(deps); await orch.runDailyTournament(); expect(deps.spawnSubagent).not.toHaveBeenCalled(); const today = new Date().toISOString().slice(0, 10); const roundId = `round-${today.replace(/-/g, "")}`; const round = tdb.getRound(roundId); expect(round?.status).toBe("skipped"); expect(round?.skip_reason).toContain("Ticker selection failed"); }); });