import { describe, expect, it, vi } from "vitest"; import type { Question, QuizRound } from "../../party-types"; import { buildAudioMetadataQuestion } from "../audio-question-generator"; import { buildNumericQuestion } from "../numeric-question-generator"; import { buildMemberOptions, type PartyAnalytics, type PartyQuestionMember, pickQuestionCandidate, selectQuestionSong, } from "../question-utils"; import { buildSocialQuestion } from "../social-question-generator"; type Db = typeof import("../../db").db; type Song = NonNullable; function makeChoiceQuestion( text: string, key: string, subjectKey: string, ): Question { return { type: "choice", text, correct: 0, startTimestamp: 1, endTimestamp: 2, points: 10, options: ["A", "B"], questionKey: key, subjectKey, }; } function createFakeDb(trackReleaseDate: Date | null) { const trackRecord = { id: "track-1", name: "Shared Track", album: { name: "Shared Album", release_date: trackReleaseDate, }, artists: [{ id: "artist-1", name: "Shared Artist" }], }; return { query: { track: { findFirst: vi.fn(async () => trackRecord), findMany: vi.fn(async () => []), }, artist: { findFirst: vi.fn(async () => ({ id: "artist-1", name: "Shared Artist", })), }, }, select: vi.fn(() => ({ from: () => ({ where: async () => [], }), })), } as unknown as Db; } function createDetailedTrackDb() { const tracks = [ { id: "track-1", albumId: "album-1", platform: "spotify", platform_id: "spotify:track:one", name: "First Track", popularity: 1, duration: 180000, explicit: false, disc_number: 1, track_number: 1, album: { name: "First Album", release_date: new Date("2001-01-01") }, artists: [{ id: "artist-1", name: "Shared Artist" }], }, { id: "track-2", albumId: "album-2", platform: "spotify", platform_id: "spotify:track:two", name: "Second Track", popularity: 1, duration: 240000, explicit: false, disc_number: 1, track_number: 1, album: { name: "Second Album", release_date: new Date("2010-01-01") }, artists: [{ id: "artist-1", name: "Shared Artist" }], }, ]; return { query: { track: { findFirst: vi.fn(async () => tracks[0]), findMany: vi.fn(async ({ where }: { where?: { name?: string } } = {}) => tracks.filter((track) => !where?.name || track.name === where.name), ), }, artist: { findFirst: vi.fn(async () => ({ id: "artist-1", name: "Shared Artist", })), }, }, select: vi.fn(() => ({ from: () => ({ where: async () => [], }), })), } as unknown as Db; } function makeSong(id: string, platformId: string, name: string): Song { return { id, albumId: "album-1", platform: "spotify", platform_id: platformId, name, popularity: 1, duration: 1, explicit: false, disc_number: 1, track_number: 1, }; } function createSongFallbackDb(rows: Song[]) { return { query: { topTrack: { findMany: vi.fn(async () => rows.map((row, index) => ({ position: index + 1, track: row, })), ), }, track: { findMany: vi.fn(async () => []), }, }, } as unknown as Db; } describe("question generation", () => { it("skips repeated question keys, subjects, and text", () => { const history: QuizRound[] = [ { questionIndex: 0, question: { ...makeChoiceQuestion( "Which genre appears most in the party analytics?", "audio:genre:pop", "genre:pop", ), }, responses: [], }, ]; const question = pickQuestionCandidate( [ { key: "audio:genre:pop", subjectKey: "genre:pop", question: makeChoiceQuestion( "Which genre appears most in the party analytics?", "audio:genre:pop", "genre:pop", ), }, { key: "audio:artist:abba", subjectKey: "artist:abba", question: makeChoiceQuestion( "Which artist shows up most often in the shared audio data?", "audio:artist:abba", "artist:abba", ), }, ], history, 0, ); expect(question?.text).toBe( "Which artist shows up most often in the shared audio data?", ); }); it("preserves candidate metadata on the generated question", () => { const question = pickQuestionCandidate( [ { key: "social:leader", subjectKey: "member:a", question: { type: "choice", text: "Who is leading the quiz right now?", correct: 0, startTimestamp: 1, endTimestamp: 2, points: 10, options: ["A", "B"], } as Question, }, ], [], 0, ); expect(question?.questionKey).toBe("social:leader"); expect(question?.subjectKey).toBe("member:a"); }); it("returns null when member options would require fake placeholders", () => { const members: PartyQuestionMember[] = [ { userId: "a", name: "Sam" }, { userId: "b", name: "Sam" }, ]; const correctMember = members[0]; expect(correctMember).toBeDefined(); if (correctMember) { expect(buildMemberOptions(correctMember, members)).toBeNull(); } }); it("skips numeric questions with missing release dates and zero counts", async () => { const db = createFakeDb(null); const analytics = { storyClusters: [ { tracks: [ { name: "Shared Track", artists: [{ name: "Shared Artist" }], albumName: "Shared Album", }, ], artists: [{ name: "Shared Artist" }], genres: [], }, ], groupSummary: { mostSharedGenres: [], }, } as PartyAnalytics; const members: PartyQuestionMember[] = [ { userId: "a", name: "A" }, { userId: "b", name: "B" }, ]; const history: QuizRound[] = [ { questionIndex: 0, question: { type: "numeric", text: "What's the release year of Shared Album?", correct: 2010, startTimestamp: 1, endTimestamp: 2, points: 10, range: { min: 2000, max: 2010 }, questionKey: "numeric:album-year:Shared Album", subjectKey: "album:Shared Album", }, responses: [], }, ]; const question = await buildNumericQuestion({ db, analytics, index: 0, members, history, }); expect(question).toBeNull(); }); it("builds a numeric release-year question for a track", async () => { const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); const analytics = { storyClusters: [ { tracks: [ { name: "First Track", artists: [{ name: "Shared Artist" }], albumName: "First Album", }, { name: "Second Track", artists: [{ name: "Shared Artist" }], albumName: "Second Album", }, ], artists: [{ name: "Shared Artist" }], genres: [], }, ], groupSummary: { mostSharedGenres: [] }, } as PartyAnalytics; try { const question = await buildNumericQuestion({ db: createDetailedTrackDb(), analytics, index: 0, members: [ { userId: "a", name: "A" }, { userId: "b", name: "B" }, ], history: [ { questionIndex: 0, question: { type: "numeric", text: "What's the release year of First Album?", correct: 2001, startTimestamp: 1, endTimestamp: 2, points: 10, range: { min: 1991, max: 2011 }, questionKey: "numeric:album-year:First Album", subjectKey: "album:First Album", }, responses: [], }, ], }); expect(question?.text).toBe('What year did "First Track" come out?'); expect(question?.correct).toBe(2001); expect(question?.questionKey).toBe("numeric:track-year:First Track"); } finally { randomSpy.mockRestore(); } }); it("skips social fallback names for duplicate members", async () => { const db = createFakeDb(null); const members: PartyQuestionMember[] = [ { userId: "a", name: "Sam" }, { userId: "b", name: "Sam" }, ]; const quizState = { status: "running" as const, workflowId: null, questionIndex: 0, currentQuestion: null, answers: {}, scores: { a: 3, b: 1 }, history: [], }; const question = await buildSocialQuestion( db, quizState, { groupSummary: { mostDiverseMember: { userId: "a", genreEntropy: 1 }, mostSharedGenres: [], mostAlignedPair: null, }, }, members, 0, ); expect(question).toBeNull(); }); it("builds audio questions with fewer than four real options", async () => { const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.99); const db = createFakeDb(null); const analytics = { storyClusters: [ { tracks: [ { name: "Shared Track One", artists: [{ name: "Shared Artist One" }], albumName: "Shared Album One", }, { name: "Shared Track Two", artists: [{ name: "Shared Artist Two" }], albumName: "Shared Album Two", }, ], artists: [ { name: "Shared Artist One" }, { name: "Shared Artist Two" }, ], genres: [], }, ], groupSummary: { mostSharedGenres: [], }, } as PartyAnalytics; try { const question = await buildAudioMetadataQuestion(db, analytics, 0, []); expect(question).not.toBeNull(); expect(question?.type).toBe("choice"); if (question?.type === "choice") { expect(question.options).toHaveLength(2); expect(question.text).toContain("Shared Track Two"); } } finally { randomSpy.mockRestore(); } }); it("selects a fresh party song when the current one was already used", async () => { const db = createSongFallbackDb([ makeSong("track-1", "spotify:track:one", "One"), makeSong("track-2", "spotify:track:two", "Two"), ]); const question = { type: "choice" as const, text: "Which genre appears most in the party analytics?", correct: 0, startTimestamp: 1, endTimestamp: 2, points: 10, options: ["A", "B"], questionKey: "audio:genre:pop", subjectKey: "genre:pop", }; const song = await selectQuestionSong({ db, analytics: null, members: [{ userId: "a", name: "A" }], history: [ { questionIndex: 0, question: { ...question, song: makeSong("track-1", "spotify:track:one", "One"), }, responses: [], }, ], question, }); expect(song?.platform_id).toBe("spotify:track:two"); }); it("keeps a song-target question on the same track", async () => { const db = createSongFallbackDb([ makeSong("track-1", "spotify:track:one", "One"), makeSong("track-2", "spotify:track:two", "Two"), ]); const question = { type: "choice" as const, text: "What song is currently playing?", correct: 0, startTimestamp: 1, endTimestamp: 2, points: 10, options: ["A", "B"], questionKey: "audio:current-song:One", subjectKey: "track:One", hideSongTitle: true, song: { ...makeSong("track-1", "spotify:track:one", "One"), }, }; const song = await selectQuestionSong({ db, analytics: null, members: [{ userId: "a", name: "A" }], history: [ { questionIndex: 0, question, responses: [], }, ], question, }); expect(song?.platform_id).toBe("spotify:track:one"); }); });