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

122 lines
2.9 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
MaskRegionSchema,
MaskRegionsSchema,
parseMaskRegions,
maskRegionCount,
maskForSubIndex,
} from '../src/image-occlusion.ts';
describe('MaskRegionSchema', () => {
it('akzeptiert valide Region', () => {
const r = MaskRegionSchema.safeParse({
id: 'm1',
x: 0.1,
y: 0.2,
w: 0.3,
h: 0.1,
});
expect(r.success).toBe(true);
});
it('akzeptiert mit Label', () => {
const r = MaskRegionSchema.safeParse({
id: 'm1',
x: 0,
y: 0,
w: 1,
h: 1,
label: 'Hippocampus',
});
expect(r.success).toBe(true);
});
it('lehnt Coordinaten außerhalb 0..1 ab', () => {
expect(MaskRegionSchema.safeParse({ id: 'm', x: 1.5, y: 0, w: 0.1, h: 0.1 }).success).toBe(
false
);
expect(MaskRegionSchema.safeParse({ id: 'm', x: 0, y: 0, w: -0.1, h: 0.1 }).success).toBe(
false
);
});
it('lehnt extra Felder ab (strict)', () => {
const r = MaskRegionSchema.safeParse({
id: 'm1',
x: 0,
y: 0,
w: 0.1,
h: 0.1,
malicious: 'x',
});
expect(r.success).toBe(false);
});
});
describe('MaskRegionsSchema', () => {
it('verlangt mindestens eine Region', () => {
expect(MaskRegionsSchema.safeParse([]).success).toBe(false);
});
it('akzeptiert mehrere Regionen', () => {
const r = MaskRegionsSchema.safeParse([
{ id: 'm1', x: 0, y: 0, w: 0.1, h: 0.1 },
{ id: 'm2', x: 0.5, y: 0.5, w: 0.1, h: 0.1 },
]);
expect(r.success).toBe(true);
});
it('cap bei 100 Regionen', () => {
const tooMany = Array.from({ length: 101 }, (_, i) => ({
id: `m${i}`,
x: 0,
y: 0,
w: 0.01,
h: 0.01,
}));
expect(MaskRegionsSchema.safeParse(tooMany).success).toBe(false);
});
});
describe('parseMaskRegions', () => {
it('parst und sortiert nach ID', () => {
const json = JSON.stringify([
{ id: 'm3', x: 0, y: 0, w: 0.1, h: 0.1 },
{ id: 'm1', x: 0, y: 0, w: 0.1, h: 0.1 },
{ id: 'm2', x: 0, y: 0, w: 0.1, h: 0.1 },
]);
const out = parseMaskRegions(json);
expect(out.map((r) => r.id)).toEqual(['m1', 'm2', 'm3']);
});
it('liefert leere Liste bei kaputtem JSON', () => {
expect(parseMaskRegions('not json')).toEqual([]);
});
it('liefert leere Liste bei Schema-Mismatch', () => {
expect(parseMaskRegions(JSON.stringify([{ x: 0, y: 0, w: 0.1, h: 0.1 }]))).toEqual([]);
});
});
describe('maskRegionCount + maskForSubIndex', () => {
const json = JSON.stringify([
{ id: 'm2', x: 0.1, y: 0.1, w: 0.2, h: 0.2 },
{ id: 'm1', x: 0.3, y: 0.3, w: 0.2, h: 0.2 },
]);
it('zählt Regionen', () => {
expect(maskRegionCount(json)).toBe(2);
});
it('mapt subIndex auf sortierte Mask', () => {
expect(maskForSubIndex(json, 0)?.id).toBe('m1');
expect(maskForSubIndex(json, 1)?.id).toBe('m2');
expect(maskForSubIndex(json, 2)).toBe(null);
});
it('returnt 0 / null bei kaputtem JSON', () => {
expect(maskRegionCount('garbage')).toBe(0);
expect(maskForSubIndex('garbage', 0)).toBe(null);
});
});