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>
176 lines
4.5 KiB
TypeScript
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);
|
|
});
|
|
});
|