Phase 8a: Cloze als MVP-Card-Type, Cluster-Counter
CardTypeSchema öffnet 'cloze' als drittes MVP-Set-Mitglied. Domain-Modul
@cards/domain/src/cloze.ts kapselt die Cluster-Logik (extractClusterIds,
subIndexCountForCloze, clusterIdForSubIndex, renderClozePrompt/Answer)
— Hint-Markup wird MVP-stumm gedroppt.
subIndexCount('cloze') wirft jetzt explizit, statt still auf 1 zu fallen,
weil die Cluster-Anzahl text-abhängig ist und ein silent-default falsch
dimensionierte Review-Tabellen produzieren würde. Card-POST-Handler holt
für Cloze die Anzahl aus subIndexCountForCloze und lehnt 422 ab, wenn
kein {{cN::…}}-Markup vorhanden ist.
12 neue Cloze-Tests, alle Domain- und API-Tests grün (41 + 46).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2bed28212d
commit
553a78d73b
9 changed files with 249 additions and 14 deletions
|
|
@ -1,7 +1,13 @@
|
|||
import { and, eq } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { CardCreateSchema, CardUpdateSchema, newReview, subIndexCount } from '@cards/domain';
|
||||
import {
|
||||
CardCreateSchema,
|
||||
CardUpdateSchema,
|
||||
newReview,
|
||||
subIndexCount,
|
||||
subIndexCountForCloze,
|
||||
} from '@cards/domain';
|
||||
|
||||
import { getDb, type CardsDb } from '../db/connection.ts';
|
||||
import { cards, decks, reviews } from '../db/schema/index.ts';
|
||||
|
|
@ -33,6 +39,22 @@ 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.
|
||||
let count: number;
|
||||
if (parsed.data.type === 'cloze') {
|
||||
count = subIndexCountForCloze(parsed.data.fields.text ?? '');
|
||||
if (count === 0) {
|
||||
return c.json(
|
||||
{ error: 'invalid_input', issues: ['cloze.text contains no {{cN::…}} clusters'] },
|
||||
422
|
||||
);
|
||||
}
|
||||
} else {
|
||||
count = subIndexCount(parsed.data.type);
|
||||
}
|
||||
|
||||
const [deck] = await dbOf()
|
||||
.select({ id: decks.id, userId: decks.userId })
|
||||
.from(decks)
|
||||
|
|
@ -43,7 +65,7 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }>
|
|||
|
||||
const cardId = ulid();
|
||||
const now = new Date();
|
||||
const subIndices = Array.from({ length: subIndexCount(parsed.data.type) }, (_, i) => i);
|
||||
const subIndices = Array.from({ length: count }, (_, i) => i);
|
||||
|
||||
const [cardRow] = await dbOf().transaction(async (tx) => {
|
||||
const [card] = await tx
|
||||
|
|
|
|||
|
|
@ -62,13 +62,59 @@ describe('cardsRouter — Input-Validation', () => {
|
|||
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
deck_id: 'd-1',
|
||||
type: 'cloze',
|
||||
fields: { text: 'x' },
|
||||
type: 'image-occlusion',
|
||||
fields: { image_ref: 'x', mask_regions: 'y' },
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
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', {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue