diff --git a/api/src/party/__tests__/question-generation.test.ts b/api/src/party/__tests__/question-generation.test.ts index 8ba70d5..3048bce 100644 --- a/api/src/party/__tests__/question-generation.test.ts +++ b/api/src/party/__tests__/question-generation.test.ts @@ -107,6 +107,31 @@ describe("question generation", () => { ); }); + 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" }, diff --git a/api/src/party/__tests__/question-generator.test.ts b/api/src/party/__tests__/question-generator.test.ts new file mode 100644 index 0000000..5aef0f7 --- /dev/null +++ b/api/src/party/__tests__/question-generator.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; +import type { QuizState } from "../../party-types"; + +vi.mock("../audio-question-generator", () => ({ + buildAudioMetadataQuestion: vi.fn(async () => null), +})); + +vi.mock("../social-question-generator", () => ({ + buildSocialQuestion: vi.fn(async () => null), +})); + +vi.mock("../numeric-question-generator", () => ({ + buildNumericQuestion: vi.fn(async () => null), +})); + +import { generatePartyQuestion } from "../question-generator"; + +function createFakeDb() { + return { + query: { + partyMember: { + findMany: vi.fn(async () => [{ userId: "a", user: { name: "A" } }]), + }, + }, + }; +} + +describe("generatePartyQuestion", () => { + it("returns null when all real question sources are exhausted", async () => { + const quizState = { + status: "running", + workflowId: null, + questionIndex: 0, + currentQuestion: null, + answers: {}, + scores: {}, + history: [], + } as QuizState; + + const question = await generatePartyQuestion({ + db: createFakeDb() as never, + partyId: "party-1", + quizState, + analytics: null, + index: 0, + }); + + expect(question).toBeNull(); + }); +}); diff --git a/api/src/party/audio-question-generator.ts b/api/src/party/audio-question-generator.ts index 3425130..f8ae5f3 100644 --- a/api/src/party/audio-question-generator.ts +++ b/api/src/party/audio-question-generator.ts @@ -10,7 +10,6 @@ import { isUsableText, type PartyAnalytics, pickQuestionCandidate, - pickRandom, type QuestionCandidate, resolveQuestionSong, } from "./question-utils"; @@ -115,15 +114,13 @@ export async function buildAudioMetadataQuestion( } } - const randomTopTrack = pickRandom(topTracks); - if (randomTopTrack) { - const randomTrackSong = await resolveQuestionSong(dbClient, analytics, { - trackName: randomTopTrack.name, - artistNames: randomTopTrack.artists?.map((artist) => artist.name), - albumName: randomTopTrack.albumName, + for (const topTrack of topTracks) { + const trackSong = await resolveQuestionSong(dbClient, analytics, { + trackName: topTrack.name, + artistNames: topTrack.artists?.map((artist) => artist.name), + albumName: topTrack.albumName, }); - const trackArtists = - randomTopTrack.artists?.map((artist) => artist.name) ?? []; + const trackArtists = topTrack.artists?.map((artist) => artist.name) ?? []; const allArtists = topArtists.length > 0 ? topArtists : trackArtists; const correctArtist = trackArtists[0] ?? allArtists[0]; if (correctArtist) { @@ -134,41 +131,41 @@ export async function buildAudioMetadataQuestion( ); if (artistOptions) { questions.push({ - key: `audio:performer:${randomTopTrack.name}`, - subjectKey: `track:${randomTopTrack.name}`, + key: `audio:performer:${topTrack.name}`, + subjectKey: `track:${topTrack.name}`, question: { type: "choice", - text: `Who performs "${randomTopTrack.name}"?`, + text: `Who performs "${topTrack.name}"?`, options: artistOptions, correct: 0, points: 10, - song: randomTrackSong ?? topSong ?? undefined, + song: trackSong ?? topSong ?? undefined, }, }); } const trackNames = topTracks.map((t) => t.name); const trackNameOptions = buildOptionsWithCorrect( - randomTopTrack.name, + topTrack.name, trackNames, 4, ); if (trackNameOptions) { questions.push({ - key: `audio:title:${randomTopTrack.name}`, - subjectKey: `track:${randomTopTrack.name}`, + key: `audio:title:${topTrack.name}`, + subjectKey: `track:${topTrack.name}`, question: { type: "choice", text: `What is the name of this track by ${correctArtist}?`, options: trackNameOptions, correct: 0, points: 10, - song: randomTrackSong ?? topSong ?? undefined, + song: trackSong ?? topSong ?? undefined, }, }); } - if (isUsableText(topSongName) && topSongName !== randomTopTrack.name) { + if (isUsableText(topSongName) && topSongName !== topTrack.name) { const alternateSongOptions = buildOptionsWithCorrect( topSongName, trackNames, @@ -192,26 +189,26 @@ export async function buildAudioMetadataQuestion( } } - if (randomTopTrack.albumName) { + if (topTrack.albumName) { const albumNames = topTracks .map((track) => track.albumName) .filter((name): name is string => Boolean(name)); const albumOptions = buildOptionsWithCorrect( - randomTopTrack.albumName, + topTrack.albumName, albumNames, 4, ); if (albumOptions) { questions.push({ - key: `audio:album:${randomTopTrack.albumName}`, - subjectKey: `track:${randomTopTrack.name}`, + key: `audio:album:${topTrack.albumName}`, + subjectKey: `track:${topTrack.name}`, question: { type: "choice", - text: `"${randomTopTrack.name}" appears on which album?`, + text: `"${topTrack.name}" appears on which album?`, options: albumOptions, correct: 0, points: 10, - song: randomTrackSong ?? topSong ?? undefined, + song: trackSong ?? topSong ?? undefined, }, }); } diff --git a/api/src/party/question-generator.ts b/api/src/party/question-generator.ts index 0a65f27..1a44d42 100644 --- a/api/src/party/question-generator.ts +++ b/api/src/party/question-generator.ts @@ -3,7 +3,7 @@ import type { Question, QuizState } from "../party-types"; import { buildAudioMetadataQuestion } from "./audio-question-generator"; import { buildNumericQuestion } from "./numeric-question-generator"; import type { PartyAnalytics } from "./question-utils"; -import { buildQuestionWindow, fetchPartyMembers } from "./question-utils"; +import { fetchPartyMembers } from "./question-utils"; import { buildSocialQuestion } from "./social-question-generator"; export type PartyQuestionType = "audio-metadata" | "social" | "numeric"; @@ -69,13 +69,5 @@ export async function generatePartyQuestion({ if (q) return q; } - return buildQuestionWindow({ - type: "numeric" as const, - text: "How many players are in this party?", - correct: members.length, - range: { min: 0, max: members.length }, - points: 5, - subjectKey: "party-size", - questionKey: `fallback:party-size:${members.length}`, - }); + return null; } diff --git a/api/src/party/question-utils.ts b/api/src/party/question-utils.ts index b65873e..af51e04 100644 --- a/api/src/party/question-utils.ts +++ b/api/src/party/question-utils.ts @@ -165,7 +165,14 @@ export function pickQuestionCandidate( if (fresh.length === 0) return null; const pool = fresh; - return pool[index % pool.length]?.question ?? null; + const candidate = pool[index % pool.length]; + if (!candidate) return null; + return { + ...candidate.question, + questionKey: candidate.question.questionKey ?? candidate.key, + subjectKey: + candidate.question.subjectKey ?? candidate.subjectKey ?? undefined, + } as T; } function normalizeQuestionKey(value: string): string { diff --git a/api/src/party/social-question-generator.ts b/api/src/party/social-question-generator.ts index b2ebafa..e6da155 100644 --- a/api/src/party/social-question-generator.ts +++ b/api/src/party/social-question-generator.ts @@ -12,7 +12,6 @@ import { type PartyAnalytics, type PartyQuestionMember, pickQuestionCandidate, - pickRandom, type QuestionCandidate, resolveQuestionSong, } from "./question-utils"; @@ -72,27 +71,28 @@ export async function buildSocialQuestion( } const topTracks = getTopClusterTracks(analytics); - const randomTrack = pickRandom(topTracks); - if (randomTrack && hasMultipleMembers) { - const topListener = getTopTrackListener(randomTrack, members); - if (topListener) { - const randomTrackSong = await resolveQuestionSong(dbClient, analytics, { - trackName: randomTrack.name, - artistNames: randomTrack.artists?.map((artist) => artist.name), - albumName: randomTrack.albumName, + if (hasMultipleMembers) { + for (const topTrack of topTracks) { + const topListener = getTopTrackListener(topTrack, members); + if (!topListener) continue; + + const trackSong = await resolveQuestionSong(dbClient, analytics, { + trackName: topTrack.name, + artistNames: topTrack.artists?.map((artist) => artist.name), + albumName: topTrack.albumName, }); const options = buildMemberOptions(topListener, members); if (options) { questions.push({ - key: `social:track-listener:${randomTrack.name}`, - subjectKey: `track:${randomTrack.name}`, + key: `social:track-listener:${topTrack.name}`, + subjectKey: `track:${topTrack.name}`, question: { type: "choice", - text: `Who listens the most to "${randomTrack.name}"?`, + text: `Who listens the most to "${topTrack.name}"?`, options, correct: 0, points: 10, - song: randomTrackSong ?? topSong ?? undefined, + song: trackSong ?? topSong ?? undefined, }, }); } diff --git a/api/src/workflows/quiz.ts b/api/src/workflows/quiz.ts index 32285bb..e440d97 100644 --- a/api/src/workflows/quiz.ts +++ b/api/src/workflows/quiz.ts @@ -61,6 +61,9 @@ export class QuizWorkflow extends ConfiguredInstance { quizState, i, ); + if (!question) { + break; + } quizState.currentQuestion = question; quizState.answers = {}; const round: QuizRound = { @@ -148,7 +151,7 @@ export class QuizWorkflow extends ConfiguredInstance { partyId: string, quizState: QuizState, index: number, - ): Promise { + ): Promise { const partyRecord = await db.query.party.findFirst({ where: { id: partyId, @@ -162,9 +165,6 @@ export class QuizWorkflow extends ConfiguredInstance { analytics, index, }); - if (!question) { - throw new Error("Failed to generate quiz question"); - } return question; }