import { describe, expect, it } from "vitest"; import { coverageKey, registrableDomain } from "@websensor/core"; import type { DeepCandidate } from "@websensor/connectors"; import { itemsPerDayOf } from "@websensor/connectors"; import { KIND_WEIGHT, scoreCandidate, selectForShadow } from "./score"; const cand = (over: Partial): DeepCandidate => ({ url: "https://example.org/feed", type: "RSS", connector: "rss", kind: "news", name: "news feed", config: {}, suggestedTier: "B", evidence: "well-known path", value: 0.8, itemsPerDay: 0.5, firstPartyConfidence: 1, fetchCost: { bytes: 20_000, ms: 400 }, ...over }); describe("registrable domain (coverage key)", () => { it("collapses hosts to the organization's registrable domain", () => { expect(registrableDomain("ir.apple.com")).toBe("apple.com"); expect(registrableDomain("www.ons.gov.uk")).toBe("ons.gov.uk"); expect(registrableDomain("www.bankofengland.co.uk")).toBe("bankofengland.co.uk"); expect(registrableDomain("statcan.gc.ca")).toBe("statcan.gc.ca"); expect(registrableDomain("open.canada.ca")).toBe("open.canada.ca"); // federal departments are distinct organizations expect(registrableDomain("www.canada.ca")).toBe("canada.ca"); expect(registrableDomain("boj.or.jp")).toBe("boj.or.jp"); expect(coverageKey("https://WWW.Example.COM/path")).toBe("example.com"); }); }); describe("factory scoring", () => { it("weights page classes and organization importance", () => { const status = scoreCandidate(cand({ kind: "status", connector: "statuspage", value: 0.95, itemsPerDay: null }), { importance: 2 }); const careers = scoreCandidate(cand({ kind: "careers", connector: "http", value: 0.35, itemsPerDay: null }), { importance: 2 }); expect(status.score).toBeGreaterThan(careers.score); expect(KIND_WEIGHT.status).toBeGreaterThan(KIND_WEIGHT.careers); const big = scoreCandidate(cand({}), { importance: 3 }); const small = scoreCandidate(cand({}), { importance: 1 }); expect(big.score).toBeGreaterThan(small.score); expect(big.reasons.some((r) => /importance 3/.test(r))).toBe(true); }); it("penalises firehoses, dormant feeds and slow endpoints, explainably", () => { const base = scoreCandidate(cand({}), { importance: 2 }).score; expect(scoreCandidate(cand({ itemsPerDay: 200 }), { importance: 2 }).score).toBeLessThan(base); expect(scoreCandidate(cand({ itemsPerDay: 0.001 }), { importance: 2 }).score).toBeLessThan(base); const slow = scoreCandidate(cand({ fetchCost: { bytes: 100, ms: 12_000 } }), { importance: 2 }); expect(slow.score).toBeLessThan(base); expect(slow.reasons.some((r) => /slow/.test(r))).toBe(true); }); it("applies per-organization caps and the minimum score", () => { const feeds = Array.from({ length: 6 }, (_, i) => scoreCandidate(cand({ url: `https://example.org/f${i}`, value: 0.9 - i * 0.02 }), { importance: 2 })); const low = scoreCandidate(cand({ url: "https://example.org/careers", kind: "careers", connector: "http", value: 0.2, itemsPerDay: null }), { importance: 1 }); const legal = ["terms", "privacy", "cookies"].map((p) => scoreCandidate(cand({ url: `https://example.org/${p}`, kind: "legal", connector: "http", type: "HTML", value: 0.7, itemsPerDay: null }), { importance: 2 })); const { selected, skipped } = selectForShadow([...feeds, low, ...legal], { kinds: new Map(), total: 0 }); expect(selected.filter((s) => s.cand.connector === "rss")).toHaveLength(4); expect(selected.filter((s) => s.cand.kind === "legal")).toHaveLength(2); expect(skipped.some((s) => s.s === low && /score/.test(s.reason))).toBe(true); expect(skipped.some((s) => /feeds cap/.test(s.reason))).toBe(true); }); it("respects sensors the organization already has", () => { const feed = scoreCandidate(cand({}), { importance: 2 }); const { selected, skipped } = selectForShadow([feed], { kinds: new Map([["news", 4]]), total: 4 }); expect(selected).toHaveLength(0); expect(skipped[0]?.reason).toMatch(/feeds cap/); }); }); describe("expected change frequency", () => { it("uses the median gap and survives one bogus date", () => { const day = 86400e3; const now = Date.UTC(2026, 8, 13); const dates = [now, now - day, now - 2 * day, now - 3 * day, now - 4 * day, now - 5000 * day]; expect(itemsPerDayOf(dates)).toBe(1); expect(itemsPerDayOf([now, now - day])).toBeNull(); expect(itemsPerDayOf([now, now - 30 * day, now - 60 * day, now - 90 * day])).toBeCloseTo(0.033, 2); }); });