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

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)

View File

@@ -0,0 +1,4 @@
module Program
[<EntryPoint>]
let main _ = 0

View 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"