Initial commit: standalone quiz-testing system

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.
This commit is contained in:
danamir
2026-08-06 12:36:16 +03:00
commit 942dfc9c1a
134 changed files with 10712 additions and 0 deletions

View File

@@ -0,0 +1,182 @@
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)