diff --git a/apps/api/src/routes/cards.ts b/apps/api/src/routes/cards.ts index 5fe9a9c..421e83d 100644 --- a/apps/api/src/routes/cards.ts +++ b/apps/api/src/routes/cards.ts @@ -5,6 +5,7 @@ import { CardCreateSchema, CardUpdateSchema, cardContentHash, + maskRegionCount, newReview, subIndexCount, subIndexCountForCloze, @@ -40,9 +41,9 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }> } const userId = c.get('userId'); - // Cloze: Sub-Index-Anzahl hängt vom Cluster-Markup im Text ab. - // Eine Cloze-Karte ohne `{{cN::…}}` ist sinnlos — vor dem Deck-Lookup - // ablehnen, damit Validation-Errors konsistent 422 statt 404 sind. + // Text-abhängige Sub-Index-Counts (Cloze, Image-Occlusion) vor + // dem Deck-Lookup auflösen — Validation-Errors bleiben 422 statt + // versehentlich auf 404 zu fallen. let count: number; if (parsed.data.type === 'cloze') { count = subIndexCountForCloze(parsed.data.fields.text ?? ''); @@ -52,6 +53,17 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }> 422 ); } + } else if (parsed.data.type === 'image-occlusion') { + count = maskRegionCount(parsed.data.fields.mask_regions ?? ''); + if (count === 0) { + return c.json( + { + error: 'invalid_input', + issues: ['image-occlusion.mask_regions must be a JSON array with >=1 valid region'], + }, + 422 + ); + } } else { count = subIndexCount(parsed.data.type); } diff --git a/apps/api/tests/cards.test.ts b/apps/api/tests/cards.test.ts index df637ef..0905bd1 100644 --- a/apps/api/tests/cards.test.ts +++ b/apps/api/tests/cards.test.ts @@ -68,13 +68,64 @@ describe('cardsRouter — Input-Validation', () => { headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ deck_id: 'd-1', - type: 'image-occlusion', - fields: { image_ref: 'x', mask_regions: 'y' }, + 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', { diff --git a/apps/web/src/lib/components/ImageOcclusionEditor.svelte b/apps/web/src/lib/components/ImageOcclusionEditor.svelte new file mode 100644 index 0000000..a349e6f --- /dev/null +++ b/apps/web/src/lib/components/ImageOcclusionEditor.svelte @@ -0,0 +1,216 @@ + + + +
{t('image_occlusion.uploading')}
+ {/if} + + {#if imageRef} +{t('image_occlusion.draw_hint')}
+ + {#if masks.length > 0} +{t('card_edit.type_locked_help')}