import { describe, expect, it } from "vitest"; import type { ObservedEntity, PageState, SemanticAction } from "@src/shared"; import { WorldModel } from "./WorldModel.ts"; import { LoopDetector } from "./LoopDetector.ts"; import { scoreActions, relevanceOf } from "./InformationGain.ts"; import { HeuristicPlanner } from "./planner.ts"; const ent = (ref: string, name: string, type: ObservedEntity["type"] = "video"): ObservedEntity => ({ ref, type, platform: "youtube", platform_id: ref, url: `https://youtube.com/watch?v=${ref}`, name, fields: {}, provenance: [{ surface: "dom", confidence: 0.9 }, { surface: "network", confidence: 0.95 }], fingerprint: `youtube:${type}:${ref}` }); const state = (entities: ObservedEntity[], actions: SemanticAction[]): PageState => ({ url: "https://youtube.com/results?search_query=ai+quebec", title: "results", platform: "youtube", classification: { page_type: "SEARCH_RESULTS", confidence: 0.95, signals: [] }, entities, actions, media: [], summary_text: "AI Québec results", fingerprint: "fp", captured_at: new Date().toISOString() }); describe("information gain + planner", () => { it("prefers relevant, unvisited, multi-surface entities and penalizes visited ones", async () => { const world = new WorldModel(); const e1 = ent("v1", "Intelligence artificielle au Québec : table ronde"); const e2 = ent("v2", "Recette de tarte aux pommes"); world.observe([e1, e2], 0); world.markVisited(e2.fingerprint, e2.url); const actions: SemanticAction[] = [ { id: "A1", type: "OPEN_VIDEO", target_ref: "v1", target_url: e1.url, label: "open v1", cost: 2 }, { id: "A2", type: "OPEN_VIDEO", target_ref: "v2", target_url: e2.url, label: "open v2", cost: 2 }, { id: "A3", type: "SCROLL_DOWN", label: "scroll", cost: 1 }, { id: "A4", type: "END_SESSION", label: "end", cost: 0.5 }, ]; const st = state([e1, e2], actions); const scores = scoreActions({ goal: "intelligence artificielle Québec", mode: "research", world, state: st, recentActionTypes: [], recentUrls: [], stepsWithoutNewEntities: 0 }, actions); expect(scores[0]!.action_id).toBe("A1"); const visited = scores.find((s) => s.action_id === "A2")!; expect(visited.penalties).toContain("already_seen"); expect(scores.at(-1)!.action_id).toBe("A4"); const d = await new HeuristicPlanner().plan({ goal: "intelligence artificielle Québec", mode: "research", world, state: st, recentActionTypes: [], recentUrls: [], stepsWithoutNewEntities: 0 }, 1); expect(d.chosen_action.id).toBe("A1"); expect(d.reason.length).toBeGreaterThan(0); }); it("relevance follows lexical overlap with the goal", () => { expect(relevanceOf("AI Quebec panel", "AI Quebec")).toBeGreaterThan(relevanceOf("Apple pie recipe", "AI Quebec")); }); it("observe mode suppresses navigation", () => { const world = new WorldModel(); const e1 = ent("v1", "AI"); const actions: SemanticAction[] = [{ id: "A1", type: "OPEN_VIDEO", target_ref: "v1", target_url: e1.url, label: "open", cost: 2 }, { id: "A2", type: "SCROLL_DOWN", label: "scroll", cost: 1 }]; const scores = scoreActions({ goal: "AI", mode: "observe", world, state: state([e1], actions), recentActionTypes: [], recentUrls: [], stepsWithoutNewEntities: 0 }, actions); expect(scores[0]!.action_id).toBe("A2"); }); }); describe("LoopDetector", () => { it("detects ABAB", () => { const l = new LoopDetector(); for (const f of ["a", "b", "a", "b"]) l.record(f, "OPEN_VIDEO:x"); expect(l.detect().loop).toBe(true); }); it("tolerates repeated scrolling", () => { const l = new LoopDetector(); for (let i = 0; i < 6; i++) l.record(`p${i}`, "SCROLL_DOWN:"); expect(l.detect().loop).toBe(false); }); }); describe("WorldModel novelty", () => { it("scores near-duplicates as low novelty", () => { const w = new WorldModel(); w.observe([ent("a", "Intelligence artificielle Québec conférence 2026")], 0); expect(w.novelty(ent("b", "Intelligence artificielle Québec conférence 2026 partie 2"))).toBeLessThan(0.5); expect(w.novelty(ent("c", "Tarte aux pommes maison"))).toBeGreaterThan(0.9); }); });