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>
201 lines
5.9 KiB
TypeScript
201 lines
5.9 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { Hono } from 'hono';
|
|
|
|
import { cardsRouter } from '../src/routes/cards.ts';
|
|
import type { CardsDb } from '../src/db/connection.ts';
|
|
|
|
/**
|
|
* Routen-Tests ohne echte DB. Drizzle-Aufrufe werden durch eine
|
|
* minimale Stub-DB ersetzt, die nur die Validations-Pfade abdeckt.
|
|
*/
|
|
|
|
function buildApp() {
|
|
const app = new Hono();
|
|
const stub = {
|
|
select: () => ({
|
|
from: () => ({
|
|
where: () => ({ limit: () => [] }),
|
|
}),
|
|
}),
|
|
};
|
|
app.route('/api/v1/cards', cardsRouter({ db: stub as unknown as CardsDb }));
|
|
return { app };
|
|
}
|
|
|
|
describe('cardsRouter — auth-gate', () => {
|
|
it('GET ohne X-User-Id ist 401', async () => {
|
|
const { app } = buildApp();
|
|
const res = await app.request('/api/v1/cards');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('GET /hashes ohne X-User-Id ist 401', async () => {
|
|
const { app } = buildApp();
|
|
const res = await app.request('/api/v1/cards/hashes');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('cardsRouter — Input-Validation', () => {
|
|
it('POST mit leerem Body ist 422', async () => {
|
|
const { app } = buildApp();
|
|
const res = await app.request('/api/v1/cards', {
|
|
method: 'POST',
|
|
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
|
body: '{}',
|
|
});
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it('POST mit basic-Card ohne back-Feld ist 422', async () => {
|
|
const { app } = buildApp();
|
|
const res = await app.request('/api/v1/cards', {
|
|
method: 'POST',
|
|
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
deck_id: 'd-1',
|
|
type: 'basic',
|
|
fields: { front: 'Q' },
|
|
}),
|
|
});
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it('POST mit unknown CardType ist 422', async () => {
|
|
const { app } = buildApp();
|
|
const res = await app.request('/api/v1/cards', {
|
|
method: 'POST',
|
|
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
deck_id: 'd-1',
|
|
type: 'audio',
|
|
fields: { audio_ref: 'x' },
|
|
}),
|
|
});
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it('POST mit image-occlusion ohne mask_regions ist 422', async () => {
|
|
const { app } = buildApp();
|
|
const res = await app.request('/api/v1/cards', {
|
|
method: 'POST',
|
|
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
deck_id: 'd-1',
|
|
type: 'image-occlusion',
|
|
fields: { image_ref: 'm1' },
|
|
}),
|
|
});
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it('POST mit image-occlusion mit kaputtem mask_regions ist 422', async () => {
|
|
const { app } = buildApp();
|
|
const res = await app.request('/api/v1/cards', {
|
|
method: 'POST',
|
|
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
deck_id: 'd-1',
|
|
type: 'image-occlusion',
|
|
fields: { image_ref: 'm1', mask_regions: 'not json' },
|
|
}),
|
|
});
|
|
expect(res.status).toBe(422);
|
|
const body = (await res.json()) as { issues: string[] };
|
|
expect(body.issues[0]).toMatch(/mask_regions/);
|
|
});
|
|
|
|
it('POST mit gültiger image-occlusion erreicht Deck-Lookup (404 bei stub)', async () => {
|
|
const { app } = buildApp();
|
|
const res = await app.request('/api/v1/cards', {
|
|
method: 'POST',
|
|
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
deck_id: 'd-1',
|
|
type: 'image-occlusion',
|
|
fields: {
|
|
image_ref: 'm1',
|
|
mask_regions: JSON.stringify([
|
|
{ id: 'r1', x: 0.1, y: 0.1, w: 0.1, h: 0.1 },
|
|
]),
|
|
},
|
|
}),
|
|
});
|
|
expect(res.status).toBe(404);
|
|
const body = (await res.json()) as { error: string };
|
|
expect(body.error).toBe('deck_not_found');
|
|
});
|
|
|
|
it('POST mit cloze-Card ohne text-Feld ist 422', async () => {
|
|
const { app } = buildApp();
|
|
const res = await app.request('/api/v1/cards', {
|
|
method: 'POST',
|
|
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
deck_id: 'd-1',
|
|
type: 'cloze',
|
|
fields: {},
|
|
}),
|
|
});
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it('POST mit cloze-Card aber Text ohne Cluster ist 422', async () => {
|
|
const { app } = buildApp();
|
|
const res = await app.request('/api/v1/cards', {
|
|
method: 'POST',
|
|
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
deck_id: 'd-1',
|
|
type: 'cloze',
|
|
fields: { text: 'plain text without any {{cN::…}} markup' },
|
|
}),
|
|
});
|
|
expect(res.status).toBe(422);
|
|
const body = (await res.json()) as { error: string; issues: string[] };
|
|
expect(body.issues[0]).toMatch(/cloze\.text/);
|
|
});
|
|
|
|
it('POST mit gültiger cloze-Card erreicht Deck-Lookup (404 bei stub)', async () => {
|
|
const { app } = buildApp();
|
|
const res = await app.request('/api/v1/cards', {
|
|
method: 'POST',
|
|
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
deck_id: 'd-1',
|
|
type: 'cloze',
|
|
fields: { text: 'Capital of {{c1::France}} is {{c2::Paris}}.' },
|
|
}),
|
|
});
|
|
expect(res.status).toBe(404);
|
|
const body = (await res.json()) as { error: string };
|
|
expect(body.error).toBe('deck_not_found');
|
|
});
|
|
|
|
it('PATCH mit extra prop ist 422', async () => {
|
|
const { app } = buildApp();
|
|
const res = await app.request('/api/v1/cards/c-1', {
|
|
method: 'PATCH',
|
|
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fields: { front: 'X' }, leak: 'bad' }),
|
|
});
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it('POST mit gültigem basic-Card erreicht Deck-Lookup (404 bei stub)', async () => {
|
|
const { app } = buildApp();
|
|
const res = await app.request('/api/v1/cards', {
|
|
method: 'POST',
|
|
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
deck_id: 'd-1',
|
|
type: 'basic',
|
|
fields: { front: 'Q', back: 'A' },
|
|
}),
|
|
});
|
|
// Stub-DB gibt empty array → Deck-Not-Found-Pfad
|
|
expect(res.status).toBe(404);
|
|
const body = (await res.json()) as { error: string };
|
|
expect(body.error).toBe('deck_not_found');
|
|
});
|
|
});
|