322 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|