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>
This commit is contained in:
parent
c9eb0a6f80
commit
39b1791fb9
17 changed files with 682 additions and 34 deletions
|
|
@ -144,7 +144,9 @@ export function subIndexCount(type: string): number {
|
|||
case 'type-in':
|
||||
return 1;
|
||||
case 'image-occlusion':
|
||||
return 1; // pro Mask-Region in Phase 8+ angepasst
|
||||
throw new Error(
|
||||
'subIndexCount("image-occlusion") not supported — use maskRegionCount(fields.mask_regions) from @cards/domain'
|
||||
);
|
||||
case 'audio':
|
||||
return 1;
|
||||
case 'multiple-choice':
|
||||
|
|
|
|||
61
packages/cards-domain/src/image-occlusion.ts
Normal file
61
packages/cards-domain/src/image-occlusion.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* Image-Occlusion: Bild + N rechteckige Masken. Jede Maske wird zu
|
||||
* einem eigenen Review (sub_index = sortierter Index der Mask-ID),
|
||||
* im Prompt wird die aktive Maske als opakes Rechteck überlagert,
|
||||
* andere Masken bleiben transparent.
|
||||
*
|
||||
* Field-Schema (in cards.fields):
|
||||
* image_ref: string — media_files.id (Phase 9k Storage)
|
||||
* mask_regions: string — JSON-Array von MaskRegion (siehe unten)
|
||||
* note?: string — optionale Bildunterschrift / Lerntipp
|
||||
*
|
||||
* Coordinaten: 0..1 relativ zur Bildgröße, damit das Rendering
|
||||
* unabhängig vom Display-Skalierungsfaktor funktioniert.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const MaskRegionSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
x: z.number().min(0).max(1),
|
||||
y: z.number().min(0).max(1),
|
||||
w: z.number().min(0).max(1),
|
||||
h: z.number().min(0).max(1),
|
||||
label: z.string().max(200).optional(),
|
||||
})
|
||||
.strict();
|
||||
export type MaskRegion = z.infer<typeof MaskRegionSchema>;
|
||||
|
||||
export const MaskRegionsSchema = z.array(MaskRegionSchema).min(1).max(100);
|
||||
|
||||
/**
|
||||
* Liest das `mask_regions`-Feld (JSON-String) und liefert die Liste,
|
||||
* sortiert nach Mask-ID. Bei Parse- oder Schema-Fehler: leere Liste.
|
||||
* Caller muss den 0-Fall als Validation-Error behandeln (analog
|
||||
* Cloze ohne Cluster).
|
||||
*/
|
||||
export function parseMaskRegions(maskRegionsJson: string): MaskRegion[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(maskRegionsJson);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const result = MaskRegionsSchema.safeParse(parsed);
|
||||
if (!result.success) return [];
|
||||
return [...result.data].sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/** Anzahl Mask-Regionen → Anzahl Reviews. */
|
||||
export function maskRegionCount(maskRegionsJson: string): number {
|
||||
return parseMaskRegions(maskRegionsJson).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping Sub-Index → Mask-Region. Sub-Index 0 = lexikographisch
|
||||
* niedrigste Mask-ID, etc.
|
||||
*/
|
||||
export function maskForSubIndex(maskRegionsJson: string, subIndex: number): MaskRegion | null {
|
||||
return parseMaskRegions(maskRegionsJson)[subIndex] ?? null;
|
||||
}
|
||||
|
|
@ -12,3 +12,4 @@ export * from './fsrs.ts';
|
|||
export * from './protocol/index.ts';
|
||||
export * from './cloze.ts';
|
||||
export * from './content-hash.ts';
|
||||
export * from './image-occlusion.ts';
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* MVP-CardType-Set. Cloze in Phase 8 (Anki-Import) ergänzt; weitere
|
||||
* Erweiterung (type-in, image-occlusion, audio, multiple-choice)
|
||||
* vorbereitet im CardTypeFutureSchema.
|
||||
* MVP-CardType-Set. Cloze in Phase 8 (Anki-Import) ergänzt,
|
||||
* image-occlusion in Phase 9l. Weitere Erweiterung (type-in, audio,
|
||||
* multiple-choice) bleibt im CardTypeFutureSchema vorbereitet.
|
||||
*/
|
||||
export const CardTypeSchema = z.enum(['basic', 'basic-reverse', 'cloze']);
|
||||
export const CardTypeSchema = z.enum([
|
||||
'basic',
|
||||
'basic-reverse',
|
||||
'cloze',
|
||||
'image-occlusion',
|
||||
]);
|
||||
export type CardType = z.infer<typeof CardTypeSchema>;
|
||||
|
||||
/** Future-Set für Schema-Migration-Vorbereitung. */
|
||||
|
|
|
|||
|
|
@ -85,6 +85,10 @@ describe('subIndexCount', () => {
|
|||
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', () => {
|
||||
|
|
|
|||
122
packages/cards-domain/tests/image-occlusion.test.ts
Normal file
122
packages/cards-domain/tests/image-occlusion.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -15,10 +15,10 @@ describe('CardTypeSchema', () => {
|
|||
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('image-occlusion')).toThrow();
|
||||
expect(() => CardTypeSchema.parse('type-in')).toThrow();
|
||||
expect(() => CardTypeSchema.parse('audio')).toThrow();
|
||||
expect(() => CardTypeSchema.parse('multiple-choice')).toThrow();
|
||||
|
|
@ -68,11 +68,20 @@ describe('CardCreateSchema', () => {
|
|||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unknown type via CardTypeSchema', () => {
|
||||
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: 'x', mask_regions: 'y' },
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue