evOo-local/packages/media/src/clustering.test.ts
Claude a6598f09d9
test(media): cover clustering, EDL, compiler, teaser, gate and marketing
Includes compiler snapshots, injection-proof escaping checks, and an
ffmpeg integration test on synthetic testsrc/sine inputs that is skipped
when ffmpeg is not installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:06:02 +00:00

207 lines
7.3 KiB
TypeScript

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> = {},
): 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");
});
});