Full-stack F# (Domain/Server/Client via Fable+Elmish+Feliz), PostgreSQL persistence via Dapper, Docker Compose deployment. Student quiz-taking flow with time-limit enforcement and focus-loss tracking, Teacher question bank and quiz builder with results analytics, Admin user management.
183 lines
6.3 KiB
Forth
183 lines
6.3 KiB
Forth
module Domain.Tests.GradingTests
|
|
|
|
open System
|
|
open Xunit
|
|
open Domain
|
|
|
|
let private optA = { Id = OptionId(Guid.NewGuid()); Text = "A" }
|
|
let private optB = { Id = OptionId(Guid.NewGuid()); Text = "B" }
|
|
let private optC = { Id = OptionId(Guid.NewGuid()); Text = "C" }
|
|
|
|
let private newQuestionId () = QuestionId(Guid.NewGuid())
|
|
|
|
[<Fact>]
|
|
let ``single choice: correct option awards full points`` () =
|
|
let qId = newQuestionId ()
|
|
let qType = SingleChoice([ optA; optB; optC ], optB.Id)
|
|
let grade = Grading.gradeResponse qId qType 10.0 (SingleChoiceResponse(Some optB.Id))
|
|
Assert.True grade.IsCorrect
|
|
Assert.Equal(10.0, grade.PointsAwarded)
|
|
|
|
[<Fact>]
|
|
let ``single choice: wrong option awards zero`` () =
|
|
let qId = newQuestionId ()
|
|
let qType = SingleChoice([ optA; optB; optC ], optB.Id)
|
|
let grade = Grading.gradeResponse qId qType 10.0 (SingleChoiceResponse(Some optA.Id))
|
|
Assert.False grade.IsCorrect
|
|
Assert.Equal(0.0, grade.PointsAwarded)
|
|
|
|
[<Fact>]
|
|
let ``single choice: unanswered awards zero`` () =
|
|
let qId = newQuestionId ()
|
|
let qType = SingleChoice([ optA; optB ], optB.Id)
|
|
let grade = Grading.gradeResponse qId qType 10.0 (SingleChoiceResponse None)
|
|
Assert.False grade.IsCorrect
|
|
|
|
[<Fact>]
|
|
let ``multiple choice: exact set match is correct`` () =
|
|
let qId = newQuestionId ()
|
|
let correct = Set.ofList [ optA.Id; optC.Id ]
|
|
let qType = MultipleChoice([ optA; optB; optC ], correct)
|
|
let grade = Grading.gradeResponse qId qType 5.0 (MultipleChoiceResponse(Set.ofList [ optA.Id; optC.Id ]))
|
|
Assert.True grade.IsCorrect
|
|
|
|
[<Fact>]
|
|
let ``multiple choice: partial selection is incorrect`` () =
|
|
let qId = newQuestionId ()
|
|
let correct = Set.ofList [ optA.Id; optC.Id ]
|
|
let qType = MultipleChoice([ optA; optB; optC ], correct)
|
|
let grade = Grading.gradeResponse qId qType 5.0 (MultipleChoiceResponse(Set.ofList [ optA.Id ]))
|
|
Assert.False grade.IsCorrect
|
|
|
|
[<Fact>]
|
|
let ``true false: matching answer is correct`` () =
|
|
let qId = newQuestionId ()
|
|
let grade = Grading.gradeResponse qId (TrueFalse true) 2.0 (TrueFalseResponse(Some true))
|
|
Assert.True grade.IsCorrect
|
|
|
|
[<Fact>]
|
|
let ``short answer: case-insensitive match by default`` () =
|
|
let qId = newQuestionId ()
|
|
let qType = ShortAnswer([ "Paris" ], caseSensitive = false)
|
|
let grade = Grading.gradeResponse qId qType 3.0 (ShortAnswerResponse " paris ")
|
|
Assert.True grade.IsCorrect
|
|
|
|
[<Fact>]
|
|
let ``short answer: case-sensitive rejects mismatched case`` () =
|
|
let qId = newQuestionId ()
|
|
let qType = ShortAnswer([ "Paris" ], caseSensitive = true)
|
|
let grade = Grading.gradeResponse qId qType 3.0 (ShortAnswerResponse "paris")
|
|
Assert.False grade.IsCorrect
|
|
|
|
[<Fact>]
|
|
let ``numeric: within tolerance is correct`` () =
|
|
let qId = newQuestionId ()
|
|
let qType = Numeric(3.14, 0.01)
|
|
let grade = Grading.gradeResponse qId qType 4.0 (NumericResponse(Some 3.145))
|
|
Assert.True grade.IsCorrect
|
|
|
|
[<Fact>]
|
|
let ``numeric: outside tolerance is incorrect`` () =
|
|
let qId = newQuestionId ()
|
|
let qType = Numeric(3.14, 0.01)
|
|
let grade = Grading.gradeResponse qId qType 4.0 (NumericResponse(Some 3.2))
|
|
Assert.False grade.IsCorrect
|
|
|
|
let private sampleQuiz (questionRefs: QuizQuestionRef list) : Quiz =
|
|
{ Id = QuizId(Guid.NewGuid())
|
|
OwnerId = UserId(Guid.NewGuid())
|
|
Title = "Sample quiz"
|
|
Description = ""
|
|
TimeLimit = None
|
|
MaxAttempts = None
|
|
GradingMethod = HighestAttempt
|
|
ShuffleQuestions = false
|
|
ShuffleAnswers = false
|
|
OpenFrom = None
|
|
OpenTo = None
|
|
PassingScore = None
|
|
QuestionSources = questionRefs |> List.map FixedQuestion
|
|
AssignedStudentIds = Set.empty }
|
|
|
|
[<Fact>]
|
|
let ``gradeAttempt: unanswered questions score zero without crashing`` () =
|
|
let qId = newQuestionId ()
|
|
let question =
|
|
{ Id = qId
|
|
TopicId = TopicId(Guid.NewGuid())
|
|
Text = "2 + 2 = ?"
|
|
Points = 5.0
|
|
Type = Numeric(4.0, 0.0) }
|
|
|
|
let refs = [ { QuestionId = qId; Points = 5.0; Order = 0 } ]
|
|
let quiz = sampleQuiz refs
|
|
let questions = Map.ofList [ qId, question ]
|
|
|
|
let attempt =
|
|
Attempt.start (AttemptId(Guid.NewGuid())) quiz refs (UserId(Guid.NewGuid())) DateTimeOffset.UtcNow
|
|
|
|
let graded = Grading.gradeAttempt questions attempt
|
|
|
|
Assert.Equal(Some 0.0, graded.Score)
|
|
Assert.Equal(Graded, graded.State)
|
|
|
|
[<Fact>]
|
|
let ``gradeAttempt: sums points across multiple questions`` () =
|
|
let q1Id, q2Id = newQuestionId (), newQuestionId ()
|
|
|
|
let q1 =
|
|
{ Id = q1Id
|
|
TopicId = TopicId(Guid.NewGuid())
|
|
Text = "Is the sky blue?"
|
|
Points = 1.0
|
|
Type = TrueFalse true }
|
|
|
|
let q2 =
|
|
{ Id = q2Id
|
|
TopicId = TopicId(Guid.NewGuid())
|
|
Text = "Capital of France?"
|
|
Points = 1.0
|
|
Type = ShortAnswer([ "Paris" ], false) }
|
|
|
|
let refs =
|
|
[ { QuestionId = q1Id; Points = 3.0; Order = 0 }
|
|
{ QuestionId = q2Id; Points = 7.0; Order = 1 } ]
|
|
|
|
let quiz = sampleQuiz refs
|
|
let questions = Map.ofList [ q1Id, q1; q2Id, q2 ]
|
|
|
|
let attempt =
|
|
Attempt.start (AttemptId(Guid.NewGuid())) quiz refs (UserId(Guid.NewGuid())) DateTimeOffset.UtcNow
|
|
|> Attempt.recordResponse q1Id (TrueFalseResponse(Some true))
|
|
|> Attempt.recordResponse q2Id (ShortAnswerResponse "paris")
|
|
|
|
let graded = Grading.gradeAttempt questions attempt
|
|
|
|
Assert.Equal(Some 10.0, graded.Score)
|
|
|
|
[<Fact>]
|
|
let ``applyGradingMethod: HighestAttempt picks the best score`` () =
|
|
let mkAttempt score started =
|
|
{ Id = AttemptId(Guid.NewGuid())
|
|
QuizId = QuizId(Guid.NewGuid())
|
|
UserId = UserId(Guid.NewGuid())
|
|
StartedAt = started
|
|
SubmittedAt = Some started
|
|
State = Graded
|
|
Questions = []
|
|
Responses = Map.empty
|
|
Grades = Map.empty
|
|
Score = Some score
|
|
FocusLossCount = 0 }
|
|
|
|
let now = DateTimeOffset.UtcNow
|
|
let attempts = [ mkAttempt 4.0 now; mkAttempt 9.0 (now.AddMinutes 1.0); mkAttempt 6.0 (now.AddMinutes 2.0) ]
|
|
|
|
let result = Grading.applyGradingMethod HighestAttempt attempts
|
|
Assert.Equal(Some 9.0, result |> Option.bind (fun a -> a.Score))
|
|
|
|
[<Fact>]
|
|
let ``applyGradingMethod: no graded attempts returns None`` () =
|
|
let result = Grading.applyGradingMethod HighestAttempt []
|
|
Assert.Equal(None, result)
|