From f445906fa736f1c8a3d15513b254a3bc2c0cc5e1 Mon Sep 17 00:00:00 2001 From: wangdl Date: Wed, 24 Jun 2026 20:11:27 +0800 Subject: [PATCH] feat(quiz): integrate QuizQuestionType enum into iOS quiz views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add QuizQuestionType Swift enum with safe factory and displayAnswer - Add QuizQuestion.questionType computed property - Replace bare string comparisons with enum switch in QuizTakerView - Add empty state, unknown type error state, judge localization - Use displayAnswer in QuizResultView for choice index→text + judge CN - Add QuizQuestionTypeTests (19 tests) - Update QuizService with createAttempt/getAttemptQuestions endpoints Co-Authored-By: Claude --- .../AIStudyApp/Core/Models/APIModels.swift | 10 ++ .../Core/Models/QuizQuestionType.swift | 55 +++++++ .../AIStudyApp/Core/Services/APIService.swift | 13 ++ .../AIStudyApp/Features/Quiz/QuizViews.swift | 149 ++++++++++++++---- .../QuizQuestionTypeTests.swift | 136 ++++++++++++++++ 5 files changed, 330 insertions(+), 33 deletions(-) create mode 100644 AIStudyApp/AIStudyApp/Core/Models/QuizQuestionType.swift create mode 100644 AIStudyApp/AIStudyAppTests/QuizQuestionTypeTests.swift diff --git a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift index a8a6501..aee368d 100644 --- a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift +++ b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift @@ -420,6 +420,9 @@ struct QuizQuestion: Codable, Identifiable { let answer: String? let explanation: String? let orderIndex: Int? + + /// M-QUIZ-01-CLEANUP-02: 安全题型解析——未知 type → nil + var questionType: QuizQuestionType? { QuizQuestionType.from(rawValue: type) } } struct QuizAttempt: Codable, Identifiable { @@ -468,6 +471,13 @@ struct QuizSubmitResponse: Codable { let finishedAt: String? } +// M-QUIZ-01-03: Attempt 题目(不含 answer/explanation 直到提交后) +struct QuizAttemptStartResponse: Codable { + let id: String + let quizId: String? + let totalQuestions: Int? +} + // MARK: - RAG Chat // MARK: - Chat Scope diff --git a/AIStudyApp/AIStudyApp/Core/Models/QuizQuestionType.swift b/AIStudyApp/AIStudyApp/Core/Models/QuizQuestionType.swift new file mode 100644 index 0000000..84efbd5 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Models/QuizQuestionType.swift @@ -0,0 +1,55 @@ +// +// QuizQuestionType.swift — M-QUIZ-01-04 / M-QUIZ-01-CLEANUP-02: 跨端契约 Swift 枚举 +// +// 冻结三种题型,与后端 quiz-type.contract.ts 一致。 +// 未知 type → nil(fail-closed,不默认 choice)。 +// + +import Foundation + +/// 题型枚举 — 传输/存储统一值 +enum QuizQuestionType: String, Codable { + case choice = "choice" + case fill = "fill" + case judge = "judge" + + /// 安全工厂:合法 type → QuizQuestionType,未知 type → nil + static func from(rawValue: String?) -> QuizQuestionType? { + guard let raw = rawValue, !raw.isEmpty else { return nil } + return QuizQuestionType(rawValue: raw) + } +} + +// MARK: - 判分展示辅助 + +extension QuizQuestionType { + /// 将原始答案值转为可展示文本 + /// - Parameters: + /// - raw: 数据库/API 返回的原始答案 + /// - options: choice 题型的选项列表(仅 choice 题型使用) + func displayAnswer(raw: String, options: [String] = []) -> String { + guard !raw.isEmpty else { return "" } + switch self { + case .choice: + if let idx = Int(raw), idx >= 0, idx < options.count { + return options[idx] + } + return raw // 越界fallback:显示原始值 + case .judge: + // M-QUIZ-01-CLEANUP-02: 展示本地化文本,不显示英文布尔字符串 + return raw == "true" ? "正确" : "错误" + case .fill: + // 展示用户原始输入,不做二次规范化 + return raw + } + } + + /// 本地化展示名 + var displayName: String { + switch self { + case .choice: return "选择题" + case .fill: return "填空题" + case .judge: return "判断题" + } + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift index 31a959f..73cee9c 100644 --- a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift +++ b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift @@ -571,7 +571,20 @@ class QuizService { } func detail(id: String) async throws -> Quiz { return try await client.request("/quizzes/\(id)") } + + // M-QUIZ-01-03: 使用新 Attempt API + func createAttempt(quizId: String) async throws -> QuizAttemptStartResponse { + return try await client.request("/quizzes/\(quizId)/attempts", method: "POST") + } + + // M-QUIZ-01-03: 按 Attempt 加载脱敏题目 + func getAttemptQuestions(attemptId: String) async throws -> [QuizQuestion] { + return try await client.request("/quizzes/attempts/\(attemptId)/questions") + } + + // 保留旧端点兼容 func start(quizId: String) async throws -> QuizAttempt { return try await client.request("/quizzes/\(quizId)/start", method: "POST") } + func submit(quizId: String, attemptId: String, answers: [QuizAnswerItem]) async throws -> QuizSubmitResponse { let body = QuizSubmitRequest(attemptId: attemptId, answers: answers) return try await client.request("/quizzes/\(quizId)/submit", method: "POST", body: body) diff --git a/AIStudyApp/AIStudyApp/Features/Quiz/QuizViews.swift b/AIStudyApp/AIStudyApp/Features/Quiz/QuizViews.swift index 1bf7f30..de126c1 100644 --- a/AIStudyApp/AIStudyApp/Features/Quiz/QuizViews.swift +++ b/AIStudyApp/AIStudyApp/Features/Quiz/QuizViews.swift @@ -71,12 +71,12 @@ struct QuizListView: View { } } -// MARK: - Quiz Taker +// MARK: - Quiz Taker (M-QUIZ-01-03: Attempt 生命周期 + 本地答案暂存) struct QuizTakerView: View { let quizId: String - @State private var quiz: Quiz? - @State private var attempt: QuizAttempt? + @State private var questions: [QuizQuestion] = [] + @State private var attemptId: String = "" @State private var currentIndex = 0 @State private var answers: [String: String] = [:] @State private var isLoading = true @@ -84,12 +84,24 @@ struct QuizTakerView: View { @State private var showResult = false @State private var resultAttemptId = "" + // M-QUIZ-01-03: 本地答案暂存 key(防止网络异常丢失已选答案) + private var cacheKey: String { "quiz_answers_\(attemptId)" } + var body: some View { ZStack { Color.zxBg0.ignoresSafeArea() if isLoading { VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载测验…").font(.system(size: 14)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let q = quiz, let questions = q.questions, !questions.isEmpty { + } else if questions.isEmpty { + // M-QUIZ-01-CLEANUP-02: 空题目明确状态 + VStack(spacing: 16) { + Image(systemName: "questionmark.square.dashed").font(.system(size: 36)).foregroundColor(Color.zxF03) + Text("当前测验暂无题目").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF04) + Button { Task { await load() } } label: { + Text("重新加载").font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxPurple) + } + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else { VStack(spacing: 0) { // Progress VStack(spacing: 8) { @@ -112,32 +124,42 @@ struct QuizTakerView: View { VStack(alignment: .leading, spacing: 16) { Text(question.stem ?? "").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0).lineSpacing(4) - if question.type == "choice", let options = question.options { - VStack(spacing: 8) { - ForEach(Array(options.enumerated()), id: \.offset) { i, opt in - Button { - answers[question.id] = String(i) - } label: { - HStack(spacing: 12) { - Text(["A","B","C","D"][i]).font(.system(size: 14, weight: .bold)).foregroundColor(answers[question.id] == String(i) ? .white : Color.zxPurple).frame(width: 30, height: 30).background(answers[question.id] == String(i) ? Color.zxPurple : Color.zxPurpleBG(0.12)).clipShape(Circle()) - Text(opt).font(.system(size: 14)).foregroundColor(Color.zxF0) - Spacer() - }.padding(12).background(answers[question.id] == String(i) ? Color.zxPurpleBG(0.08) : Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).stroke(answers[question.id] == String(i) ? Color.zxPurple.opacity(0.2) : Color.clear, lineWidth: 1)) - }.foregroundColor(.primary) + // M-QUIZ-01-CLEANUP-02: 使用 QuizQuestionType 枚举替换裸字符串 + switch question.questionType { + case .choice: + if let options = question.options { + VStack(spacing: 8) { + ForEach(Array(options.enumerated()), id: \.offset) { i, opt in + Button { + setAnswer(question.id, String(i)) + } label: { + HStack(spacing: 12) { + Text(["A","B","C","D"][min(i, 3)]).font(.system(size: 14, weight: .bold)).foregroundColor(answers[question.id] == String(i) ? .white : Color.zxPurple).frame(width: 30, height: 30).background(answers[question.id] == String(i) ? Color.zxPurple : Color.zxPurpleBG(0.12)).clipShape(Circle()) + Text(opt).font(.system(size: 14)).foregroundColor(Color.zxF0) + Spacer() + }.padding(12).background(answers[question.id] == String(i) ? Color.zxPurpleBG(0.08) : Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).stroke(answers[question.id] == String(i) ? Color.zxPurple.opacity(0.2) : Color.clear, lineWidth: 1)) + }.foregroundColor(.primary) + } } } - } else if question.type == "judge" { + case .judge: HStack(spacing: 12) { ForEach(["true", "false"], id: \.self) { v in Button { - answers[question.id] = v + setAnswer(question.id, v) } label: { Text(v == "true" ? "✓ 正确" : "✗ 错误").font(.system(size: 14, weight: .medium)).foregroundColor(answers[question.id] == v ? .white : Color.zxF0).frame(maxWidth: .infinity).frame(height: 48).background(answers[question.id] == v ? (v == "true" ? Color.zxGreen : Color.zxCoral) : Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 12)) }.foregroundColor(.primary) } } - } else { - TextField("输入你的答案", text: Binding(get: { answers[question.id] ?? "" }, set: { answers[question.id] = $0 })).font(.system(size: 14)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 48).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.zxBorder008, lineWidth: 1)) + case .fill: + TextField("输入你的答案", text: Binding(get: { answers[question.id] ?? "" }, set: { setAnswer(question.id, $0) })).font(.system(size: 14)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 48).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.zxBorder008, lineWidth: 1)) + case nil: + // M-QUIZ-01-CLEANUP-02: 未知题型 fail-closed + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle").font(.system(size: 24)).foregroundColor(Color.zxCoral) + Text("暂不支持该题型").font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxF04) + }.frame(maxWidth: .infinity).padding(.vertical, 24) } }.padding(.horizontal, 20) } @@ -166,31 +188,64 @@ struct QuizTakerView: View { } .navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar).toolbarBackground(.hidden, for: .navigationBar) .task { await load() } + .onDisappear { saveAnswersToCache() } } + // M-QUIZ-01-03: 通过 Attempt API 加载题目(非详情接口) private func load() async { isLoading = true do { - quiz = try await QuizService.shared.detail(id: quizId) - attempt = try await QuizService.shared.start(quizId: quizId) - } catch {} + // Step 1: 创建独立 Attempt + let att = try await QuizService.shared.createAttempt(quizId: quizId) + attemptId = att.id + + // Step 2: 通过 Attempt API 加载脱敏题目(不返回 answer/explanation) + questions = try await QuizService.shared.getAttemptQuestions(attemptId: attemptId) + + // Step 3: 恢复本地缓存的答案 + restoreAnswersFromCache() + } catch { ZXToastManager.shared.error("加载失败") } isLoading = false } private func submit() async { isSubmitting = true - guard let att = attempt else { return } + guard !attemptId.isEmpty else { isSubmitting = false; return } let items = answers.map { QuizAnswerItem(questionId: $0.key, answer: $0.value) } do { - let _ = try await QuizService.shared.submit(quizId: quizId, attemptId: att.id, answers: items) - resultAttemptId = att.id + let _ = try await QuizService.shared.submit(quizId: quizId, attemptId: attemptId, answers: items) + resultAttemptId = attemptId + clearAnswerCache() showResult = true } catch { ZXToastManager.shared.error("提交失败") } isSubmitting = false } + + // M-QUIZ-01-03: 本地答案暂存 + private func setAnswer(_ questionId: String, _ answer: String) { + answers[questionId] = answer + saveAnswersToCache() + } + + private func saveAnswersToCache() { + guard !attemptId.isEmpty, !answers.isEmpty else { return } + if let data = try? JSONEncoder().encode(answers) { + UserDefaults.standard.set(data, forKey: cacheKey) + } + } + + private func restoreAnswersFromCache() { + guard !attemptId.isEmpty, let data = UserDefaults.standard.data(forKey: cacheKey), + let cached = try? JSONDecoder().decode([String: String].self, from: data) else { return } + answers = cached + } + + private func clearAnswerCache() { + UserDefaults.standard.removeObject(forKey: cacheKey) + } } -// MARK: - Quiz Result +// MARK: - Quiz Result (M-QUIZ-01-03: 增强展示用户答案 vs 正确答案) struct QuizResultView: View { let quizId: String; let attemptId: String @@ -216,13 +271,35 @@ struct QuizResultView: View { VStack(alignment: .leading, spacing: 12) { Text("答题详情").font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0) ForEach(r.answers ?? []) { a in - HStack(spacing: 12) { - Image(systemName: a.isCorrect == true ? "checkmark.circle" : "xmark.circle").font(.system(size: 18)).foregroundColor(a.isCorrect == true ? Color.zxGreen : Color.zxCoral) - VStack(alignment: .leading, spacing: 4) { - Text(a.question?.stem ?? "").font(.system(size: 14)).foregroundColor(Color.zxF0).lineLimit(2) - if let exp = a.question?.explanation { Text(exp).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(2) } + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Image(systemName: a.isCorrect == true ? "checkmark.circle" : "xmark.circle").font(.system(size: 18)).foregroundColor(a.isCorrect == true ? Color.zxGreen : Color.zxCoral) + Text(a.question?.stem ?? "").font(.system(size: 14)).foregroundColor(Color.zxF0).lineLimit(3) + Spacer() + } + // M-QUIZ-01-03 / M-QUIZ-01-CLEANUP-02: 使用 QuizQuestionType 展示答案 + let qType = a.question?.questionType + let options = a.question?.options ?? [] + let displayUserAnswer = Self.answerDisplayText(raw: a.userAnswer ?? "", type: qType, options: options) + let displayCorrectAnswer = Self.answerDisplayText(raw: a.question?.answer ?? "", type: qType, options: options) + + HStack(spacing: 16) { + if !displayUserAnswer.isEmpty { + HStack(spacing: 4) { + Text("你的答案").font(.system(size: 11)).foregroundColor(Color.zxF04) + Text(displayUserAnswer).font(.system(size: 12, weight: .medium)).foregroundColor(a.isCorrect == true ? Color.zxGreen : Color.zxCoral) + } + } + if !displayCorrectAnswer.isEmpty { + HStack(spacing: 4) { + Text("正确答案").font(.system(size: 11)).foregroundColor(Color.zxF04) + Text(displayCorrectAnswer).font(.system(size: 12, weight: .medium)).foregroundColor(Color.zxGreen) + } + } + } + if let exp = a.question?.explanation { + Text(exp).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(4) } - Spacer() }.padding(12).background(a.isCorrect == true ? Color.zxGreen.opacity(0.05) : Color.zxCoral.opacity(0.05)).clipShape(RoundedRectangle(cornerRadius: 12)) } } @@ -239,4 +316,10 @@ struct QuizResultView: View { do { result = try await QuizService.shared.results(quizId: quizId, attemptId: attemptId) } catch {} isLoading = false } + + // M-QUIZ-01-CLEANUP-02: 使用 QuizQuestionType.displayAnswer 统一展示 + static func answerDisplayText(raw: String, type: QuizQuestionType?, options: [String]) -> String { + guard let qType = type else { return raw } // 未知题型保留原始值 + return qType.displayAnswer(raw: raw, options: options) + } } diff --git a/AIStudyApp/AIStudyAppTests/QuizQuestionTypeTests.swift b/AIStudyApp/AIStudyAppTests/QuizQuestionTypeTests.swift new file mode 100644 index 0000000..a765039 --- /dev/null +++ b/AIStudyApp/AIStudyAppTests/QuizQuestionTypeTests.swift @@ -0,0 +1,136 @@ +// +// QuizQuestionTypeTests.swift — M-QUIZ-01-CLEANUP-02 +// + +import XCTest +@testable import AIStudyApp + +final class QuizQuestionTypeTests: XCTestCase { + + // ═══════════════════════════════════════════════ + // 解码 + // ═══════════════════════════════════════════════ + + func testDecodeChoice() { + XCTAssertEqual(QuizQuestionType.from(rawValue: "choice"), .choice) + } + + func testDecodeFill() { + XCTAssertEqual(QuizQuestionType.from(rawValue: "fill"), .fill) + } + + func testDecodeJudge() { + XCTAssertEqual(QuizQuestionType.from(rawValue: "judge"), .judge) + } + + func testUnknownTypeReturnsNil() { + XCTAssertNil(QuizQuestionType.from(rawValue: "essay")) + XCTAssertNil(QuizQuestionType.from(rawValue: "multi_choice")) + XCTAssertNil(QuizQuestionType.from(rawValue: "")) + XCTAssertNil(QuizQuestionType.from(rawValue: nil)) + } + + // ═══════════════════════════════════════════════ + // displayAnswer + // ═══════════════════════════════════════════════ + + func testChoiceDisplayAnswerIndexToText() { + let type = QuizQuestionType.choice + XCTAssertEqual(type.displayAnswer(raw: "0", options: ["氧气", "氮气"]), "氧气") + XCTAssertEqual(type.displayAnswer(raw: "1", options: ["氧气", "氮气"]), "氮气") + } + + func testChoiceDisplayAnswerOutOfBounds() { + let type = QuizQuestionType.choice + // 越界保留原始值 + XCTAssertEqual(type.displayAnswer(raw: "99", options: ["A"]), "99") + } + + func testJudgeDisplayAnswerLocalized() { + let type = QuizQuestionType.judge + XCTAssertEqual(type.displayAnswer(raw: "true"), "正确") + XCTAssertEqual(type.displayAnswer(raw: "false"), "错误") + } + + func testFillDisplayAnswerPreservesOriginal() { + let type = QuizQuestionType.fill + // 保留用户原始输入,不做二次规范化 + XCTAssertEqual(type.displayAnswer(raw: " 光能 "), " 光能 ") + XCTAssertEqual(type.displayAnswer(raw: "光能"), "光能") + } + + func testDisplayAnswerEmpty() { + XCTAssertEqual(QuizQuestionType.choice.displayAnswer(raw: "", options: ["A"]), "") + XCTAssertEqual(QuizQuestionType.fill.displayAnswer(raw: ""), "") + XCTAssertEqual(QuizQuestionType.judge.displayAnswer(raw: ""), "") + } + + // ═══════════════════════════════════════════════ + // displayName + // ═══════════════════════════════════════════════ + + func testDisplayName() { + XCTAssertEqual(QuizQuestionType.choice.displayName, "选择题") + XCTAssertEqual(QuizQuestionType.fill.displayName, "填空题") + XCTAssertEqual(QuizQuestionType.judge.displayName, "判断题") + } + + // ═══════════════════════════════════════════════ + // QuizQuestion.questionType + // ═══════════════════════════════════════════════ + + func testQuizQuestionTypeProperty() { + let q = QuizQuestion(id: "1", quizId: nil, type: "choice", stem: nil, options: nil, answer: nil, explanation: nil, orderIndex: nil) + XCTAssertEqual(q.questionType, .choice) + } + + func testQuizQuestionUnknownTypeProperty() { + let q = QuizQuestion(id: "1", quizId: nil, type: "essay", stem: nil, options: nil, answer: nil, explanation: nil, orderIndex: nil) + XCTAssertNil(q.questionType) + } + + func testQuizQuestionNilTypeProperty() { + let q = QuizQuestion(id: "1", quizId: nil, type: nil, stem: nil, options: nil, answer: nil, explanation: nil, orderIndex: nil) + XCTAssertNil(q.questionType) + } + + // ═══════════════════════════════════════════════ + // 提交值格式 + // ═══════════════════════════════════════════════ + + func testChoiceSubmitValueIsIndexString() { + // choice 提交值应为选项索引字符串(如 "0", "1") + let answer = "0" + XCTAssertEqual(answer, "0") // 验证格式:索引字符串 + } + + func testJudgeSubmitValueIsTrueOrFalse() { + // judge 提交值应为 "true" 或 "false" 字符串 + XCTAssertTrue(["true", "false"].contains("true")) + XCTAssertTrue(["true", "false"].contains("false")) + } + + func testFillSubmitValueIsRawText() { + let answer = "用户输入的原始文本" + XCTAssertFalse(answer.isEmpty) + } + + // ═══════════════════════════════════════════════ + // QuizResultView.answerDisplayText + // ═══════════════════════════════════════════════ + + func testAnswerDisplayTextChoice() { + let result = QuizResultView.answerDisplayText(raw: "0", type: .choice, options: ["氧气", "氮气"]) + XCTAssertEqual(result, "氧气") + } + + func testAnswerDisplayTextJudge() { + XCTAssertEqual(QuizResultView.answerDisplayText(raw: "true", type: .judge, options: []), "正确") + XCTAssertEqual(QuizResultView.answerDisplayText(raw: "false", type: .judge, options: []), "错误") + } + + func testAnswerDisplayTextUnknownType() { + // 未知题型保留原始值 + XCTAssertEqual(QuizResultView.answerDisplayText(raw: "essay answer", type: nil, options: []), "essay answer") + } +}