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