import { describe, expect, it } from "vitest"; import type { ClusterAsset } from "./clustering.js"; import { buildSessions, calendarDay, clusterFaces, clusterThemes, ruleLabel, } from "./clustering.js"; const at = (iso: string) => new Date(iso); function asset( id: string, captured: string | null, extra: Partial = {}, ): ClusterAsset { return { id, kind: "photo", capturedAt: captured ? at(captured) : null, gpsLat: null, gpsLon: null, deviceModel: null, albumNames: [], embedding: null, ...extra, }; } const vec = (...values: number[]) => new Float32Array(values); describe("buildSessions", () => { it("splits on gaps longer than 45 minutes and sorts by capture time", () => { const sessions = buildSessions([ asset("c", "2026-05-01T10:40:00Z"), asset("a", "2026-05-01T10:00:00Z"), asset("b", "2026-05-01T10:20:00Z"), asset("d", "2026-05-01T11:26:00Z"), ]); expect(sessions.map((s) => s.assetIds)).toEqual([["a", "b", "c"], ["d"]]); expect(sessions[0]!.key).toBe("session:a"); expect(sessions[0]!.startAt).toEqual(at("2026-05-01T10:00:00Z")); expect(sessions[0]!.endAt).toEqual(at("2026-05-01T10:40:00Z")); }); it("keeps a gap of exactly 45 minutes in one session", () => { const sessions = buildSessions([ asset("a", "2026-05-01T10:00:00Z"), asset("b", "2026-05-01T10:45:00Z"), ]); expect(sessions).toHaveLength(1); }); it("puts undated assets into one trailing session", () => { const sessions = buildSessions([ asset("z", null), asset("a", "2026-05-01T10:00:00Z"), asset("m", null), asset("bad", null, { capturedAt: new Date(Number.NaN) }), ]); expect(sessions.map((s) => s.key)).toEqual(["session:a", "session:undated"]); expect(sessions[1]!.assetIds).toEqual(["bad", "m", "z"]); expect(sessions[1]!.startAt).toBeNull(); }); it("ignores duplicate ids and mixes photos and videos", () => { const sessions = buildSessions([ asset("a", "2026-05-01T10:00:00Z"), asset("v", "2026-05-01T10:05:00Z", { kind: "video" }), asset("a", "2026-05-01T23:00:00Z"), asset("l", "2026-05-01T10:06:00Z", { kind: "live_photo" }), ]); expect(sessions.map((s) => s.assetIds)).toEqual([["a", "v", "l"]]); }); it("splits on a location jump over 2 km and reports a rounded centroid", () => { const sessions = buildSessions([ asset("a", "2026-05-01T10:00:00Z", { gpsLat: 10.0, gpsLon: 20.0 }), asset("b", "2026-05-01T10:05:00Z"), asset("c", "2026-05-01T10:10:00Z", { gpsLat: 10.004, gpsLon: 20.004 }), asset("d", "2026-05-01T10:15:00Z", { gpsLat: 10.1, gpsLon: 20.0 }), ]); expect(sessions.map((s) => s.assetIds)).toEqual([["a", "b", "c"], ["d"]]); expect(sessions[0]!.centroid).toEqual({ lat: 10, lon: 20 }); expect(sessions[1]!.centroid).toEqual({ lat: 10.1, lon: 20 }); }); it("breaks on a calendar-day change only with a gap over 3 hours, in the given time zone", () => { const assets = [asset("a", "2026-05-01T22:30:00Z"), asset("b", "2026-05-02T02:00:00Z")]; // Long gap rule disabled so only the day-break rule decides. const options = { gapMinutes: 24 * 60 }; expect(buildSessions(assets, options)).toHaveLength(2); // In UTC-10 both are on 1 May: one session. expect(buildSessions(assets, { ...options, timeZone: "Pacific/Honolulu" })).toHaveLength(1); // Crossing midnight with a short gap stays one session. expect( buildSessions( [asset("x", "2026-05-01T23:50:00Z"), asset("y", "2026-05-02T00:20:00Z")], options, ), ).toHaveLength(1); expect(calendarDay(at("2026-05-01T22:30:00Z"), "Europe/Berlin")).toBe("2026-05-02"); }); it("merges neighbours with similar embeddings and a gap under 2 hours", () => { const red = vec(1, 0, 0); const blue = vec(0, 1, 0); const sessions = buildSessions([ asset("a", "2026-05-01T10:00:00Z", { embedding: red }), asset("b", "2026-05-01T11:00:00Z", { embedding: vec(0.95, 0.05, 0) }), asset("c", "2026-05-01T12:00:00Z", { embedding: blue }), asset("d", "2026-05-01T15:00:00Z", { embedding: blue }), ]); expect(sessions.map((s) => s.assetIds)).toEqual([["a", "b"], ["c"], ["d"]]); }); it("is deterministic for any input order", () => { const assets = [ asset("a", "2026-05-01T10:00:00Z"), asset("b", "2026-05-01T10:00:00Z"), asset("c", "2026-05-02T10:00:00Z"), asset("d", null), ]; const forward = buildSessions(assets); const reversed = buildSessions([...assets].reverse()); expect(reversed).toEqual(forward); expect(forward[0]!.assetIds).toEqual(["a", "b"]); }); }); describe("clusterThemes and clusterFaces", () => { const items = [ { id: "p3", vector: vec(0.9, 0.1, 0) }, { id: "p1", vector: vec(1, 0, 0) }, { id: "q1", vector: vec(0, 1, 0) }, { id: "p2", vector: vec(0.95, 0.05, 0) }, { id: "q2", vector: vec(0.05, 0.95, 0) }, { id: "r1", vector: vec(0, 0, 1) }, ]; it("groups by average linkage with stable keys, independent of input order", () => { const themes = clusterThemes(items); expect(themes).toEqual([ { key: "theme:p1", ids: ["p1", "p2", "p3"] }, { key: "theme:q1", ids: ["q1", "q2"] }, { key: "theme:r1", ids: ["r1"] }, ]); expect(clusterThemes([...items].reverse())).toEqual(themes); expect(clusterThemes(items, { minSize: 2 }).map((t) => t.key)).toEqual([ "theme:p1", "theme:q1", ]); expect(clusterThemes(items, { similarity: -1 })).toHaveLength(1); expect(clusterThemes([])).toEqual([]); }); it("uses average rather than single linkage", () => { // b is close to a and c, but a and c are far apart: average linkage keeps c out. const chain = [ { id: "a", vector: vec(1, 0) }, { id: "b", vector: vec(Math.cos(0.4), Math.sin(0.4)) }, { id: "c", vector: vec(Math.cos(1.0), Math.sin(1.0)) }, ]; expect(clusterThemes(chain, { similarity: 0.8 }).map((g) => g.ids)).toEqual([ ["a", "b"], ["c"], ]); }); it("returns face groups with a mean centroid", () => { const faces = clusterFaces([ { id: "t1", vector: vec(1, 0) }, { id: "t2", vector: vec(0.8, 0.2) }, { id: "t3", vector: vec(0, 1) }, ]); expect(faces.map((f) => f.key)).toEqual(["person:t1", "person:t3"]); expect(Array.from(faces[0]!.centroid)).toEqual([0.8999999761581421, 0.10000000149011612]); expect(() => clusterFaces([ { id: "x", vector: vec(1) }, { id: "y", vector: vec(1, 2) }, ]), ).toThrow(); }); }); describe("ruleLabel", () => { it("combines the dominant album with a locale-aware date range", () => { const label = ruleLabel( { startAt: at("2026-05-01T10:00:00Z"), endAt: at("2026-05-03T10:00:00Z"), albumNames: ["Trip", "Favorites", "Trip"], }, "en-US", ); expect(label.startsWith("Trip ยท ")).toBe(true); expect(label).toMatch(/May 1\D+3, 2026/); expect( ruleLabel({ startAt: at("2026-05-01T10:00:00Z"), endAt: null, albumNames: [] }, "de-DE"), ).toBe("01.05.2026"); expect(ruleLabel({ startAt: null, endAt: null, albumNames: [" "] })).toBe(""); expect(ruleLabel({ startAt: null, endAt: null, albumNames: ["B", "A"] })).toBe("A"); }); });