cards/packages/cards-domain/tests/schemas.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

176 lines
4.5 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
CardCreateSchema,
CardSchema,
CardTypeSchema,
DeckCreateSchema,
DeckSchema,
GradeReviewInputSchema,
validateFieldsForType,
} from '../src/schemas/index.ts';
describe('CardTypeSchema', () => {
it('accepts MVP types', () => {
expect(() => CardTypeSchema.parse('basic')).not.toThrow();
expect(() => CardTypeSchema.parse('basic-reverse')).not.toThrow();
expect(() => CardTypeSchema.parse('cloze')).not.toThrow();
expect(() => CardTypeSchema.parse('image-occlusion')).not.toThrow();
});
it('rejects future types not yet in MVP schema', () => {
expect(() => CardTypeSchema.parse('type-in')).toThrow();
expect(() => CardTypeSchema.parse('audio')).toThrow();
expect(() => CardTypeSchema.parse('multiple-choice')).toThrow();
});
});
describe('validateFieldsForType', () => {
it('basic requires front + back', () => {
expect(validateFieldsForType('basic', { front: 'q', back: 'a' })).toEqual({ ok: true });
expect(validateFieldsForType('basic', { front: 'q' })).toEqual({
ok: false,
missing: ['back'],
});
});
it('cloze requires text', () => {
expect(validateFieldsForType('cloze', { text: 'x' })).toEqual({ ok: true });
expect(validateFieldsForType('cloze', {})).toEqual({ ok: false, missing: ['text'] });
});
});
describe('CardCreateSchema', () => {
it('accepts a basic card', () => {
const r = CardCreateSchema.safeParse({
deck_id: 'd-1',
type: 'basic',
fields: { front: 'Q', back: 'A' },
});
expect(r.success).toBe(true);
});
it('rejects basic card without back', () => {
const r = CardCreateSchema.safeParse({
deck_id: 'd-1',
type: 'basic',
fields: { front: 'Q' },
});
expect(r.success).toBe(false);
});
it('accepts a cloze card with text field', () => {
const r = CardCreateSchema.safeParse({
deck_id: 'd-1',
type: 'cloze',
fields: { text: '{{c1::Paris}} ist die Hauptstadt.' },
});
expect(r.success).toBe(true);
});
it('accepts an image-occlusion card with image_ref + mask_regions', () => {
const r = CardCreateSchema.safeParse({
deck_id: 'd-1',
type: 'image-occlusion',
fields: { image_ref: 'media-id', mask_regions: '[]' },
});
expect(r.success).toBe(true);
});
it('rejects unknown type via CardTypeSchema', () => {
const r = CardCreateSchema.safeParse({
deck_id: 'd-1',
type: 'audio',
fields: { audio_ref: 'x' },
});
expect(r.success).toBe(false);
});
it('rejects extra fields (strict)', () => {
const r = CardCreateSchema.safeParse({
deck_id: 'd-1',
type: 'basic',
fields: { front: 'Q', back: 'A' },
malicious: 'inject',
});
expect(r.success).toBe(false);
});
});
describe('DeckCreateSchema', () => {
it('minimal valid deck', () => {
const r = DeckCreateSchema.safeParse({ name: 'My Deck' });
expect(r.success).toBe(true);
});
it('rejects empty name', () => {
const r = DeckCreateSchema.safeParse({ name: '' });
expect(r.success).toBe(false);
});
it('rejects invalid color', () => {
const r = DeckCreateSchema.safeParse({ name: 'X', color: 'red' });
expect(r.success).toBe(false);
});
it('accepts hex color', () => {
const r = DeckCreateSchema.safeParse({ name: 'X', color: '#ff8800' });
expect(r.success).toBe(true);
});
});
describe('GradeReviewInputSchema', () => {
it('accepts a grade input', () => {
const r = GradeReviewInputSchema.safeParse({
card_id: 'c-1',
sub_index: 0,
rating: 'good',
});
expect(r.success).toBe(true);
});
it('rejects unknown rating', () => {
const r = GradeReviewInputSchema.safeParse({
card_id: 'c-1',
sub_index: 0,
rating: 'perfect',
});
expect(r.success).toBe(false);
});
it('defaults sub_index to 0', () => {
const r = GradeReviewInputSchema.parse({ card_id: 'c-1', rating: 'good' });
expect(r.sub_index).toBe(0);
});
});
describe('strict variants reject extras', () => {
it('DeckSchema rejects extra props', () => {
const r = DeckSchema.safeParse({
id: 'd-1',
user_id: 'u-1',
name: 'D',
visibility: 'private',
fsrs_settings: {},
created_at: '2026-05-08T10:00:00.000Z',
updated_at: '2026-05-08T10:00:00.000Z',
leaks: 'no',
});
expect(r.success).toBe(false);
});
it('CardSchema rejects extra props', () => {
const r = CardSchema.safeParse({
id: 'c-1',
deck_id: 'd-1',
user_id: 'u-1',
type: 'basic',
fields: { front: 'Q', back: 'A' },
media_refs: [],
created_at: '2026-05-08T10:00:00.000Z',
updated_at: '2026-05-08T10:00:00.000Z',
leaked: 'yes',
});
expect(r.success).toBe(false);
});
});