A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
533 lines
12 KiB
Go
533 lines
12 KiB
Go
package services
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/breakpilot/consent-service/internal/models"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// TestValidateGrade tests grade validation
|
|
func TestValidateGrade(t *testing.T) {
|
|
schoolYearID := uuid.New()
|
|
gradeScaleID := uuid.New()
|
|
|
|
tests := []struct {
|
|
name string
|
|
grade models.Grade
|
|
expectValid bool
|
|
}{
|
|
{
|
|
name: "valid grade 1",
|
|
grade: models.Grade{
|
|
StudentID: uuid.New(),
|
|
SubjectID: uuid.New(),
|
|
TeacherID: uuid.New(),
|
|
SchoolYearID: schoolYearID,
|
|
GradeScaleID: gradeScaleID,
|
|
Value: 1.0,
|
|
Type: models.GradeTypeExam,
|
|
Weight: 1.0,
|
|
Date: time.Now(),
|
|
Semester: 1,
|
|
},
|
|
expectValid: true,
|
|
},
|
|
{
|
|
name: "valid grade 6",
|
|
grade: models.Grade{
|
|
StudentID: uuid.New(),
|
|
SubjectID: uuid.New(),
|
|
TeacherID: uuid.New(),
|
|
SchoolYearID: schoolYearID,
|
|
GradeScaleID: gradeScaleID,
|
|
Value: 6.0,
|
|
Type: models.GradeTypeOral,
|
|
Weight: 0.5,
|
|
Date: time.Now(),
|
|
Semester: 2,
|
|
},
|
|
expectValid: true,
|
|
},
|
|
{
|
|
name: "valid grade with plus (1.3)",
|
|
grade: models.Grade{
|
|
StudentID: uuid.New(),
|
|
SubjectID: uuid.New(),
|
|
TeacherID: uuid.New(),
|
|
SchoolYearID: schoolYearID,
|
|
GradeScaleID: gradeScaleID,
|
|
Value: 1.3,
|
|
Type: models.GradeTypeTest,
|
|
Weight: 0.25,
|
|
Date: time.Now(),
|
|
Semester: 1,
|
|
},
|
|
expectValid: true,
|
|
},
|
|
{
|
|
name: "invalid grade 0",
|
|
grade: models.Grade{
|
|
StudentID: uuid.New(),
|
|
SubjectID: uuid.New(),
|
|
TeacherID: uuid.New(),
|
|
SchoolYearID: schoolYearID,
|
|
GradeScaleID: gradeScaleID,
|
|
Value: 0.0,
|
|
Type: models.GradeTypeExam,
|
|
Weight: 1.0,
|
|
Date: time.Now(),
|
|
Semester: 1,
|
|
},
|
|
expectValid: false,
|
|
},
|
|
{
|
|
name: "invalid grade 7",
|
|
grade: models.Grade{
|
|
StudentID: uuid.New(),
|
|
SubjectID: uuid.New(),
|
|
TeacherID: uuid.New(),
|
|
SchoolYearID: schoolYearID,
|
|
GradeScaleID: gradeScaleID,
|
|
Value: 7.0,
|
|
Type: models.GradeTypeExam,
|
|
Weight: 1.0,
|
|
Date: time.Now(),
|
|
Semester: 1,
|
|
},
|
|
expectValid: false,
|
|
},
|
|
{
|
|
name: "missing student ID",
|
|
grade: models.Grade{
|
|
StudentID: uuid.Nil,
|
|
SubjectID: uuid.New(),
|
|
TeacherID: uuid.New(),
|
|
SchoolYearID: schoolYearID,
|
|
GradeScaleID: gradeScaleID,
|
|
Value: 2.0,
|
|
Type: models.GradeTypeExam,
|
|
Weight: 1.0,
|
|
Date: time.Now(),
|
|
Semester: 1,
|
|
},
|
|
expectValid: false,
|
|
},
|
|
{
|
|
name: "invalid weight negative",
|
|
grade: models.Grade{
|
|
StudentID: uuid.New(),
|
|
SubjectID: uuid.New(),
|
|
TeacherID: uuid.New(),
|
|
SchoolYearID: schoolYearID,
|
|
GradeScaleID: gradeScaleID,
|
|
Value: 2.0,
|
|
Type: models.GradeTypeExam,
|
|
Weight: -0.5,
|
|
Date: time.Now(),
|
|
Semester: 1,
|
|
},
|
|
expectValid: false,
|
|
},
|
|
{
|
|
name: "invalid semester 0",
|
|
grade: models.Grade{
|
|
StudentID: uuid.New(),
|
|
SubjectID: uuid.New(),
|
|
TeacherID: uuid.New(),
|
|
SchoolYearID: schoolYearID,
|
|
GradeScaleID: gradeScaleID,
|
|
Value: 2.0,
|
|
Type: models.GradeTypeExam,
|
|
Weight: 1.0,
|
|
Date: time.Now(),
|
|
Semester: 0,
|
|
},
|
|
expectValid: false,
|
|
},
|
|
{
|
|
name: "invalid semester 3",
|
|
grade: models.Grade{
|
|
StudentID: uuid.New(),
|
|
SubjectID: uuid.New(),
|
|
TeacherID: uuid.New(),
|
|
SchoolYearID: schoolYearID,
|
|
GradeScaleID: gradeScaleID,
|
|
Value: 2.0,
|
|
Type: models.GradeTypeExam,
|
|
Weight: 1.0,
|
|
Date: time.Now(),
|
|
Semester: 3,
|
|
},
|
|
expectValid: false,
|
|
},
|
|
{
|
|
name: "invalid type",
|
|
grade: models.Grade{
|
|
StudentID: uuid.New(),
|
|
SubjectID: uuid.New(),
|
|
TeacherID: uuid.New(),
|
|
SchoolYearID: schoolYearID,
|
|
GradeScaleID: gradeScaleID,
|
|
Value: 2.0,
|
|
Type: "invalid_type",
|
|
Weight: 1.0,
|
|
Date: time.Now(),
|
|
Semester: 1,
|
|
},
|
|
expectValid: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
isValid := validateGrade(tt.grade)
|
|
if isValid != tt.expectValid {
|
|
t.Errorf("expected valid=%v, got valid=%v", tt.expectValid, isValid)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// validateGrade validates a grade
|
|
func validateGrade(grade models.Grade) bool {
|
|
if grade.StudentID == uuid.Nil {
|
|
return false
|
|
}
|
|
if grade.SubjectID == uuid.Nil {
|
|
return false
|
|
}
|
|
if grade.TeacherID == uuid.Nil {
|
|
return false
|
|
}
|
|
// German grading scale: 1 (best) to 6 (worst)
|
|
if grade.Value < 1.0 || grade.Value > 6.0 {
|
|
return false
|
|
}
|
|
if grade.Weight < 0 {
|
|
return false
|
|
}
|
|
if grade.Semester < 1 || grade.Semester > 2 {
|
|
return false
|
|
}
|
|
|
|
// Validate type
|
|
validTypes := map[string]bool{
|
|
models.GradeTypeExam: true,
|
|
models.GradeTypeTest: true,
|
|
models.GradeTypeOral: true,
|
|
models.GradeTypeHomework: true,
|
|
models.GradeTypeProject: true,
|
|
models.GradeTypeParticipation: true,
|
|
models.GradeTypeSemester: true,
|
|
models.GradeTypeFinal: true,
|
|
}
|
|
|
|
if !validTypes[grade.Type] {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// TestCalculateWeightedAverage tests weighted average calculation
|
|
func TestCalculateWeightedAverage(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
grades []models.Grade
|
|
expectedAverage float64
|
|
}{
|
|
{
|
|
name: "simple average equal weights",
|
|
grades: []models.Grade{
|
|
{Value: 1.0, Weight: 1.0},
|
|
{Value: 2.0, Weight: 1.0},
|
|
{Value: 3.0, Weight: 1.0},
|
|
},
|
|
expectedAverage: 2.0,
|
|
},
|
|
{
|
|
name: "weighted average different weights",
|
|
grades: []models.Grade{
|
|
{Value: 1.0, Weight: 2.0}, // Exam counts double
|
|
{Value: 3.0, Weight: 1.0},
|
|
},
|
|
// (1*2 + 3*1) / (2+1) = 5/3 = 1.67
|
|
expectedAverage: 1.67,
|
|
},
|
|
{
|
|
name: "single grade",
|
|
grades: []models.Grade{
|
|
{Value: 2.5, Weight: 1.0},
|
|
},
|
|
expectedAverage: 2.5,
|
|
},
|
|
{
|
|
name: "empty grades",
|
|
grades: []models.Grade{},
|
|
expectedAverage: 0.0,
|
|
},
|
|
{
|
|
name: "all same grades",
|
|
grades: []models.Grade{
|
|
{Value: 2.0, Weight: 1.0},
|
|
{Value: 2.0, Weight: 1.0},
|
|
{Value: 2.0, Weight: 1.0},
|
|
},
|
|
expectedAverage: 2.0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
avg := calculateWeightedAverage(tt.grades)
|
|
// Allow small floating point differences
|
|
if avg < tt.expectedAverage-0.01 || avg > tt.expectedAverage+0.01 {
|
|
t.Errorf("expected average=%.2f, got average=%.2f", tt.expectedAverage, avg)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// calculateWeightedAverage calculates weighted average of grades
|
|
func calculateWeightedAverage(grades []models.Grade) float64 {
|
|
if len(grades) == 0 {
|
|
return 0.0
|
|
}
|
|
|
|
var weightedSum float64
|
|
var totalWeight float64
|
|
|
|
for _, g := range grades {
|
|
weightedSum += g.Value * g.Weight
|
|
totalWeight += g.Weight
|
|
}
|
|
|
|
if totalWeight == 0 {
|
|
return 0.0
|
|
}
|
|
|
|
avg := weightedSum / totalWeight
|
|
// Round to 2 decimal places
|
|
return float64(int(avg*100)) / 100
|
|
}
|
|
|
|
// TestGradeDistribution tests grade distribution calculation
|
|
func TestGradeDistribution(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
grades []models.Grade
|
|
expectedDist map[int]int
|
|
}{
|
|
{
|
|
name: "varied distribution",
|
|
grades: []models.Grade{
|
|
{Value: 1.0}, {Value: 1.3},
|
|
{Value: 2.0}, {Value: 2.0}, {Value: 2.7},
|
|
{Value: 3.0}, {Value: 3.0}, {Value: 3.0},
|
|
{Value: 4.0}, {Value: 4.3},
|
|
{Value: 5.0},
|
|
},
|
|
expectedDist: map[int]int{
|
|
1: 2, // 1.0, 1.3 (rounded: 1, 1)
|
|
2: 2, // 2.0, 2.0 (rounded: 2, 2)
|
|
3: 4, // 2.7, 3.0, 3.0, 3.0 (rounded: 3, 3, 3, 3)
|
|
4: 2, // 4.0, 4.3 (rounded: 4, 4)
|
|
5: 1, // 5.0 (rounded: 5)
|
|
6: 0,
|
|
},
|
|
},
|
|
{
|
|
name: "empty grades",
|
|
grades: []models.Grade{},
|
|
expectedDist: map[int]int{1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0},
|
|
},
|
|
{
|
|
name: "all same grade",
|
|
grades: []models.Grade{
|
|
{Value: 2.0},
|
|
{Value: 2.0},
|
|
{Value: 2.0},
|
|
},
|
|
expectedDist: map[int]int{1: 0, 2: 3, 3: 0, 4: 0, 5: 0, 6: 0},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dist := calculateGradeDistribution(tt.grades)
|
|
for grade, count := range tt.expectedDist {
|
|
if dist[grade] != count {
|
|
t.Errorf("grade %d: expected count=%d, got count=%d", grade, count, dist[grade])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// calculateGradeDistribution calculates how many grades fall into each category
|
|
func calculateGradeDistribution(grades []models.Grade) map[int]int {
|
|
dist := map[int]int{1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0}
|
|
|
|
for _, g := range grades {
|
|
// Round to nearest integer for distribution
|
|
roundedGrade := int(g.Value + 0.5)
|
|
if roundedGrade < 1 {
|
|
roundedGrade = 1
|
|
}
|
|
if roundedGrade > 6 {
|
|
roundedGrade = 6
|
|
}
|
|
dist[roundedGrade]++
|
|
}
|
|
|
|
return dist
|
|
}
|
|
|
|
// TestGradePointConversion tests conversion between grades and points (Oberstufe)
|
|
func TestGradePointConversion(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
grade float64
|
|
expectedPoints int
|
|
}{
|
|
{"grade 1.0 = 15 points", 1.0, 15},
|
|
{"grade 1.3 = 14 points", 1.3, 14},
|
|
{"grade 1.7 = 13 points", 1.7, 13},
|
|
{"grade 2.0 = 12 points", 2.0, 12},
|
|
{"grade 2.3 = 11 points", 2.3, 11},
|
|
{"grade 2.7 = 10 points", 2.7, 10},
|
|
{"grade 3.0 = 9 points", 3.0, 9},
|
|
{"grade 3.3 = 8 points", 3.3, 8},
|
|
{"grade 3.7 = 7 points", 3.7, 7},
|
|
{"grade 4.0 = 6 points", 4.0, 6},
|
|
{"grade 4.3 = 5 points", 4.3, 5},
|
|
{"grade 4.7 = 4 points", 4.7, 4},
|
|
{"grade 5.0 = 3 points", 5.0, 3},
|
|
{"grade 5.3 = 2 points", 5.3, 2},
|
|
{"grade 5.7 = 1 point", 5.7, 1},
|
|
{"grade 6.0 = 0 points", 6.0, 0},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
points := gradeToPoints(tt.grade)
|
|
if points != tt.expectedPoints {
|
|
t.Errorf("expected points=%d, got points=%d", tt.expectedPoints, points)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// gradeToPoints converts German grade (1-6) to Oberstufe points (0-15)
|
|
func gradeToPoints(grade float64) int {
|
|
// Mapping based on German school system
|
|
if grade <= 1.0 {
|
|
return 15
|
|
} else if grade <= 1.3 {
|
|
return 14
|
|
} else if grade <= 1.7 {
|
|
return 13
|
|
} else if grade <= 2.0 {
|
|
return 12
|
|
} else if grade <= 2.3 {
|
|
return 11
|
|
} else if grade <= 2.7 {
|
|
return 10
|
|
} else if grade <= 3.0 {
|
|
return 9
|
|
} else if grade <= 3.3 {
|
|
return 8
|
|
} else if grade <= 3.7 {
|
|
return 7
|
|
} else if grade <= 4.0 {
|
|
return 6
|
|
} else if grade <= 4.3 {
|
|
return 5
|
|
} else if grade <= 4.7 {
|
|
return 4
|
|
} else if grade <= 5.0 {
|
|
return 3
|
|
} else if grade <= 5.3 {
|
|
return 2
|
|
} else if grade <= 5.7 {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// TestFindBestAndWorstGrade tests finding best and worst grades
|
|
func TestFindBestAndWorstGrade(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
grades []models.Grade
|
|
expectedBest float64
|
|
expectedWorst float64
|
|
}{
|
|
{
|
|
name: "varied grades",
|
|
grades: []models.Grade{
|
|
{Value: 2.0},
|
|
{Value: 1.0},
|
|
{Value: 3.0},
|
|
{Value: 5.0},
|
|
{Value: 2.0},
|
|
},
|
|
expectedBest: 1.0,
|
|
expectedWorst: 5.0,
|
|
},
|
|
{
|
|
name: "all same",
|
|
grades: []models.Grade{
|
|
{Value: 2.0},
|
|
{Value: 2.0},
|
|
},
|
|
expectedBest: 2.0,
|
|
expectedWorst: 2.0,
|
|
},
|
|
{
|
|
name: "single grade",
|
|
grades: []models.Grade{
|
|
{Value: 3.0},
|
|
},
|
|
expectedBest: 3.0,
|
|
expectedWorst: 3.0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
best, worst := findBestAndWorstGrade(tt.grades)
|
|
if best != tt.expectedBest {
|
|
t.Errorf("expected best=%.1f, got best=%.1f", tt.expectedBest, best)
|
|
}
|
|
if worst != tt.expectedWorst {
|
|
t.Errorf("expected worst=%.1f, got worst=%.1f", tt.expectedWorst, worst)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// findBestAndWorstGrade finds the best (lowest) and worst (highest) grade
|
|
func findBestAndWorstGrade(grades []models.Grade) (best, worst float64) {
|
|
if len(grades) == 0 {
|
|
return 0, 0
|
|
}
|
|
|
|
best = grades[0].Value
|
|
worst = grades[0].Value
|
|
|
|
for _, g := range grades[1:] {
|
|
if g.Value < best {
|
|
best = g.Value
|
|
}
|
|
if g.Value > worst {
|
|
worst = g.Value
|
|
}
|
|
}
|
|
|
|
return best, worst
|
|
}
|