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:
26
tests/Domain.Tests/Domain.Tests.fsproj
Normal file
26
tests/Domain.Tests/Domain.Tests.fsproj
Normal file
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<GenerateProgramFile>false</GenerateProgramFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="GradingTests.fs" />
|
||||
<Compile Include="ValidationTests.fs" />
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Domain\Domain.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
182
tests/Domain.Tests/GradingTests.fs
Normal file
182
tests/Domain.Tests/GradingTests.fs
Normal 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)
|
||||
4
tests/Domain.Tests/Program.fs
Normal file
4
tests/Domain.Tests/Program.fs
Normal file
@@ -0,0 +1,4 @@
|
||||
module Program
|
||||
|
||||
[<EntryPoint>]
|
||||
let main _ = 0
|
||||
98
tests/Domain.Tests/ValidationTests.fs
Normal file
98
tests/Domain.Tests/ValidationTests.fs
Normal file
@@ -0,0 +1,98 @@
|
||||
module Domain.Tests.ValidationTests
|
||||
|
||||
open System
|
||||
open Xunit
|
||||
open Domain
|
||||
|
||||
let private newQuestion (qType: QuestionType) : Question =
|
||||
{ Id = QuestionId(Guid.NewGuid())
|
||||
TopicId = TopicId(Guid.NewGuid())
|
||||
Text = "Sample?"
|
||||
Points = 1.0
|
||||
Type = qType }
|
||||
|
||||
[<Fact>]
|
||||
let ``single choice: correct option must be among the listed options`` () =
|
||||
let optA = { Id = OptionId(Guid.NewGuid()); Text = "A" }
|
||||
let optB = { Id = OptionId(Guid.NewGuid()); Text = "B" }
|
||||
let foreignId = OptionId(Guid.NewGuid())
|
||||
let question = newQuestion (SingleChoice([ optA; optB ], foreignId))
|
||||
|
||||
match QuestionValidation.validate question with
|
||||
| Error errors -> Assert.Contains("Correct option must be one of the provided options", errors)
|
||||
| Ok _ -> Assert.Fail "expected validation error"
|
||||
|
||||
[<Fact>]
|
||||
let ``multiple choice: correct set must be subset of options`` () =
|
||||
let optA = { Id = OptionId(Guid.NewGuid()); Text = "A" }
|
||||
let optB = { Id = OptionId(Guid.NewGuid()); Text = "B" }
|
||||
let foreignId = OptionId(Guid.NewGuid())
|
||||
let question = newQuestion (MultipleChoice([ optA; optB ], Set.ofList [ optA.Id; foreignId ]))
|
||||
|
||||
match QuestionValidation.validate question with
|
||||
| Error errors -> Assert.Contains("Correct options must be a subset of the provided options", errors)
|
||||
| Ok _ -> Assert.Fail "expected validation error"
|
||||
|
||||
[<Fact>]
|
||||
let ``numeric: negative tolerance is invalid`` () =
|
||||
let question = newQuestion (Numeric(1.0, -0.5))
|
||||
|
||||
match QuestionValidation.validate question with
|
||||
| Error errors -> Assert.Contains("Tolerance must not be negative", errors)
|
||||
| Ok _ -> Assert.Fail "expected validation error"
|
||||
|
||||
[<Fact>]
|
||||
let ``valid question passes validation`` () =
|
||||
let optA = { Id = OptionId(Guid.NewGuid()); Text = "A" }
|
||||
let optB = { Id = OptionId(Guid.NewGuid()); Text = "B" }
|
||||
let question = newQuestion (SingleChoice([ optA; optB ], optA.Id))
|
||||
|
||||
match QuestionValidation.validate question with
|
||||
| Ok _ -> ()
|
||||
| Error errors -> Assert.Fail(String.concat "; " errors)
|
||||
|
||||
[<Fact>]
|
||||
let ``quiz: must contain at least one question`` () =
|
||||
let quiz: Quiz =
|
||||
{ Id = QuizId(Guid.NewGuid())
|
||||
OwnerId = UserId(Guid.NewGuid())
|
||||
Title = "Empty quiz"
|
||||
Description = ""
|
||||
TimeLimit = None
|
||||
MaxAttempts = None
|
||||
GradingMethod = HighestAttempt
|
||||
ShuffleQuestions = false
|
||||
ShuffleAnswers = false
|
||||
OpenFrom = None
|
||||
OpenTo = None
|
||||
PassingScore = None
|
||||
QuestionSources = []
|
||||
AssignedStudentIds = Set.empty }
|
||||
|
||||
match QuizValidation.validate quiz with
|
||||
| Error errors -> Assert.Contains("Quiz must contain at least one question", errors)
|
||||
| Ok _ -> Assert.Fail "expected validation error"
|
||||
|
||||
[<Fact>]
|
||||
let ``quiz: OpenFrom must precede OpenTo`` () =
|
||||
let now = DateTimeOffset.UtcNow
|
||||
|
||||
let quiz: Quiz =
|
||||
{ Id = QuizId(Guid.NewGuid())
|
||||
OwnerId = UserId(Guid.NewGuid())
|
||||
Title = "Backwards window"
|
||||
Description = ""
|
||||
TimeLimit = None
|
||||
MaxAttempts = None
|
||||
GradingMethod = HighestAttempt
|
||||
ShuffleQuestions = false
|
||||
ShuffleAnswers = false
|
||||
OpenFrom = Some(now.AddDays 1.0)
|
||||
OpenTo = Some now
|
||||
PassingScore = None
|
||||
QuestionSources = [ FixedQuestion { QuestionId = QuestionId(Guid.NewGuid()); Points = 1.0; Order = 0 } ]
|
||||
AssignedStudentIds = Set.empty }
|
||||
|
||||
match QuizValidation.validate quiz with
|
||||
| Error errors -> Assert.Contains("OpenFrom must be before OpenTo", errors)
|
||||
| Ok _ -> Assert.Fail "expected validation error"
|
||||
Reference in New Issue
Block a user