diff --git a/.tasks/phase-2-content-routes/PLAN.md b/.tasks/phase-2-content-routes/PLAN.md index e5854d9..5e2691c 100644 --- a/.tasks/phase-2-content-routes/PLAN.md +++ b/.tasks/phase-2-content-routes/PLAN.md @@ -35,4 +35,3 @@ - StyleX 0.19 `unstable_createThemeNested`에 비어 있지 않은 오버라이드를 넣으면 babel-plugin이 크래시한다 (`undefined.slice`). colorway 시각 차등은 `ColorwayShell`/`ArticleLayout` variant + 팔레트 토큰으로 낸다. `themes.ts`의 createTheme 껍데기는 `{}`로 둔다. - `llm-wiki-template` 이식은 6편 게이트와 분리. wiki 21개 확인했으나 공개안전 큐레이션은 후속. - diff --git a/.tasks/phase-3-wiki-quiz/LOG.md b/.tasks/phase-3-wiki-quiz/LOG.md new file mode 100644 index 0000000..eed429d --- /dev/null +++ b/.tasks/phase-3-wiki-quiz/LOG.md @@ -0,0 +1,43 @@ +# Phase 3 LOG + +- Harness: cursor +- Started: 2026-09-10 +- Status: PR-READY #60 + +## Notes + +- 스택: `feat/phase-2-content` ← `feat/phase-3-quiz`. 머지는 사람. #56 → #58 → #59 먼저. +- Quiz island는 Home three와 별개. `client:visible` Preact. JS 꺼져도 noscript 목록. + +## 2026-09-10 implement + +- 스키마 `src/content/quiz.ts` + 덱 4×5=20 (`ts-basics`, `tdd`, `refactoring`, `design-system`). til 3편 `quizRefs`. +- 엔진 `shuffle` / `grade`(NFKC+trim+lower+공백·마침표 제거) / `session` / `progress` — vitest 21 passed. +- UI `QuizDeck` Preact + `/wiki/quiz` (덱 picker, noscript 정적 목록, 키보드 1–4·Enter·←→, aria-live, reduced-motion 페이드). +- **Quiz island gzip** (`dist/_astro/` quiz 페이지 로드 청크 합): **13,608 bytes** (QuizDeck 3,702 + Preact shared). 목표 30KB 이내. +- PR: #60 draft. 게이트: check·oxlint·vitest·lint:stylex·build·build-storybook green. oxfmt는 base부터 `.tasks/**/PLAN.md` 2건 drift(phase-3 src 무관). + +## 2026-09-10 review fix @ `61bfae5` + +- **Island always ship:** `quiz.astro`가 SSG `searchParams`로 island 분기하던 문제 수정 → `` 항상 + 전 덱 props. 덱 선택은 island 내부(`history.replaceState`). +- **dist 증명:** `rg dist/wiki/quiz/index.html` → `astro-island` + `component-url="/_astro/QuizDeck.*.js"`. +- **Scoring:** `snapshots[idx]` per card — 재방문 시 picked/revealed freeze, `recordDeckAnswer`/`score` 중복 없음. +- **oxfmt:** `bunx oxfmt .` — PLAN 2건 포함 green. +- **번들 회귀:** 전 덱 props 인라인으로 QuizDeck chunk gzip **31,247 bytes** (목표 30KB 초과 → lazy deck import 후속). + +## 2026-09-10 lazy deck @ `8b194ac` + +- `loadDeckItems()` — `import()` per deck module; island props = `deckIds` + `deckLabels` + `deckCounts` only. +- **QuizDeck entry gzip:** **4,511 bytes** (`dist/_astro/QuizDeck.*.js`). 덱 청크는 별도 lazy (예: ts-basics ~1,167 gzip). +- vitest `loadDeckItems` 2건 추가 (총 23 passed). dist HTML: `astro-island` + `component-url` 유지. + +## 2026-09-10 finished view @ `e20f48a` + +- `quizView()` — `finished` → `loading` → `empty` → `card` 순서. `idx === total`일 때 완료 UI·다시 풀기 표시. +- vitest `quizView` 5건 (총 28 passed). QuizDeck entry gzip **4,610 bytes**. dist: `astro-island` 유지. + +## 2026-09-10 review + +- Sol `gpt-5.6-sol-medium`: island 누락 → scoring freeze → finished 화면 순서. 최종 `REVIEW APPROVE phase-3 60`. usage: not visible. +- Leader 재실행 HEAD `ea043cf`: check 127/0, oxfmt, oxlint, vitest 28, lint:stylex, build 34 pages, quiz HTML `astro-island`+QuizDeck chunk. +- `gh pr ready 60`. 머지는 사람. 스택: #56 → #58 → #59 → #60. diff --git a/.tasks/phase-3-wiki-quiz/PLAN.md b/.tasks/phase-3-wiki-quiz/PLAN.md index ba47f83..f59d5d2 100644 --- a/.tasks/phase-3-wiki-quiz/PLAN.md +++ b/.tasks/phase-3-wiki-quiz/PLAN.md @@ -21,5 +21,5 @@ ## 승인 요청 -- [ ] island = Preact 동의? (React 유지 희망 시 코멘트) -- [ ] 주관식 정규화 수준 동의? +- [x] island = Preact 동의? — 2026-09-10 스택 진행. Phase 1.5에서 `@astrojs/preact` 이미 설치. +- [x] 주관식 정규화 수준 동의? — PLAN 기본안 (NFKC + trim + lower + 공백/마침표 제거). diff --git a/src/components/quiz/QuizDeck.stories.tsx b/src/components/quiz/QuizDeck.stories.tsx new file mode 100644 index 0000000..5c304da --- /dev/null +++ b/src/components/quiz/QuizDeck.stories.tsx @@ -0,0 +1,33 @@ +import { deckLabels, getDeckItemCounts, quizDeckIds } from "../../lib/quiz/decks.ts"; + +import { QuizDeck } from "./QuizDeck.tsx"; + +const deckCounts = getDeckItemCounts(); + +export default { + title: "quiz/QuizDeck", + component: QuizDeck, + parameters: { + docs: { + description: { + component: + "Wiki flash card island. 덱 데이터는 loadDeckItems로 동적 import. 키보드 1–4·Enter·화살표, aria-live.", + }, + }, + }, +}; + +export const DeckPicker = { + render: () => , +}; + +export const ShortSession = { + render: () => ( + + ), +}; diff --git a/src/components/quiz/QuizDeck.stylex.ts b/src/components/quiz/QuizDeck.stylex.ts new file mode 100644 index 0000000..c2a4628 --- /dev/null +++ b/src/components/quiz/QuizDeck.stylex.ts @@ -0,0 +1,250 @@ +import * as stylex from "@stylexjs/stylex"; + +import { tokens } from "../../styles/tokens.stylex.ts"; + +export const quizDeckStyles = stylex.create({ + root: { + display: "flex", + flexDirection: "column", + gap: tokens.space[6], + maxWidth: "42rem", + }, + header: { + display: "flex", + flexWrap: "wrap", + alignItems: "center", + justifyContent: "space-between", + gap: tokens.space[3], + }, + stats: { + display: "flex", + flexWrap: "wrap", + gap: tokens.space[4], + fontSize: tokens.fontSize.sm, + color: tokens.color.inkSecondary, + fontFamily: tokens.fontFamily.sans, + }, + progressTrack: { + width: "100%", + height: "6px", + borderRadius: tokens.radius.pill, + backgroundColor: tokens.color.surfaceMuted, + overflow: "hidden", + }, + progressFill: { + height: "100%", + backgroundColor: tokens.color.accent, + transitionProperty: "width", + transitionDuration: tokens.duration.normal, + }, + cardShell: { + perspective: "1200px", + minHeight: "18rem", + }, + cardShellReduced: { + perspective: "none", + minHeight: "18rem", + }, + card: { + position: "relative", + width: "100%", + minHeight: "18rem", + transformStyle: "preserve-3d", + transitionProperty: "transform", + transitionDuration: tokens.duration.slow, + transitionTimingFunction: "ease", + }, + cardFlipped: { + transform: "rotateY(180deg)", + }, + cardFade: { + position: "relative", + width: "100%", + minHeight: "18rem", + transitionProperty: "opacity", + transitionDuration: tokens.duration.normal, + }, + cardFace: { + position: "absolute", + inset: 0, + display: "flex", + flexDirection: "column", + gap: tokens.space[4], + padding: tokens.space[6], + borderRadius: tokens.radius.md, + borderWidth: "1px", + borderStyle: "solid", + borderColor: tokens.color.border, + backgroundColor: tokens.color.surface, + backfaceVisibility: "hidden", + }, + cardBack: { + transform: "rotateY(180deg)", + }, + cardFaceStatic: { + display: "flex", + flexDirection: "column", + gap: tokens.space[4], + padding: tokens.space[6], + borderRadius: tokens.radius.md, + borderWidth: "1px", + borderStyle: "solid", + borderColor: tokens.color.border, + backgroundColor: tokens.color.surface, + }, + cardFaceHidden: { + opacity: 0, + pointerEvents: "none", + position: "absolute", + inset: 0, + }, + prompt: { + fontSize: tokens.fontSize.lg, + fontWeight: 600, + lineHeight: tokens.lineHeight.tight, + color: tokens.color.ink, + fontFamily: tokens.fontFamily.sans, + margin: 0, + }, + choices: { + display: "flex", + flexDirection: "column", + gap: tokens.space[2], + margin: 0, + padding: 0, + listStyle: "none", + }, + choiceButton: { + display: "flex", + alignItems: "flex-start", + gap: tokens.space[3], + width: "100%", + padding: tokens.space[3], + borderRadius: tokens.radius.sm, + borderWidth: "1px", + borderStyle: "solid", + borderColor: tokens.color.border, + backgroundColor: tokens.color.canvas, + color: tokens.color.ink, + fontFamily: tokens.fontFamily.sans, + fontSize: tokens.fontSize.sm, + textAlign: "left", + cursor: "pointer", + transitionProperty: "border-color, background-color", + transitionDuration: tokens.duration.fast, + }, + choiceSelected: { + borderColor: tokens.color.accent, + backgroundColor: tokens.color.accentSoft, + }, + choiceCorrect: { + borderColor: tokens.color.accentInk, + backgroundColor: tokens.color.accentSoft, + }, + choiceWrong: { + borderColor: "#c62828", + backgroundColor: "#ffebee", + }, + shortInput: { + width: "100%", + padding: tokens.space[3], + borderRadius: tokens.radius.sm, + borderWidth: "1px", + borderStyle: "solid", + borderColor: tokens.color.border, + fontFamily: tokens.fontFamily.sans, + fontSize: tokens.fontSize.md, + color: tokens.color.ink, + backgroundColor: tokens.color.canvas, + }, + actions: { + display: "flex", + flexWrap: "wrap", + gap: tokens.space[3], + marginTop: "auto", + }, + primaryButton: { + paddingBlock: tokens.space[2], + paddingInline: tokens.space[4], + borderRadius: tokens.radius.sm, + borderWidth: "1px", + borderStyle: "solid", + borderColor: tokens.color.accentInk, + backgroundColor: tokens.color.accentInk, + color: tokens.color.canvas, + fontFamily: tokens.fontFamily.sans, + fontSize: tokens.fontSize.sm, + fontWeight: 500, + cursor: "pointer", + }, + secondaryButton: { + paddingBlock: tokens.space[2], + paddingInline: tokens.space[4], + borderRadius: tokens.radius.sm, + borderWidth: "1px", + borderStyle: "solid", + borderColor: tokens.color.border, + backgroundColor: "transparent", + color: tokens.color.ink, + fontFamily: tokens.fontFamily.sans, + fontSize: tokens.fontSize.sm, + cursor: "pointer", + }, + feedback: { + fontSize: tokens.fontSize.sm, + fontFamily: tokens.fontFamily.sans, + color: tokens.color.inkSecondary, + margin: 0, + }, + explanation: { + fontSize: tokens.fontSize.sm, + lineHeight: tokens.lineHeight.body, + color: tokens.color.ink, + fontFamily: tokens.fontFamily.sans, + margin: 0, + }, + sourceLink: { + fontSize: tokens.fontSize.sm, + color: tokens.color.accentInk, + fontFamily: tokens.fontFamily.sans, + }, + hint: { + fontSize: tokens.fontSize.sm, + color: tokens.color.inkTertiary, + fontFamily: tokens.fontFamily.mono, + }, + deckNav: { + display: "flex", + flexWrap: "wrap", + gap: tokens.space[2], + margin: 0, + padding: 0, + listStyle: "none", + }, + deckButton: { + paddingBlock: tokens.space[2], + paddingInline: tokens.space[3], + borderRadius: tokens.radius.sm, + borderWidth: "1px", + borderStyle: "solid", + borderColor: tokens.color.border, + backgroundColor: tokens.color.canvas, + color: tokens.color.ink, + fontFamily: tokens.fontFamily.sans, + fontSize: tokens.fontSize.sm, + cursor: "pointer", + textDecoration: "none", + }, + deckButtonActive: { + borderColor: tokens.color.accentInk, + backgroundColor: tokens.color.accentSoft, + color: tokens.color.accentInk, + fontWeight: 600, + }, + deckPrompt: { + fontSize: tokens.fontSize.md, + color: tokens.color.inkSecondary, + fontFamily: tokens.fontFamily.sans, + margin: 0, + }, +}); diff --git a/src/components/quiz/QuizDeck.tsx b/src/components/quiz/QuizDeck.tsx new file mode 100644 index 0000000..f3516fc --- /dev/null +++ b/src/components/quiz/QuizDeck.tsx @@ -0,0 +1,492 @@ +import * as stylex from "@stylexjs/stylex"; +import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; + +import type { QuizDeckId } from "../../content/quiz.ts"; +import { isQuizDeckId } from "../../lib/quiz/deck-meta.ts"; +import { gradeMCQ, gradeShort } from "../../lib/quiz/grade.ts"; +import { loadDeckItems } from "../../lib/quiz/load-deck.ts"; +import { readQuizProgress, recordDeckAnswer } from "../../lib/quiz/progress.ts"; +import { quizView } from "../../lib/quiz/quiz-view.ts"; +import { buildSession, isSessionMcq, type SessionItem } from "../../lib/quiz/session.ts"; + +import { quizDeckStyles } from "./QuizDeck.stylex.ts"; + +const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; + +type CardSnapshot = { + flipped: boolean; + picked?: number; + shortValue: string; + revealed: boolean; + correct?: boolean; +}; + +type QuizDeckProps = { + deckIds: readonly QuizDeckId[]; + deckLabels: Record; + deckCounts: Record; + sessionLimit?: number; +}; + +const emptySnapshot = (): CardSnapshot => ({ + flipped: false, + shortValue: "", + revealed: false, +}); + +const readDeckFromUrl = (): QuizDeckId | undefined => { + if (typeof window === "undefined") return undefined; + const param = new URLSearchParams(window.location.search).get("deck") ?? ""; + return isQuizDeckId(param) ? param : undefined; +}; + +const useReducedMotion = () => { + const [reduced, setReduced] = useState(false); + + useEffect(() => { + const media = window.matchMedia(REDUCED_MOTION_QUERY); + const sync = () => setReduced(media.matches); + sync(); + media.addEventListener("change", sync); + return () => media.removeEventListener("change", sync); + }, []); + + return reduced; +}; + +export function QuizDeck({ deckIds, deckLabels, deckCounts, sessionLimit = 10 }: QuizDeckProps) { + const reducedMotion = useReducedMotion(); + const inputRef = useRef(null); + const initialDeckId = readDeckFromUrl(); + const [selectedDeckId, setSelectedDeckId] = useState(initialDeckId); + const [loading, setLoading] = useState(!!initialDeckId); + const [order, setOrder] = useState([]); + const [idx, setIdx] = useState(0); + const [snapshots, setSnapshots] = useState>({}); + const [liveMessage, setLiveMessage] = useState(""); + const [storedProgress, setStoredProgress] = useState( + () => readQuizProgress()[selectedDeckId ?? "ts-basics"], + ); + + const deckLabel = selectedDeckId ? deckLabels[selectedDeckId] : ""; + const snap = snapshots[idx] ?? emptySnapshot(); + const { flipped, picked, shortValue, revealed } = snap; + + const current = order[idx]; + const total = order.length; + const view = quizView({ + idx, + total, + loading, + hasCurrent: current !== undefined, + }); + const finished = view === "finished"; + const score = useMemo( + () => Object.values(snapshots).filter((entry) => entry.revealed && entry.correct).length, + [snapshots], + ); + + const patchSnap = useCallback( + (patch: Partial) => { + setSnapshots((prev) => ({ + ...prev, + [idx]: { ...emptySnapshot(), ...prev[idx], ...patch }, + })); + }, + [idx], + ); + + const loadAndStartDeck = useCallback( + async (deckId: QuizDeckId) => { + setLoading(true); + const items = await loadDeckItems(deckId); + const nextLimit = Math.min(sessionLimit, items.length); + setOrder(buildSession({ items, limit: nextLimit })); + setIdx(0); + setSnapshots({}); + setStoredProgress(readQuizProgress()[deckId]); + setLiveMessage(`${deckLabels[deckId]} 덱을 시작합니다.`); + setLoading(false); + }, + [deckLabels, sessionLimit], + ); + + useEffect(() => { + if (!initialDeckId) return; + void loadAndStartDeck(initialDeckId); + }, [initialDeckId, loadAndStartDeck]); + + const selectDeck = useCallback( + (deckId: QuizDeckId) => { + const url = new URL(window.location.href); + url.searchParams.set("deck", deckId); + history.replaceState({}, "", url); + setSelectedDeckId(deckId); + void loadAndStartDeck(deckId); + }, + [loadAndStartDeck], + ); + + const restartSession = useCallback(() => { + if (!selectedDeckId) return; + void loadAndStartDeck(selectedDeckId); + }, [loadAndStartDeck, selectedDeckId]); + + const goNext = useCallback(() => { + if (idx >= total - 1) { + setIdx(total); + setLiveMessage(`세션 완료. ${score} / ${total} 정답.`); + return; + } + setIdx((value) => value + 1); + setLiveMessage(`다음 문제 ${idx + 2} / ${total}`); + }, [idx, score, total]); + + const goPrev = useCallback(() => { + if (idx <= 0) return; + setIdx((value) => value - 1); + setLiveMessage(`이전 문제 ${idx} / ${total}`); + }, [idx, total]); + + const evaluate = useCallback(() => { + if (!current || !selectedDeckId || snapshots[idx]?.revealed) return; + + if (current.type === "mcq" && picked === undefined) { + setLiveMessage("보기를 선택한 뒤 제출하세요."); + return; + } + + if (current.type === "short" && shortValue.trim() === "") { + setLiveMessage("답을 입력한 뒤 제출하세요."); + return; + } + + const correct = + current.type === "mcq" && isSessionMcq(current) + ? gradeMCQ({ + pickedIndex: picked ?? -1, + answerIndex: current.shuffledAnswerIndex, + }) + : current.type === "short" + ? gradeShort({ given: shortValue, answer: current.answer }) + : false; + + setSnapshots((prev) => ({ + ...prev, + [idx]: { + flipped: true, + picked, + shortValue, + revealed: true, + correct, + }, + })); + + const progress = recordDeckAnswer({ deckId: selectedDeckId, correct }); + setStoredProgress(progress); + setLiveMessage(correct ? "정답입니다." : "오답입니다."); + }, [current, idx, picked, selectedDeckId, shortValue, snapshots]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (!selectedDeckId || finished) return; + const target = event.target as HTMLElement | null; + const typing = target?.tagName === "INPUT" || target?.tagName === "TEXTAREA"; + + if (!flipped && event.key === "Enter" && !typing) { + event.preventDefault(); + patchSnap({ flipped: true }); + setLiveMessage("문제를 확인했습니다. 답을 입력하세요."); + return; + } + + if (revealed) { + if (event.key === "ArrowRight") { + event.preventDefault(); + goNext(); + } + if (event.key === "ArrowLeft") { + event.preventDefault(); + goPrev(); + } + return; + } + + if (current?.type === "mcq" && !typing) { + const choice = Number(event.key); + if (choice >= 1 && choice <= 4) { + event.preventDefault(); + patchSnap({ picked: choice - 1 }); + setLiveMessage(`${choice}번 보기를 선택했습니다.`); + } + } + + if (event.key === "Enter") { + event.preventDefault(); + evaluate(); + } + + if (!typing && event.key === "ArrowLeft") { + event.preventDefault(); + goPrev(); + } + if (!typing && event.key === "ArrowRight" && revealed) { + event.preventDefault(); + goNext(); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [current, evaluate, finished, flipped, goNext, goPrev, patchSnap, revealed, selectedDeckId]); + + useEffect(() => { + if (flipped && current?.type === "short" && !revealed) { + inputRef.current?.focus(); + } + }, [current?.type, flipped, revealed]); + + const progressPercent = useMemo( + () => (total === 0 ? 0 : Math.round(((idx + (revealed ? 1 : 0)) / total) * 100)), + [idx, revealed, total], + ); + + const deckPicker = ( + + ); + + if (!selectedDeckId) { + return ( +
+ {deckPicker} +

