Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 235 additions & 0 deletions src/portal/__tests__/ask-calendar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
// src/portal/__tests__/ask-calendar.test.ts
// feat/ask-calendar — perguntas de agenda buscam os calendários iCal AO VIVO,
// em vez de varrer o cérebro com brain_search. Fallback: sem iCal → rota search.
// Testes puros (sem rede, sem DB): deps injetadas via __setAskDepsForTest.
import { test } from "node:test";
import assert from "node:assert/strict";

import {
isCalendarIntent,
parseAskWindow,
handleAsk,
__setAskDepsForTest,
} from "../ask.js";
import type { IndexableDocument } from "../../rag/types.js";
import type { IcsCalendarConfig } from "../../rag/calendar-ics-source.js";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function mockReq(body: Record<string, unknown>) {
return { body } as any;
}

function mockRes(accountId = "friend:test") {
const calls: { status?: number; json?: unknown } = {};
const res: any = {
locals: { accountId },
status(code: number) { calls.status = code; return res; },
json(data: unknown) { calls.json = data; return res; },
_calls: calls,
};
return res;
}

function fakeEvent(overrides: Partial<IndexableDocument> = {}): IndexableDocument {
return {
source_type: "calendar",
source_id: "ics:Pessoal::evt-1",
workspace: "personal",
db_name: "Calendar",
parent_url: "",
text: "# Reunião com João\n**Quando:** 2026-06-20T14:00:00.000Z\n**Calendário:** Pessoal",
metadata: { calendar_label: "Pessoal", data: "2026-06-20T14:00:00.000Z" },
source_updated: new Date("2026-06-19T00:00:00Z"),
...overrides,
};
}

const cfg: IcsCalendarConfig = { url: "https://x/ical.ics", label: "Pessoal", workspace: "personal" };

// ---------------------------------------------------------------------------
// isCalendarIntent
// ---------------------------------------------------------------------------

test("isCalendarIntent: positivos (agenda/calendário/eventos)", () => {
const yes = [
"consulta meu calendário",
"quais eventos tenho hoje",
"minha agenda da semana",
"meus calendários",
"o que tenho amanhã",
"quais são os meus compromissos",
"tenho alguma reunião hoje?",
"o que tenho na agenda essa semana",
];
for (const q of yes) {
assert.equal(isCalendarIntent(q), true, `deveria ser agenda: "${q}"`);
}
});

test("isCalendarIntent: negativos (não-agenda)", () => {
const no = [
"qual o status do projeto Talos?",
"resume a última reunião do Granola",
"quem é o João da GlobalCripto",
"cria uma tarefa no notion",
"o que decidimos sobre regulação",
"me explica como você funciona",
];
for (const q of no) {
assert.equal(isCalendarIntent(q), false, `NÃO deveria ser agenda: "${q}"`);
}
});

// ---------------------------------------------------------------------------
// parseAskWindow
// ---------------------------------------------------------------------------

test("parseAskWindow: hoje → dia corrente", () => {
const now = new Date("2026-06-19T10:00:00Z");
const { from, to } = parseAskWindow("o que tenho hoje", now);
assert.ok(from <= now, "from <= now");
assert.ok(to > now, "to > now");
// janela de hoje deve caber em <= ~1 dia + margem
assert.ok(to.getTime() - from.getTime() <= 36 * 60 * 60_000, "hoje cobre ~1 dia");
});

test("parseAskWindow: amanhã → dia seguinte", () => {
const now = new Date("2026-06-19T10:00:00Z");
const { from, to } = parseAskWindow("quais eventos amanhã", now);
// from deve ser depois de agora (amanhã está no futuro)
assert.ok(from.getTime() >= now.getTime(), "amanhã começa hoje ou depois");
assert.ok(to.getTime() > from.getTime(), "to > from");
});

test("parseAskWindow: semana → próximos 7 dias", () => {
const now = new Date("2026-06-19T10:00:00Z");
const { from, to } = parseAskWindow("minha agenda da semana", now);
const days = (to.getTime() - from.getTime()) / (24 * 60 * 60_000);
assert.ok(days >= 6.5 && days <= 7.5, `~7 dias, mas foi ${days}`);
});

test("parseAskWindow: default → próximos 14 dias", () => {
const now = new Date("2026-06-19T10:00:00Z");
const { from, to } = parseAskWindow("quais são meus compromissos", now);
const days = (to.getTime() - from.getTime()) / (24 * 60 * 60_000);
assert.ok(days >= 13.5 && days <= 14.5, `~14 dias, mas foi ${days}`);
assert.ok(from.getTime() <= now.getTime() + 60_000, "default começa em now");
});

// ---------------------------------------------------------------------------
// Rota calendário: com iCal e eventos
// ---------------------------------------------------------------------------

