diff --git a/server/src/modules/leitner_box/__tests__/review_bundle.test.ts b/server/src/modules/leitner_box/__tests__/review_bundle.test.ts new file mode 100644 index 0000000..2b9deae --- /dev/null +++ b/server/src/modules/leitner_box/__tests__/review_bundle.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, jest, beforeEach, afterEach } from "@jest/globals"; +import { LeitnerService } from "../service"; +import { getCollection } from "@modular-rest/server"; + +// Mock modular-rest/server +jest.mock("@modular-rest/server", () => ({ + getCollection: jest.fn(), + Schema: class { }, + defineCollection: jest.fn(), + Permission: class { }, + schemas: { file: {} }, +})); + +// Mock BoardService (referenced by the service module) +jest.mock("../../board/service", () => ({ + BoardService: { + refreshActivity: jest.fn(), + }, +})); + +describe("LeitnerService review bundle — confirmed_chunk + source_sentence", () => { + let mockSystemCollection: any; + let mockPhraseCollection: any; + const userId = "user_123"; + const phraseId = "phrase_1"; + + // A due item pointing at phraseId + const dueItem = { + phraseId, + boxLevel: 3, + nextReviewDate: new Date("2026-01-28T10:00:00Z"), + lastAttemptDate: new Date("2026-01-27T10:00:00Z"), + consecutiveIncorrect: 0, + }; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-01-28T10:00:00Z")); + jest.clearAllMocks(); + + mockSystemCollection = { + findOne: jest.fn(), + create: jest.fn(), + updateOne: jest.fn(), + }; + // Access through the `any`-typed var so the mock isn't concretely typed to `never`. + mockSystemCollection.findOne.mockResolvedValue({ + _id: "sys_1", + userId, + settings: LeitnerService.DEFAULT_SETTINGS, + items: [dueItem], + }); + + mockPhraseCollection = { find: jest.fn() }; + + (getCollection as any).mockImplementation((_db: string, col: string) => { + if (col === "leitner_system") return Promise.resolve(mockSystemCollection); + if (col === "phrase") return Promise.resolve(mockPhraseCollection); + return Promise.resolve({}); + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("picks the highest-confidence chunk's text and mirrors context into source_sentence", async () => { + mockPhraseCollection.find.mockResolvedValue([ + { + _id: phraseId, + type: "linguistic", + phrase: "hit the sack", + context: "I'm exhausted, I think I'll hit the sack now.", + chunks: [ + { text: "I think", type: "discourse_marker", confidence: 0.4 }, + { text: "hit the sack", type: "idiom", confidence: 0.95 }, + ], + }, + ]); + + const items = await LeitnerService.getDueItems(userId); + + expect(items).toHaveLength(1); + expect(items[0].confirmed_chunk).toBe("hit the sack"); + expect(items[0].source_sentence).toBe("I'm exhausted, I think I'll hit the sack now."); + }); + + it("tie-breaks equal confidence by earliest chunk", async () => { + mockPhraseCollection.find.mockResolvedValue([ + { + _id: phraseId, + type: "linguistic", + context: "some sentence", + chunks: [ + { text: "first", type: "other", confidence: 0.8 }, + { text: "second", type: "other", confidence: 0.8 }, + ], + }, + ]); + + const items = await LeitnerService.getDueItems(userId); + + expect(items[0].confirmed_chunk).toBe("first"); + }); + + it("returns null confirmed_chunk when the phrase has no chunks", async () => { + mockPhraseCollection.find.mockResolvedValue([ + { _id: phraseId, type: "normal", phrase: "cat", translation: "gato" }, + ]); + + const items = await LeitnerService.getDueItems(userId); + + expect(items[0].confirmed_chunk).toBeNull(); + // normal phrases carry no context → source_sentence is null + expect(items[0].source_sentence).toBeNull(); + }); + + it("returns null confirmed_chunk for an empty chunks array", async () => { + mockPhraseCollection.find.mockResolvedValue([ + { _id: phraseId, type: "linguistic", context: "ctx", chunks: [] }, + ]); + + const items = await LeitnerService.getDueItems(userId); + + expect(items[0].confirmed_chunk).toBeNull(); + expect(items[0].source_sentence).toBe("ctx"); + }); +}); diff --git a/server/src/modules/leitner_box/db.ts b/server/src/modules/leitner_box/db.ts index 2f7b6d5..f204cb4 100644 --- a/server/src/modules/leitner_box/db.ts +++ b/server/src/modules/leitner_box/db.ts @@ -9,6 +9,21 @@ export interface LeitnerItem { consecutiveIncorrect: number; } +/** + * A due/custom review item as returned by the review RPCs. Extends the stored + * {@link LeitnerItem} with the joined phrase document plus the two flat fields the + * L3+ fill-in card needs: + * - `confirmed_chunk` — text of the phrase's primary chunk (highest `confidence`, + * tie-break earliest), or `null` when the phrase has no chunks (renderer falls + * back to the recognition card). + * - `source_sentence` — the phrase's `context` (kept whole), or `null` when absent. + */ +export interface ReviewItem extends LeitnerItem { + phrase: any; + confirmed_chunk: string | null; + source_sentence: string | null; +} + export interface LeitnerSystem { userId: string; settings: { diff --git a/server/src/modules/leitner_box/service.ts b/server/src/modules/leitner_box/service.ts index f2353b9..444a302 100644 --- a/server/src/modules/leitner_box/service.ts +++ b/server/src/modules/leitner_box/service.ts @@ -1,4 +1,4 @@ -import { LeitnerItem } from "./db"; +import { LeitnerItem, ReviewItem } from "./db"; import { DATABASE, PHRASE_COLLECTION, DATABASE_LEITNER, LEITNER_SYSTEM_COLLECTION, BUNDLE_COLLECTION, PROFILE_COLLECTION } from "../../config"; import { getCollection } from "@modular-rest/server"; import { Document } from "mongoose"; @@ -28,6 +28,19 @@ export class LeitnerService { return (await col.findOne({ userId })) as unknown as LeitnerSystemDoc; } + /** + * Text of the phrase's primary chunk for the L3+ fill-in: the highest-`confidence` + * chunk, tie-broken by earliest (the strict `>` keeps the earlier chunk on ties). + * Returns `null` when the phrase has no chunks so the renderer falls back to the + * recognition card. Read-path only — no schema change. + */ + private static pickConfirmedChunk(phrase: any): string | null { + const chunks = phrase?.chunks; + if (!Array.isArray(chunks) || chunks.length === 0) return null; + const best = chunks.reduce((a: any, b: any) => ((b?.confidence ?? 0) > (a?.confidence ?? 0) ? b : a)); + return best?.text ?? null; + } + public static readonly DEFAULT_SETTINGS = { dailyLimit: 20, totalBoxes: 5, @@ -114,13 +127,15 @@ export class LeitnerService { const phrases = await phraseCollection.find({ _id: { $in: phraseIds } }); // Join - return selectedItems.map((item: LeitnerItem) => { - const phrase = phrases.find((p: any) => p._id.toString() === item.phraseId.toString()); + return selectedItems.map((item: LeitnerItem): ReviewItem => { + const phrase: any = phrases.find((p: any) => p._id.toString() === item.phraseId.toString()); return { ...item, - phrase + phrase, + confirmed_chunk: this.pickConfirmedChunk(phrase), + source_sentence: phrase?.context ?? null, } - }).filter((item: any) => item.phrase); + }).filter((item: ReviewItem) => item.phrase); } static async getCustomReviewItems(userId: string, phraseIds: string[]) { @@ -145,14 +160,16 @@ export class LeitnerService { const phrases = await phraseCollection.find({ _id: { $in: phraseIds } }); return selectedItems - .map((item: LeitnerItem) => { - const phrase = phrases.find((p: any) => p._id.toString() === item.phraseId.toString()); + .map((item: LeitnerItem): ReviewItem => { + const phrase: any = phrases.find((p: any) => p._id.toString() === item.phraseId.toString()); return { ...item, phrase, + confirmed_chunk: this.pickConfirmedChunk(phrase), + source_sentence: phrase?.context ?? null, }; }) - .filter((item: any) => item.phrase); + .filter((item: ReviewItem) => item.phrase); } static async getDueCount(userId: string): Promise {