feat(quiz): integrate QuizQuestionType enum into iOS quiz views

- 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 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-24 20:11:27 +08:00
parent 5a7ee670af
commit f445906fa7
5 changed files with 330 additions and 33 deletions

View File

@ -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

View File

@ -0,0 +1,55 @@
//
// QuizQuestionType.swift M-QUIZ-01-04 / M-QUIZ-01-CLEANUP-02: Swift
//
// quiz-type.contract.ts
// type nilfail-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 "判断题"
}
}
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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")
}
}