Compare commits

...

2 Commits

Author SHA1 Message Date
jisangs d56cada1ec 리팩토링: 코드 최적화 2026-05-25 18:42:13 +09:00
jisangs 5ee33bf0d3 괄호 앞 음수 분배 스테이지 추가 2026-05-23 12:15:53 +09:00
16 changed files with 1684 additions and 31 deletions
+11
View File
@@ -52,6 +52,8 @@ lib/
redis.ts # Redis Client 싱글턴
store/
gameStore.ts # Zustand 전역 게임 상태
types/
*.d.ts # 타입 선언이 없는 서드파티 모듈 보강
prisma/
schema.prisma # MariaDB 모델 정의
.gitea/workflows/deploy.yml # CI/CD 워크플로우
@@ -76,6 +78,14 @@ prisma/
- 전역 게임 상태(점수, 진행도, 선택한 모드·유형)는 `store/gameStore.ts`의 Zustand store로 관리한다.
- 컴포넌트 내부의 로컬 상태는 `useState`를 사용한다.
- Zustand selector 안에서 매번 새 객체·배열을 반환하는 함수(`score()` 등)를 호출하지 않는다. 필요한 파생값은 구독한 state를 기준으로 컴포넌트 내부에서 계산하거나 안정적인 selector로 분리한다.
- store에는 세션 원본 상태와 상태 변경 action을 우선 두고, 렌더링 전용 파생값은 컴포넌트 또는 별도 순수 유틸에서 계산한다.
### 컴포넌트 배치
- 게임 화면에서 재사용되는 입력, 피드백, 점수 요약 UI는 `components/game/` 하위에 둔다.
- 수식 렌더링 전용 컴포넌트는 `components/math/` 하위에 둔다.
- 라우트별 조립 컴포넌트(`PlayClient` 등)는 해당 `app/` 라우트 폴더에 둘 수 있다.
### API / 백엔드
@@ -90,6 +100,7 @@ prisma/
- 문제는 AI 호출 없이 알고리즘으로 생성한다. 외부 AI API를 문제 생성에 사용하지 않는다.
- 미지수의 계수는 항상 정수이어야 한다.
- 생성 파라미터: 숫자 자릿수, 괄호 깊이(`()` / `(){}` / `(){}[]`), 괄호 개수(13), 미지수(없음/x/x,y/x,y,z).
- 문제 세션에서 중복을 방지할 때는 랜덤 id가 아니라 수학적 문제 조건을 나타내는 안정적인 key를 기준으로 한다.
---
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import AnswerForm from "@/components/game/AnswerForm";
import FeedbackPanel from "@/components/game/FeedbackPanel";
import ScoreSummary from "@/components/game/ScoreSummary";
import KatexRenderer from "@/components/math/KatexRenderer";
import { useGameStore } from "@/store/gameStore";
function parseAnswer(value: string) {
const trimmed = value.trim();
if (!/^-?\d+$/.test(trimmed)) return null;
return Number(trimmed);
}
export default function PlayClient() {
const session = useGameStore((state) => state.session);
const initSession = useGameStore((state) => state.initSession);
const submitAnswer = useGameStore((state) => state.submitAnswer);
const nextProblem = useGameStore((state) => state.nextProblem);
const resetSession = useGameStore((state) => state.resetSession);
const [answerA, setAnswerA] = useState("");
const [answerB, setAnswerB] = useState("");
const [inputError, setInputError] = useState("");
const currentProblem = session?.problems[session.currentIndex] ?? null;
const currentResult = session?.results[session.currentIndex] ?? null;
const isComplete = Boolean(
session && session.currentIndex >= session.problems.length,
);
const correctCount =
session?.results.filter((result) => result?.correct).length ?? 0;
const totalCount = session?.problems.length ?? 0;
useEffect(() => {
if (session === null) {
initSession();
}
}, [initSession, session]);
useEffect(() => {
setAnswerA("");
setAnswerB("");
setInputError("");
}, [currentProblem?.key]);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (currentResult || !currentProblem) return;
const parsedA = parseAnswer(answerA);
const parsedB = parseAnswer(answerB);
if (parsedA === null || parsedB === null) {
setInputError("두 칸 모두 정수로 입력하세요.");
return;
}
setInputError("");
submitAnswer({ a: parsedA, b: parsedB });
}
if (!session) {
return (
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
<section className="mx-auto w-full max-w-3xl">
<p className="text-lg font-semibold text-slate-700">
.
</p>
</section>
</main>
);
}
if (isComplete) {
return (
<ScoreSummary
correctCount={correctCount}
totalCount={totalCount}
onRestart={resetSession}
/>
);
}
if (!currentProblem) {
return (
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
<section className="mx-auto w-full max-w-3xl">
<p className="text-lg font-semibold text-slate-700">
.
</p>
</section>
</main>
);
}
const submitted = currentResult !== null;
const isLastProblem = session.currentIndex === session.problems.length - 1;
return (
<main className="min-h-screen bg-slate-50 px-6 py-10 text-slate-950">
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-2">
<p className="text-sm font-semibold text-emerald-700">
</p>
<h1 className="text-3xl font-bold tracking-normal sm:text-4xl">
</h1>
</div>
<p className="rounded-md bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm ring-1 ring-slate-200">
{session.currentIndex + 1} / {session.problems.length}
</p>
</div>
<div className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8">
<p className="mb-4 text-sm font-semibold text-slate-500">
.
</p>
<div className="text-3xl font-semibold text-slate-950 sm:text-4xl">
<KatexRenderer latex={currentProblem.latexBefore} displayMode />
</div>
</div>
<AnswerForm
answerA={answerA}
answerB={answerB}
disabled={submitted}
error={inputError}
onAnswerAChange={setAnswerA}
onAnswerBChange={setAnswerB}
onSubmit={handleSubmit}
/>
{currentResult ? (
<FeedbackPanel
result={currentResult}
answerLatex={currentProblem.latexAfter}
isLastProblem={isLastProblem}
onNext={nextProblem}
/>
) : null}
</section>
</main>
);
}
+2 -27
View File
@@ -1,30 +1,5 @@
import Link from "next/link";
import PlayClient from "./PlayClient";
export default function PlayPage() {
return (
<main className="flex min-h-screen items-center bg-white px-6 py-12 text-slate-950">
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
<div className="space-y-4">
<p className="text-sm font-semibold text-emerald-700">
</p>
<h1 className="text-4xl font-bold tracking-normal sm:text-5xl">
</h1>
<p className="max-w-2xl text-lg leading-8 text-slate-700">
.
</p>
</div>
<div>
<Link
href="/"
className="inline-flex h-11 items-center justify-center rounded-md border border-slate-300 px-5 text-base font-semibold text-slate-800 transition hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
</Link>
</div>
</section>
</main>
);
return <PlayClient />;
}
+1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "katex/dist/katex.min.css";
import "./globals.css";
const geistSans = localFont({
+67
View File
@@ -0,0 +1,67 @@
"use client";
import type { FormEvent } from "react";
interface AnswerFormProps {
answerA: string;
answerB: string;
disabled: boolean;
error: string;
onAnswerAChange: (value: string) => void;
onAnswerBChange: (value: string) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
}
export default function AnswerForm({
answerA,
answerB,
disabled,
error,
onAnswerAChange,
onAnswerBChange,
onSubmit,
}: AnswerFormProps) {
return (
<form
onSubmit={onSubmit}
className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8"
>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-2 text-sm font-semibold text-slate-700">
<input
value={answerA}
onChange={(event) => onAnswerAChange(event.target.value)}
disabled={disabled}
inputMode="numeric"
className="h-12 rounded-md border border-slate-300 px-4 text-lg font-semibold text-slate-950 outline-none transition focus:border-emerald-600 focus:ring-2 focus:ring-emerald-200 disabled:bg-slate-100"
/>
</label>
<label className="flex flex-col gap-2 text-sm font-semibold text-slate-700">
<input
value={answerB}
onChange={(event) => onAnswerBChange(event.target.value)}
disabled={disabled}
inputMode="numeric"
className="h-12 rounded-md border border-slate-300 px-4 text-lg font-semibold text-slate-950 outline-none transition focus:border-emerald-600 focus:ring-2 focus:ring-emerald-200 disabled:bg-slate-100"
/>
</label>
</div>
{error ? (
<p className="mt-3 text-sm font-semibold text-red-700">{error}</p>
) : null}
<div className="mt-6 flex flex-wrap gap-3">
<button
type="submit"
disabled={disabled}
className="inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 px-6 text-base font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-slate-300"
>
</button>
</div>
</form>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import KatexRenderer from "@/components/math/KatexRenderer";
import type { GradeResult } from "@/lib/engine/types";
interface FeedbackPanelProps {
result: GradeResult;
answerLatex: string;
isLastProblem: boolean;
onNext: () => void;
}
export default function FeedbackPanel({
result,
answerLatex,
isLastProblem,
onNext,
}: FeedbackPanelProps) {
return (
<div
className={`rounded-md p-6 shadow-sm ring-1 ${
result.correct
? "bg-emerald-50 text-emerald-950 ring-emerald-200"
: "bg-red-50 text-red-950 ring-red-200"
}`}
>
<p className="text-lg font-bold">
{result.correct ? "정답입니다." : "오답입니다."}
</p>
<div className="mt-3 text-xl font-semibold">
<KatexRenderer latex={answerLatex} />
</div>
<button
type="button"
onClick={onNext}
className="mt-5 inline-flex h-11 items-center justify-center rounded-md bg-slate-950 px-5 text-base font-semibold text-white transition hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2"
>
{isLastProblem ? "결과 보기" : "다음 문제"}
</button>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import Link from "next/link";
interface ScoreSummaryProps {
correctCount: number;
totalCount: number;
onRestart: () => void;
}
export default function ScoreSummary({
correctCount,
totalCount,
onRestart,
}: ScoreSummaryProps) {
return (
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
<div className="space-y-3">
<p className="text-sm font-semibold text-emerald-700">
</p>
<h1 className="text-4xl font-bold tracking-normal sm:text-5xl">
</h1>
<p className="text-xl font-semibold text-slate-800">
{correctCount} / {totalCount}
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={onRestart}
className="inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 px-6 text-base font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
</button>
<Link
href="/"
className="inline-flex h-12 items-center justify-center rounded-md border border-slate-300 px-6 text-base font-semibold text-slate-800 transition hover:bg-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
</Link>
</div>
</section>
</main>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { renderToString } from "katex";
interface KatexRendererProps {
latex: string;
displayMode?: boolean;
}
export default function KatexRenderer({
latex,
displayMode = false,
}: KatexRendererProps) {
const html = renderToString(latex, {
displayMode,
throwOnError: false,
});
const Tag = displayMode ? "div" : "span";
return <Tag dangerouslySetInnerHTML={{ __html: html }} />;
}
+358
View File
@@ -0,0 +1,358 @@
# 02. 괄호 앞 음수 분배 — 간단한 풀이 화면
## 계획 평가
Claude Code가 작성한 기존 계획은 방향이 맞다. `01-project-start.md`에서 만든 `/` -> `/play` 흐름 위에 문제 생성 엔진, KaTeX 표시, 답 입력, 채점, 10문제 세션을 붙이는 순서가 자연스럽다.
다만 실제 구현 단계로 쓰기에는 몇 가지를 조정하는 편이 좋다.
- 엔진 단위 테스트만 하는 단계에서는 `@vitejs/plugin-react`, `jsdom`, `@testing-library/react`가 아직 필요하지 않다.
- `generateSession()`의 중복 방지는 랜덤 `id`가 아니라 `multiplier/a/operator/b` 조합 기준이어야 한다.
- Zustand는 한 문제 풀이가 동작한 뒤 도입한다. 이 순서를 지키면 버그가 엔진, UI, 상태 관리 중 어디에서 생겼는지 분리하기 쉽다.
- `PlayClient`에서 세션을 초기화할 때 렌더링 중 `initSession()`을 직접 호출하지 않고 `useEffect`에서 호출해야 한다.
- 이번 문서는 “간단한 풀이 화면”이 목표이므로 Framer Motion, 정교한 진행 바, 배너 디자인, E2E는 다음 단계로 넘긴다.
---
## 목표
`/play` 화면에서 **괄호 앞 음수 분배** 문제를 10개 풀고 점수를 확인할 수 있게 만든다.
이번 단계의 최종 상태는 다음과 같다.
- 자체 알고리즘으로 괄호 앞 음수 분배 문제를 생성한다.
- 문제와 정답 수식을 KaTeX로 렌더링한다.
- 사용자가 두 항의 답을 입력하고 채점 결과를 확인한다.
- 10문제 풀이가 끝나면 점수 요약을 보여준다.
---
## 이번 단계에서 할 것
- `lib/engine/`에 문제 생성·채점 순수 함수와 Vitest 단위 테스트 추가
- `components/math/`에 KaTeX 렌더링 컴포넌트 추가
- `/play`에 한 문제 풀이 UI 구현
- 한 문제 풀이 확인 후 `store/gameStore.ts`로 10문제 세션 구현
- 세션 완료 후 간단한 점수 요약 표시
## 이번 단계에서 하지 않을 것
- Framer Motion 애니메이션
- 정교한 ProgressBar, FeedbackBanner, ScoreSummary 디자인 컴포넌트화
- DB, Prisma, Redis, NextAuth
- 유형 선택 화면, 모드 선택 화면
- 스피드 퀴즈 모드
- 미지수 `x`, `y`, `z`가 포함된 문제
- Playwright E2E 테스트
---
## 의존성
필요한 시점에 나누어 설치한다.
### 엔진 테스트
```bash
pnpm add -D vitest
```
### 수식 렌더링과 세션 상태
```bash
pnpm add katex react-katex zustand
pnpm add -D @types/katex
```
React 컴포넌트 테스트를 추가하지 않는 한 `@testing-library/react`, `jsdom`, `@vitejs/plugin-react`는 이번 단계에 넣지 않는다.
---
## 생성 또는 수정 파일
### 신규 생성
```text
lib/engine/types.ts
lib/engine/parenthesis-sign.ts
lib/engine/parenthesis-sign.test.ts
vitest.config.ts
components/math/KatexRenderer.tsx
store/gameStore.ts
app/(game)/play/PlayClient.tsx
```
### 수정
```text
package.json # test script 추가
app/layout.tsx # KaTeX CSS import 추가
app/(game)/play/page.tsx # PlayClient 마운트
```
---
## 작업 순서
### 2-A. 문제 생성 엔진
완료 기준: `pnpm test` 통과
`lib/engine/types.ts`
```ts
export interface ParenthesisSignProblem {
id: string
key: string
multiplier: number
a: number
operator: '+' | '-'
b: number
distributedA: number
distributedB: number
latexBefore: string
latexAfter: string
}
export interface UserAnswer {
a: number
b: number
}
export interface GradeResult {
correct: boolean
correctAnswer: [number, number]
}
```
`lib/engine/parenthesis-sign.ts`에 구현할 함수:
- `generateProblem(): ParenthesisSignProblem`
- `multiplier`: `-9`부터 `-1` 사이 정수
- `a`, `b`: `1`부터 `9` 사이 정수
- `a !== b`
- `operator`: `'+' | '-'`
- `distributedA = multiplier * a`
- `distributedB = operator === '-' ? multiplier * -b : multiplier * b`
- `key``multiplier:a:operator:b` 형식으로 만든다.
- `latexBefore`: 예시 `-2(3 - 6)`
- `latexAfter`: 예시 `-6 + 12`
- `generateSession(count = 10): ParenthesisSignProblem[]`
- `count``1` 이상인지 확인한다.
- 가능한 문제 수는 `9 * 8 * 2 = 144`개이므로 `count > 144`이면 에러를 던진다.
- 중복 방지는 `id`가 아니라 `key` 기준으로 한다.
- `gradeAnswer(problem, answer): GradeResult`
- 두 항이 모두 일치할 때만 정답이다.
- 입력 순서를 바꾸어 쓴 답은 오답으로 처리한다.
`lib/engine/parenthesis-sign.test.ts` 검증 항목:
- `multiplier`는 항상 음수 정수다.
- `a`, `b`는 범위 안에 있고 서로 다르다.
- `distributedA`, `distributedB` 계산이 `operator`별로 맞다.
- `gradeAnswer()`는 정답과 오답을 정확히 구분한다.
- `generateSession(10)`은 10개 문제를 만들고 `key` 중복이 없다.
- `generateSession(145)`는 에러를 던진다.
`vitest.config.ts`
```ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
},
})
```
`package.json`
```json
"test": "vitest run"
```
---
### 2-B. KaTeX 렌더링
완료 기준: 브라우저에서 문제 수식이 KaTeX로 표시됨
`app/layout.tsx`에 전역 CSS를 추가한다.
```ts
import 'katex/dist/katex.min.css'
```
`components/math/KatexRenderer.tsx`
```tsx
'use client'
import { BlockMath, InlineMath } from 'react-katex'
interface KatexRendererProps {
latex: string
displayMode?: boolean
}
export default function KatexRenderer({
latex,
displayMode = false,
}: KatexRendererProps) {
return displayMode ? <BlockMath math={latex} /> : <InlineMath math={latex} />
}
```
이 단계에서는 `/play`에 임시 문제 하나를 표시해서 KaTeX 렌더링만 확인한다.
---
### 2-C. 한 문제 풀이 UI
완료 기준: 한 문제를 풀고 정답·오답 피드백이 표시됨
`app/(game)/play/PlayClient.tsx`를 클라이언트 컴포넌트로 만든다.
```tsx
'use client'
```
구성:
- 문제 수식: `KatexRenderer``problem.latexBefore` 표시
- 답 입력: 숫자 입력 두 칸
- 제출 버튼
- Enter 제출
- 정답이면 간단한 성공 문구
- 오답이면 정답 수식과 정답 두 항 표시
- 새 문제 버튼
로컬 상태:
```ts
const [problem, setProblem] = useState<ParenthesisSignProblem>(() => generateProblem())
const [answerA, setAnswerA] = useState('')
const [answerB, setAnswerB] = useState('')
const [result, setResult] = useState<GradeResult | null>(null)
```
입력 처리:
- 빈 문자열은 제출하지 않는다.
- `Number.parseInt(value, 10)` 결과가 `NaN`이면 제출하지 않는다.
- 채점 후에는 같은 문제에서 중복 제출하지 않도록 제출 버튼을 비활성화한다.
이 단계에서는 Zustand를 사용하지 않는다.
---
### 2-D. 10문제 세션
완료 기준: 10문제를 모두 풀면 점수 요약 화면이 표시됨
`store/gameStore.ts`
```ts
interface GameSession {
problems: ParenthesisSignProblem[]
currentIndex: number
results: (GradeResult | null)[]
}
interface GameState {
session: GameSession | null
initSession: (count?: number) => void
submitAnswer: (answer: UserAnswer) => void
nextProblem: () => void
resetSession: () => void
currentProblem: () => ParenthesisSignProblem | null
currentResult: () => GradeResult | null
isComplete: () => boolean
score: () => { correct: number; total: number }
}
```
상태 규칙:
- `initSession(10)`은 10개 문제와 `null` 결과 배열을 만든다.
- `submitAnswer()`는 현재 문제의 결과가 `null`일 때만 채점한다.
- `nextProblem()`은 채점 후에만 다음 문제로 이동한다.
- 마지막 문제에서 `nextProblem()`을 호출하면 완료 상태가 된다.
- `resetSession()`은 기존 세션을 버리고 새 세션을 시작한다.
`PlayClient.tsx` 전환:
- `useEffect`에서 `session === null`이면 `initSession()` 호출
- `currentProblem() === null`이면 로딩 또는 완료 화면 표시
- 풀이 중에는 현재 문제 번호를 텍스트로 표시한다. 예: `3 / 10`
- 세션 완료 후 맞은 개수와 전체 개수를 표시한다.
- 다시 시작 버튼은 `resetSession()`을 호출한다.
`app/(game)/play/page.tsx`
```tsx
import PlayClient from './PlayClient'
export default function PlayPage() {
return <PlayClient />
}
```
---
## 화면 흐름
```text
/ 시작 화면
- "연습 시작" 클릭
/play 풀이 화면
├─ 현재 문제 번호
├─ 문제 수식 (KaTeX)
├─ 답 입력 두 칸
├─ 제출 버튼
├─ 정답/오답 피드백
├─ 다음 문제 버튼
└─ 10문제 완료 후 점수 요약
- n / 10 정답
- 다시 시작
```
---
## 완료 기준
- `pnpm test` 통과
- `pnpm lint` 통과
- 브라우저에서 `/play` 수식이 KaTeX로 렌더링된다.
- 정답 입력 시 정답 피드백이 표시된다.
- 오답 입력 시 오답 피드백과 정답이 표시된다.
- 10문제를 모두 풀면 점수 요약 화면이 표시된다.
- 다시 시작을 누르면 새 10문제 세션이 시작된다.
## 확인 명령
```bash
pnpm test
pnpm lint
pnpm dev
```
브라우저 확인은 `pnpm dev` 실행 후 `http://localhost:3000/play`에서 한다.
---
## 다음 단계
`03-ux-polish.md`에서 UI 품질을 높인다.
- 진행률 바
- 정답·오답 배너 컴포넌트
- 점수 요약 컴포넌트
- Framer Motion 기반 전환 애니메이션
- 키보드 탐색과 포커스 처리 개선
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
generateProblem,
generateSession,
gradeAnswer,
} from "./parenthesis-sign";
describe("parenthesis sign problem engine", () => {
it("generates a problem within the configured ranges", () => {
for (let index = 0; index < 100; index += 1) {
const problem = generateProblem();
expect(Number.isInteger(problem.multiplier)).toBe(true);
expect(problem.multiplier).toBeGreaterThanOrEqual(-9);
expect(problem.multiplier).toBeLessThanOrEqual(-1);
expect(problem.a).toBeGreaterThanOrEqual(1);
expect(problem.a).toBeLessThanOrEqual(9);
expect(problem.b).toBeGreaterThanOrEqual(1);
expect(problem.b).toBeLessThanOrEqual(9);
expect(problem.a).not.toBe(problem.b);
expect(["+", "-"]).toContain(problem.operator);
}
});
it("distributes the multiplier correctly for both operators", () => {
for (let index = 0; index < 100; index += 1) {
const problem = generateProblem();
const signedB = problem.operator === "-" ? -problem.b : problem.b;
expect(problem.distributedA).toBe(problem.multiplier * problem.a);
expect(problem.distributedB).toBe(problem.multiplier * signedB);
}
});
it("grades correct and incorrect answers", () => {
const problem = generateProblem();
expect(
gradeAnswer(problem, {
a: problem.distributedA,
b: problem.distributedB,
}).correct,
).toBe(true);
expect(
gradeAnswer(problem, {
a: problem.distributedB,
b: problem.distributedA,
}).correct,
).toBe(false);
});
it("generates a session with unique problem keys", () => {
const problems = generateSession(10);
const keys = new Set(problems.map((problem) => problem.key));
expect(problems).toHaveLength(10);
expect(keys.size).toBe(10);
});
it("rejects impossible session sizes", () => {
expect(() => generateSession(0)).toThrow();
expect(() => generateSession(145)).toThrow();
});
});
+125
View File
@@ -0,0 +1,125 @@
import type {
GradeResult,
ParenthesisSignProblem,
UserAnswer,
} from "./types";
const MIN_MULTIPLIER = -9;
const MAX_MULTIPLIER = -1;
const MIN_TERM = 1;
const MAX_TERM = 9;
const MAX_PROBLEM_COUNT = 9 * 8 * 2;
type ProblemSeed = Pick<
ParenthesisSignProblem,
"multiplier" | "a" | "operator" | "b"
>;
function randomInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function formatLatexTermPair(first: number, second: number) {
const operator = second < 0 ? "-" : "+";
return `${first} ${operator} ${Math.abs(second)}`;
}
function createProblem({
multiplier,
a,
operator,
b,
}: ProblemSeed): ParenthesisSignProblem {
const distributedA = multiplier * a;
const distributedB = operator === "-" ? multiplier * -b : multiplier * b;
const key = `${multiplier}:${a}:${operator}:${b}`;
return {
id: `parenthesis-sign:${key}`,
key,
multiplier,
a,
operator,
b,
distributedA,
distributedB,
latexBefore: `${multiplier}\\left(${a} ${operator} ${b}\\right)`,
latexAfter: formatLatexTermPair(distributedA, distributedB),
};
}
function createAllProblemSeeds() {
const seeds: ProblemSeed[] = [];
for (
let multiplier = MIN_MULTIPLIER;
multiplier <= MAX_MULTIPLIER;
multiplier += 1
) {
for (let a = MIN_TERM; a <= MAX_TERM; a += 1) {
for (let b = MIN_TERM; b <= MAX_TERM; b += 1) {
if (a === b) continue;
seeds.push({ multiplier, a, operator: "+", b });
seeds.push({ multiplier, a, operator: "-", b });
}
}
}
return seeds;
}
const ALL_PROBLEM_SEEDS = createAllProblemSeeds();
function shuffle<T>(items: T[]) {
const shuffled = [...items];
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const targetIndex = randomInt(0, index);
const current = shuffled[index];
shuffled[index] = shuffled[targetIndex];
shuffled[targetIndex] = current;
}
return shuffled;
}
export function generateProblem(): ParenthesisSignProblem {
const a = randomInt(MIN_TERM, MAX_TERM);
let b = randomInt(MIN_TERM, MAX_TERM);
while (a === b) {
b = randomInt(MIN_TERM, MAX_TERM);
}
return createProblem({
multiplier: randomInt(MIN_MULTIPLIER, MAX_MULTIPLIER),
a,
operator: Math.random() < 0.5 ? "+" : "-",
b,
});
}
export function generateSession(count = 10): ParenthesisSignProblem[] {
if (!Number.isInteger(count) || count < 1) {
throw new Error("count must be a positive integer.");
}
if (count > MAX_PROBLEM_COUNT) {
throw new Error(`count cannot exceed ${MAX_PROBLEM_COUNT}.`);
}
return shuffle(ALL_PROBLEM_SEEDS).slice(0, count).map(createProblem);
}
export function gradeAnswer(
problem: ParenthesisSignProblem,
answer: UserAnswer,
): GradeResult {
return {
correct:
answer.a === problem.distributedA && answer.b === problem.distributedB,
correctAnswer: [problem.distributedA, problem.distributedB],
};
}
+22
View File
@@ -0,0 +1,22 @@
export interface ParenthesisSignProblem {
id: string;
key: string;
multiplier: number;
a: number;
operator: "+" | "-";
b: number;
distributedA: number;
distributedB: number;
latexBefore: string;
latexAfter: string;
}
export interface UserAnswer {
a: number;
b: number;
}
export interface GradeResult {
correct: boolean;
correctAnswer: [number, number];
}
+9 -4
View File
@@ -6,21 +6,26 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "vitest run",
"lint": "next lint"
},
"dependencies": {
"katex": "^0.17.0",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18",
"next": "14.2.35"
"zustand": "^5.0.13"
},
"devDependencies": {
"typescript": "^5",
"@types/katex": "^0.16.8",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"eslint": "^8",
"eslint-config-next": "14.2.35"
"typescript": "^5",
"vitest": "^4.1.7"
}
}
+673
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
katex:
specifier: ^0.17.0
version: 0.17.0
next:
specifier: 14.2.35
version: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -17,7 +20,13 @@ importers:
react-dom:
specifier: ^18
version: 18.3.1(react@18.3.1)
zustand:
specifier: ^5.0.13
version: 5.0.13(@types/react@18.3.29)(react@18.3.1)
devDependencies:
'@types/katex':
specifier: ^0.16.8
version: 0.16.8
'@types/node':
specifier: ^20
version: 20.19.41
@@ -42,6 +51,9 @@ importers:
typescript:
specifier: ^5
version: 5.9.3
vitest:
specifier: ^4.1.7
version: 4.1.7(@types/node@20.19.41)(vite@8.0.14(@types/node@20.19.41)(jiti@1.21.7))
packages:
@@ -192,16 +204,120 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@oxc-project/types@0.132.0':
resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@rolldown/binding-android-arm64@1.0.2':
resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/binding-darwin-arm64@1.0.2':
resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/binding-darwin-x64@1.0.2':
resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/binding-freebsd-x64@1.0.2':
resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.0.2':
resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.0.2':
resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.2':
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.2':
resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.2':
resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.2':
resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.2':
resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/binding-wasm32-wasi@1.0.2':
resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32]
'@rolldown/binding-win32-arm64-msvc@1.0.2':
resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.0.2':
resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
'@rushstack/eslint-patch@1.16.1':
resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
@@ -211,9 +327,21 @@ packages:
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/katex@0.16.8':
resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
'@types/node@20.19.41':
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
@@ -410,6 +538,35 @@ packages:
cpu: [x64]
os: [win32]
'@vitest/expect@4.1.7':
resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==}
'@vitest/mocker@4.1.7':
resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.1.7':
resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==}
'@vitest/runner@4.1.7':
resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==}
'@vitest/snapshot@4.1.7':
resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==}
'@vitest/spy@4.1.7':
resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==}
'@vitest/utils@4.1.7':
resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -488,6 +645,10 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-types-flow@0.0.8:
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
@@ -559,6 +720,10 @@ packages:
caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -581,9 +746,16 @@ packages:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
commander@8.3.0:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -639,6 +811,10 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
@@ -682,6 +858,9 @@ packages:
resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==}
engines: {node: '>= 0.4'}
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
@@ -810,10 +989,17 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1150,6 +1336,10 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
katex@0.17.0:
resolution: {integrity: sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==}
hasBin: true
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -1164,6 +1354,80 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-darwin-arm64@1.32.0:
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.32.0:
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.32.0:
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.32.0:
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.32.0:
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.32.0:
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.32.0:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@@ -1185,6 +1449,9 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -1296,6 +1563,9 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -1338,6 +1608,9 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1479,6 +1752,11 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rolldown@1.0.2:
resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -1542,6 +1820,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
@@ -1553,6 +1834,12 @@ packages:
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
@@ -1649,10 +1936,21 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@1.1.2:
resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==}
engines: {node: '>=18'}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@@ -1717,6 +2015,90 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vite@8.0.14:
resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.1.18
esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
'@vitejs/devtools':
optional: true
esbuild:
optional: true
jiti:
optional: true
less:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vitest@4.1.7:
resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.7
'@vitest/browser-preview': 4.1.7
'@vitest/browser-webdriverio': 4.1.7
'@vitest/coverage-istanbul': 4.1.7
'@vitest/coverage-v8': 4.1.7
'@vitest/ui': 4.1.7
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -1738,6 +2120,11 @@ packages:
engines: {node: '>= 8'}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
@@ -1757,6 +2144,24 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zustand@5.0.13:
resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots:
'@alloc/quick-lru@5.2.0': {}
@@ -1889,13 +2294,68 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@oxc-project/types@0.132.0': {}
'@pkgjs/parseargs@0.11.0':
optional: true
'@rolldown/binding-android-arm64@1.0.2':
optional: true
'@rolldown/binding-darwin-arm64@1.0.2':
optional: true
'@rolldown/binding-darwin-x64@1.0.2':
optional: true
'@rolldown/binding-freebsd-x64@1.0.2':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.0.2':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-arm64-musl@1.0.2':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-x64-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-x64-musl@1.0.2':
optional: true
'@rolldown/binding-openharmony-arm64@1.0.2':
optional: true
'@rolldown/binding-wasm32-wasi@1.0.2':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
'@rolldown/binding-win32-arm64-msvc@1.0.2':
optional: true
'@rolldown/binding-win32-x64-msvc@1.0.2':
optional: true
'@rolldown/pluginutils@1.0.1': {}
'@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.16.1': {}
'@standard-schema/spec@1.1.0': {}
'@swc/counter@0.1.3': {}
'@swc/helpers@0.5.5':
@@ -1908,8 +2368,19 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.9': {}
'@types/json5@0.0.29': {}
'@types/katex@0.16.8': {}
'@types/node@20.19.41':
dependencies:
undici-types: 6.21.0
@@ -2088,6 +2559,47 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
'@vitest/expect@4.1.7':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.7
'@vitest/utils': 4.1.7
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.7(vite@8.0.14(@types/node@20.19.41)(jiti@1.21.7))':
dependencies:
'@vitest/spy': 4.1.7
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.14(@types/node@20.19.41)(jiti@1.21.7)
'@vitest/pretty-format@4.1.7':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.7':
dependencies:
'@vitest/utils': 4.1.7
pathe: 2.0.3
'@vitest/snapshot@4.1.7':
dependencies:
'@vitest/pretty-format': 4.1.7
'@vitest/utils': 4.1.7
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.7': {}
'@vitest/utils@4.1.7':
dependencies:
'@vitest/pretty-format': 4.1.7
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
@@ -2191,6 +2703,8 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
assertion-error@2.0.1: {}
ast-types-flow@0.0.8: {}
async-function@1.0.0: {}
@@ -2253,6 +2767,8 @@ snapshots:
caniuse-lite@1.0.30001793: {}
chai@6.2.2: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -2280,8 +2796,12 @@ snapshots:
commander@4.1.1: {}
commander@8.3.0: {}
concat-map@0.0.1: {}
convert-source-map@2.0.0: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -2334,6 +2854,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
detect-libc@2.1.2: {}
didyoumean@1.2.2: {}
dlv@1.1.3: {}
@@ -2438,6 +2960,8 @@ snapshots:
iterator.prototype: 1.1.5
math-intrinsics: 1.1.0
es-module-lexer@2.1.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
@@ -2657,8 +3181,14 @@ snapshots:
estraverse@5.3.0: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
esutils@2.0.3: {}
expect-type@1.3.0: {}
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
@@ -3009,6 +3539,10 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
katex@0.17.0:
dependencies:
commander: 8.3.0
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -3024,6 +3558,55 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lightningcss-android-arm64@1.32.0:
optional: true
lightningcss-darwin-arm64@1.32.0:
optional: true
lightningcss-darwin-x64@1.32.0:
optional: true
lightningcss-freebsd-x64@1.32.0:
optional: true
lightningcss-linux-arm-gnueabihf@1.32.0:
optional: true
lightningcss-linux-arm64-gnu@1.32.0:
optional: true
lightningcss-linux-arm64-musl@1.32.0:
optional: true
lightningcss-linux-x64-gnu@1.32.0:
optional: true
lightningcss-linux-x64-musl@1.32.0:
optional: true
lightningcss-win32-arm64-msvc@1.32.0:
optional: true
lightningcss-win32-x64-msvc@1.32.0:
optional: true
lightningcss@1.32.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.32.0
lightningcss-darwin-arm64: 1.32.0
lightningcss-darwin-x64: 1.32.0
lightningcss-freebsd-x64: 1.32.0
lightningcss-linux-arm-gnueabihf: 1.32.0
lightningcss-linux-arm64-gnu: 1.32.0
lightningcss-linux-arm64-musl: 1.32.0
lightningcss-linux-x64-gnu: 1.32.0
lightningcss-linux-x64-musl: 1.32.0
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
@@ -3040,6 +3623,10 @@ snapshots:
lru-cache@10.4.3: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
math-intrinsics@1.1.0: {}
merge2@1.4.1: {}
@@ -3157,6 +3744,8 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
obug@2.1.1: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -3201,6 +3790,8 @@ snapshots:
lru-cache: 10.4.3
minipass: 7.1.3
pathe@2.0.3: {}
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -3334,6 +3925,27 @@ snapshots:
dependencies:
glob: 7.2.3
rolldown@1.0.2:
dependencies:
'@oxc-project/types': 0.132.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm64': 1.0.2
'@rolldown/binding-darwin-arm64': 1.0.2
'@rolldown/binding-darwin-x64': 1.0.2
'@rolldown/binding-freebsd-x64': 1.0.2
'@rolldown/binding-linux-arm-gnueabihf': 1.0.2
'@rolldown/binding-linux-arm64-gnu': 1.0.2
'@rolldown/binding-linux-arm64-musl': 1.0.2
'@rolldown/binding-linux-ppc64-gnu': 1.0.2
'@rolldown/binding-linux-s390x-gnu': 1.0.2
'@rolldown/binding-linux-x64-gnu': 1.0.2
'@rolldown/binding-linux-x64-musl': 1.0.2
'@rolldown/binding-openharmony-arm64': 1.0.2
'@rolldown/binding-wasm32-wasi': 1.0.2
'@rolldown/binding-win32-arm64-msvc': 1.0.2
'@rolldown/binding-win32-x64-msvc': 1.0.2
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -3421,12 +4033,18 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
signal-exit@4.1.0: {}
source-map-js@1.2.1: {}
stable-hash@0.0.5: {}
stackback@0.0.2: {}
std-env@4.1.0: {}
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -3567,11 +4185,17 @@ snapshots:
dependencies:
any-promise: 1.3.0
tinybench@2.9.0: {}
tinyexec@1.1.2: {}
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinyrainbow@3.1.0: {}
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@@ -3674,6 +4298,45 @@ snapshots:
util-deprecate@1.0.2: {}
vite@8.0.14(@types/node@20.19.41)(jiti@1.21.7):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.15
rolldown: 1.0.2
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 20.19.41
fsevents: 2.3.3
jiti: 1.21.7
vitest@4.1.7(@types/node@20.19.41)(vite@8.0.14(@types/node@20.19.41)(jiti@1.21.7)):
dependencies:
'@vitest/expect': 4.1.7
'@vitest/mocker': 4.1.7(vite@8.0.14(@types/node@20.19.41)(jiti@1.21.7))
'@vitest/pretty-format': 4.1.7
'@vitest/runner': 4.1.7
'@vitest/snapshot': 4.1.7
'@vitest/spy': 4.1.7
'@vitest/utils': 4.1.7
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.1.2
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.14(@types/node@20.19.41)(jiti@1.21.7)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.19.41
transitivePeerDependencies:
- msw
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@@ -3719,6 +4382,11 @@ snapshots:
dependencies:
isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
word-wrap@1.2.5: {}
wrap-ansi@7.0.0:
@@ -3736,3 +4404,8 @@ snapshots:
wrappy@1.0.2: {}
yocto-queue@0.1.0: {}
zustand@5.0.13(@types/react@18.3.29)(react@18.3.1):
optionalDependencies:
'@types/react': 18.3.29
react: 18.3.1
+78
View File
@@ -0,0 +1,78 @@
import { create } from "zustand";
import {
generateSession,
gradeAnswer,
} from "@/lib/engine/parenthesis-sign";
import type {
GradeResult,
ParenthesisSignProblem,
UserAnswer,
} from "@/lib/engine/types";
export interface GameSession {
problems: ParenthesisSignProblem[];
currentIndex: number;
results: (GradeResult | null)[];
}
interface GameState {
session: GameSession | null;
initSession: (count?: number) => void;
submitAnswer: (answer: UserAnswer) => void;
nextProblem: () => void;
resetSession: () => void;
}
const DEFAULT_SESSION_COUNT = 10;
function createSession(count = DEFAULT_SESSION_COUNT): GameSession {
const problems = generateSession(count);
return {
problems,
currentIndex: 0,
results: problems.map(() => null),
};
}
export const useGameStore = create<GameState>((set, get) => ({
session: null,
initSession: (count = DEFAULT_SESSION_COUNT) => {
set({ session: createSession(count) });
},
submitAnswer: (answer) => {
const { session } = get();
if (!session || session.currentIndex >= session.problems.length) return;
if (session.results[session.currentIndex] !== null) return;
const problem = session.problems[session.currentIndex];
const result = gradeAnswer(problem, answer);
const results = [...session.results];
results[session.currentIndex] = result;
set({
session: {
...session,
results,
},
});
},
nextProblem: () => {
const { session } = get();
if (!session || session.currentIndex >= session.problems.length) return;
if (session.results[session.currentIndex] === null) return;
set({
session: {
...session,
currentIndex: session.currentIndex + 1,
},
});
},
resetSession: () => {
set({ session: createSession() });
},
}));
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});