itpdp/api/src/workflows/__tests__/party-analysis.test.ts
2026-04-30 21:54:13 +02:00

322 lines
11 KiB
TypeScript

/** biome-ignore-all lint/style/noNonNullAssertion: <explanation> */
import { DBOS } from "@dbos-inc/dbos-sdk";
import { describe, expect, it } from "vitest";
import {
addFollowedArtist,
addPlaybackHistory,
addSavedTrack,
addTopArtist,
addTopTrack,
createAlbum,
createArtist,
createParty,
createTrack,
createUser,
joinParty,
seedPartyWithThreeDiverseUsers,
seedPartyWithTwoSimilarUsers,
} from "../../test/factories";
import { partyAnalysisWorkflow } from "../party-analysis";
import "../../dbos";
await DBOS.launch();
describe("PartyAnalysisWorkflow", () => {
describe("analyzeParty - basic behavior", () => {
it("returns empty results for party with fewer than 2 members", async () => {
const user = await createUser("Solo User");
const party = await createParty(user.id);
await joinParty(party.partyId, user.id);
const result = await partyAnalysisWorkflow.analyzeParty(party.partyId);
expect(result.storyClusters).toHaveLength(0);
expect(result.pairwise).toHaveLength(0);
expect(result.groupSummary.totalMembers).toBe(1);
expect(result.groupSummary.mostDiverseMember).toBeNull();
expect(result.groupSummary.mostAlignedPair).toBeNull();
expect(result.memberProfiles).toHaveLength(0);
});
it("returns empty results for party with 0 members", async () => {
const user = await createUser("Host Only");
const party = await createParty(user.id);
// Don't add any members
const result = await partyAnalysisWorkflow.analyzeParty(party.partyId);
expect(result.storyClusters).toHaveLength(0);
expect(result.groupSummary.totalMembers).toBe(0);
});
it("returns empty results for party with 1 member and no data", async () => {
const user = await createUser("Empty Member");
const party = await createParty(user.id);
await joinParty(party.partyId, user.id);
const result = await partyAnalysisWorkflow.analyzeParty(party.partyId);
expect(result.storyClusters).toHaveLength(0);
expect(result.groupSummary.totalMembers).toBe(1);
expect(result.memberProfiles).toHaveLength(0);
});
});
describe("analyzeParty - two similar users", () => {
it("correctly identifies shared tracks, artists, and genres", async () => {
const { partyId, userIdA, userIdB, sharedTrackId, sharedArtistId } =
await seedPartyWithTwoSimilarUsers();
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
expect(result.storyClusters).toHaveLength(1);
const cluster = result.storyClusters[0]!;
expect(cluster.memberCount).toBe(2);
// The shared track should appear in the cluster
const sharedClusterTrack = cluster.tracks.find(
(t) => t.id === sharedTrackId,
);
expect(sharedClusterTrack).toBeDefined();
if (sharedClusterTrack) {
expect(sharedClusterTrack.memberCount).toBe(2);
}
// The shared artist should appear in the cluster
const sharedClusterArtist = cluster.artists.find(
(a) => a.id === sharedArtistId,
);
expect(sharedClusterArtist).toBeDefined();
if (sharedClusterArtist) {
expect(sharedClusterArtist.memberCount).toBe(2);
}
// Should have exactly 1 pairwise comparison
expect(result.pairwise).toHaveLength(1);
const comparison = result.pairwise[0]!;
expect(comparison.userIdA).toBe(userIdA);
expect(comparison.userIdB).toBe(userIdB);
expect(comparison.sharedTracks).toBeGreaterThan(0);
expect(comparison.sharedArtists).toBeGreaterThan(0);
expect(comparison.similarity).toBeGreaterThan(0);
expect(comparison.similarity).toBeLessThan(1);
// Should have member profiles
expect(result.memberProfiles).toHaveLength(2);
const profileA = result.memberProfiles.find((p) => p.userId === userIdA);
const profileB = result.memberProfiles.find((p) => p.userId === userIdB);
expect(profileA).toBeDefined();
expect(profileB).toBeDefined();
if (profileA) {
expect(profileA.trackCount).toBeGreaterThan(0);
}
if (profileB) {
expect(profileB.trackCount).toBeGreaterThan(0);
}
});
it("correctly identifies group summary", async () => {
const { partyId, userIdA } = await seedPartyWithTwoSimilarUsers();
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
expect(result.groupSummary.totalMembers).toBe(2);
expect(result.groupSummary.mostAlignedPair).toBeDefined();
if (result.groupSummary.mostAlignedPair) {
expect(result.groupSummary.mostAlignedPair.userIdA).toBe(userIdA);
}
expect(result.groupSummary.mostSharedGenres).toHaveLength(1);
});
});
describe("analyzeParty - three diverse users", () => {
it("does not find shared tracks across all members", async () => {
const { partyId } = await seedPartyWithThreeDiverseUsers();
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
expect(result.storyClusters).toHaveLength(3);
expect(result.pairwise).toHaveLength(3); // C(3,2) = 3 pairs
// No track should be shared by all 3 members
const allMembersCluster = result.storyClusters.find(
(c) => c.memberCount === 3,
);
expect(allMembersCluster).toBeUndefined();
});
it("identifies pairwise comparisons for all member pairs", async () => {
const { partyId } = await seedPartyWithThreeDiverseUsers();
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
expect(result.pairwise).toHaveLength(3);
result.pairwise.forEach((comparison) => {
expect(comparison.sharedTracks).toBe(0);
expect(comparison.similarity).toBe(0);
});
});
it("correctly identifies genre diversity for each member", async () => {
const { partyId } = await seedPartyWithThreeDiverseUsers();
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
expect(result.memberProfiles).toHaveLength(3);
expect(result.groupSummary.mostDiverseMember).toBeDefined();
if (result.groupSummary.mostDiverseMember) {
expect(
result.groupSummary.mostDiverseMember.genreEntropy,
).toBeGreaterThan(0);
}
});
});
describe("analyzeParty - scoring sources", () => {
it("includes playback history in scoring", async () => {
const { partyId, userIdA, userIdB } =
await seedPartyWithTwoSimilarUsers();
// Add old playback history (more than a week ago)
const oldDate = new Date();
oldDate.setDate(oldDate.getDate() - 8);
const { id: trackC } = await createTrack(
"Old Track C",
(await createAlbum("Old Album C")).id,
);
await addTopTrack(userIdA, trackC, 3);
await addTopTrack(userIdB, trackC, 4);
await addPlaybackHistory(userIdA, trackC, oldDate);
await addPlaybackHistory(userIdB, trackC, oldDate);
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
const comparison = result.pairwise[0]!;
expect(comparison.sharedTracks).toBeGreaterThan(1); // sharedTrack + trackC
// Both members should have track count reflecting playback data
const profileA = result.memberProfiles.find((p) => p.userId === userIdA);
if (profileA) {
expect(profileA.trackCount).toBeGreaterThan(1);
}
});
it("includes saved tracks in scoring", async () => {
const { partyId, userIdA } = await seedPartyWithTwoSimilarUsers();
const extraAlbum = await createAlbum("Extra Saved Album");
const extraArtist = await createArtist("Extra Saved Artist");
const extraTrack = await createTrack("Extra Saved Track", extraAlbum.id, [
extraArtist.id,
]);
await addSavedTrack(userIdA, extraTrack.id);
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
const profileA = result.memberProfiles.find((p) => p.userId === userIdA);
expect(profileA).toBeDefined();
if (profileA) {
expect(profileA.trackCount).toBeGreaterThan(1);
}
});
it("includes followed artists in scoring", async () => {
const { partyId, userIdA } = await seedPartyWithTwoSimilarUsers();
const followedArtist = await createArtist("Followed Artist");
await addTopArtist(userIdA, followedArtist.id, 5);
await addFollowedArtist(userIdA, followedArtist.id);
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
const profileA = result.memberProfiles.find((p) => p.userId === userIdA);
expect(profileA).toBeDefined();
if (profileA) {
expect(profileA.artistCount).toBeGreaterThan(1);
}
});
});
describe("analyzeParty - story clusters", () => {
it("sorts clusters with all-member cluster first", async () => {
const { partyId, sharedTrackId } = await seedPartyWithTwoSimilarUsers();
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
// The cluster with both members should be first
expect(result.storyClusters[0]?.memberCount).toBe(2);
// The shared track should be in the first (all-members) cluster
const firstCluster = result.storyClusters[0]!;
const trackInFirstCluster = firstCluster.tracks.find(
(t) => t.id === sharedTrackId,
);
expect(trackInFirstCluster).toBeDefined();
});
it("sorts tracks within cluster by total score descending", async () => {
const { partyId, userIdA, userIdB, sharedTrackId } =
await seedPartyWithTwoSimilarUsers();
// Add another shared track at a lower position (lower score)
const extraAlbum = await createAlbum("Extra Album A");
const extraArtist = await createArtist("Extra Artist A");
const extraTrack = await createTrack("Extra Track A", extraAlbum.id, [
extraArtist.id,
]);
await addTopTrack(userIdA, extraTrack.id, 50);
await addTopTrack(userIdB, extraTrack.id, 50);
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
const cluster = result.storyClusters[0]!;
expect(cluster.tracks.length).toBeGreaterThan(1);
// Shared track (position 1) should have higher score than extra track (position 50)
const sharedClusterTrack = cluster.tracks.find(
(t) => t.id === sharedTrackId,
);
if (sharedClusterTrack) {
expect(sharedClusterTrack.memberScores.length).toBe(2);
}
});
});
describe("analyzeParty - similarity calculation", () => {
it("calculates Jaccard-like similarity using min/max scoring", async () => {
const { partyId } = await seedPartyWithTwoSimilarUsers();
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
const comparison = result.pairwise[0]!;
expect(comparison.sharedTracks).toBeGreaterThanOrEqual(1);
expect(comparison.sharedArtists).toBeGreaterThanOrEqual(1);
expect(comparison.sharedGenres).toBeGreaterThanOrEqual(1);
expect(comparison.similarity).toBeGreaterThanOrEqual(0);
expect(comparison.similarity).toBeLessThanOrEqual(1);
});
});
describe("analyzeParty - save analysis", () => {
it("saves the analysis result to the party record", async () => {
const { partyId } = await seedPartyWithTwoSimilarUsers();
await partyAnalysisWorkflow.analyzeParty(partyId);
const { db } = await import("../../db");
const savedParty = await db.query.party.findFirst({
where: { id: partyId },
});
expect(savedParty).toBeDefined();
expect(savedParty?.analysisData).toBeDefined();
const analysisData = savedParty?.analysisData as
| Record<string, unknown>
| undefined;
expect(analysisData?.storyClusters).toHaveLength(1);
expect(analysisData?.pairwise).toHaveLength(1);
});
});
});