Files
coding-mentor-agent/tests/exercises-projects.test.ts

369 lines
17 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { gradeSubmission, selectExercise } from "../src/tools/exercise-tools.js";
import { createProjectPlan, recordProjectProgress, reviewProjectCode, submitProjectStep } from "../src/tools/project-tools.js";
import { createSession } from "../src/server/services.js";
import { getLatestCatalogRun } from "../src/server/course-catalog.js";
import { createId, nowIso } from "../src/security/ids.js";
import { createTestRuntime } from "./utils/runtime.js";
import { insertGeneratedExerciseFixture, insertProjectPlanFixture, upsertMasteryFixture } from "./utils/content-fixtures.js";
describe("exercise and project flows", () => {
it("rejects session-scoped exercise generation before the initial diagnostic is complete", async () => {
let sandboxRuns = 0;
const runtime = await createTestRuntime({
sandbox: {
runPython: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", traceback: "", duration_ms: 1, truncated: false }),
runPytest: async () => {
sandboxRuns++;
return { status: "passed", exit_code: 0, stdout: "", stderr: "", traceback: "", duration_ms: 1, truncated: false, test_results: [] };
},
lint: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", traceback: "", duration_ms: 1, truncated: false }),
},
});
const session = createSession(runtime, { resume: false });
const selected = await selectExercise(runtime, { concept_ids: ["loop"], difficulty: 2, mode: "practice" }, { sessionId: session.session_id });
expect(selected).toMatchObject({
ok: false,
code: "DIAGNOSTIC_REQUIRED",
});
expect(sandboxRuns).toBe(0);
expect(runtime.db.query<{ count: number }>("SELECT COUNT(*) AS count FROM generated_exercises").get()?.count).toBe(0);
});
it("rejects session-scoped exercise generation during guidance-first handoff", async () => {
const runtime = await createTestRuntime({
sandbox: {
runPython: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", traceback: "", duration_ms: 1, truncated: false }),
runPytest: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", traceback: "", duration_ms: 1, truncated: false, test_results: [] }),
lint: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", traceback: "", duration_ms: 1, truncated: false }),
},
});
const session = createSession(runtime, { resume: false });
completeDiagnosticWithWeakConcept(runtime, session.session_id, "loop");
insertGeneratedExerciseFixture(runtime, { conceptIds: ["loop"], difficulty: 2 });
const selected = await selectExercise(runtime, { concept_ids: ["loop"], difficulty: 2, mode: "practice" }, { sessionId: session.session_id });
expect(selected).toMatchObject({
ok: false,
code: "DIAGNOSTIC_REQUIRED",
});
});
it("grades an existing generated exercise fixture with the private evaluator", async () => {
let evaluatorSeenBySandbox = "";
const runtime = await createTestRuntime({
sandbox: {
runPython: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", traceback: "", duration_ms: 1, truncated: false }),
runPytest: async (request) => {
evaluatorSeenBySandbox = request.public_tests;
return {
status: request.public_tests.includes("test_fixture_even_numbers") ? "passed" : "failed",
exit_code: 0,
stdout: "",
stderr: "",
traceback: "",
duration_ms: 10,
truncated: false,
test_results: [{ name: "test_fixture_even_numbers", passed: true, message: "" }],
};
},
lint: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", traceback: "", duration_ms: 1, truncated: false }),
},
});
const fixture = insertGeneratedExerciseFixture(runtime, { conceptIds: ["loop"], difficulty: 2 });
const selected = await selectExercise(runtime, { concept_ids: ["loop"], difficulty: 2, mode: "practice" });
expect(selected).toMatchObject({
ok: true,
data: {
exercise: {
id: fixture.id,
concept_ids: ["loop"],
difficulty: 2,
},
},
});
expect(JSON.stringify(selected.data)).not.toContain("test_fixture_even_numbers");
expect(JSON.stringify(selected.data)).not.toContain("evaluator_private");
const generated = runtime.db.query<{
id: string;
evaluator_private_ref: string;
evaluator_hash: string;
validation_status: string;
schema_version: string;
sandbox_image_version: string;
validation_report_json: string;
}>("SELECT id, evaluator_private_ref, evaluator_hash, validation_status, schema_version, sandbox_image_version, validation_report_json FROM generated_exercises WHERE id = ?").get([fixture.id]);
expect(generated).toMatchObject({
id: fixture.id,
validation_status: "validated",
schema_version: "generated_exercise.v1",
sandbox_image_version: "python:3.13-slim-bookworm",
});
expect(generated?.evaluator_hash).toMatch(/^[a-f0-9]{64}$/);
expect(JSON.parse(generated?.validation_report_json ?? "{}")).toMatchObject({
validation_status: "validated",
});
const privateEvaluator = runtime.db.query<{ evaluator_private: string }>(
"SELECT evaluator_private FROM generated_exercise_evaluators WHERE generated_exercise_id = ?",
).get([fixture.id]);
expect(privateEvaluator?.evaluator_private).toContain("test_fixture_even_numbers");
const graded = await gradeSubmission(runtime, {
exercise_id: fixture.id,
code: "n=int(input())\nfor i in range(1,n+1):\n if i%2==0: print(i)",
hint_count: 0,
});
expect(graded.ok).toBe(true);
expect(graded.data.status).toBe("passed");
expect(graded.data.hidden_tests?.summary).toBe("");
expect(evaluatorSeenBySandbox).toContain("test_fixture_even_numbers");
expect(JSON.stringify(graded.data)).not.toContain("test_fixture_even_numbers");
const evaluatorEvidence = runtime.db.query<{ summary_json: string }>(
"SELECT summary_json FROM tool_evidence WHERE tool_name = ? ORDER BY created_at DESC LIMIT 1",
).get(["read_private_evaluator"]);
const evaluatorPolicy = JSON.parse(evaluatorEvidence?.summary_json ?? "{}").policy;
expect(evaluatorPolicy).toMatchObject({
policy_group: "exercise_submission_tools",
caller: "workflow",
result_code: "allowed_success",
});
expect(evaluatorEvidence?.summary_json).not.toContain("test_fixture_even_numbers");
const learningEvidence = runtime.db.query<{ source_type: string; concept_id: string; outcome: string; source_id: string; validity_state: string }>(
"SELECT source_type, concept_id, outcome, source_id, validity_state FROM learning_evidence WHERE source_id = ?",
).all([graded.data.attempt_id]);
expect(learningEvidence).toEqual([
expect.objectContaining({
source_type: "exercise",
concept_id: "loop",
outcome: "completed_independently",
source_id: graded.data.attempt_id,
validity_state: "valid",
}),
]);
const projected = runtime.db.query<{ mastery_level: number; readiness: number; evidence_count: number; last_evidence_at: string | null }>(
"SELECT mastery_level, readiness, evidence_count, last_evidence_at FROM concept_mastery WHERE concept_id = 'loop'",
).get();
expect(projected?.mastery_level).toBeGreaterThan(0);
expect(projected?.readiness).toBeGreaterThan(0);
expect(projected?.evidence_count).toBeGreaterThan(0);
expect(projected?.last_evidence_at).toBeTruthy();
});
it("records failed exercise evidence without increasing readiness", async () => {
const runtime = await createTestRuntime({
sandbox: {
runPython: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", traceback: "", duration_ms: 1, truncated: false }),
runPytest: async (request) => {
const isValidation = request.code.includes("range(1, n + 1)") || request.code.includes("BROKEN_GENERATED_SOLUTION");
return {
status: isValidation && !request.code.includes("BROKEN_GENERATED_SOLUTION") ? "passed" : "failed",
exit_code: isValidation ? 0 : 1,
stdout: "",
stderr: "",
traceback: "",
duration_ms: 10,
truncated: false,
test_results: [{ name: "test_fixture_even_numbers", passed: isValidation, message: isValidation ? "" : "wrong output" }],
};
},
lint: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", traceback: "", duration_ms: 1, truncated: false }),
},
});
const fixture = insertGeneratedExerciseFixture(runtime, { conceptIds: ["loop"], difficulty: 2 });
const before = runtime.db.query<{ readiness: number; mastery_level: number }>(
"SELECT readiness, mastery_level FROM concept_mastery WHERE concept_id = 'loop'",
).get();
const graded = await gradeSubmission(runtime, {
exercise_id: fixture.id,
code: "print('wrong')",
hint_count: 1,
});
const after = runtime.db.query<{ readiness: number; mastery_level: number; review_priority: number }>(
"SELECT readiness, mastery_level, review_priority FROM concept_mastery WHERE concept_id = 'loop'",
).get();
expect(graded.data.status).toBe("failed");
expect(after?.readiness).toBeLessThanOrEqual(before?.readiness ?? 0);
expect(after?.mastery_level).toBeLessThanOrEqual(before?.mastery_level ?? 0);
expect(after?.review_priority).toBeGreaterThanOrEqual(1);
});
it("rejects exercise concept ids outside the active catalog", async () => {
const runtime = await createTestRuntime();
const selected = await selectExercise(runtime, { concept_ids: ["not-in-current-catalog"], difficulty: 2, mode: "practice" });
expect(selected).toMatchObject({
ok: false,
code: "CATALOG_CONCEPT_NOT_FOUND",
});
expect(runtime.db.query<{ count: number }>("SELECT COUNT(*) AS count FROM generated_exercises").get()?.count).toBe(0);
});
it("unlocks the first exercise from completed diagnostic weak concepts and starting difficulty", async () => {
const runtime = await createTestRuntime({
sandbox: {
runPython: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", traceback: "", duration_ms: 1, truncated: false }),
runPytest: async () => ({ status: "sandbox_error", exit_code: 1, stdout: "", stderr: "Docker daemon unavailable in test runtime", traceback: "", duration_ms: 1, truncated: false, test_results: [] }),
lint: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", traceback: "", duration_ms: 1, truncated: false }),
},
});
const session = createSession(runtime, { resume: false });
completeDiagnosticWithWeakConcept(runtime, session.session_id, "variable");
markGuidanceStarted(runtime, session.session_id);
const selected = await selectExercise(runtime, {}, { sessionId: session.session_id });
expect(selected).toMatchObject({ ok: false, code: "EXERCISE_CONTENT_UNAVAILABLE" });
});
it("returns unavailable instead of creating project plans from templates", async () => {
const runtime = await createTestRuntime();
const created = await createProjectPlan(runtime, { project_goal: "做一个猜数字游戏", preferred_difficulty: 2 });
expect(created).toMatchObject({ ok: false, code: "PROJECT_CONTENT_UNAVAILABLE" });
expect(runtime.db.query<{ count: number }>("SELECT COUNT(*) AS count FROM project_plans").get()?.count).toBe(0);
});
it("rejects project submissions when KB public tests are unavailable", async () => {
let publicTests = "";
const runtime = await createTestRuntime({
sandbox: {
runPython: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", traceback: "", duration_ms: 1, truncated: false }),
runPytest: async (request) => {
publicTests = request.public_tests;
return {
status: request.public_tests.includes("subprocess.run") && request.public_tests.includes("/work/main.py") ? "passed" : "failed",
exit_code: request.public_tests.includes("subprocess.run") ? 0 : 1,
stdout: "",
stderr: "",
traceback: "",
duration_ms: 10,
truncated: false,
test_results: [],
};
},
lint: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", traceback: "", duration_ms: 1, truncated: false }),
},
});
const fixture = insertProjectPlanFixture(runtime);
const submitted = await submitProjectStep(runtime, {
project_plan_id: fixture.planId,
project_step_id: fixture.activeStepId,
code: "guess = int(input())\nprint('猜对了' if guess == 5 else '大了')",
files: [],
});
expect(submitted).toMatchObject({ ok: false, code: "PROJECT_CONTENT_UNAVAILABLE" });
expect(publicTests).toBe("");
});
it("rejects project steps that do not belong to the submitted plan", async () => {
const runtime = await createTestRuntime();
const first = insertProjectPlanFixture(runtime);
const second = insertProjectPlanFixture(runtime);
const rejected = await submitProjectStep(runtime, {
project_plan_id: second.planId,
project_step_id: first.activeStepId,
code: "print('wrong plan')",
files: [],
});
expect(rejected.ok).toBe(false);
expect(rejected.code).toBe("FORBIDDEN");
});
it("reviews project submissions and records project progress through constrained tools", async () => {
const runtime = await createTestRuntime({
sandbox: {
runPython: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", traceback: "", duration_ms: 1, truncated: false }),
runPytest: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", traceback: "", duration_ms: 10, truncated: false, test_results: [] }),
lint: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", traceback: "", duration_ms: 1, truncated: false }),
},
});
const fixture = insertProjectPlanFixture(runtime);
const submissionId = createId("psub");
runtime.db.query(
"INSERT INTO project_step_submissions(id, project_plan_id, project_step_id, code_hash, code_snapshot, status, review_summary_json, created_at) VALUES (?, ?, ?, 'fixture', ?, 'passed', ?, ?)",
).run([
submissionId,
fixture.planId,
fixture.activeStepId,
"guess = int(input())\nprint('猜对了')",
JSON.stringify({ passed: true, message: "测试夹具提交通过。", concept_ids: ["loop"] }),
nowIso(),
]);
const reviewed = await reviewProjectCode(runtime, {
project_plan_id: fixture.planId,
project_step_id: fixture.activeStepId,
submission_id: submissionId,
});
expect(reviewed.ok).toBe(true);
expect(reviewed.data.message).toContain("测试夹具提交通过");
expect(JSON.stringify(reviewed.data)).not.toContain("progress.db");
const recorded = await recordProjectProgress(runtime, {
project_plan_id: fixture.planId,
project_step_id: fixture.activeStepId,
submission_id: submissionId,
status: "passed",
summary: "完成第一步。",
mastery_level: 100,
});
expect(recorded.ok).toBe(true);
const event = runtime.db.query<{ event_type: string; payload_json: string }>(
"SELECT event_type, payload_json FROM learning_events WHERE id = ?",
).get([recorded.data.event_id]);
expect(event?.event_type).toBe("project_step_completed");
expect(event?.payload_json).not.toContain("mastery_level");
});
});
function completeDiagnosticWithWeakConcept(runtime: Awaited<ReturnType<typeof createTestRuntime>>, sessionId: string, conceptId: string): void {
const now = nowIso();
const catalogRun = getLatestCatalogRun(runtime);
runtime.db.query(
"INSERT INTO diagnostic_sessions(id, session_id, status, target_concepts_json, stop_reason, catalog_version, catalog_run_id, started_at, ended_at) VALUES (?, ?, 'completed', ?, 'test_complete', ?, ?, ?, ?)",
).run([createId("diag"), sessionId, JSON.stringify([conceptId]), catalogRun?.kb_version ?? runtime.config.kbVersion, catalogRun?.id ?? null, now, now]);
runtime.db.query("UPDATE local_profile SET profile_json = ?, updated_at = ? WHERE id = 'local'").run([
JSON.stringify({
profile_summary: "Python 课程学习者,已完成初始测评。",
current_level: "初学者",
current_goal: "先巩固变量和表达式",
}),
now,
]);
upsertMasteryFixture(runtime, conceptId, { mastery: 15, confidence: 0.82, readiness: 10, evidenceCount: 2, reviewPriority: 9 });
}
function markGuidanceStarted(runtime: Awaited<ReturnType<typeof createTestRuntime>>, sessionId: string): void {
const now = nowIso();
const turnId = createId("turn");
runtime.db.transaction(() => {
runtime.db.query("INSERT INTO session_turns(id, session_id, status, user_message_summary, code_ref, assistant_message_summary, started_at, ended_at) VALUES (?, ?, 'done', ?, NULL, NULL, ?, ?)").run([
turnId,
sessionId,
"开始导师指导。",
now,
now,
]);
runtime.db.query("INSERT INTO session_messages(id, session_id, turn_id, message_id, role, content_redacted_text, created_at) VALUES (?, ?, ?, ?, 'user', ?, ?)").run([
createId("msg"),
sessionId,
turnId,
createId("msg"),
"开始导师指导。",
now,
]);
});
}