- Pro $${amount}/mo
No sources tracked for this competitor.
+ ) : ( ++ {source.lastRunAt ? `Last checked ${fmtDate(source.lastRunAt)}` : "Never checked"} +
+Pages tracked
+{siteCount}
++ Continuous analysis of each competitor — pricing, social, and page changes +
++ Add competitors and configure the pages we watch for changes +
+Plan $29
Plan $29
Enterprise
`; + expect(normalizeHtml(html)).toBe("Free\nPro\nEnterprise"); + }); + + it("collapses whitespace, decodes entities, drops empty lines", () => { + const html = `A & B
\n\n
C's
`; + expect(normalizeHtml(html)).toBe("A & B\nC's"); + }); + + it("is idempotent-stable: comment-only difference yields identical text", () => { + const a = `Same
`; + const b = `Same
`; + expect(normalizeHtml(a)).toBe(normalizeHtml(b)); + }); + + it("returns empty string for tag-only / empty input", () => { + expect(normalizeHtml(``)).toBe(""); + expect(normalizeHtml("")).toBe(""); + }); +}); diff --git a/packages/engine/src/competitor/__tests__/rules.test.ts b/packages/engine/src/competitor/__tests__/rules.test.ts new file mode 100644 index 00000000..313d47c1 --- /dev/null +++ b/packages/engine/src/competitor/__tests__/rules.test.ts @@ -0,0 +1,93 @@ +// packages/engine/src/competitor/__tests__/rules.test.ts +import { describe, it, expect } from "vitest"; +import { evaluateRules } from "../rules"; +import type { Change } from "../types"; + +describe("evaluateRules — pricing", () => { + it("flags a >=10% price increase as warning with % in summary", () => { + const changes: Change[] = [ + { kind: "modified", path: ["plans", "0", "price", "amount"], before: 29, after: 39 }, + ]; + const [alert] = evaluateRules("pricing", changes); + expect(alert.changeType).toBe("price_increase"); + expect(alert.severity).toBe("warning"); + expect(alert.summary).toContain("29"); + expect(alert.summary).toContain("39"); + expect(alert.summary).toContain("34"); // ~+34% + }); + + it("flags a small price decrease as info", () => { + const changes: Change[] = [ + { kind: "modified", path: ["plans", "0", "price", "amount"], before: 100, after: 95 }, + ]; + const [alert] = evaluateRules("pricing", changes); + expect(alert.changeType).toBe("price_decrease"); + expect(alert.severity).toBe("info"); + }); + + it("flags an added plan", () => { + const changes: Change[] = [{ kind: "added", path: ["plans", "2"], after: { name: "Team" } }]; + const [alert] = evaluateRules("pricing", changes); + expect(alert.changeType).toBe("plan_added"); + }); +}); + +describe("evaluateRules — social", () => { + it("flags follower growth", () => { + const changes: Change[] = [{ kind: "modified", path: ["followers"], before: 1000, after: 2200 }]; + const [alert] = evaluateRules("social", changes); + expect(alert.changeType).toBe("followers_up"); + expect(alert.summary).toContain("1,200"); + }); +}); + +describe("evaluateRules — fallback", () => { + it("emits a generic field_changed alert for unknown paths (never drops)", () => { + const changes: Change[] = [{ kind: "modified", path: ["mystery"], before: "a", after: "b" }]; + const out = evaluateRules("pricing", changes); + expect(out).toHaveLength(1); + expect(out[0].changeType).toBe("field_changed"); + }); +}); + +describe("evaluateRules — feed", () => { + it("emits post_published for an added item, ignores removed/other", () => { + const changes = [ + { kind: "added", path: ["items", "g1"], after: { title: "Hello v2", link: "https://x/2", publishedAt: null } }, + { kind: "removed", path: ["items", "g0"], before: { title: "old" } }, + ]; + const alerts = evaluateRules("feed", changes as never); + expect(alerts).toHaveLength(1); + expect(alerts[0]).toMatchObject({ changeType: "post_published", summary: "New post: Hello v2", severity: "info" }); + }); +}); + +describe("evaluateRules — sitemap", () => { + it("emits page_added / page_removed, ignores count", () => { + const changes = [ + { kind: "added", path: ["urls", "https://x/a"], after: 1 }, + { kind: "removed", path: ["urls", "https://x/b"], before: 1 }, + { kind: "modified", path: ["count"], before: 10, after: 11 }, + ]; + const alerts = evaluateRules("sitemap", changes as never); + expect(alerts.map((a) => a.changeType).sort()).toEqual(["page_added", "page_removed"]); + expect(alerts.find((a) => a.changeType === "page_added")!.summary).toBe("Page added: https://x/a"); + }); + + it("caps a large same-type burst at 25 individual + 1 summary", () => { + const changes = Array.from({ length: 40 }, (_, i) => ({ kind: "added", path: ["urls", `https://x/${i}`], after: 1 })); + const alerts = evaluateRules("sitemap", changes as never); + const added = alerts.filter((a) => a.changeType === "page_added"); + expect(added).toHaveLength(26); // 25 individual + 1 summary + expect(added[25]!.summary).toBe("+15 pages added"); + }); +}); + +describe("evaluateRules — non-regression", () => { + it("pricing still falls back to genericAlert for unmatched changes", () => { + const changes = [{ kind: "modified", path: ["plans", "0", "name"], before: "Pro", after: "Business" }]; + const alerts = evaluateRules("pricing", changes as never); + expect(alerts).toHaveLength(1); + expect(alerts[0]!.changeType).toBe("field_changed"); // genericAlert, unchanged + }); +}); diff --git a/packages/engine/src/competitor/__tests__/text-diff.test.ts b/packages/engine/src/competitor/__tests__/text-diff.test.ts new file mode 100644 index 00000000..841c81c4 --- /dev/null +++ b/packages/engine/src/competitor/__tests__/text-diff.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { diffText, contentChangeAlert } from "../text-diff"; + +describe("diffText", () => { + it("reports added and removed lines by multiset difference", () => { + const d = diffText("Free\nPro $29\nEnterprise", "Free\nPro $39\nEnterprise\nTeam"); + expect(d.removedLines).toEqual(["Pro $29"]); + expect(d.addedLines).toEqual(["Pro $39", "Team"]); + expect(d.removedCount).toBe(1); + expect(d.addedCount).toBe(2); + expect(d.truncated).toBe(false); + }); + + it("treats identical content (any order) as no change", () => { + const d = diffText("A\nB\nC", "C\nB\nA"); + expect(d.addedCount).toBe(0); + expect(d.removedCount).toBe(0); + }); + + it("handles empty previous (first snapshot)", () => { + const d = diffText("", "Only"); + expect(d.addedLines).toEqual(["Only"]); + expect(d.removedCount).toBe(0); + }); + + it("caps displayed lines at 20 and flags truncation", () => { + const next = Array.from({ length: 25 }, (_, i) => `L${i}`).join("\n"); + const d = diffText("", next); + expect(d.addedCount).toBe(25); + expect(d.addedLines).toHaveLength(20); + expect(d.truncated).toBe(true); + }); +}); + +describe("contentChangeAlert", () => { + it("returns null when nothing changed", () => { + expect(contentChangeAlert(diffText("A", "A"), "Pricing page")).toBeNull(); + }); + + it("builds an info alert with counts and capped line snippets", () => { + const alert = contentChangeAlert(diffText("Pro 29", "Pro 39"), "Pricing page"); + expect(alert).not.toBeNull(); + expect(alert!.changeType).toBe("content_changed"); + expect(alert!.severity).toBe("info"); + expect(alert!.summary).toBe("Pricing page content changed: +1 / -1 lines"); + expect(alert!.before).toEqual({ lines: ["Pro 29"], truncated: false }); + expect(alert!.after).toEqual({ lines: ["Pro 39"], truncated: false }); + }); + + it("flags truncation per side under asymmetric change sizes", () => { + const d = diffText(Array.from({ length: 25 }, (_, i) => `R${i}`).join("\n"), "One"); + const a = contentChangeAlert(d, "X"); + expect(a).not.toBeNull(); + expect((a!.before as { truncated: boolean }).truncated).toBe(true); + expect((a!.after as { truncated: boolean }).truncated).toBe(false); + }); +}); diff --git a/packages/engine/src/competitor/diff.ts b/packages/engine/src/competitor/diff.ts new file mode 100644 index 00000000..68724401 --- /dev/null +++ b/packages/engine/src/competitor/diff.ts @@ -0,0 +1,43 @@ +import type { Change } from "./types"; + +function isObject(v: unknown): v is Record