itpdp/api/src/party/__tests__/question-generation.test.ts
2026-05-25 18:57:07 +02:00

503 lines
11 KiB
TypeScript

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<Question["song"]>;
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");
});
});