+ 덱을 선택하면 flash card 세션이 시작됩니다. +

+
+ ); + } + + if (view === "finished") { + return ( +
+ {deckPicker} +
+

{deckLabel} 완료

+
+

+ {liveMessage || `${score} / ${total} 정답`} +

+ {storedProgress && ( +

+ 누적 — seen {storedProgress.seen}, correct {storedProgress.correct} +

+ )} +
+ +
+
+ ); + } + + if (view === "loading" || view === "empty") { + return ( +
+ {deckPicker} +

+ {view === "loading" ? `${deckLabel} 덱을 불러오는 중…` : liveMessage} +

+
+ ); + } + + if (!current) return null; + + const mcq = isSessionMcq(current) ? current : undefined; + const choices = mcq?.shuffledChoices ?? []; + + const front = ( +
+

{current.prompt}

+

Enter — 뒤집기 · ←/→ — 이동

+
+ +
+
+ ); + + const back = ( +
+

{current.prompt}

+ + {current.type === "mcq" && mcq && ( +
    + {choices.map((choice, choiceIndex) => { + const selected = picked === choiceIndex; + const showResult = revealed; + const isCorrect = choiceIndex === mcq.shuffledAnswerIndex; + + return ( +
  • + +
  • + ); + })} +
+ )} + + {current.type === "short" && ( + patchSnap({ shortValue: (event.target as HTMLInputElement).value })} + /> + )} + + {revealed && ( + <> +

{current.explanation}

+ + 출처 보기 + + + )} + +
+ {!revealed && ( + + )} + {revealed && ( + + )} + +
+
+ ); + + return ( +
+ {deckPicker} +
+

{deckLabel}

+
+ + {idx + 1} / {total} + + 점수 {score} + {storedProgress && ( + + 누적 {storedProgress.correct}/{storedProgress.seen} + + )} +
+
+ +
+
+
+ +

+ {liveMessage} +

+ + {reducedMotion ? ( +
+
+ {front} +
+
+ {back} +
+
+ ) : ( +
+
+ {front} + {back} +
+
+ )} + +

키보드: 1–4 선택 · Enter 제출/뒤집기 · ←/→ 이동

+
+ ); +} + +export default QuizDeck; diff --git a/src/content/quiz.ts b/src/content/quiz.ts new file mode 100644 index 0000000..ec6841a --- /dev/null +++ b/src/content/quiz.ts @@ -0,0 +1,39 @@ +import { z } from "astro/zod"; + +export const quizDeckIds = ["ts-basics", "tdd", "refactoring", "design-system"] as const; + +export type QuizDeckId = (typeof quizDeckIds)[number]; + +const quizDeckSchema = z.enum(quizDeckIds); + +const quizBaseSchema = z.object({ + id: z.string(), + deck: quizDeckSchema, + prompt: z.string(), + explanation: z.string(), + /** Published content path, e.g. /til/ts/enum-to-template-literal */ + source: z.string(), +}); + +export const mcqQuizItemSchema = quizBaseSchema.extend({ + type: z.literal("mcq"), + choices: z.tuple([z.string(), z.string(), z.string(), z.string()]), + answerIndex: z.number().int().min(0).max(3), +}); + +export const shortQuizItemSchema = quizBaseSchema.extend({ + type: z.literal("short"), + answer: z.string(), +}); + +export const quizItemSchema = z.discriminatedUnion("type", [ + mcqQuizItemSchema, + shortQuizItemSchema, +]); + +export type McqQuizItem = z.infer; +export type ShortQuizItem = z.infer; +export type QuizItem = z.infer; + +export const parseQuizItems = (items: unknown[]): QuizItem[] => + items.map((item) => quizItemSchema.parse(item)); diff --git a/src/content/quiz/design-system.ts b/src/content/quiz/design-system.ts new file mode 100644 index 0000000..309facb --- /dev/null +++ b/src/content/quiz/design-system.ts @@ -0,0 +1,70 @@ +import { parseQuizItems, type QuizItem } from "../quiz.ts"; + +export const deckId = "design-system" as const; + +export const items: QuizItem[] = parseQuizItems([ + { + id: "design-system-01", + deck: "design-system", + type: "mcq", + prompt: "GetIt-UI monorepo에서 Atomic Design으로 구성한 패키지 깊이는?", + choices: [ + "atoms, molecules, organisms 정도", + "atoms만", + "templates까지 전부", + "pages 레벨까지", + ], + answerIndex: 0, + explanation: "컴포넌트 라이브러리이므로 atoms·molecules·organisms 정도까지만 구성한다.", + source: "/articles/design-system/01", + }, + { + id: "design-system-02", + deck: "design-system", + type: "short", + prompt: "Storybook으로 컴포넌트 결과를 별도 페이지 없이 확인할 때 강조한 개발 경험(영문)?", + answer: "HMR", + explanation: "별도 페이지 구성 없이 HMR로 컴포넌트 구현 결과를 바로 볼 수 있다.", + source: "/articles/design-system/01", + }, + { + id: "design-system-03", + deck: "design-system", + type: "mcq", + prompt: "스타일링에 CSS Module을 선호한 이유로 맞는 것은?", + choices: [ + "CSS 그대로 쓰면서 className을 JS로 제어할 수 있다", + "styled-components보다 타입 추론이 강하다", + "Storybook과만 호환된다", + "IE에서 grid를 쓸 수 없어서", + ], + answerIndex: 0, + explanation: "CSS-in-JS 대신 CSS Module로 className 적용을 컨트롤하는 방식을 선호했다.", + source: "/articles/design-system/01", + }, + { + id: "design-system-04", + deck: "design-system", + type: "short", + prompt: "monorepo workspace에 쓴 Yarn berry 기능 이름(영문 두 단어)?", + answer: "PnP", + explanation: "Yarn berry PnP 모드에서 workspace 플러그인으로 monorepo를 구성했다.", + source: "/articles/design-system/01", + }, + { + id: "design-system-05", + deck: "design-system", + type: "mcq", + prompt: "Vite 라이브러리 모드에서 CSS Module 스타일이 다른 패키지에 안 먹힐 때 쓴 해결책은?", + choices: [ + "vite-plugin-css-injected-by-js", + "styled-components 테마 provider", + "Webpack 5로 전환", + "CSS layers만 켜기", + ], + answerIndex: 0, + explanation: + "라이브러리 빌드 시 CSS import가 빠지는 문제를 css-injected-by-js 플러그인으로 해결했다.", + source: "/articles/design-system/01", + }, +]); diff --git a/src/content/quiz/refactoring.ts b/src/content/quiz/refactoring.ts new file mode 100644 index 0000000..843651d --- /dev/null +++ b/src/content/quiz/refactoring.ts @@ -0,0 +1,65 @@ +import { parseQuizItems, type QuizItem } from "../quiz.ts"; + +export const deckId = "refactoring" as const; + +export const items: QuizItem[] = parseQuizItems([ + { + id: "refactoring-01", + deck: "refactoring", + type: "mcq", + prompt: "리팩토링의 핵심 정의로 맞는 것은?", + choices: [ + "동작을 유지하면서 코드 품질을 향상하는 것", + "새 기능을 추가하면서 테스트를 작성하는 것", + "인터페이스(동작)를 변경해 API를 개선하는 것", + "성능만 최적화하고 동작은 바꿀 수 있는 것", + ], + answerIndex: 0, + explanation: "리팩토링의 요점은 행동을 유지하면서 품질을 향상하는 것이다.", + source: "/til/refactoring-javascript/01", + }, + { + id: "refactoring-02", + deck: "refactoring", + type: "short", + prompt: "테스트 없이 리팩토링할 수 없다는 문장에서 '없다' 앞에 오는 조건(한글)?", + answer: "거치지않은", + explanation: "「테스트를 거치지 않은 코드는 리팩토링할 수 없다」는 원칙을 강조한다.", + source: "/til/refactoring-javascript/01", + }, + { + id: "refactoring-03", + deck: "refactoring", + type: "mcq", + prompt: "다음 중 리팩토링이 아닌 것은?", + choices: [ + "새로운 코드와 기능을 만드는 것", + "함수와 모듈을 추출해 인터페이스를 단순화하는 것", + "변수·함수 이름을 개선하는 것", + "구현 세부 사항만 바꾸는 것", + ], + answerIndex: 0, + explanation: + "새 코드·기능 추가는 리팩토링이 아니며, 인터페이스 변경은 테스트를 깨뜨릴 수 있다.", + source: "/til/refactoring-javascript/01", + }, + { + id: "refactoring-04", + deck: "refactoring", + type: "short", + prompt: "number * 2 대신 number << 1로 바꿀 때 주의해야 하는 것(한글 두 단어)?", + answer: "구현세부사항", + explanation: "곱셈과 비트 시프트는 구현 세부 사항이며, 큰 수에서는 동작이 달라질 수 있다.", + source: "/til/refactoring-javascript/01", + }, + { + id: "refactoring-05", + deck: "refactoring", + type: "mcq", + prompt: "코드 품질 원칙 예시로 글에 나온 것은?", + choices: ["SOLID", "CAP", "ACID", "CRUD"], + answerIndex: 0, + explanation: "SOLID, DRY, KISS, GRASP, YAGNI 등 품질 원칙을 나열한다.", + source: "/til/refactoring-javascript/01", + }, +]); diff --git a/src/content/quiz/tdd.ts b/src/content/quiz/tdd.ts new file mode 100644 index 0000000..9abeb8c --- /dev/null +++ b/src/content/quiz/tdd.ts @@ -0,0 +1,73 @@ +import { parseQuizItems, type QuizItem } from "../quiz.ts"; + +export const deckId = "tdd" as const; + +export const items: QuizItem[] = parseQuizItems([ + { + id: "tdd-01", + deck: "tdd", + type: "mcq", + prompt: "글에서 정리한 테스트 구조의 given/when/then은 무엇을 검증하는가?", + choices: [ + "주어진 상황에서 성공·실패 조건과 그 결과", + "Mock 객체의 생성 순서", + "E2E 시나리오의 UI 픽셀 단위 일치", + "커버리지 100% 달성 여부", + ], + answerIndex: 0, + explanation: + "모든 테스트는 given 컨텍스트에서 when(성공/실패 조건)과 then(결과)을 검증한다고 정리했다.", + source: "/til/tdd/fullstack-tdd-by-newbie", + }, + { + id: "tdd-02", + deck: "tdd", + type: "short", + prompt: "단위 테스트에서 외부 의존을 Mock으로 대체하는 이유를 한 단어로?", + answer: "가정", + explanation: + "단위 테스트는 함수/클래스 내부 동작만 보므로 외부는 정상 동작한다고 가정하고 Mock으로 분리한다.", + source: "/til/tdd/fullstack-tdd-by-newbie", + }, + { + id: "tdd-03", + deck: "tdd", + type: "mcq", + prompt: "파일럿 프로젝트에서 Frontend TDD가 실패한 주요 원인은?", + choices: [ + "단위 테스트 범위를 넘는 e2e 성격의 검증을 하려 했다", + "Jest 대신 Vitest를 쓰지 않았다", + "Vue.js에서 TDD가 원천적으로 불가능하다", + "MongoDB와 TypeORM 조합 때문이다", + ], + answerIndex: 0, + explanation: + "컴ponent 단위 테스트에서 이벤트·API 등 e2e에서 해야 할 것을 단위 테스트에서 하려다 혼란이 컸다.", + source: "/til/tdd/fullstack-tdd-by-newbie", + }, + { + id: "tdd-04", + deck: "tdd", + type: "short", + prompt: "통합·E2E 테스트가 단위 테스트와 달리 검증하는 범위를 한글 두 글자로?", + answer: "기능", + explanation: "E2E/통합 테스트는 내부 구현보다 end-to-end 기능과 input→output을 검증한다.", + source: "/til/tdd/fullstack-tdd-by-newbie", + }, + { + id: "tdd-05", + deck: "tdd", + type: "mcq", + prompt: "Nest.js 전환 후 TDD에 다시 관심을 갖게 된 계기는?", + choices: [ + "수작업 회귀 확인이 반복되어 귀찮아졌기 때문", + "Vue에서 React로 바꿨기 때문", + "테스트 없는 리팩토링이 더 빠르다는 걸 깨달았기 때문", + "Jest 공식 문서가 부족했기 때문", + ], + answerIndex: 0, + explanation: + "모노레포·Nest 전환 후 변경 검증을 손으로 반복하다 보니 TDD로 자동화하고 싶어졌다.", + source: "/til/tdd/fullstack-tdd-by-newbie", + }, +]); diff --git a/src/content/quiz/ts-basics.ts b/src/content/quiz/ts-basics.ts new file mode 100644 index 0000000..75c5bae --- /dev/null +++ b/src/content/quiz/ts-basics.ts @@ -0,0 +1,75 @@ +import { parseQuizItems, type QuizItem } from "../quiz.ts"; + +export const deckId = "ts-basics" as const; + +export const items: QuizItem[] = parseQuizItems([ + { + id: "ts-basics-01", + deck: "ts-basics", + type: "mcq", + prompt: "TypeScript enum을 피하는 대표적인 이유는?", + choices: [ + "tree-shaking이 어렵고 메모리 낭비가 생길 수 있다", + "런타임에 enum 키를 동적으로 추가할 수 없다", + "union 타입과 완전히 동일한 타입 추론을 제공한다", + "class-validator에서 IsEnum과 함께 쓸 수 없다", + ], + answerIndex: 0, + explanation: + "enum은 transpile 후에도 객체로 남아 tree-shaking이 어렵고, 불필요한 메모리 사용으로 이어질 수 있다.", + source: "/til/ts/enum-to-template-literal", + }, + { + id: "ts-basics-02", + deck: "ts-basics", + type: "short", + prompt: + "class-validator IsEnum에 넘기기 위해 enum 대신 쓰는 Readonly 객체 패턴의 핵심 타입 이름은?", + answer: "ReadonlyRecord", + explanation: + "literal union 타입과 ReadonlyRecord로 key/value가 고정된 객체를 만들면 IsEnum에 넘길 수 있고 tree-shaking에도 유리하다.", + source: "/til/ts/enum-to-template-literal", + }, + { + id: "ts-basics-03", + deck: "ts-basics", + type: "mcq", + prompt: + "서로 다른 두 enum 타입 PeriodA | PeriodCommon을 union하면 실무에서 어떤 문제가 생기는가?", + choices: [ + "타입 에러 없이 선언되지만 실질적으로 사용할 수 없는 union이 된다", + "자동으로 하나의 enum 객체로 merge된다", + "intersection과 동일하게 never가 된다", + "런타임에 PeriodA 값만 허용된다", + ], + answerIndex: 0, + explanation: + "enum끼리 union은 문법상 허용되지만 PeriodA | PeriodCommon처럼 실사용 불가능한 타입이 된다.", + source: "/til/ts/enum-to-template-literal", + }, + { + id: "ts-basics-04", + deck: "ts-basics", + type: "short", + prompt: "PeriodA와 PeriodCommon을 하나의 객체로 합칠 때 spread로 만든 상수 이름은?", + answer: "Periods", + explanation: "각 literal 타입별 ReadonlyRecord 객체를 spread해 Periods 하나로 합친다.", + source: "/til/ts/enum-to-template-literal", + }, + { + id: "ts-basics-05", + deck: "ts-basics", + type: "mcq", + prompt: "template literal 타입을 literal union과 함께 쓰면 얻는 이점은?", + choices: [ + "MarketNames와 CategoryNames를 조합한 새 타입을 선언하기 쉽다", + "enum union 문제를 자동으로 해결한다", + "런타임 validation 없이 API 스키마를 생성한다", + "class-validator 의존성을 제거한다", + ], + answerIndex: 0, + explanation: + "`${MarketNames}-${CategoryNames}`처럼 문자열 조합 타입을 선언·자동완성하기 편해진다.", + source: "/til/ts/enum-to-template-literal", + }, +]); diff --git a/src/content/til/refactoring-javascript/01.mdx b/src/content/til/refactoring-javascript/01.mdx index 12db315..5c57d18 100644 --- a/src/content/til/refactoring-javascript/01.mdx +++ b/src/content/til/refactoring-javascript/01.mdx @@ -4,6 +4,8 @@ summary: "리팩토링의 정의와 품질 균형" pubDate: 2023-06-01 tags: ["refactoring", "javascript", "study"] visibility: public +quizRefs: + - /wiki/quiz?deck=refactoring --- > 리팩토링은 코드를 변경하는 것이 아니다 diff --git a/src/content/til/tdd/fullstack-tdd-by-newbie.mdx b/src/content/til/tdd/fullstack-tdd-by-newbie.mdx index e1d24d3..294a406 100644 --- a/src/content/til/tdd/fullstack-tdd-by-newbie.mdx +++ b/src/content/til/tdd/fullstack-tdd-by-newbie.mdx @@ -4,6 +4,8 @@ summary: "주간회고 — Full-stack TDD 경험 회고" pubDate: 2022-01-24 tags: ["tdd", "testing", "career"] visibility: public +quizRefs: + - /wiki/quiz?deck=tdd --- - 주간회고 02. TDD 뉴비의 Full-stack(?) TDD 1편 diff --git a/src/content/til/ts/enum-to-template-literal.mdx b/src/content/til/ts/enum-to-template-literal.mdx index 1c8df5d..4102666 100644 --- a/src/content/til/ts/enum-to-template-literal.mdx +++ b/src/content/til/ts/enum-to-template-literal.mdx @@ -4,6 +4,8 @@ summary: "enum 대신 literal + ReadonlyRecord 패턴 정리" pubDate: 2022-01-20 tags: ["typescript", "enum", "class-validator"] visibility: public +quizRefs: + - /wiki/quiz?deck=ts-basics --- ### 3줄 요약 diff --git a/src/lib/quiz/deck-meta.ts b/src/lib/quiz/deck-meta.ts new file mode 100644 index 0000000..b31d9cf --- /dev/null +++ b/src/lib/quiz/deck-meta.ts @@ -0,0 +1,13 @@ +import { quizDeckIds, type QuizDeckId } from "../../content/quiz.ts"; + +export { quizDeckIds, type QuizDeckId }; + +export const deckLabels: Record = { + "ts-basics": "TypeScript 기초", + tdd: "TDD", + refactoring: "리팩토링", + "design-system": "Design System", +}; + +export const isQuizDeckId = (value: string): value is QuizDeckId => + (quizDeckIds as readonly string[]).includes(value); diff --git a/src/lib/quiz/decks.ts b/src/lib/quiz/decks.ts new file mode 100644 index 0000000..459ac9f --- /dev/null +++ b/src/lib/quiz/decks.ts @@ -0,0 +1,25 @@ +import { items as designSystemItems } from "../../content/quiz/design-system.ts"; +import { items as refactoringItems } from "../../content/quiz/refactoring.ts"; +import { items as tddItems } from "../../content/quiz/tdd.ts"; +import { items as tsBasicsItems } from "../../content/quiz/ts-basics.ts"; +import type { QuizDeckId, QuizItem } from "../../content/quiz.ts"; + +export { deckLabels, isQuizDeckId, quizDeckIds, type QuizDeckId } from "./deck-meta.ts"; +export type { QuizItem } from "../../content/quiz.ts"; + +const deckCatalog: Record = { + "ts-basics": tsBasicsItems, + tdd: tddItems, + refactoring: refactoringItems, + "design-system": designSystemItems, +}; + +export const getDeckItems = (deckId: QuizDeckId): QuizItem[] => deckCatalog[deckId]; + +export const getAllQuizItems = (): QuizItem[] => + (Object.keys(deckCatalog) as QuizDeckId[]).flatMap((id) => deckCatalog[id]); + +export const getDeckItemCounts = (): Record => + Object.fromEntries( + (Object.keys(deckCatalog) as QuizDeckId[]).map((id) => [id, deckCatalog[id].length]), + ) as Record; diff --git a/src/lib/quiz/grade.test.ts b/src/lib/quiz/grade.test.ts new file mode 100644 index 0000000..4f4bdd2 --- /dev/null +++ b/src/lib/quiz/grade.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { gradeMCQ, gradeShort, normalizeShortAnswer } from "./grade.ts"; + +describe("normalizeShortAnswer", () => { + it("NFKC·trim·소문자·공백/마침표를 제거한다", () => { + expect(normalizeShortAnswer(" Readonly Record. ")).toBe("readonlyrecord"); + expect(normalizeShortAnswer("ABC")).toBe("abc"); + }); +}); + +describe("gradeMCQ", () => { + it("선택 인덱스가 정답과 같으면 true", () => { + expect(gradeMCQ({ pickedIndex: 2, answerIndex: 2 })).toBe(true); + }); + + it("선택 인덱스가 다르면 false", () => { + expect(gradeMCQ({ pickedIndex: 0, answerIndex: 1 })).toBe(false); + }); +}); + +describe("gradeShort", () => { + it("정규화 후 일치하면 true", () => { + expect(gradeShort({ given: " ReadonlyRecord ", answer: "readonly record" })).toBe(true); + }); + + it("정규화 후 불일치하면 false", () => { + expect(gradeShort({ given: "enum", answer: "ReadonlyRecord" })).toBe(false); + }); +}); diff --git a/src/lib/quiz/grade.ts b/src/lib/quiz/grade.ts new file mode 100644 index 0000000..3d71b44 --- /dev/null +++ b/src/lib/quiz/grade.ts @@ -0,0 +1,22 @@ +export const normalizeShortAnswer = (value: string): string => + value + .normalize("NFKC") + .trim() + .toLowerCase() + .replace(/[\s.]+/g, ""); + +type GradeMcqArgs = { + pickedIndex: number; + answerIndex: number; +}; + +export const gradeMCQ = ({ pickedIndex, answerIndex }: GradeMcqArgs): boolean => + pickedIndex === answerIndex; + +type GradeShortArgs = { + given: string; + answer: string; +}; + +export const gradeShort = ({ given, answer }: GradeShortArgs): boolean => + normalizeShortAnswer(given) === normalizeShortAnswer(answer); diff --git a/src/lib/quiz/load-deck.test.ts b/src/lib/quiz/load-deck.test.ts new file mode 100644 index 0000000..3f8dd2d --- /dev/null +++ b/src/lib/quiz/load-deck.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { loadDeckItems } from "./load-deck.ts"; + +describe("loadDeckItems", () => { + it("ts-basics 덱 5문항을 동적 import로 로드한다", async () => { + const items = await loadDeckItems("ts-basics"); + expect(items).toHaveLength(5); + expect(items[0]?.deck).toBe("ts-basics"); + }); + + it("design-system 덱 source가 기사 경로를 가리킨다", async () => { + const items = await loadDeckItems("design-system"); + expect(items.every((item) => item.source.startsWith("/articles/"))).toBe(true); + }); +}); diff --git a/src/lib/quiz/load-deck.ts b/src/lib/quiz/load-deck.ts new file mode 100644 index 0000000..18cabf5 --- /dev/null +++ b/src/lib/quiz/load-deck.ts @@ -0,0 +1,17 @@ +import type { QuizDeckId, QuizItem } from "../../content/quiz.ts"; + +type DeckModule = { + items: QuizItem[]; +}; + +const deckLoaders: Record Promise> = { + "ts-basics": () => import("../../content/quiz/ts-basics.ts"), + tdd: () => import("../../content/quiz/tdd.ts"), + refactoring: () => import("../../content/quiz/refactoring.ts"), + "design-system": () => import("../../content/quiz/design-system.ts"), +}; + +export const loadDeckItems = async (deckId: QuizDeckId): Promise => { + const mod = await deckLoaders[deckId](); + return mod.items; +}; diff --git a/src/lib/quiz/progress.ts b/src/lib/quiz/progress.ts new file mode 100644 index 0000000..0b4e6a9 --- /dev/null +++ b/src/lib/quiz/progress.ts @@ -0,0 +1,43 @@ +import type { QuizDeckId } from "../../content/quiz.ts"; + +export const QUIZ_PROGRESS_KEY = "quiz-progress-v1"; + +export type DeckProgress = { + seen: number; + correct: number; +}; + +export type QuizProgressStore = Partial>; + +export const readQuizProgress = (): QuizProgressStore => { + if (typeof localStorage === "undefined") return {}; + + try { + const raw = localStorage.getItem(QUIZ_PROGRESS_KEY); + if (!raw) return {}; + return JSON.parse(raw) as QuizProgressStore; + } catch { + return {}; + } +}; + +export const writeQuizProgress = (store: QuizProgressStore): void => { + if (typeof localStorage === "undefined") return; + localStorage.setItem(QUIZ_PROGRESS_KEY, JSON.stringify(store)); +}; + +type RecordAnswerArgs = { + deckId: QuizDeckId; + correct: boolean; +}; + +export const recordDeckAnswer = ({ deckId, correct }: RecordAnswerArgs): DeckProgress => { + const store = readQuizProgress(); + const current = store[deckId] ?? { seen: 0, correct: 0 }; + const next: DeckProgress = { + seen: current.seen + 1, + correct: current.correct + (correct ? 1 : 0), + }; + writeQuizProgress({ ...store, [deckId]: next }); + return next; +}; diff --git a/src/lib/quiz/quiz-view.test.ts b/src/lib/quiz/quiz-view.test.ts new file mode 100644 index 0000000..13ae1ed --- /dev/null +++ b/src/lib/quiz/quiz-view.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { quizView } from "./quiz-view.ts"; + +describe("quizView", () => { + it("마지막 문제 제출 후 idx === total이면 finished를 반환한다", () => { + expect(quizView({ idx: 5, total: 5, loading: false, hasCurrent: false })).toBe("finished"); + }); + + it("finished 상태에서 loading·empty보다 finished가 우선한다", () => { + expect(quizView({ idx: 3, total: 3, loading: false, hasCurrent: false })).toBe("finished"); + }); + + it("진행 중이면 card를 반환한다", () => { + expect(quizView({ idx: 2, total: 5, loading: false, hasCurrent: true })).toBe("card"); + }); + + it("덱 로드 중이면 loading을 반환한다", () => { + expect(quizView({ idx: 0, total: 5, loading: true, hasCurrent: false })).toBe("loading"); + }); + + it("current가 없고 세션이 끝나지 않았으면 empty를 반환한다", () => { + expect(quizView({ idx: 0, total: 0, loading: false, hasCurrent: false })).toBe("empty"); + }); +}); diff --git a/src/lib/quiz/quiz-view.ts b/src/lib/quiz/quiz-view.ts new file mode 100644 index 0000000..16597da --- /dev/null +++ b/src/lib/quiz/quiz-view.ts @@ -0,0 +1,15 @@ +export type QuizView = "finished" | "loading" | "empty" | "card"; + +type QuizViewArgs = { + idx: number; + total: number; + loading: boolean; + hasCurrent: boolean; +}; + +export const quizView = ({ idx, total, loading, hasCurrent }: QuizViewArgs): QuizView => { + if (total > 0 && idx >= total) return "finished"; + if (loading) return "loading"; + if (!hasCurrent) return "empty"; + return "card"; +}; diff --git a/src/lib/quiz/session.test.ts b/src/lib/quiz/session.test.ts new file mode 100644 index 0000000..9452d7b --- /dev/null +++ b/src/lib/quiz/session.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import type { QuizItem } from "../../content/quiz.ts"; + +import { createSeededRng } from "./shuffle.ts"; +import { buildSession, isSessionMcq } from "./session.ts"; + +const sampleItems: QuizItem[] = [ + { + id: "m1", + deck: "ts-basics", + type: "mcq", + prompt: "Q1", + choices: ["a", "b", "c", "d"], + answerIndex: 1, + explanation: "e1", + source: "/til/ts/enum-to-template-literal", + }, + { + id: "m2", + deck: "ts-basics", + type: "mcq", + prompt: "Q2", + choices: ["a", "b", "c", "d"], + answerIndex: 0, + explanation: "e2", + source: "/til/ts/enum-to-template-literal", + }, + { + id: "s1", + deck: "ts-basics", + type: "short", + prompt: "Q3", + answer: "foo", + explanation: "e3", + source: "/til/ts/enum-to-template-literal", + }, + { + id: "s2", + deck: "ts-basics", + type: "short", + prompt: "Q4", + answer: "bar", + explanation: "e4", + source: "/til/ts/enum-to-template-literal", + }, +]; + +describe("buildSession", () => { + it("limit 이하 개수를 반환한다", () => { + const session = buildSession({ items: sampleItems, limit: 3, rng: createSeededRng(1) }); + expect(session.length).toBeLessThanOrEqual(3); + }); + + it("mcq 항목은 보기 순서가 셔플된다", () => { + const session = buildSession({ + items: sampleItems, + limit: 4, + mcqRatio: 1, + rng: createSeededRng(5), + }); + const mcq = session.filter(isSessionMcq); + expect(mcq.length).toBeGreaterThan(0); + mcq.forEach((item) => { + expect(item.shuffledChoices).toHaveLength(4); + expect(item.shuffledAnswerIndex).toBeGreaterThanOrEqual(0); + expect(item.shuffledAnswerIndex).toBeLessThanOrEqual(3); + expect(item.shuffledChoices[item.shuffledAnswerIndex]).toBe(item.choices[item.answerIndex]); + }); + }); + + it("같은 seed면 동일한 세션 순서를 만든다", () => { + const first = buildSession({ items: sampleItems, limit: 4, rng: createSeededRng(11) }); + const second = buildSession({ items: sampleItems, limit: 4, rng: createSeededRng(11) }); + expect(first.map((item) => item.id)).toEqual(second.map((item) => item.id)); + }); +}); + +describe("isSessionMcq", () => { + it("mcq 세션 항목만 true", () => { + const session = buildSession({ items: sampleItems, limit: 4, rng: createSeededRng(3) }); + session.forEach((item) => { + if (item.type === "mcq") { + expect(isSessionMcq(item)).toBe(true); + } else { + expect(isSessionMcq(item)).toBe(false); + } + }); + }); +}); diff --git a/src/lib/quiz/session.ts b/src/lib/quiz/session.ts new file mode 100644 index 0000000..b2c2a84 --- /dev/null +++ b/src/lib/quiz/session.ts @@ -0,0 +1,59 @@ +import type { McqQuizItem, QuizItem } from "../../content/quiz.ts"; + +import { shuffle, type Rng } from "./shuffle.ts"; + +export type SessionMcqItem = McqQuizItem & { + shuffledChoices: [string, string, string, string]; + shuffledAnswerIndex: number; +}; + +export type SessionItem = QuizItem | SessionMcqItem; + +export const isSessionMcq = (item: SessionItem): item is SessionMcqItem => + item.type === "mcq" && "shuffledChoices" in item; + +type BuildSessionArgs = { + items: QuizItem[]; + mcqRatio?: number; + limit?: number; + rng?: Rng; +}; + +const shuffleMcqChoices = (item: McqQuizItem, rng: Rng): SessionMcqItem => { + const order = shuffle([0, 1, 2, 3], rng); + const shuffledChoices = order.map((index) => item.choices[index]) as [ + string, + string, + string, + string, + ]; + const shuffledAnswerIndex = order.indexOf(item.answerIndex); + + return { + ...item, + shuffledChoices, + shuffledAnswerIndex: shuffledAnswerIndex >= 0 ? shuffledAnswerIndex : 0, + }; +}; + +export const buildSession = ({ + items, + mcqRatio = 0.6, + limit = 10, + rng = Math.random, +}: BuildSessionArgs): SessionItem[] => { + const mcqItems = items.filter((item): item is McqQuizItem => item.type === "mcq"); + const shortItems = items.filter((item) => item.type === "short"); + const mcqCount = Math.min(Math.round(limit * mcqRatio), mcqItems.length); + const shortCount = Math.min(limit - mcqCount, shortItems.length); + const adjustedMcqCount = Math.min(limit - shortCount, mcqCount); + + const selected = [ + ...shuffle(mcqItems, rng).slice(0, adjustedMcqCount), + ...shuffle(shortItems, rng).slice(0, shortCount), + ]; + + return shuffle(selected, rng).map((item) => + item.type === "mcq" ? shuffleMcqChoices(item, rng) : item, + ); +}; diff --git a/src/lib/quiz/shuffle.test.ts b/src/lib/quiz/shuffle.test.ts new file mode 100644 index 0000000..e9f157f --- /dev/null +++ b/src/lib/quiz/shuffle.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { createSeededRng, shuffle } from "./shuffle.ts"; + +describe("shuffle", () => { + it("같은 seed면 동일한 순서를 반환한다", () => { + const source = ["a", "b", "c", "d", "e"]; + const first = shuffle(source, createSeededRng(42)); + const second = shuffle(source, createSeededRng(42)); + expect(first).toEqual(second); + }); + + it("요소 개수와 멀티셋을 유지한다", () => { + const source = [1, 2, 2, 3]; + const result = shuffle(source, createSeededRng(7)); + expect(result).toHaveLength(source.length); + expect(result.sort()).toEqual(source.sort()); + }); + + it("원본 배열을 변경하지 않는다", () => { + const source = ["x", "y", "z"]; + const copy = [...source]; + shuffle(source, createSeededRng(1)); + expect(source).toEqual(copy); + }); +}); + +describe("createSeededRng", () => { + it("0 이상 1 미만 값을 생성한다", () => { + const rng = createSeededRng(99); + Array.from({ length: 20 }, () => { + const value = rng(); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(1); + }); + }); +}); diff --git a/src/lib/quiz/shuffle.ts b/src/lib/quiz/shuffle.ts new file mode 100644 index 0000000..005d17b --- /dev/null +++ b/src/lib/quiz/shuffle.ts @@ -0,0 +1,23 @@ +export type Rng = () => number; + +export const createSeededRng = (seed: number): Rng => { + let state = seed >>> 0; + + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = Math.imul(state ^ (state >>> 15), 1 | state); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +}; + +export const shuffle = (items: readonly T[], rng: Rng = Math.random): T[] => { + const result = [...items]; + + for (let i = result.length - 1; i > 0; i -= 1) { + const j = Math.floor(rng() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + + return result; +}; diff --git a/src/pages/wiki/quiz.astro b/src/pages/wiki/quiz.astro new file mode 100644 index 0000000..30f2fe0 --- /dev/null +++ b/src/pages/wiki/quiz.astro @@ -0,0 +1,81 @@ +--- +import QuizDeck from '../../components/quiz/QuizDeck.tsx'; +import Heading from '../../components/ui/Heading.astro'; +import Stack from '../../components/ui/Stack.astro'; +import Text from '../../components/ui/Text.astro'; +import { + deckLabels, + getAllQuizItems, + getDeckItemCounts, + getDeckItems, + quizDeckIds, +} from '../../lib/quiz/decks.ts'; +import Page from '../../layouts/Page.astro'; + +const deckCounts = getDeckItemCounts(); +const allItems = getAllQuizItems(); +--- + + + +
+ Wiki Quiz + + TIL·기사 내용을 flash card로 복습합니다. JavaScript 없이도 아래 noscript 목록에서 문제를 볼 수 + 있습니다. + +
+ +
+ +
+ + +
+