/** biome-ignore-all lint/style/noNonNullAssertion: */ 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 | undefined; expect(analysisData?.storyClusters).toHaveLength(1); expect(analysisData?.pairwise).toHaveLength(1); }); }); });