Skip to content

Commit 272619d

Browse files
committed
feat: add Visualize, Math Animator, Deep Solve features (full-stack TDD)
Three DeepTutor-inspired features implemented across Python + TypeScript: - Visualize: 3-stage LLM pipeline for SVG/Chart.js generation - Math Animator: 5-agent Manim code generation pipeline with AST validation - Deep Solve: Plan→ReAct→Write multi-agent solver with SymPy CAS tools Python (14 tests): models, services, routers for all 3 features TypeScript (17 tests): HTTP client services + tRPC routers Components (15 tests): visual-solution, animation-preview, deep-solve-panel
1 parent 593588a commit 272619d

30 files changed

Lines changed: 2941 additions & 0 deletions
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// @vitest-environment jsdom
2+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3+
import { render, fireEvent, cleanup } from "@testing-library/react";
4+
import { AnimationPreview } from "../animation-preview";
5+
6+
// ─────────────────────────────────────────────
7+
// Mock: tRPC
8+
// ─────────────────────────────────────────────
9+
const mockMutate = vi.fn();
10+
let mockMutation = {
11+
mutate: mockMutate,
12+
isPending: false,
13+
data: null as Record<string, unknown> | null,
14+
error: null as Error | null,
15+
isSuccess: false,
16+
};
17+
18+
vi.mock("@/lib/trpc", () => ({
19+
trpc: {
20+
animate: {
21+
generate: {
22+
useMutation: () => mockMutation,
23+
},
24+
},
25+
},
26+
}));
27+
28+
// ─────────────────────────────────────────────
29+
// 테스트
30+
// ─────────────────────────────────────────────
31+
describe("AnimationPreview", () => {
32+
afterEach(cleanup);
33+
34+
beforeEach(() => {
35+
vi.clearAllMocks();
36+
mockMutation = {
37+
mutate: mockMutate,
38+
isPending: false,
39+
data: null,
40+
error: null,
41+
isSuccess: false,
42+
};
43+
});
44+
45+
it("idle 상태 → 애니메이션 생성 버튼 렌더링", () => {
46+
const { getByText } = render(<AnimationPreview latex="x^2 + 1" />);
47+
expect(getByText("애니메이션 생성")).toBeDefined();
48+
});
49+
50+
it("success 상태 → Manim 코드 블록 표시", () => {
51+
mockMutation = {
52+
...mockMutation,
53+
isSuccess: true,
54+
data: {
55+
success: true,
56+
manimCode: "class Example(Scene):\n def construct(self):\n pass",
57+
summary: "이차함수 그래프 애니메이션",
58+
},
59+
};
60+
const { container, getByText } = render(<AnimationPreview latex="x^2 + 1" />);
61+
expect(container.querySelector("pre")).not.toBeNull();
62+
expect(getByText("이차함수 그래프 애니메이션")).toBeDefined();
63+
});
64+
65+
it("error 상태 → 에러 메시지 표시", () => {
66+
mockMutation = {
67+
...mockMutation,
68+
error: new Error("애니메이션 생성 실패"),
69+
};
70+
const { getByText } = render(<AnimationPreview latex="x^2 + 1" />);
71+
expect(getByText("애니메이션 생성 실패")).toBeDefined();
72+
});
73+
74+
it("버튼 클릭 → mutate 호출 (기본 스타일)", () => {
75+
const { getByText } = render(<AnimationPreview latex="x^2 + 1" />);
76+
fireEvent.click(getByText("애니메이션 생성"));
77+
expect(mockMutate).toHaveBeenCalledWith({
78+
latex: "x^2 + 1",
79+
style: "step_by_step",
80+
});
81+
});
82+
83+
it("스타일 변경 후 mutate 호출", () => {
84+
const { getByText, getByDisplayValue } = render(
85+
<AnimationPreview latex="x^2 + 1" />,
86+
);
87+
fireEvent.change(getByDisplayValue("step_by_step"), {
88+
target: { value: "transform" },
89+
});
90+
fireEvent.click(getByText("애니메이션 생성"));
91+
expect(mockMutate).toHaveBeenCalledWith({
92+
latex: "x^2 + 1",
93+
style: "transform",
94+
});
95+
});
96+
});
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// @vitest-environment jsdom
2+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3+
import { render, fireEvent, cleanup, screen } from "@testing-library/react";
4+
import { DeepSolvePanel } from "../deep-solve-panel";
5+
6+
// ─────────────────────────────────────────────
7+
// Mock: tRPC
8+
// ─────────────────────────────────────────────
9+
const mockMutate = vi.fn();
10+
let mockMutation = {
11+
mutate: mockMutate,
12+
isPending: false,
13+
data: null as Record<string, unknown> | null,
14+
error: null as Error | null,
15+
isSuccess: false,
16+
};
17+
18+
vi.mock("@/lib/trpc", () => ({
19+
trpc: {
20+
deepSolve: {
21+
solve: {
22+
useMutation: () => mockMutation,
23+
},
24+
},
25+
},
26+
}));
27+
28+
// ─────────────────────────────────────────────
29+
// 테스트 데이터
30+
// ─────────────────────────────────────────────
31+
const mockSolveData = {
32+
success: true,
33+
steps: [
34+
{
35+
stepNumber: 1,
36+
latex: "x + 1 = 0",
37+
explanation: "양변에서 1을 빼기",
38+
toolUsed: "sympy",
39+
},
40+
{
41+
stepNumber: 2,
42+
latex: "x = -1",
43+
explanation: "최종 결과",
44+
},
45+
],
46+
finalAnswer: "x = -1",
47+
verification: "검증 완료: x = -1 대입 시 0 = 0",
48+
};
49+
50+
// ─────────────────────────────────────────────
51+
// 테스트
52+
// ─────────────────────────────────────────────
53+
describe("DeepSolvePanel", () => {
54+
afterEach(cleanup);
55+
56+
beforeEach(() => {
57+
vi.clearAllMocks();
58+
mockMutation = {
59+
mutate: mockMutate,
60+
isPending: false,
61+
data: null,
62+
error: null,
63+
isSuccess: false,
64+
};
65+
});
66+
67+
it("idle 상태 → 심층 풀이 버튼 렌더링", () => {
68+
const { getByText } = render(
69+
<DeepSolvePanel latex="x + 1 = 0" schoolLevel="middle" />,
70+
);
71+
expect(getByText("심층 풀이")).toBeDefined();
72+
});
73+
74+
it("success 상태 → 풀이 단계 + 최종 답 렌더링", () => {
75+
mockMutation = {
76+
...mockMutation,
77+
isSuccess: true,
78+
data: mockSolveData,
79+
};
80+
const { getByText, getAllByText } = render(
81+
<DeepSolvePanel latex="x + 1 = 0" schoolLevel="middle" />,
82+
);
83+
expect(getByText("양변에서 1을 빼기")).toBeDefined();
84+
expect(getByText("최종 결과")).toBeDefined();
85+
// "x = -1" appears in both step latex and final answer
86+
expect(getAllByText("x = -1").length).toBeGreaterThanOrEqual(1);
87+
expect(getByText("검증 완료: x = -1 대입 시 0 = 0")).toBeDefined();
88+
});
89+
90+
it("success 상태 → toolUsed 배지 표시", () => {
91+
mockMutation = {
92+
...mockMutation,
93+
isSuccess: true,
94+
data: mockSolveData,
95+
};
96+
const { getByText } = render(
97+
<DeepSolvePanel latex="x + 1 = 0" schoolLevel="middle" />,
98+
);
99+
expect(getByText("sympy")).toBeDefined();
100+
});
101+
102+
it("error 상태 → 에러 메시지 표시", () => {
103+
mockMutation = {
104+
...mockMutation,
105+
error: new Error("풀이 실패"),
106+
};
107+
const { getByText } = render(
108+
<DeepSolvePanel latex="x + 1 = 0" schoolLevel="middle" />,
109+
);
110+
expect(getByText("풀이 실패")).toBeDefined();
111+
});
112+
113+
it("버튼 클릭 → mutate 호출", () => {
114+
const onSolved = vi.fn();
115+
const { getByText } = render(
116+
<DeepSolvePanel latex="x + 1 = 0" schoolLevel="middle" onSolved={onSolved} />,
117+
);
118+
fireEvent.click(getByText("심층 풀이"));
119+
expect(mockMutate).toHaveBeenCalledWith(
120+
{ latex: "x + 1 = 0", schoolLevel: "middle" },
121+
expect.objectContaining({ onSuccess: expect.any(Function) }),
122+
);
123+
});
124+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// @vitest-environment jsdom
2+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3+
import { render, fireEvent, cleanup } from "@testing-library/react";
4+
import { VisualSolution } from "../visual-solution";
5+
6+
// ─────────────────────────────────────────────
7+
// Mock: tRPC
8+
// ─────────────────────────────────────────────
9+
const mockMutate = vi.fn();
10+
let mockMutation = {
11+
mutate: mockMutate,
12+
isPending: false,
13+
data: null as Record<string, unknown> | null,
14+
error: null as Error | null,
15+
isSuccess: false,
16+
};
17+
18+
vi.mock("@/lib/trpc", () => ({
19+
trpc: {
20+
visualize: {
21+
generate: {
22+
useMutation: () => mockMutation,
23+
},
24+
},
25+
},
26+
}));
27+
28+
// ─────────────────────────────────────────────
29+
// 테스트
30+
// ─────────────────────────────────────────────
31+
describe("VisualSolution", () => {
32+
afterEach(cleanup);
33+
34+
beforeEach(() => {
35+
vi.clearAllMocks();
36+
mockMutation = {
37+
mutate: mockMutate,
38+
isPending: false,
39+
data: null,
40+
error: null,
41+
isSuccess: false,
42+
};
43+
});
44+
45+
it("idle 상태 → 시각화 생성 버튼 렌더링", () => {
46+
const { getByText } = render(<VisualSolution latex="x^2 + 1" />);
47+
expect(getByText("시각화 생성")).toBeDefined();
48+
});
49+
50+
it("loading 상태 → 스피너 표시", () => {
51+
mockMutation = { ...mockMutation, isPending: true };
52+
const { container } = render(<VisualSolution latex="x^2 + 1" />);
53+
expect(container.querySelector(".animate-spin")).not.toBeNull();
54+
});
55+
56+
it("success 상태 → SVG 콘텐츠 렌더링", () => {
57+
mockMutation = {
58+
...mockMutation,
59+
isSuccess: true,
60+
data: {
61+
success: true,
62+
visualizationType: "svg",
63+
content: '<svg><circle r="10"/></svg>',
64+
reviewNotes: "그래프가 정확합니다",
65+
},
66+
};
67+
const { container, getByText } = render(<VisualSolution latex="x^2 + 1" />);
68+
expect(container.querySelector("svg")).not.toBeNull();
69+
expect(getByText("그래프가 정확합니다")).toBeDefined();
70+
});
71+
72+
it("error 상태 → 에러 메시지 표시", () => {
73+
mockMutation = {
74+
...mockMutation,
75+
error: new Error("시각화 생성 실패"),
76+
};
77+
const { getByText } = render(<VisualSolution latex="x^2 + 1" />);
78+
expect(getByText("시각화 생성 실패")).toBeDefined();
79+
});
80+
81+
it("버튼 클릭 → mutate 호출", () => {
82+
const onGenerate = vi.fn();
83+
const { getByText } = render(
84+
<VisualSolution latex="x^2 + 1" onGenerate={onGenerate} />,
85+
);
86+
fireEvent.click(getByText("시각화 생성"));
87+
expect(mockMutate).toHaveBeenCalledWith({ latex: "x^2 + 1" });
88+
});
89+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"use client";
2+
3+
// 애니메이션 미리보기 컴포넌트
4+
// Manim 코드를 생성하고 코드 블록 + 요약을 표시한다.
5+
import { useState } from "react";
6+
import { trpc } from "@/lib/trpc";
7+
8+
type AnimationStyle = "step_by_step" | "transform" | "graph";
9+
10+
interface AnimationPreviewProps {
11+
readonly latex: string;
12+
readonly onGenerate?: () => void;
13+
}
14+
15+
export function AnimationPreview({ latex, onGenerate }: AnimationPreviewProps) {
16+
const [style, setStyle] = useState<AnimationStyle>("step_by_step");
17+
const mutation = trpc.animate.generate.useMutation();
18+
19+
const handleGenerate = () => {
20+
mutation.mutate({ latex, style });
21+
onGenerate?.();
22+
};
23+
24+
return (
25+
<div className="rounded-lg border border-slate-200 bg-white p-5">
26+
<div className="flex items-center gap-3">
27+
<select
28+
value={style}
29+
onChange={(e) => setStyle(e.target.value as AnimationStyle)}
30+
className="rounded border border-slate-300 px-2 py-1 text-sm"
31+
>
32+
<option value="step_by_step">step_by_step</option>
33+
<option value="transform">transform</option>
34+
<option value="graph">graph</option>
35+
</select>
36+
37+
<button
38+
type="button"
39+
onClick={handleGenerate}
40+
disabled={mutation.isPending}
41+
className="rounded-md bg-slate-700 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-50"
42+
>
43+
애니메이션 생성
44+
</button>
45+
46+
{mutation.isPending && (
47+
<div className="h-5 w-5 animate-spin rounded-full border-2 border-slate-300 border-t-slate-700" />
48+
)}
49+
</div>
50+
51+
{mutation.error && (
52+
<p className="mt-3 text-sm text-red-600">{mutation.error.message}</p>
53+
)}
54+
55+
{mutation.isSuccess && mutation.data && (
56+
<div className="mt-4 flex flex-col gap-3">
57+
<pre className="overflow-auto rounded bg-slate-50 p-3 text-xs">
58+
{mutation.data.manimCode}
59+
</pre>
60+
61+
{mutation.data.summary && (
62+
<p className="text-sm text-slate-600">{mutation.data.summary}</p>
63+
)}
64+
</div>
65+
)}
66+
</div>
67+
);
68+
}

0 commit comments

Comments
 (0)