cards/packages/cards-domain/tests/fsrs.test.ts
Till JS 39b1791fb9 Phase 9l: Image-Occlusion als 4. MVP-CardType
Domain: CardTypeSchema öffnet 'image-occlusion'. Neues Modul
@cards/domain/src/image-occlusion.ts mit MaskRegionSchema (zod-strict,
0..1-relative Coords + optionalem Label, max 100 Regionen),
parseMaskRegions (parse + sort-by-id), maskRegionCount, maskForSubIndex.

Field-Schema (cards.fields):
  image_ref:    string  — media_files.id (Phase 9k Storage)
  mask_regions: string  — JSON-Array<MaskRegion>
  note?:        string  — optionale Bildunterschrift

subIndexCount('image-occlusion') wirft analog zu cloze, weil die
Anzahl text-abhängig ist. Card-POST-Handler ruft maskRegionCount
und lehnt 422 ab, wenn das Mask-Array leer / kaputt ist (vor dem
Deck-Lookup).

UI-Komponenten:
  - ImageOcclusionEditor: File-Picker → uploadMedia (Phase 9k),
    SVG-Overlay über das Bild, Drag-to-create-Rectangle (mind. 2%
    Größe, sonst Klick-Filter), Mask-Liste mit Label-Input und
    Delete-Button. Pointer-Events für Touch-Mobile-Support.
  - ImageOcclusionView (Study-Render): Bild + SVG-Overlay; aktive
    Mask ist im Prompt opake schwarz, im Reveal transparent grün
    mit Label-Text; andere Masken bleiben dezent gelb-durchsichtig
    als Lern-Hinweis.

/cards/new + /cards/[id]/edit: Type-Picker um Image-Occlusion
erweitert, Branch-Logik schaltet auf den Editor um. canSave-
Validierung: imageRef gesetzt + mind. 1 Mask. /study/[deckId]
nutzt ImageOcclusionView statt Markdown-Render. /decks/[id]-Liste
zeigt "🖼 image-occlusion · <ref-prefix>" statt "(leer) → (leer)".

i18n DE/EN: type_image_occlusion, toast_image_occlusion,
image_occlusion-Namespace (image_label, draw_hint,
label_placeholder, delete_mask, no_image_selected, etc.).

Tests: 11 neue Domain-Tests für MaskRegion-Schema/Parse/Mapping
(66 Domain ges.), 3 neue API-Tests für 422-Branches und
Validation-vor-Deck-Lookup-Pfad (56 API ges.). 129 Tests grün
ges. (66 + 56 + 7), type-check 384 files 0 errors, prod-Build
sauber.

E2E-Smoke: Image-Occlusion-Card mit 2 Masken (image_ref auf das
Sprint-9k-Test-PNG) → API legt content_hash + 2 Reviews mit
sub_index 0+1 an, reviews/due returnt sie korrekt typisiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:50:45 +02:00

104 lines
3 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
buildScheduler,
fromFsrsCard,
gradeReview,
newReview,
subIndexCount,
toFsrsCard,
} from '../src/fsrs.ts';
describe('newReview', () => {
it('initializes with state=new and reps=0', () => {
const r = newReview({
userId: 'u-1',
cardId: 'c-1',
now: new Date('2026-05-08T10:00:00Z'),
});
expect(r.card_id).toBe('c-1');
expect(r.sub_index).toBe(0);
expect(r.user_id).toBe('u-1');
expect(r.state).toBe('new');
expect(r.reps).toBe(0);
expect(r.lapses).toBe(0);
expect(r.due).toBe('2026-05-08T10:00:00.000Z');
});
it('honours provided sub_index', () => {
const r = newReview({ userId: 'u-1', cardId: 'c-1', subIndex: 2 });
expect(r.sub_index).toBe(2);
});
});
describe('gradeReview', () => {
const fixedNow = new Date('2026-05-08T10:00:00Z');
const reviewedAt = new Date('2026-05-08T10:01:00Z');
const baseReview = newReview({
userId: 'u-1',
cardId: 'c-1',
now: fixedNow,
});
it('Again from new keeps reps=0 increments lapses', () => {
// Disable fuzz for deterministic test outputs
const next = gradeReview(baseReview, 'again', reviewedAt, { enable_fuzz: false });
expect(next.state).not.toBe('new');
expect(next.due).not.toBe(baseReview.due);
expect(next.reps).toBeGreaterThanOrEqual(1);
});
it('Easy from new transitions to a future-dated review', () => {
const next = gradeReview(baseReview, 'easy', reviewedAt, { enable_fuzz: false });
expect(new Date(next.due).getTime()).toBeGreaterThan(reviewedAt.getTime());
});
it('preserves card_id, sub_index, user_id', () => {
const next = gradeReview(baseReview, 'good', reviewedAt, { enable_fuzz: false });
expect(next.card_id).toBe(baseReview.card_id);
expect(next.sub_index).toBe(baseReview.sub_index);
expect(next.user_id).toBe(baseReview.user_id);
});
});
describe('toFsrsCard / fromFsrsCard roundtrip', () => {
it('roundtrips a new review without loss', () => {
const r = newReview({ userId: 'u-1', cardId: 'c-1' });
const fc = toFsrsCard(r);
const back = fromFsrsCard(r, fc);
expect(back.due).toBe(r.due);
expect(back.stability).toBe(r.stability);
expect(back.state).toBe(r.state);
});
});
describe('subIndexCount', () => {
it('basic = 1, basic-reverse = 2', () => {
expect(subIndexCount('basic')).toBe(1);
expect(subIndexCount('basic-reverse')).toBe(2);
});
it('unknown type defaults to 1', () => {
expect(subIndexCount('unknown-future-type')).toBe(1);
});
it('cloze wirft — Caller muss subIndexCountForCloze nutzen', () => {
expect(() => subIndexCount('cloze')).toThrow(/subIndexCountForCloze/);
});
it('image-occlusion wirft — Caller muss maskRegionCount nutzen', () => {
expect(() => subIndexCount('image-occlusion')).toThrow(/maskRegionCount/);
});
});
describe('buildScheduler', () => {
it('builds with defaults', () => {
const s = buildScheduler();
expect(s).toBeDefined();
});
it('honours per-deck overrides', () => {
const s = buildScheduler({ request_retention: 0.85, enable_fuzz: false });
expect(s).toBeDefined();
});
});