test("handler: pergunta de agenda com iCal e eventos → responde via generateAnswer com eventos no contexto", async () => {
let searchCalled = false;
let systemSeen = "";
let userSeen = "";
const ev1 = fakeEvent({ source_id: "ics:Pessoal::e1", text: "# Reunião com João\n**Quando:** 2026-06-20T14:00\n**Calendário:** Pessoal", metadata: { calendar_label: "Pessoal", data: "2026-06-20T14:00:00.000Z" } });
const ev2 = fakeEvent({ source_id: "ics:Pessoal::e2", text: "# Almoço com a equipe\n**Quando:** 2026-06-21T12:00\n**Calendário:** Pessoal", metadata: { calendar_label: "Pessoal", data: "2026-06-21T12:00:00.000Z" } });

__setAskDepsForTest({
search: async () => { searchCalled = true; return []; },
complete: async (sys: string, user: string) => { systemSeen = sys; userSeen = user; return "Você tem [1] e [2]."; },
classify: async () => "search",
loadIcalConfigs: async () => [cfg],
fetchLiveCalendar: async () => [ev1, ev2],
});
const res = mockRes();
try {
await handleAsk(mockReq({ question: "quais eventos tenho essa semana" }), res);
assert.equal(searchCalled, false, "search NÃO deve ser chamada na rota calendário");
const body = res._calls.json as any;
assert.equal(body.route, "search", "front só conhece meta/search");
assert.ok(typeof body.answer === "string");
assert.equal(body.sources.length, 2, "deve ter as 2 fontes de calendário");
assert.ok(body.sources.every((s: any) => s.source_type === "calendar"), "source_type = calendar");
// contexto numerado com os eventos
assert.ok(userSeen.includes("Reunião com João"), "contexto deve conter o evento 1");
assert.ok(userSeen.includes("Almoço com a equipe"), "contexto deve conter o evento 2");
assert.ok(userSeen.includes("[1]") && userSeen.includes("[2]"), "contexto numerado");
// CALENDAR_SYSTEM
assert.ok(systemSeen.includes("AO VIVO") || systemSeen.toLowerCase().includes("ao vivo"), "system deve ser o CALENDAR_SYSTEM");
assert.ok(systemSeen.includes("Zinom"), "identidade Zinom no system");
} finally {
__setAskDepsForTest(null);
}
});

// ---------------------------------------------------------------------------
// Fallback: sem iCal → rota search normal
// ---------------------------------------------------------------------------

test("handler: pergunta de agenda SEM iCal → cai na rota search (brain_search é chamado)", async () => {
let searchCalled = false;
let fetchCalled = false;
__setAskDepsForTest({
search: async () => { searchCalled = true; return []; },
complete: async () => "resposta da busca",
classify: async () => "search",
loadIcalConfigs: async () => [],
fetchLiveCalendar: async () => { fetchCalled = true; return []; },
});
const res = mockRes();
try {
await handleAsk(mockReq({ question: "quais eventos tenho hoje" }), res);
assert.equal(searchCalled, true, "sem iCal deve cair na rota search");
assert.equal(fetchCalled, false, "não deve buscar calendário ao vivo sem iCal");
const body = res._calls.json as any;
assert.equal(body.route, "search");
} finally {
__setAskDepsForTest(null);
}
});

// ---------------------------------------------------------------------------
// iCal mas zero eventos na janela → resposta honesta
// ---------------------------------------------------------------------------

test("handler: iCal configurado mas zero eventos na janela → resposta honesta sem fontes", async () => {
let searchCalled = false;
__setAskDepsForTest({
search: async () => { searchCalled = true; return []; },
complete: async () => "não deveria chamar a IA",
classify: async () => "search",
loadIcalConfigs: async () => [cfg],
fetchLiveCalendar: async () => [],
});
const res = mockRes();
try {
await handleAsk(mockReq({ question: "quais eventos tenho hoje" }), res);
assert.equal(searchCalled, false, "não deve cair na busca: a conta TEM iCal");
const body = res._calls.json as any;
assert.equal(body.route, "search");
assert.ok(body.answer.toLowerCase().includes("não encontrei"), `resposta honesta, foi: ${body.answer}`);
assert.deepEqual(body.sources, []);
} finally {
__setAskDepsForTest(null);
}
});

// ---------------------------------------------------------------------------
// Pergunta NÃO-agenda não dispara a rota calendário
// ---------------------------------------------------------------------------

test("handler: pergunta não-agenda não dispara rota calendário (não carrega iCal)", async () => {
let loadCalled = false;
let searchCalled = false;
__setAskDepsForTest({
search: async () => { searchCalled = true; return []; },
complete: async () => "resposta",
classify: async () => "search",
loadIcalConfigs: async () => { loadCalled = true; return [cfg]; },
fetchLiveCalendar: async () => [fakeEvent()],
});
const res = mockRes();
try {
await handleAsk(mockReq({ question: "qual o status do projeto Talos?" }), res);
assert.equal(loadCalled, false, "não-agenda não deve carregar iCal");
assert.equal(searchCalled, true, "deve usar a rota search");
} finally {
__setAskDepsForTest(null);
}
});
Loading
Loading