리펙토링: 코드 최적화

This commit is contained in:
2026-05-31 16:21:55 +09:00
parent 6300fe1082
commit de0a92d10f
11 changed files with 130 additions and 156 deletions
+14 -7
View File
@@ -51,6 +51,7 @@ components/
lib/ lib/
engine/ # 문제 생성 알고리즘 엔진 engine/ # 문제 생성 알고리즘 엔진
parenthesis-sign.ts # 숫자 괄호 엔진 parenthesis-sign.ts # 숫자 괄호 엔진
parenthesis-latex.ts # 괄호 식 KaTeX 포맷 및 현재 항 강조 유틸
variable-parenthesis.ts # 미지수 괄호 엔진 variable-parenthesis.ts # 미지수 괄호 엔진
types.ts # 숫자 괄호 타입 types.ts # 숫자 괄호 타입
variable-types.ts # 미지수 괄호 타입 variable-types.ts # 미지수 괄호 타입
@@ -82,6 +83,7 @@ prisma/
- 모든 수학 수식은 KaTeX로 렌더링한다. 일반 텍스트나 Unicode 수식 문자를 직접 사용하지 않는다. - 모든 수학 수식은 KaTeX로 렌더링한다. 일반 텍스트나 Unicode 수식 문자를 직접 사용하지 않는다.
- KaTeX 컴포넌트는 `components/math/` 하위에 위치한다. - KaTeX 컴포넌트는 `components/math/` 하위에 위치한다.
- 현재 수식 컴포넌트는 `katex.renderToString()`을 직접 사용한다. 별도 래퍼 라이브러리를 추가하기 전에 Next.js App Router 클라이언트 번들에서 런타임 문제가 없는지 확인한다. - 현재 수식 컴포넌트는 `katex.renderToString()`을 직접 사용한다. 별도 래퍼 라이브러리를 추가하기 전에 Next.js App Router 클라이언트 번들에서 런타임 문제가 없는지 확인한다.
- 괄호 식 수식 조립과 풀이 중 현재 항 강조는 `lib/engine/parenthesis-latex.ts`의 공통 유틸을 사용한다. 숫자/미지수 풀이 화면에 같은 포맷 로직을 중복 구현하지 않는다.
### 상태 관리 ### 상태 관리
@@ -109,8 +111,8 @@ prisma/
- 모든 항을 하나의 공유 input으로 입력한다. - 모든 항을 하나의 공유 input으로 입력한다.
- input 위 우상단에는 각 항의 진행 상태를 progress 스타일로 표시한다. - input 위 우상단에는 각 항의 진행 상태를 progress 스타일로 표시한다.
- 진행 상태는 값 입력 전에는 괄호 안 원래 항 값을 표시하고, 값 입력 중이거나 입력 후에는 사용자가 입력한 값을 표시한다. - 진행 상태는 값 입력 전에는 괄호 안 원래 항 값을 표시하고, 값 입력 중이거나 입력 후에는 사용자가 입력한 값을 표시한다.
- 첫째 항에서 Enter를 누르면 같은 input이 둘째 항 입력으로 전환된다. - Enter를 누르면 다음 항 입력으로 전환되고, 마지막 항에서 Enter를 누르면 제출 동작을 실행한다. 별도 제출 버튼이 없어도 form submit 흐름이 유지되어야 한다.
- 둘째 항에서 Enter를 누르면 제출 동작을 실행한다. 별도 제출 버튼이 없어도 form submit 흐름이 유지되어야 한다. - 풀이 중인 항의 수, 곱해지는 수, 해당 괄호는 KaTeX 색상으로 강조하고 나머지 항은 강조하지 않는다.
- 터치 기반 입력 환경에서는 input focus 시 커스텀 숫자 키패드를 표시한다. 키패드는 숫자, `+/-`, `.`, `⌫`, `↵` 버튼을 제공한다. - 터치 기반 입력 환경에서는 input focus 시 커스텀 숫자 키패드를 표시한다. 키패드는 숫자, `+/-`, `.`, `⌫`, `↵` 버튼을 제공한다.
- 마우스/트랙패드처럼 hover 가능한 정밀 포인터가 있는 환경은 물리 키보드 사용 환경으로 간주하고 커스텀 숫자 키패드를 숨긴다. 웹 표준 API로 물리 키보드 연결 여부 자체를 안정적으로 판별할 수 없으므로 해상도 기준으로 분기하지 않는다. (`lib/input-device.ts``DESKTOP_INPUT_DEVICE_QUERY` 사용) - 마우스/트랙패드처럼 hover 가능한 정밀 포인터가 있는 환경은 물리 키보드 사용 환경으로 간주하고 커스텀 숫자 키패드를 숨긴다. 웹 표준 API로 물리 키보드 연결 여부 자체를 안정적으로 판별할 수 없으므로 해상도 기준으로 분기하지 않는다. (`lib/input-device.ts``DESKTOP_INPUT_DEVICE_QUERY` 사용)
- 입력 중에는 `-`, `1.` 같은 임시 값을 허용할 수 있지만 제출 시에는 정상적인 양의 실수 또는 음의 실수 표현만 통과시킨다. - 입력 중에는 `-`, `1.` 같은 임시 값을 허용할 수 있지만 제출 시에는 정상적인 양의 실수 또는 음의 실수 표현만 통과시킨다.
@@ -120,10 +122,11 @@ prisma/
#### 미지수 괄호 풀이 화면 (`/variable-parentheses/play`) #### 미지수 괄호 풀이 화면 (`/variable-parentheses/play`)
- 답을 직접 입력하지 않는다. 각 항의 보기 버튼 중에서 선택한다. - 답을 직접 입력하지 않는다. 각 항의 보기 버튼 중에서 선택한다.
- 첫째 항 선택 자동으로 둘째 항 보기로 focus가 이동한다. - 선택하면 자동으로 다음 항 보기로 focus가 이동한다.
- 둘째 항을 선택하면 별도 `정답 확인` 버튼 없이 즉시 제출되어 피드백 화면으로 전환된다. - 마지막 항을 선택하면 별도 `정답 확인` 버튼 없이 즉시 제출되어 피드백 화면으로 전환된다.
- 입력 항 진행 상태는 progress 스타일로 표시한다. 선택 전에는 괄호 안 원래 항 값을 표시하고, 선택 후에는 고른 보기 값을 표시한다. - 입력 항 진행 상태는 progress 스타일로 표시한다. 선택 전에는 괄호 안 원래 항 값을 표시하고, 선택 후에는 고른 보기 값을 표시한다.
- 선택된 보기 버튼은 `aria-pressed`로 표시한다. - 선택된 보기 버튼은 `aria-pressed`로 표시한다.
- 풀이 중인 항의 수, 곱해지는 수, 해당 괄호는 KaTeX 색상으로 강조하고 나머지 항은 강조하지 않는다.
### API / 백엔드 ### API / 백엔드
@@ -140,8 +143,11 @@ prisma/
- 문제 세션에서 중복을 방지할 때는 랜덤 id가 아니라 수학적 문제 조건을 나타내는 안정적인 key를 기준으로 한다. - 문제 세션에서 중복을 방지할 때는 랜덤 id가 아니라 수학적 문제 조건을 나타내는 안정적인 key를 기준으로 한다.
- 모든 엔진은 전체 가능한 시드 목록을 모듈 로드 시 한 번 생성(`createAllProblemSeeds`)하고, 세션 생성 시 셔플 후 슬라이스하는 방식으로 중복 없이 출제한다. - 모든 엔진은 전체 가능한 시드 목록을 모듈 로드 시 한 번 생성(`createAllProblemSeeds`)하고, 세션 생성 시 셔플 후 슬라이스하는 방식으로 중복 없이 출제한다.
- 숫자 괄호 엔진: `lib/engine/parenthesis-sign.ts` / 타입: `lib/engine/types.ts` - 숫자 괄호 엔진: `lib/engine/parenthesis-sign.ts` / 타입: `lib/engine/types.ts`
- 문제 구조는 `terms`, `operators`, `distributedTerms` 배열을 기준으로 한다. `a`, `b`, `distributedA`, `distributedB` 같은 2항 전용 필드를 추가하지 않는다.
- 채점 입력은 `{ terms: number[] }` 형태만 사용한다.
- 미지수 괄호 엔진: `lib/engine/variable-parenthesis.ts` / 타입: `lib/engine/variable-types.ts` - 미지수 괄호 엔진: `lib/engine/variable-parenthesis.ts` / 타입: `lib/engine/variable-types.ts`
- 괄호 안 항 중 정확히 하나가 `정수 × x` 항이다. - 괄호 안 항 중 정확히 하나가 `정수 × x` 항이다.
- 문제 구조는 `terms`, `operators`, `correctTerms`, `choices` 배열을 기준으로 한다. `firstTerm`, `secondTerm`, `operator` 같은 2항 전용 필드를 추가하지 않는다.
- 각 항의 보기는 정답 계수의 절대값 기준 음수/양수 두 개를 셔플해 제공한다. - 각 항의 보기는 정답 계수의 절대값 기준 음수/양수 두 개를 셔플해 제공한다.
- 채점은 choice id 기반으로 수행한다. - 채점은 choice id 기반으로 수행한다.
@@ -194,9 +200,10 @@ NEXTAUTH_SECRET=your-secret
``` ```
- 시작 화면(`/`)은 학습 내용 선택 화면이다. `숫자 괄호 풀기`, `미지수 괄호 풀기`가 활성화되어 있고 분수/실수 괄호는 disabled 상태다. - 시작 화면(`/`)은 학습 내용 선택 화면이다. `숫자 괄호 풀기`, `미지수 괄호 풀기`가 활성화되어 있고 분수/실수 괄호는 disabled 상태다.
- 준비 화면은 문제 수 `[5] [10]` 버튼 그룹과 괄호 종류 버튼 그룹을 제공한다. 기본값은 `10`이며, 현재 `()`만 선택 가능하다. - 준비 화면은 문제 수 `[5] [10]`, 괄호 종류, 괄호 안 최대 항 수 `[2] [3] [4]` 버튼 그룹을 제공한다. 기본 문제 수는 `10`, 기본 최대 항 수는 `2`이며, 괄호 종류는 현재 `()`만 선택 가능하다.
- 준비 화면의 `연습 시작`은 Zustand 세션을 초기화하고 풀이 화면으로 이동한다. - 준비 화면의 `연습 시작`은 Zustand 세션을 초기화하고 풀이 화면으로 이동한다.
- 풀이 화면은 URL의 `count` 쿼리와 Zustand의 선택 문제 수를 동기화한다. 허용 문제 수는 `5`, `10`이며 유효하지 않은 값은 `10`으로 처리한다. - 풀이 화면은 URL의 `count`, `terms` 쿼리와 Zustand의 선택 문제 수·최대 항 수를 동기화한다. 허용 문제 수는 `5`, `10`이며 유효하지 않은 값은 `10`으로 처리한다. 허용 최대 항 수는 `2`, `3`, `4`이며 유효하지 않은 값은 `2`로 처리한다.
- 풀이 화면의 문제 수식은 현재 풀고 있는 항의 수, 곱해지는 수, 해당 괄호를 강조 색상으로 표시하고 나머지 항은 기본 색상으로 표시한다.
- 정답·오답 피드백은 `계산식 = 내가 입력한(고른) 답` 형식으로 표시한다. 정답은 `○`, 오답은 붉은색 답과 `×`, 굵은 정답값을 함께 보여준다. - 정답·오답 피드백은 `계산식 = 내가 입력한(고른) 답` 형식으로 표시한다. 정답은 `○`, 오답은 붉은색 답과 `×`, 굵은 정답값을 함께 보여준다.
- 최종 결과 화면은 지금까지 푼 문제를 `문제 = 내가 입력한(고른) 답` 형식으로 나열하고, 피드백 화면과 같은 정답·오답 색상 및 기호 규칙을 사용한다. - 최종 결과 화면은 지금까지 푼 문제를 `문제 = 내가 입력한(고른) 답` 형식으로 나열하고, 피드백 화면과 같은 정답·오답 색상 및 기호 규칙을 사용한다.
+10 -6
View File
@@ -47,6 +47,7 @@
- 시작 화면(`/`)에서 학습 내용을 선택합니다. `숫자 괄호 풀기`, `미지수 괄호 풀기`가 활성 상태이고 분수·실수 괄호는 준비 중으로 비활성화되어 있습니다. - 시작 화면(`/`)에서 학습 내용을 선택합니다. `숫자 괄호 풀기`, `미지수 괄호 풀기`가 활성 상태이고 분수·실수 괄호는 준비 중으로 비활성화되어 있습니다.
- 문제와 정답 수식은 KaTeX로 렌더링합니다. - 문제와 정답 수식은 KaTeX로 렌더링합니다.
- 풀이 중인 문제 수식은 현재 항, 곱해지는 수, 해당 괄호를 강조 색상으로 표시하고 나머지 항은 기본 색상으로 표시합니다.
- 진행 단계는 현재 문제, 완료된 정답, 완료된 오답, 남은 문제를 구분해서 표시합니다. - 진행 단계는 현재 문제, 완료된 정답, 완료된 오답, 남은 문제를 구분해서 표시합니다.
- 정답·오답 피드백은 `계산식 = 내가 입력한(고른) 답` 형식으로 표시합니다. - 정답·오답 피드백은 `계산식 = 내가 입력한(고른) 답` 형식으로 표시합니다.
- 정답은 정답 색상 박스와 `○` 표시, 오답은 오답 색상 박스, 붉은색 답, `×`, 굵은 정답값을 표시합니다. - 정답은 정답 색상 박스와 `○` 표시, 오답은 오답 색상 박스, 붉은색 답, `×`, 굵은 정답값을 표시합니다.
@@ -55,20 +56,20 @@
### 숫자 괄호 풀기 (`/number-parentheses` → `/number-parentheses/play`) ### 숫자 괄호 풀기 (`/number-parentheses` → `/number-parentheses/play`)
- 준비 화면에서 문제 수 5 / 10 선택하고 연습을 시작합니다. 기본값은 10. - 준비 화면에서 문제 수 5 / 10과 괄호 안 최대 항 수 2 / 3 / 4를 선택하고 연습을 시작합니다. 기본값은 문제 수 10, 최대 항 수 2입니다.
- 괄호 종류는 현재 `()`만 선택 가능하고 `{}`, `[]`는 비활성화되어 있습니다. - 괄호 종류는 현재 `()`만 선택 가능하고 `{}`, `[]`는 비활성화되어 있습니다.
- 풀이 화면에서 항을 직접 숫자로 입력합니다. - 풀이 화면에서 항을 직접 숫자로 입력합니다.
- 모든 항은 하나의 input에서 순서대로 입력하며, 첫째 항 Enter → 둘째 항 전환, 둘째 항 Enter → 제출. - 모든 항은 하나의 input에서 순서대로 입력하며, Enter로 다음 항으로 이동하고 마지막 항에서 Enter를 누르면 제출합니다.
- input 위 우상단의 progress 표시에서 각 항의 진행 상태를 보여줍니다. 입력 전에는 괄호 안 원래 항 값, 입력 중/입력 후에는 사용자가 입력한 값을 표시합니다. - input 위 우상단의 progress 표시에서 각 항의 진행 상태를 보여줍니다. 입력 전에는 괄호 안 원래 항 값, 입력 중/입력 후에는 사용자가 입력한 값을 표시합니다.
- 터치 환경에서는 커스텀 숫자 키패드(숫자, `+/-`, `.`, `⌫`, `↵`)가 표시되며, 정밀 포인터가 있는 환경(마우스/트랙패드)에서는 숨깁니다. - 터치 환경에서는 커스텀 숫자 키패드(숫자, `+/-`, `.`, `⌫`, `↵`)가 표시되며, 정밀 포인터가 있는 환경(마우스/트랙패드)에서는 숨깁니다.
### 미지수 괄호 풀기 (`/variable-parentheses` → `/variable-parentheses/play`) ### 미지수 괄호 풀기 (`/variable-parentheses` → `/variable-parentheses/play`)
- 준비 화면 구성은 숫자 괄호와 동일합니다. - 준비 화면 구성은 숫자 괄호와 동일합니다.
- 풀이 화면에서 항을 보기 중에서 선택합니다. - 풀이 화면에서 항을 보기 중에서 선택합니다.
- 각 항의 보기는 정답 계수의 절대값을 기준으로 음수/양수 두 개를 제공합니다. - 각 항의 보기는 정답 계수의 절대값을 기준으로 음수/양수 두 개를 제공합니다.
- 첫째 항 선택 후 자동으로 둘째 항 보기로 focus가 이동합니다. - 항 선택 후 자동으로 다음 항 보기로 focus가 이동합니다.
- 둘째 항을 선택하면 별도 `정답 확인` 버튼 없이 즉시 피드백 화면으로 전환됩니다. - 마지막 항을 선택하면 별도 `정답 확인` 버튼 없이 즉시 피드백 화면으로 전환됩니다.
- 입력 항 progress 표시는 선택 전에는 괄호 안 원래 항 값, 선택 후에는 고른 보기 값을 표시합니다. - 입력 항 progress 표시는 선택 전에는 괄호 안 원래 항 값, 선택 후에는 고른 보기 값을 표시합니다.
--- ---
@@ -141,7 +142,10 @@ components/
lib/ lib/
engine/ # 문제 생성 알고리즘 엔진 engine/ # 문제 생성 알고리즘 엔진
parenthesis-sign.ts # 숫자 괄호 엔진 parenthesis-sign.ts # 숫자 괄호 엔진
parenthesis-latex.ts # 괄호 식 KaTeX 포맷 및 현재 항 강조 유틸
variable-parenthesis.ts # 미지수 괄호 엔진 variable-parenthesis.ts # 미지수 괄호 엔진
types.ts # 숫자 괄호 타입
variable-types.ts # 미지수 괄호 타입
input-device.ts # 입력 장치 감지 미디어 쿼리 input-device.ts # 입력 장치 감지 미디어 쿼리
prisma.ts # Prisma Client 싱글턴 prisma.ts # Prisma Client 싱글턴
redis.ts # Redis Client 싱글턴 redis.ts # Redis Client 싱글턴
@@ -17,34 +17,9 @@ import { DESKTOP_INPUT_DEVICE_QUERY } from "@/lib/input-device";
import KatexRenderer from "@/components/math/KatexRenderer"; import KatexRenderer from "@/components/math/KatexRenderer";
import { useGameStore } from "@/store/gameStore"; import { useGameStore } from "@/store/gameStore";
import type { MaxTermCount, ProblemCount } from "@/store/gameStore"; import type { MaxTermCount, ProblemCount } from "@/store/gameStore";
import type { ParenthesisSignProblem } from "@/lib/engine/types"; import { formatParenthesisExpressionLatex } from "@/lib/engine/parenthesis-latex";
const REAL_NUMBER_PATTERN = /^-?\d+(?:\.\d+)?$/; const REAL_NUMBER_PATTERN = /^-?\d+(?:\.\d+)?$/;
const ACTIVE_TERM_COLOR = "#f97316";
function highlightLatex(latex: string) {
return `\\textcolor{${ACTIVE_TERM_COLOR}}{${latex}}`;
}
function formatActiveTermProblemLatex(
problem: ParenthesisSignProblem,
activeTermIndex: number,
) {
const termLatex = problem.terms
.map((term, index) => {
const displayTerm =
index === activeTermIndex ? highlightLatex(`${term}`) : `${term}`;
if (index === 0) return displayTerm;
return `${problem.operators[index - 1]} ${displayTerm}`;
})
.join(" ");
return `${highlightLatex(`${problem.multiplier}`)}${highlightLatex(
"(",
)}${termLatex}${highlightLatex(")")}`;
}
function parseAnswer(value: string) { function parseAnswer(value: string) {
const trimmed = value.trim(); const trimmed = value.trim();
@@ -233,7 +208,13 @@ export default function NumberPlayClient({
const isLastProblem = session.currentIndex === session.problems.length - 1; const isLastProblem = session.currentIndex === session.problems.length - 1;
const problemLatex = submitted const problemLatex = submitted
? currentProblem.latexBefore ? currentProblem.latexBefore
: formatActiveTermProblemLatex(currentProblem, activeInput); : formatParenthesisExpressionLatex({
multiplier: currentProblem.multiplier,
terms: currentProblem.terms,
operators: currentProblem.operators,
formatTermLatex: (term) => `${term}`,
activeTermIndex: activeInput,
});
const pendingAnswerLabels = currentProblem.terms.map((term, index) => { const pendingAnswerLabels = currentProblem.terms.map((term, index) => {
if (index === 0) return String(term); if (index === 0) return String(term);
@@ -10,41 +10,13 @@ import VariableFeedbackPanel from "@/components/game/VariableFeedbackPanel";
import VariableScoreSummary from "@/components/game/VariableScoreSummary"; import VariableScoreSummary from "@/components/game/VariableScoreSummary";
import KatexRenderer from "@/components/math/KatexRenderer"; import KatexRenderer from "@/components/math/KatexRenderer";
import { formatVariableTermLatex } from "@/lib/engine/variable-parenthesis"; import { formatVariableTermLatex } from "@/lib/engine/variable-parenthesis";
import type { VariableParenthesisProblem } from "@/lib/engine/variable-types"; import { formatParenthesisExpressionLatex } from "@/lib/engine/parenthesis-latex";
import { import {
useVariableGameStore, useVariableGameStore,
type VariableMaxTermCount, type VariableMaxTermCount,
type VariableProblemCount, type VariableProblemCount,
} from "@/store/variableGameStore"; } from "@/store/variableGameStore";
const ACTIVE_TERM_COLOR = "#f97316";
function highlightLatex(latex: string) {
return `\\textcolor{${ACTIVE_TERM_COLOR}}{${latex}}`;
}
function formatActiveTermProblemLatex(
problem: VariableParenthesisProblem,
activeTermIndex: number,
) {
const termLatex = problem.terms
.map((term, index) => {
const displayTerm =
index === activeTermIndex
? highlightLatex(formatVariableTermLatex(term))
: formatVariableTermLatex(term);
if (index === 0) return displayTerm;
return `${problem.operators[index - 1]} ${displayTerm}`;
})
.join(" ");
return `${highlightLatex(`${problem.multiplier}`)}${highlightLatex(
"(",
)}${termLatex}${highlightLatex(")")}`;
}
interface VariablePlayClientProps { interface VariablePlayClientProps {
problemCount: VariableProblemCount; problemCount: VariableProblemCount;
maxTermCount: VariableMaxTermCount; maxTermCount: VariableMaxTermCount;
@@ -187,7 +159,13 @@ export default function VariablePlayClient({
const isLastProblem = session.currentIndex === session.problems.length - 1; const isLastProblem = session.currentIndex === session.problems.length - 1;
const problemLatex = submitted const problemLatex = submitted
? currentProblem.latexBefore ? currentProblem.latexBefore
: formatActiveTermProblemLatex(currentProblem, activeTermIndex); : formatParenthesisExpressionLatex({
multiplier: currentProblem.multiplier,
terms: currentProblem.terms,
operators: currentProblem.operators,
formatTermLatex: formatVariableTermLatex,
activeTermIndex,
});
const pendingChoiceLabels = currentProblem.terms.map((term, index) => const pendingChoiceLabels = currentProblem.terms.map((term, index) =>
formatVariableTermLatex({ formatVariableTermLatex({
...term, ...term,
+48
View File
@@ -0,0 +1,48 @@
export const ACTIVE_DISTRIBUTION_COLOR = "#f97316";
function colorLatex(latex: string, color: string) {
return `\\textcolor{${color}}{${latex}}`;
}
interface FormatParenthesisExpressionLatexParams<T> {
multiplier: number;
terms: T[];
operators: ("+" | "-")[];
formatTermLatex: (term: T) => string;
activeTermIndex?: number;
activeColor?: string;
}
export function formatParenthesisExpressionLatex<T>({
multiplier,
terms,
operators,
formatTermLatex,
activeTermIndex,
activeColor = ACTIVE_DISTRIBUTION_COLOR,
}: FormatParenthesisExpressionLatexParams<T>) {
const hasActiveTerm = activeTermIndex !== undefined;
const maybeColor = (latex: string, active: boolean) =>
hasActiveTerm && active ? colorLatex(latex, activeColor) : latex;
const termLatex = terms
.map((term, index) => {
const displayTerm = maybeColor(
formatTermLatex(term),
index === activeTermIndex,
);
if (index === 0) return displayTerm;
return `${operators[index - 1]} ${displayTerm}`;
})
.join(" ");
if (hasActiveTerm) {
return `${colorLatex(`${multiplier}`, activeColor)}${colorLatex(
"(",
activeColor,
)}${termLatex}${colorLatex(")", activeColor)}`;
}
return `${multiplier}\\left(${termLatex}\\right)`;
}
+16 -17
View File
@@ -14,22 +14,26 @@ describe("parenthesis sign problem engine", () => {
expect(Number.isInteger(problem.multiplier)).toBe(true); expect(Number.isInteger(problem.multiplier)).toBe(true);
expect(problem.multiplier).toBeGreaterThanOrEqual(-9); expect(problem.multiplier).toBeGreaterThanOrEqual(-9);
expect(problem.multiplier).toBeLessThanOrEqual(-1); expect(problem.multiplier).toBeLessThanOrEqual(-1);
expect(problem.a).toBeGreaterThanOrEqual(1); expect(problem.terms).toHaveLength(2);
expect(problem.a).toBeLessThanOrEqual(9); expect(problem.operators).toHaveLength(1);
expect(problem.b).toBeGreaterThanOrEqual(1); expect(problem.terms.every((term) => term >= 1 && term <= 9)).toBe(true);
expect(problem.b).toBeLessThanOrEqual(9); expect(new Set(problem.terms).size).toBe(problem.terms.length);
expect(problem.a).not.toBe(problem.b); expect(["+", "-"]).toContain(problem.operators[0]);
expect(["+", "-"]).toContain(problem.operator);
} }
}); });
it("distributes the multiplier correctly for both operators", () => { it("distributes the multiplier correctly for both operators", () => {
for (let index = 0; index < 100; index += 1) { for (let index = 0; index < 100; index += 1) {
const problem = generateProblem(); const problem = generateProblem();
const signedB = problem.operator === "-" ? -problem.b : problem.b; const signedTerms = problem.terms.map((term, termIndex) => {
if (termIndex === 0) return term;
expect(problem.distributedA).toBe(problem.multiplier * problem.a); return problem.operators[termIndex - 1] === "-" ? -term : term;
expect(problem.distributedB).toBe(problem.multiplier * signedB); });
expect(problem.distributedTerms).toEqual(
signedTerms.map((term) => problem.multiplier * term),
);
} }
}); });
@@ -37,20 +41,15 @@ describe("parenthesis sign problem engine", () => {
const problem = generateProblem(); const problem = generateProblem();
const correctResult = gradeAnswer(problem, { const correctResult = gradeAnswer(problem, {
a: problem.distributedA, terms: problem.distributedTerms,
b: problem.distributedB,
}); });
expect(correctResult.correct).toBe(true); expect(correctResult.correct).toBe(true);
expect(correctResult.userAnswer).toEqual([ expect(correctResult.userAnswer).toEqual(problem.distributedTerms);
problem.distributedA,
problem.distributedB,
]);
expect( expect(
gradeAnswer(problem, { gradeAnswer(problem, {
a: problem.distributedB, terms: [...problem.distributedTerms].reverse(),
b: problem.distributedA,
}).correct, }).correct,
).toBe(false); ).toBe(false);
}); });
+8 -31
View File
@@ -3,6 +3,7 @@ import type {
ParenthesisSignProblem, ParenthesisSignProblem,
UserAnswer, UserAnswer,
} from "./types"; } from "./types";
import { formatParenthesisExpressionLatex } from "./parenthesis-latex";
const MIN_MULTIPLIER = -9; const MIN_MULTIPLIER = -9;
const MAX_MULTIPLIER = -1; const MAX_MULTIPLIER = -1;
@@ -33,19 +34,6 @@ function formatLatexTerms(terms: number[]) {
.join(" "); .join(" ");
} }
function formatParenthesisTerms(
terms: number[],
operators: ("+" | "-")[],
) {
return terms
.map((term, index) => {
if (index === 0) return `${term}`;
return `${operators[index - 1]} ${term}`;
})
.join(" ");
}
function createProblem({ function createProblem({
multiplier, multiplier,
terms, terms,
@@ -57,9 +45,6 @@ function createProblem({
return operators[index - 1] === "-" ? -term : term; return operators[index - 1] === "-" ? -term : term;
}); });
const distributedTerms = signedTerms.map((term) => multiplier * term); const distributedTerms = signedTerms.map((term) => multiplier * term);
const [a, b] = terms;
const [operator] = operators;
const [distributedA, distributedB] = distributedTerms;
const key = [ const key = [
multiplier, multiplier,
...terms.flatMap((term, index) => ...terms.flatMap((term, index) =>
@@ -73,16 +58,13 @@ function createProblem({
multiplier, multiplier,
terms, terms,
operators, operators,
a,
operator,
b,
distributedTerms, distributedTerms,
distributedA, latexBefore: formatParenthesisExpressionLatex({
distributedB, multiplier,
latexBefore: `${multiplier}\\left(${formatParenthesisTerms(
terms, terms,
operators, operators,
)}\\right)`, formatTermLatex: (term) => `${term}`,
}),
latexAfter: formatLatexTerms(distributedTerms), latexAfter: formatLatexTerms(distributedTerms),
}; };
} }
@@ -222,16 +204,11 @@ export function gradeAnswer(
problem: ParenthesisSignProblem, problem: ParenthesisSignProblem,
answer: UserAnswer, answer: UserAnswer,
): GradeResult { ): GradeResult {
const userAnswer =
answer.terms ?? (answer.a !== undefined && answer.b !== undefined
? [answer.a, answer.b]
: []);
return { return {
correct: correct:
userAnswer.length === problem.distributedTerms.length && answer.terms.length === problem.distributedTerms.length &&
userAnswer.every((value, index) => value === problem.distributedTerms[index]), answer.terms.every((value, index) => value === problem.distributedTerms[index]),
userAnswer, userAnswer: answer.terms,
correctAnswer: problem.distributedTerms, correctAnswer: problem.distributedTerms,
}; };
} }
+1 -8
View File
@@ -4,20 +4,13 @@ export interface ParenthesisSignProblem {
multiplier: number; multiplier: number;
terms: number[]; terms: number[];
operators: ("+" | "-")[]; operators: ("+" | "-")[];
a: number;
operator: "+" | "-";
b: number;
distributedTerms: number[]; distributedTerms: number[];
distributedA: number;
distributedB: number;
latexBefore: string; latexBefore: string;
latexAfter: string; latexAfter: string;
} }
export interface UserAnswer { export interface UserAnswer {
terms?: number[]; terms: number[];
a?: number;
b?: number;
} }
export interface GradeResult { export interface GradeResult {
+10 -11
View File
@@ -25,27 +25,26 @@ function findCorrectChoiceIds(problem: VariableParenthesisProblem) {
describe("variable parenthesis engine", () => { describe("variable parenthesis engine", () => {
it("generates a problem with exactly one variable term inside parentheses", () => { it("generates a problem with exactly one variable term inside parentheses", () => {
const problem = generateVariableParenthesisProblem(); const problem = generateVariableParenthesisProblem();
const terms = [problem.firstTerm, problem.secondTerm];
expect(terms.filter((term) => term.kind === "variable")).toHaveLength(1); expect(problem.terms.filter((term) => term.kind === "variable")).toHaveLength(1);
expect(terms.filter((term) => term.kind === "constant")).toHaveLength(1); expect(problem.terms.filter((term) => term.kind === "constant")).toHaveLength(1);
expect(problem.multiplier).toBeLessThan(0); expect(problem.multiplier).toBeLessThan(0);
expect(Number.isInteger(problem.multiplier)).toBe(true); expect(Number.isInteger(problem.multiplier)).toBe(true);
expect(problem.firstTerm.coefficient).toBeGreaterThanOrEqual(1); expect(
expect(problem.secondTerm.coefficient).toBeGreaterThanOrEqual(1); problem.terms.every((term) => term.coefficient >= 1 && term.coefficient <= 9),
).toBe(true);
}); });
it("distributes signs correctly for addition and subtraction", () => { it("distributes signs correctly for addition and subtraction", () => {
const problems = generateVariableParenthesisSession(50); const problems = generateVariableParenthesisSession(50);
for (const problem of problems) { for (const problem of problems) {
const secondSign = problem.operator === "-" ? -1 : 1; expect(problem.correctTerms.map((term) => term.coefficient)).toEqual(
problem.terms.map((term, index) => {
const sign = index === 0 || problem.operators[index - 1] === "+" ? 1 : -1;
expect(problem.correctTerms[0].coefficient).toBe( return problem.multiplier * sign * term.coefficient;
problem.multiplier * problem.firstTerm.coefficient, }),
);
expect(problem.correctTerms[1].coefficient).toBe(
problem.multiplier * secondSign * problem.secondTerm.coefficient,
); );
} }
}); });
+7 -16
View File
@@ -6,6 +6,7 @@ import type {
VariableUserAnswer, VariableUserAnswer,
VariableGradeResult, VariableGradeResult,
} from "./variable-types"; } from "./variable-types";
import { formatParenthesisExpressionLatex } from "./parenthesis-latex";
const MIN_MULTIPLIER = -9; const MIN_MULTIPLIER = -9;
const MAX_MULTIPLIER = -1; const MAX_MULTIPLIER = -1;
@@ -95,10 +96,6 @@ export function formatVariableTermLatex(
return `${term.coefficient}x`; return `${term.coefficient}x`;
} }
function formatParenthesisTermLatex(term: VariableParenthesisTerm) {
return formatVariableTermLatex(term);
}
export function formatVariableExpressionLatex( export function formatVariableExpressionLatex(
terms: DistributedVariableTerm[], terms: DistributedVariableTerm[],
) { ) {
@@ -164,8 +161,6 @@ function createChoices(
function createProblem(seed: VariableProblemSeed): VariableParenthesisProblem { function createProblem(seed: VariableProblemSeed): VariableParenthesisProblem {
const { multiplier, terms, operators } = seed; const { multiplier, terms, operators } = seed;
const [firstTerm, secondTerm] = terms;
const [operator] = operators;
const key = [ const key = [
multiplier, multiplier,
...terms.flatMap((term, index) => ...terms.flatMap((term, index) =>
@@ -189,18 +184,14 @@ function createProblem(seed: VariableProblemSeed): VariableParenthesisProblem {
multiplier, multiplier,
terms, terms,
operators, operators,
operator,
firstTerm,
secondTerm,
correctTerms, correctTerms,
choices, choices,
latexBefore: `${multiplier}\\left(${terms latexBefore: formatParenthesisExpressionLatex({
.map((term, index) => { multiplier,
if (index === 0) return formatParenthesisTermLatex(term); terms,
operators,
return `${operators[index - 1]} ${formatParenthesisTermLatex(term)}`; formatTermLatex: formatVariableTermLatex,
}) }),
.join(" ")}\\right)`,
latexAfter: formatVariableExpressionLatex(correctTerms), latexAfter: formatVariableExpressionLatex(correctTerms),
}; };
} }
-3
View File
@@ -26,9 +26,6 @@ export interface VariableParenthesisProblem {
multiplier: number; multiplier: number;
terms: VariableParenthesisTerm[]; terms: VariableParenthesisTerm[];
operators: ("+" | "-")[]; operators: ("+" | "-")[];
operator: "+" | "-";
firstTerm: VariableParenthesisTerm;
secondTerm: VariableParenthesisTerm;
correctTerms: DistributedVariableTerm[]; correctTerms: DistributedVariableTerm[];
choices: VariableTermChoice[][]; choices: VariableTermChoice[][];
latexBefore: string; latexBefore: string;