Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions server/src/modules/leitner_box/__tests__/review_bundle.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
15 changes: 15 additions & 0 deletions server/src/modules/leitner_box/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
33 changes: 25 additions & 8 deletions server/src/modules/leitner_box/service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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[]) {
Expand All @@ -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<number> {
Expand Down
Loading