diff --git a/app/lib/grades/grades_service/grades_service.dart b/app/lib/grades/grades_service/grades_service.dart index c809a50f6..4bd37dbce 100644 --- a/app/lib/grades/grades_service/grades_service.dart +++ b/app/lib/grades/grades_service/grades_service.dart @@ -6,6 +6,10 @@ // // SPDX-License-Identifier: EUPL-1.2 +import 'dart:developer'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:cloud_firestore_helper/cloud_firestore_helper.dart'; import 'package:collection/collection.dart'; import 'package:common_domain_models/common_domain_models.dart'; import 'package:date/date.dart'; @@ -17,6 +21,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:rxdart/rxdart.dart'; import 'package:rxdart/subjects.dart' as rx; import 'package:sharezone/grades/models/grade_id.dart'; + import '../models/subject_id.dart'; import '../models/term_id.dart'; @@ -24,9 +29,9 @@ export '../models/grade_id.dart'; export '../models/subject_id.dart'; export '../models/term_id.dart'; -part 'src/term.dart'; -part 'src/grading_systems.dart'; part 'src/grades_repository.dart'; +part 'src/grading_systems.dart'; +part 'src/term.dart'; class GradesService { final rx.BehaviorSubject> terms; @@ -35,48 +40,42 @@ class GradesService { GradesService({GradesStateRepository? repository}) : _repository = repository ?? InMemoryGradesStateRepository(), terms = rx.BehaviorSubject.seeded(const IListConst([])) { - _state = _repository.state.value; - _updateView(); - _repository.state.listen((state) { - _state = state; - // Update view gets called in [_updateState] anyways, so we don't need it - // here. Moving the method from [_updateState] to here would make the view - // being updated async. This would currently breaks our tests. - // _updateView(); + _updateView(); }); + + _updateView(); } - late GradesState _state; + GradesState get _state => _repository.state.value; IList get _terms => _state.terms; - set _terms(IList value) { - _state = _state.copyWith(terms: value); - } IList get _subjects => _state.subjects; - set _subjects(IList value) { - _state = _state.copyWith(subjects: value); - } IList get _customGradeTypes => _state.customGradeTypes; - set _customGradeTypes(IList value) { - _state = _state.copyWith(customGradeTypes: value); - } - void _updateState() { - _repository.updateState(_state); + void _updateState(GradesState state) { + _repository.updateState(state); + // Removing this breaks our tests because they assume that the state is + // updated synchronously. Usually calling [_updateView] in the .listen from + // the repository would be enough. _updateView(); } + void _updateTerms(IList terms) { + final newState = _state.copyWith(terms: terms); + _updateState(newState); + } + void _updateView() { final termRes = _terms.map(_toTermResult).toIList(); terms.add(termRes); } void _updateTerm(TermModel term) { - _terms = _terms.replaceAllWhere((t) => t.id == term.id, term); - _updateState(); + final newTerms = _terms.replaceAllWhere((t) => t.id == term.id, term); + _updateTerms(newTerms); } TermResult _toTermResult(TermModel term) { @@ -131,15 +130,16 @@ class GradesService { required GradingSystem gradingSystem, required bool isActiveTerm, }) { + IList newTerms = _terms; if (isActiveTerm) { - _terms = _terms.map((term) => term.setIsActiveTerm(false)).toIList(); + newTerms = newTerms.map((term) => term.setIsActiveTerm(false)).toIList(); } if (!_hasGradeTypeWithId(finalGradeType)) { throw GradeTypeNotFoundException(finalGradeType); } - _terms = _terms.add( + newTerms = newTerms.add( TermModel( id: id, isActiveTerm: isActiveTerm, @@ -148,7 +148,7 @@ class GradesService { gradingSystem: gradingSystem.toGradingSystemModel(), ), ); - _updateState(); + _updateTerms(newTerms); } /// Edits the given values of the term (does not edit if the value is null). @@ -168,8 +168,10 @@ class GradesService { final GradeTypeId? finalGradeType, final GradingSystem? gradingSystem, }) { + IList newTerms = _terms; + if (isActiveTerm != null) { - _terms = _terms.map((term) { + newTerms = newTerms.map((term) { if (id == term.id) { return term.setIsActiveTerm(isActiveTerm); } else { @@ -182,7 +184,7 @@ class GradesService { }).toIList(); } if (name != null) { - _terms = _terms + newTerms = newTerms .map((term) => term.id == id ? term.setName(name) : term) .toIList(); } @@ -190,20 +192,20 @@ class GradesService { if (!_hasGradeTypeWithId(finalGradeType)) { throw GradeTypeNotFoundException(finalGradeType); } - _terms = _terms + newTerms = newTerms .map((term) => term.id == id ? term.setFinalGradeType(finalGradeType) : term) .toIList(); } if (gradingSystem != null) { - _terms = _terms + newTerms = newTerms .map((term) => term.id == id ? term.setGradingSystem(gradingSystem.toGradingSystemModel()) : term) .toIList(); } - _updateState(); + _updateTerms(newTerms); } /// Deletes the term with the given [id] any grades inside it. @@ -212,10 +214,12 @@ class GradesService { /// /// Throws [ArgumentError] if the term with the given [id] does not exist. void deleteTerm(TermId id) { - final termOrNull = _terms.firstWhereOrNull((term) => term.id == id); + IList newTerms = _terms; + + final termOrNull = newTerms.firstWhereOrNull((term) => term.id == id); if (termOrNull != null) { - _terms = _terms.remove(termOrNull); - _updateState(); + newTerms = _terms.remove(termOrNull); + _updateTerms(newTerms); return; } throw ArgumentError("Can't delete term, unknown $TermId: '$id'."); @@ -366,8 +370,9 @@ class GradesService { // Already exists return; } - _customGradeTypes = _customGradeTypes.add(gradeType); - _updateState(); + final newState = + _state.copyWith(customGradeTypes: _customGradeTypes.add(gradeType)); + _updateState(newState); } GradeType _getGradeType(GradeTypeId finalGradeType) { @@ -375,11 +380,15 @@ class GradesService { } void addSubject(Subject subject) { + if (subject.createdOn != null) { + throw ArgumentError( + 'The createdOn field should not be set when adding a new subject.'); + } if (getSubjects().any((s) => s.id == subject.id)) { throw SubjectAlreadyExistsException(subject.id); } - _subjects = _subjects.add(subject); - _updateState(); + final newState = _state.copyWith(subjects: _subjects.add(subject)); + _updateState(newState); } IList getSubjects() { @@ -495,15 +504,15 @@ class ContinuousNumericalPossibleGradesResult extends PossibleGradesResult { final num max; final bool decimalsAllowed; - @override - List get props => [min, max, decimalsAllowed, specialGrades]; - /// Special non-numerical grade strings that have an assigned numerical value. /// /// For example [GradingSystem.oneToSixWithPlusAndMinus] might have the values: /// `{'1+':0.75,'1-':1.25, /**...*/ '5-':5.25}`. final IMap specialGrades; + @override + List get props => [min, max, decimalsAllowed, specialGrades]; + const ContinuousNumericalPossibleGradesResult({ required this.min, required this.max, @@ -767,7 +776,11 @@ class Grade { }); } -enum WeightType { perGrade, perGradeType, inheritFromTerm } +enum WeightType { + perGrade, + perGradeType, + inheritFromTerm; +} class GradeTypeId extends Id { const GradeTypeId(super.id); diff --git a/app/lib/grades/grades_service/src/grades_repository.dart b/app/lib/grades/grades_service/src/grades_repository.dart index 6325b383b..f59a9aab2 100644 --- a/app/lib/grades/grades_service/src/grades_repository.dart +++ b/app/lib/grades/grades_service/src/grades_repository.dart @@ -6,6 +6,8 @@ // // SPDX-License-Identifier: EUPL-1.2 +// ignore_for_file: library_private_types_in_public_api + part of '../grades_service.dart'; typedef GradesState = ({ @@ -15,8 +17,10 @@ typedef GradesState = ({ }); extension GradesStateCopyWith on GradesState { + bool get isEmpty => + terms.isEmpty && customGradeTypes.isEmpty && subjects.isEmpty; + GradesState copyWith({ - // ignore: library_private_types_in_public_api IList? terms, IList? customGradeTypes, IList? subjects, @@ -46,8 +50,728 @@ class InMemoryGradesStateRepository extends GradesStateRepository { )); } + Map _data = {}; + @override void updateState(GradesState state) { - this.state.add(state); + // We use the Firestore Repository methods so we test the (de)serialization + // even in our unit tests, which means a bigger test coverage. + _data = FirestoreGradesStateRepository.toDto(state); + // debugPrint(json.encode(_data, toEncodable: (val) { + // if (val is Timestamp) { + // return val.toDate().toIso8601String(); + // } + // })); + this.state.add(FirestoreGradesStateRepository.fromData(_data)); + } +} + +class FirestoreGradesStateRepository extends GradesStateRepository { + final DocumentReference userDocumentRef; + DocumentReference get gradesDocumentRef => + userDocumentRef.collection('Grades').doc('AllInOne'); + + FirestoreGradesStateRepository({required this.userDocumentRef}) { + gradesDocumentRef.snapshots().listen((event) { + if (event.exists) { + final data = event.data() as Map; + final newState = fromData(data); + state.add(newState); + } + }); + } + + @override + BehaviorSubject state = BehaviorSubject.seeded( + ( + terms: const IListConst([]), + customGradeTypes: const IListConst([]), + subjects: const IListConst([]), + ), + ); + + @override + void updateState(GradesState state) { + final stateBefore = this.state.value; + + final data = toDto(state); + gradesDocumentRef.set(data, SetOptions(merge: true)); + + // If our state is empty, we assume that the user might have just created + // their first term which is why this method was called. Thus we want to add + // a createdOn field to the top level of the document (if it doesn't exist + // yet). + if (stateBefore.isEmpty) { + tryAddTopLevelCreatedOn(); + } + } + + Future tryAddTopLevelCreatedOn() async { + final snap = + await gradesDocumentRef.get(const GetOptions(source: Source.cache)); + final data = snap.data(); + if (data is Map && data['createdOn'] == null) { + gradesDocumentRef.set( + {'createdOn': FieldValue.serverTimestamp()}, SetOptions(merge: true)); + } + } + + static Map toDto(GradesState state) { + final currentTermOrNull = + state.terms.firstWhereOrNull((term) => term.isActiveTerm)?.id.id; + return { + if (currentTermOrNull != null) 'currentTerm': currentTermOrNull, + 'terms': state.terms + .map((term) => MapEntry(term.id.id, TermDto.fromTerm(term).toData())) + .toMap(), + 'grades': state.terms + .expand((term) => term.subjects.expand((p0) => p0.grades)) + .map((g) => MapEntry(g.id.id, GradeDto.fromGrade(g).toData())) + .toMap(), + 'customGradeTypes': state.customGradeTypes + .map((element) => MapEntry(element.id.id, + CustomGradeTypeDto.fromGradeType(element).toData())) + .toMap(), + 'subjects': state.subjects + .map((subject) => + MapEntry(subject.id.id, SubjectDto.fromSubject(subject).toData())) + .toMap() + }; + } + + static GradesState fromData(Map data) { + IList termDtos = const IListConst([]); + + // Cant use this from data from Firestore as the maps and Lists are all + // dynamic. + // if (data case {'terms': Map> termData}) { + if (data case {'terms': Map termData}) { + termDtos = + termData.mapTo((value, key) => TermDto.fromData(key)).toIList(); + } + + IList subjectDtos = const IListConst([]); + if (data case {'subjects': Map subjectData}) { + subjectDtos = + subjectData.mapTo((value, key) => SubjectDto.fromData(key)).toIList(); + } + + IList gradeDtos = const IListConst([]); + if (data case {'grades': Map gradeData}) { + gradeDtos = + gradeData.mapTo((value, key) => GradeDto.fromData(key)).toIList(); + } + + IList customGradeTypeDtos = const IListConst([]); + if (data case {'customGradeTypes': Map gradeTypeData}) { + customGradeTypeDtos = gradeTypeData + .mapTo((value, key) => CustomGradeTypeDto.fromData(key)) + .toIList(); + } + + final grades = gradeDtos.map( + (dto) { + final gradingSystem = dto.gradingSystem.toGradingSystemModel(); + return GradeModel( + id: GradeId(dto.id), + termId: TermId(dto.termId), + date: dto.receivedAt, + gradingSystem: gradingSystem, + gradeType: GradeTypeId(dto.gradeType), + subjectId: SubjectId(dto.subjectId), + takenIntoAccount: dto.includeInGrading, + title: dto.title, + details: dto.details, + createdOn: dto.createdOn?.toDate(), + value: gradingSystem.toGradeResult(dto.numValue), + originalInput: dto.originalInput, + weight: termDtos + .firstWhereOrNull((element) => element.id == dto.termId) + ?.subjects + .firstWhereOrNull((element) => element.id == dto.subjectId) + ?.gradeComposition + .gradeWeights + .map((key, value) => + MapEntry(key, value.toWeight()))[dto.id] ?? + const Weight.factor(1), + ); + }, + ).toIList(); + + final subjects = subjectDtos.map( + (dto) { + return Subject( + id: SubjectId(dto.id), + createdOn: dto.createdOn?.toDate(), + design: dto.design, + name: dto.name, + abbreviation: dto.abbreviation, + connectedCourses: dto.connectedCourses + .map((cc) => cc.toConnectedCourse()) + .toIList(), + ); + }, + ).toIList(); + + final allTermSubjects = termDtos + .expand((term) => term.subjects + .map((subject) => (termSubject: subject, termId: term.id))) + .toIList(); + + final combinedTermSubjects = allTermSubjects + .map((termSub) { + final subject = subjects.firstWhereOrNull( + (subject) => subject.id.id == termSub.termSubject.id); + if (subject == null) { + log('No subject found for the term subject id ${termSub.termSubject.id}.'); + return null; + } + return (subject: subject, termSubjectObj: termSub); + }) + .whereNotNull() + .toIList(); + + final termSubjects = combinedTermSubjects.map((s) { + var (:subject, :termSubjectObj) = s; + var (:termSubject, :termId) = termSubjectObj; + + final subTerm = termDtos.firstWhere( + (term) => term.subjects.any((sub) => sub.id == subject.id.id)); + return SubjectModel( + id: subject.id, + termId: TermId(subTerm.id), + name: subject.name, + connectedCourses: subject.connectedCourses, + createdOn: termSubject.createdOn?.toDate(), + isFinalGradeTypeOverridden: + termSubject.finalGradeType != subTerm.finalGradeTypeId, + gradeTypeWeightings: termSubject.gradeComposition.gradeTypeWeights + .map((key, value) => MapEntry(GradeTypeId(key), value.toWeight())) + .toIMap(), + gradeTypeWeightingsFromTerm: subTerm.gradeTypeWeights + .map((key, value) => MapEntry(GradeTypeId(key), value.toWeight())) + .toIMap(), + weightingForTermGrade: + subTerm.subjectWeights[subject.id.id]?.toWeight() ?? + const Weight.factor(1), + grades: grades + .where((grade) => + grade.subjectId == subject.id && grade.termId.id == termId) + .toIList(), + weightType: termSubject.gradeComposition.weightType, + gradingSystem: subTerm.gradingSystem.toGradingSystemModel(), + finalGradeType: GradeTypeId(termSubject.finalGradeType), + abbreviation: subject.abbreviation, + design: subject.design, + ); + }).toIList(); + + var terms = termDtos + .map( + (dto) => TermModel( + id: TermId(dto.id), + finalGradeType: GradeTypeId(dto.finalGradeTypeId), + createdOn: dto.createdOn?.toDate(), + gradingSystem: dto.gradingSystem.toGradingSystemModel(), + isActiveTerm: data['currentTerm'] == dto.id, + subjects: termSubjects + .where((subject) => subject.termId.id == dto.id) + .toIList(), + name: dto.displayName, + // Change both to num + gradeTypeWeightings: dto.gradeTypeWeights + .map((key, value) => + MapEntry(GradeTypeId(key), value.toWeight())) + .toIMap(), + ), + ) + .toIList(); + + final customGradeTypes = customGradeTypeDtos + .map((dto) => GradeType( + id: GradeTypeId(dto.id), + displayName: dto.displayName, + )) + .toIList(); + + return ( + terms: terms, + customGradeTypes: customGradeTypes, + subjects: subjects, + ); + } +} + +extension ToMap on Iterable> { + Map toMap() => Map.fromEntries(this); +} + +extension on Weight { + WeightDto toDto() => WeightDto.fromWeight(this); +} + +extension on Object? { + Map toWeightsDtoMap() => _toWeightsDto(this); + // We use FieldValue.serverTimestamp() as a value, which will create an error + // in local-only unit tests as the FieldValue is not converted to a Timestamp + // as expected when using Firestore. This is why we use this method to check + // if the value is a Timestamp or not so no error is thrown when running this + // code in our local unit tests. + Timestamp? tryConvertToTimestampOrNull() { + if (this is Timestamp?) { + return this as Timestamp?; + } else { + return null; + } + } +} + +extension on DateTime? { + Timestamp? toTimestampOrNull() => + this == null ? null : Timestamp.fromDate(this!); +} + +extension on Map { + Map> toWeightDataMap() => + map((key, value) => MapEntry(key, value.toData())); +} + +enum _WeightNumberType { factor, percent } + +typedef _TermId = String; +typedef _SubjectId = String; +typedef _GradeId = String; +typedef _GradeTypeId = String; + +Map _toWeightsDto(Object? data) { + return (data as Map) + .map((key, value) => MapEntry(key, WeightDto.fromData(value))); +} + +class WeightDto { + final num value; + final _WeightNumberType type; + + WeightDto({ + required this.value, + required this.type, + }); + + factory WeightDto.fromWeight(Weight weight) { + return WeightDto( + value: weight.asFactor, + type: _WeightNumberType.factor, + ); + } + + factory WeightDto.fromData(Map data) { + return WeightDto( + value: data['value'] as num, + type: _WeightNumberType.values.byName(data['type'] as String), + ); + } + + Weight toWeight() { + return switch (type) { + _WeightNumberType.factor => Weight.factor(value), + _WeightNumberType.percent => Weight.percent(value), + }; + } + + Map toData() { + return { + 'value': value, + 'type': type.name, + }; + } +} + +class CustomGradeTypeDto { + final _GradeTypeId id; + final String displayName; + + CustomGradeTypeDto({ + required this.id, + required this.displayName, + }); + + factory CustomGradeTypeDto.fromData(Map data) { + return CustomGradeTypeDto( + id: data['id'] as String, + displayName: data['displayName'] as String, + ); + } + + factory CustomGradeTypeDto.fromGradeType(GradeType gradeType) { + return CustomGradeTypeDto( + id: gradeType.id.id, + displayName: gradeType.displayName!, + ); + } + + Map toData() { + return { + 'id': id, + 'displayName': displayName, + }; + } +} + +class TermDto { + final _TermId id; + final String displayName; + final Timestamp? createdOn; + final GradingSystem gradingSystem; + final Map<_SubjectId, WeightDto> subjectWeights; + final Map<_GradeTypeId, WeightDto> gradeTypeWeights; + final List subjects; + final _GradeTypeId finalGradeTypeId; + + TermDto({ + required this.id, + required this.displayName, + required this.createdOn, + required this.gradingSystem, + required this.subjectWeights, + required this.gradeTypeWeights, + required this.finalGradeTypeId, + required this.subjects, + }); + + factory TermDto.fromTerm(TermModel term) { + return TermDto( + id: term.id.id, + displayName: term.name, + createdOn: term.createdOn?.toTimestampOrNull(), + finalGradeTypeId: term.finalGradeType.id, + gradingSystem: term.gradingSystem.spec.gradingSystem, + subjects: term.subjects.map(TermSubjectDto.fromSubject).toList(), + subjectWeights: Map.fromEntries(term.subjects.map((subject) => + MapEntry(subject.id.id, subject.weightingForTermGrade.toDto()))), + gradeTypeWeights: term.gradeTypeWeightings + .map((gradeId, weight) => MapEntry(gradeId.id, weight.toDto())) + .unlock, + ); + } + + factory TermDto.fromData(Map data) { + return TermDto( + id: data['id'] as String, + displayName: data['displayName'] as String, + createdOn: data['createdOn'].tryConvertToTimestampOrNull(), + gradingSystem: + GradingSystem.values.byName(data['gradingSystem'] as String), + subjectWeights: data['subjectWeights'].toWeightsDtoMap(), + gradeTypeWeights: data['gradeTypeWeights'].toWeightsDtoMap(), + subjects: (data['subjects'] as Map) + .mapTo((_, sub) => TermSubjectDto.fromData(sub)) + .toList(), + finalGradeTypeId: data['finalGradeType'] as String, + ); + } + + Map toData() { + return { + 'id': id, + 'displayName': displayName, + if (createdOn != null) 'createdOn': createdOn!, + if (createdOn == null) 'createdOn': FieldValue.serverTimestamp(), + 'gradingSystem': gradingSystem.name, + 'subjectWeights': subjectWeights.toWeightDataMap(), + 'gradeTypeWeights': gradeTypeWeights.toWeightDataMap(), + 'subjects': subjects + .map((subject) => MapEntry(subject.id, subject.toData())) + .toMap(), + 'finalGradeType': finalGradeTypeId, + }; + } +} + +class TermSubjectDto { + final _SubjectId id; + final SubjectGradeCompositionDto gradeComposition; + final _GradeTypeId finalGradeType; + final List<_GradeId> grades; + final Timestamp? createdOn; + + TermSubjectDto({ + required this.id, + required this.grades, + required this.gradeComposition, + required this.finalGradeType, + required this.createdOn, + }); + + factory TermSubjectDto.fromSubject(SubjectModel subject) { + return TermSubjectDto( + id: subject.id.id, + grades: subject.grades.map((grade) => grade.id.id).toList(), + finalGradeType: subject.finalGradeType.id, + gradeComposition: SubjectGradeCompositionDto.fromSubject(subject), + createdOn: subject.createdOn?.toTimestampOrNull(), + ); + } + + factory TermSubjectDto.fromData(Map data) { + return TermSubjectDto( + id: data['id'] as String, + grades: decodeList(data['grades'], (g) => g as String), + finalGradeType: data['finalGradeType'] as String, + gradeComposition: SubjectGradeCompositionDto.fromData( + decodeMap(data['gradeComposition'], + (key, decodedMapValue) => decodedMapValue as Object), + ), + createdOn: data['createdOn'].tryConvertToTimestampOrNull(), + ); + } + + Map toData() { + return { + 'id': id, + 'grades': grades, + 'gradeComposition': gradeComposition.toData(), + 'finalGradeType': finalGradeType, + if (createdOn != null) 'createdOn': createdOn!, + if (createdOn == null) 'createdOn': FieldValue.serverTimestamp(), + }; + } +} + +class SubjectGradeCompositionDto { + final WeightType weightType; + final Map<_GradeTypeId, WeightDto> gradeTypeWeights; + final Map<_GradeId, WeightDto> gradeWeights; + + SubjectGradeCompositionDto( + {required this.weightType, + required this.gradeTypeWeights, + required this.gradeWeights}); + + factory SubjectGradeCompositionDto.fromSubject(SubjectModel subject) { + return SubjectGradeCompositionDto( + weightType: subject.weightType, + gradeTypeWeights: subject.gradeTypeWeightings + .map((gradeId, weight) => MapEntry(gradeId.id, weight.toDto())) + .unlock, + gradeWeights: Map.fromEntries(subject.grades + .map((grade) => MapEntry(grade.id.id, grade.weight.toDto()))), + ); + } + + factory SubjectGradeCompositionDto.fromData(Map data) { + return SubjectGradeCompositionDto( + weightType: WeightType.values.byName(data['weightType'] as String), + gradeTypeWeights: data['gradeTypeWeights'].toWeightsDtoMap(), + gradeWeights: data['gradeWeights'].toWeightsDtoMap(), + ); + } + + Map toData() { + return { + 'weightType': weightType.name, + 'gradeTypeWeights': gradeTypeWeights.toWeightDataMap(), + 'gradeWeights': gradeWeights.toWeightDataMap(), + }; + } +} + +class GradeDto { + final _GradeId id; + final Timestamp? createdOn; + final _TermId termId; + final _SubjectId subjectId; + final num numValue; + final GradingSystem gradingSystem; + final _GradeTypeId gradeType; + final Date receivedAt; + final bool includeInGrading; + final String title; + final String? details; + final Object originalInput; + + GradeDto( + {required this.id, + required this.termId, + required this.subjectId, + required this.numValue, + required this.gradingSystem, + required this.gradeType, + required this.receivedAt, + required this.includeInGrading, + required this.title, + required this.details, + required this.createdOn, + required this.originalInput}); + + factory GradeDto.fromGrade(GradeModel grade) { + return GradeDto( + id: grade.id.id, + termId: grade.termId.id, + subjectId: grade.subjectId.id, + numValue: grade.value.asNum, + originalInput: grade.originalInput, + gradingSystem: grade.gradingSystem.spec.gradingSystem, + gradeType: grade.gradeType.id, + receivedAt: grade.date, + includeInGrading: grade.takenIntoAccount, + title: grade.title, + details: grade.details, + createdOn: grade.createdOn?.toTimestampOrNull(), + ); + } + + factory GradeDto.fromData(Map data) { + return GradeDto( + id: data['id'] as String, + termId: data['termId'] as String, + subjectId: data['subjectId'] as String, + numValue: data['numValue'] as num, + originalInput: data['originalInput'] as Object, + gradingSystem: + GradingSystem.values.byName(data['gradingSystem'] as String), + gradeType: data['gradeType'] as String, + receivedAt: Date.parse(data['receivedAt'] as String), + includeInGrading: data['includeInGrading'] as bool, + title: data['title'] as String, + details: data['details'] as String?, + createdOn: data['createdOn'].tryConvertToTimestampOrNull(), + ); + } + + Map toData() { + // Currently the originalInput would use the grade display string for the + // original value but we want to transition to using an Enum in the future. + // We use this to fake using an enum for the original value. We'll implement + // really using the enum in the future. + Object originalInp = originalInput; + if (gradingSystem == GradingSystem.austrianBehaviouralGrades) { + originalInp = getAustrianBehaviouralGradeDbKeyFromNum(numValue); + } + return { + 'id': id, + 'termId': termId, + 'subjectId': subjectId, + 'originalInput': originalInp, + 'numValue': numValue, + 'gradingSystem': gradingSystem.name, + 'gradeType': gradeType, + 'receivedAt': receivedAt.toDateString, + 'includeInGrading': includeInGrading, + 'title': title, + if (details != null) 'details': details!, + if (createdOn == null) 'createdOn': FieldValue.serverTimestamp(), + if (createdOn != null) 'createdOn': createdOn!, + }; + } +} + +class SubjectDto { + final _SubjectId id; + final String name; + final String abbreviation; + final Design design; + final IList connectedCourses; + final Timestamp? createdOn; + + SubjectDto({ + required this.id, + required this.name, + required this.abbreviation, + required this.design, + required this.connectedCourses, + required this.createdOn, + }); + + factory SubjectDto.fromSubject(Subject subject) { + return SubjectDto( + id: subject.id.id, + design: subject.design, + name: subject.name, + abbreviation: subject.abbreviation, + createdOn: subject.createdOn.toTimestampOrNull(), + connectedCourses: subject.connectedCourses + .map((cc) => ConnectedCourseDto.fromConnectedCourse(cc)) + .toIList(), + ); + } + + factory SubjectDto.fromData(Map data) { + return SubjectDto( + id: data['id'] as String, + name: data['name'] as String, + abbreviation: data['abbreviation'] as String, + design: Design.fromData(data['design']), + createdOn: data['createdOn'].tryConvertToTimestampOrNull(), + connectedCourses: (data['connectedCourses'] as Map) + .mapTo((key, value) => ConnectedCourseDto.fromData(value)) + .toIList()); + } + + Map toData() { + return { + 'id': id, + 'name': name, + 'abbreviation': abbreviation, + 'design': design.toJson(), + 'connectedCourses': + connectedCourses.map((cc) => MapEntry(cc.id, cc.toData())).toMap(), + if (createdOn == null) 'createdOn': FieldValue.serverTimestamp(), + if (createdOn != null) 'createdOn': createdOn!, + }; + } +} + +class ConnectedCourseDto { + final String id; + final String name; + final String abbreviation; + final String subjectName; + final Timestamp? addedOn; + + ConnectedCourseDto({ + required this.id, + required this.name, + required this.abbreviation, + required this.subjectName, + required this.addedOn, + }); + + factory ConnectedCourseDto.fromConnectedCourse(ConnectedCourse course) { + return ConnectedCourseDto( + id: course.id.id, + name: course.name, + abbreviation: course.abbreviation, + subjectName: course.subjectName, + addedOn: course.addedOn.toTimestampOrNull(), + ); + } + + factory ConnectedCourseDto.fromData(Map data) { + return ConnectedCourseDto( + id: data['id'] as String, + name: data['name'] as String, + abbreviation: data['abbreviation'] as String, + subjectName: data['subjectName'] as String, + addedOn: data['addedOn'].tryConvertToTimestampOrNull(), + ); + } + + ConnectedCourse toConnectedCourse() { + return ConnectedCourse( + id: CourseId(id), + name: name, + abbreviation: abbreviation, + subjectName: subjectName, + addedOn: addedOn?.toDate(), + ); + } + + Map toData() { + return { + 'id': id, + 'name': name, + 'abbreviation': abbreviation, + 'subjectName': subjectName, + }; } } diff --git a/app/lib/grades/grades_service/src/grading_systems.dart b/app/lib/grades/grades_service/src/grading_systems.dart index f29d0fd86..8aee2da32 100644 --- a/app/lib/grades/grades_service/src/grading_systems.dart +++ b/app/lib/grades/grades_service/src/grading_systems.dart @@ -37,27 +37,30 @@ extension _ToGradingSystems on GradingSystemModel { } } -class GradingSystemModel { +class GradingSystemModel extends Equatable { static final oneToSixWithPlusAndMinus = GradingSystemModel(spec: oneToSixWithPlusAndMinusSpec); - static final oneToSixWithDecimals = + static const oneToSixWithDecimals = GradingSystemModel(spec: oneToSixWithDecimalsSpec); - static final zeroToFifteenPoints = + static const zeroToFifteenPoints = GradingSystemModel(spec: zeroToFifteenPointsSpec); - static final zeroToFifteenPointsWithDecimals = + static const zeroToFifteenPointsWithDecimals = GradingSystemModel(spec: zeroToFifteenPointsWithDecimalsSpec); - static final zeroToHundredPercentWithDecimals = + static const zeroToHundredPercentWithDecimals = GradingSystemModel(spec: zeroToHundredPercentWithDecimalsSpec); static final austrianBehaviouralGrades = GradingSystemModel(spec: austrianBehaviouralGradesSpec); - static final oneToFiveWithDecimals = + static const oneToFiveWithDecimals = GradingSystemModel(spec: oneToFiveWithDecimalsSpec); - static final sixToOneWithDecimals = + static const sixToOneWithDecimals = GradingSystemModel(spec: sixToOneWithDecimalsSpec); final GradingSystemSpec spec; - GradingSystemModel({required this.spec}); + @override + List get props => [spec]; + + const GradingSystemModel({required this.spec}); num _toNumOrThrow(String grade) { // 2,3 -> 2.3 (format expected by num.tryParse()) @@ -127,12 +130,18 @@ extension HasDecimals on num { bool get hasDecimals => this % 1 != 0; } -class GradingSystemSpec { +class GradingSystemSpec extends Equatable { final GradingSystem gradingSystem; final PossibleGradesResult possibleGrades; final num? Function(String grade)? specialDisplayableGradeToNumOrNull; final String? Function(num grade)? getSpecialDisplayableGradeOrNull; + @override + List get props => [ + gradingSystem, + possibleGrades, + ]; + const GradingSystemSpec({ required this.gradingSystem, required this.possibleGrades, @@ -288,3 +297,13 @@ final austrianBehaviouralGradesSpec = GradingSystemSpec.nonNumerical( 'Nicht zufriedenstellend': 4, }), ); + +String getAustrianBehaviouralGradeDbKeyFromNum(num grade) { + return switch (grade) { + 1 => 'verySatisfactory', + 2 => 'satisfactory', + 3 => 'lessSatisfactory', + 4 => 'notSatisfactory', + _ => throw ArgumentError('Cant get db key for austrian behavioural grade') + }; +} diff --git a/app/lib/grades/grades_service/src/term.dart b/app/lib/grades/grades_service/src/term.dart index 1516a6463..36c69a04a 100644 --- a/app/lib/grades/grades_service/src/term.dart +++ b/app/lib/grades/grades_service/src/term.dart @@ -499,7 +499,7 @@ class GradeModel extends Equatable { required this.takenIntoAccount, required this.title, required this.originalInput, - this.details, + required this.details, this.createdOn, }) : assert(originalInput is String || originalInput is num); diff --git a/app/lib/main/sharezone_bloc_providers.dart b/app/lib/main/sharezone_bloc_providers.dart index 3f1e217b6..32be0815c 100644 --- a/app/lib/main/sharezone_bloc_providers.dart +++ b/app/lib/main/sharezone_bloc_providers.dart @@ -328,7 +328,10 @@ class _SharezoneBlocProvidersState extends State { final gradesEnabledFlag = GradesEnabledFlag(widget.blocDependencies.keyValueStore); - final gradesService = GradesService(); + final userDocRef = api.references.users.doc(api.uID); + final gradesService = GradesService( + repository: + FirestoreGradesStateRepository(userDocumentRef: userDocRef)); // In the past we used BlocProvider for everything (even non-bloc classes). // This forced us to use BlocProvider wrapper classes for non-bloc entities, diff --git a/app/test/grades/grades_repository_test.dart b/app/test/grades/grades_repository_test.dart new file mode 100644 index 000000000..9913a8eab --- /dev/null +++ b/app/test/grades/grades_repository_test.dart @@ -0,0 +1,722 @@ +// Copyright (c) 2024 Sharezone UG (haftungsbeschränkt) +// Licensed under the EUPL-1.2-or-later. +// +// You may obtain a copy of the Licence at: +// https://joinup.ec.europa.eu/software/page/eupl +// +// SPDX-License-Identifier: EUPL-1.2 + +import 'dart:math'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:common_domain_models/common_domain_models.dart'; +import 'package:date/date.dart'; +import 'package:design/design.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rxdart/src/subjects/behavior_subject.dart'; +import 'package:sharezone/grades/grades_service/grades_service.dart'; + +import 'grades_test_common.dart'; + +void main() { + group('GradesRepository', () { + group('basic repository tests', () { + late GradesStateRepository repository; + late GradesService service; + late GradesTestController controller; + + setUp(() { + repository = InMemoryGradesStateRepository(); + service = GradesService(repository: repository); + controller = GradesTestController(gradesService: service); + }); + + void replaceGradesServiceWithSameRepository() { + controller.service = GradesService(repository: repository); + } + + test( + 'A term is still saved when deleting the Grade service as long as the repository is the same', + () { + var term = termWith(name: 'term1'); + controller.createTerm(term); + + replaceGradesServiceWithSameRepository(); + + expect(controller.terms, hasLength(1)); + }); + test( + 'A gradeType is still saved when deleting the Grade service as long as the repository is the same', + () { + const gradeType = GradeType(id: GradeTypeId('foo'), displayName: 'Foo'); + controller.createCustomGradeType(gradeType); + + replaceGradesServiceWithSameRepository(); + + expect(controller.getPossibleGradeTypes(), contains(gradeType)); + }); + test( + 'A subject is still saved when deleting the Grade service as long as the repository is the same', + () { + var subject = + subjectWith(id: const SubjectId('foo'), name: 'Foo Subject'); + controller.addSubject(subject); + + replaceGradesServiceWithSameRepository(); + + expect( + controller.getSubjects(), + contains( + predicate( + (sub) => sub.id == subject.id && sub.name == subject.name, + ), + ), + ); + }); + test( + 'If the $GradesService is replaced without the same $GradesStateRepository then old values wont be there anymore', + () { + final term = termWith( + subjects: [ + subjectWith( + id: const SubjectId('Philosophie'), + grades: [ + gradeWith( + id: GradeId('grade1'), + value: 4.0, + ), + ], + ), + ], + ); + controller.createTerm(term); + + // We don't reuse the repository here, so the data will be lost + controller.service = + GradesService(repository: InMemoryGradesStateRepository()); + + expect(controller.terms, isEmpty); + }); + }); + test('serializes expected data map for empty state', () { + final res = FirestoreGradesStateRepository.toDto(( + customGradeTypes: const IListConst([]), + subjects: const IListConst([]), + terms: const IListConst([]), + )); + + expect(res, { + 'customGradeTypes': {}, + 'subjects': {}, + 'grades': {}, + 'terms': {}, + }); + }); + test('deserializes expected state from data map', () { + final res = FirestoreGradesStateRepository.fromData({ + 'customGradeTypes': {}, + 'subjects': {}, + 'grades': {}, + 'terms': {}, + }); + + expect(res, ( + customGradeTypes: const IListConst([]), + subjects: const IListConst([]), + terms: const IListConst([]), + )); + }); + test('serializes expected data map for service usage', () { + final repository = TestFirestoreGradesStateRepository(); + final service = GradesService(repository: repository); + final random = Random(42); + + service.addCustomGradeType( + const GradeType( + id: GradeTypeId('my-custom-grade-type'), + displayName: 'My Custom Grade Type', + ), + ); + + service.addTerm( + id: const TermId('02-10-term'), + name: '02/10', + finalGradeType: GradeType.schoolReportGrade.id, + gradingSystem: GradingSystem.zeroToFifteenPoints, + isActiveTerm: true, + ); + service.addTerm( + id: const TermId('01-10-term'), + name: '01/10', + finalGradeType: const GradeTypeId('my-custom-grade-type'), + gradingSystem: GradingSystem.oneToSixWithPlusAndMinus, + isActiveTerm: false, + ); + + service.addSubject(Subject( + id: const SubjectId('mathe'), + design: Design.random(random), + name: 'Mathe', + abbreviation: 'M', + connectedCourses: const IListConst([ + ConnectedCourse( + id: CourseId('connected-mathe-course'), + name: 'Mathe 8a', + abbreviation: 'M', + subjectName: 'Mathe', + ) + ]), + )); + service.addSubject(Subject( + id: const SubjectId('englisch'), + design: Design.random(random), + name: 'Englisch', + abbreviation: 'E', + connectedCourses: const IListConst([ + ConnectedCourse( + id: CourseId('connected-englisch-course'), + name: 'Englisch 8a', + abbreviation: 'E', + subjectName: 'Englisch', + ) + ]), + )); + + service.addGrade( + subjectId: const SubjectId('mathe'), + termId: const TermId('02-10-term'), + value: Grade( + id: GradeId('grade-1'), + value: '13', + gradingSystem: GradingSystem.zeroToFifteenPoints, + type: const GradeTypeId('my-custom-grade-type'), + date: Date('2024-10-02'), + takeIntoAccount: true, + title: 'hallo', + details: 'hello', + )); + service.addGrade( + subjectId: const SubjectId('mathe'), + termId: const TermId('02-10-term'), + value: Grade( + id: GradeId('grade-2'), + value: '3', + gradingSystem: GradingSystem.zeroToFifteenPoints, + type: GradeType.vocabularyTest.id, + date: Date('2024-10-03'), + takeIntoAccount: true, + title: 'abcdef', + details: 'ghijkl', + )); + + service.addGrade( + subjectId: const SubjectId('englisch'), + termId: const TermId('01-10-term'), + value: Grade( + id: GradeId('grade-3'), + value: '2-', + gradingSystem: GradingSystem.oneToSixWithPlusAndMinus, + type: const GradeTypeId('my-custom-grade-type'), + date: Date('2024-10-16'), + takeIntoAccount: false, + title: 'hallo', + details: 'ollah', + )); + service.addGrade( + subjectId: const SubjectId('englisch'), + termId: const TermId('01-10-term'), + value: Grade( + id: GradeId('grade-4'), + value: 'Sehr zufriedenstellend', + gradingSystem: GradingSystem.austrianBehaviouralGrades, + type: GradeType.oralParticipation.id, + date: Date('2024-10-18'), + takeIntoAccount: true, + title: 'Beep boop', + details: 'robot noises', + )); + + service.changeSubjectFinalGradeType( + id: const SubjectId('englisch'), + termId: const TermId('01-10-term'), + gradeType: GradeType.oralParticipation.id); + service.changeGradeTypeWeightForTerm( + termId: const TermId('02-10-term'), + gradeType: GradeType.vocabularyTest.id, + weight: const Weight.factor(1.5)); + service.changeGradeTypeWeightForSubject( + id: const SubjectId('mathe'), + termId: const TermId('02-10-term'), + gradeType: const GradeTypeId('my-custom-grade-type'), + weight: const Weight.percent(200)); + service.changeGradeWeight( + id: GradeId('grade-1'), + termId: const TermId('02-10-term'), + weight: const Weight.factor(0.5)); + service.changeSubjectWeightTypeSettings( + id: const SubjectId('mathe'), + termId: const TermId('02-10-term'), + perGradeType: WeightType.perGrade); + service.changeSubjectWeightForTermGrade( + id: const SubjectId('mathe'), + termId: const TermId('02-10-term'), + weight: const Weight.percent(250)); + + final res = repository.data; + + expect(res, { + 'currentTerm': '02-10-term', + 'terms': { + '02-10-term': { + 'id': '02-10-term', + 'displayName': '02/10', + 'createdOn': FieldValue.serverTimestamp(), + 'gradingSystem': 'zeroToFifteenPoints', + 'subjectWeights': { + 'mathe': { + 'value': 2.5, + 'type': 'factor', + } + }, + 'gradeTypeWeights': { + 'vocabulary-test': {'value': 1.5, 'type': 'factor'} + }, + 'subjects': { + 'mathe': { + 'id': 'mathe', + 'createdOn': FieldValue.serverTimestamp(), + 'grades': ['grade-1', 'grade-2'], + 'gradeComposition': { + 'weightType': 'perGrade', + 'gradeTypeWeights': { + 'my-custom-grade-type': {'value': 2.0, 'type': 'factor'} + }, + 'gradeWeights': { + 'grade-1': { + 'value': 0.5, + 'type': 'factor', + }, + 'grade-2': { + 'value': 1.0, + 'type': 'factor', + }, + } + }, + 'finalGradeType': 'school-report-grade' + } + }, + 'finalGradeType': 'school-report-grade' + }, + '01-10-term': { + 'id': '01-10-term', + 'displayName': '01/10', + 'createdOn': FieldValue.serverTimestamp(), + 'gradingSystem': 'oneToSixWithPlusAndMinus', + 'subjectWeights': { + 'englisch': {'value': 1.0, 'type': 'factor'}, + }, + 'gradeTypeWeights': {}, + 'subjects': { + 'englisch': { + 'id': 'englisch', + 'createdOn': FieldValue.serverTimestamp(), + 'grades': ['grade-3', 'grade-4'], + 'gradeComposition': { + 'weightType': 'inheritFromTerm', + 'gradeTypeWeights': {}, + 'gradeWeights': { + 'grade-3': { + 'value': 1.0, + 'type': 'factor', + }, + 'grade-4': { + 'value': 1.0, + 'type': 'factor', + }, + } + }, + 'finalGradeType': 'oral-participation' + }, + }, + 'finalGradeType': 'my-custom-grade-type' + } + }, + 'grades': { + 'grade-1': { + 'id': 'grade-1', + 'termId': '02-10-term', + 'subjectId': 'mathe', + 'originalInput': '13', + 'numValue': 13, + 'gradingSystem': 'zeroToFifteenPoints', + 'gradeType': 'my-custom-grade-type', + 'receivedAt': '2024-10-02', + 'includeInGrading': true, + 'title': 'hallo', + 'details': 'hello', + 'createdOn': FieldValue.serverTimestamp(), + }, + 'grade-2': { + 'id': 'grade-2', + 'termId': '02-10-term', + 'subjectId': 'mathe', + 'originalInput': '3', + 'numValue': 3, + 'gradingSystem': 'zeroToFifteenPoints', + 'gradeType': 'vocabulary-test', + 'receivedAt': '2024-10-03', + 'includeInGrading': true, + 'title': 'abcdef', + 'details': 'ghijkl', + 'createdOn': FieldValue.serverTimestamp(), + }, + 'grade-3': { + 'id': 'grade-3', + 'termId': '01-10-term', + 'subjectId': 'englisch', + 'originalInput': '2-', + 'numValue': 2.25, + 'gradingSystem': 'oneToSixWithPlusAndMinus', + 'gradeType': 'my-custom-grade-type', + 'receivedAt': '2024-10-16', + 'includeInGrading': false, + 'title': 'hallo', + 'details': 'ollah', + 'createdOn': FieldValue.serverTimestamp(), + }, + 'grade-4': { + 'id': 'grade-4', + 'termId': '01-10-term', + 'subjectId': 'englisch', + 'originalInput': 'verySatisfactory', + 'numValue': 1, + 'gradingSystem': 'austrianBehaviouralGrades', + 'gradeType': 'oral-participation', + 'receivedAt': '2024-10-18', + 'includeInGrading': true, + 'title': 'Beep boop', + 'details': 'robot noises', + 'createdOn': FieldValue.serverTimestamp(), + }, + }, + 'customGradeTypes': { + 'my-custom-grade-type': { + 'id': 'my-custom-grade-type', + 'displayName': 'My Custom Grade Type', + } + }, + 'subjects': { + 'mathe': { + 'id': 'mathe', + 'name': 'Mathe', + 'abbreviation': 'M', + 'createdOn': FieldValue.serverTimestamp(), + 'design': {'color': '795548', 'type': 'color'}, + 'connectedCourses': { + 'connected-mathe-course': { + 'id': 'connected-mathe-course', + 'name': 'Mathe 8a', + 'abbreviation': 'M', + 'subjectName': 'Mathe' + } + } + }, + 'englisch': { + 'id': 'englisch', + 'name': 'Englisch', + 'abbreviation': 'E', + 'createdOn': FieldValue.serverTimestamp(), + 'design': {'color': '000000', 'type': 'color'}, + 'connectedCourses': { + 'connected-englisch-course': { + 'id': 'connected-englisch-course', + 'name': 'Englisch 8a', + 'abbreviation': 'E', + 'subjectName': 'Englisch' + } + } + } + } + }); + + final state = repository.state.value; + + expect( + state.customGradeTypes, + const IListConst([ + GradeType( + id: GradeTypeId('my-custom-grade-type'), + displayName: 'My Custom Grade Type', + ), + ])); + + expect( + state.subjects, + IListConst([ + Subject( + id: const SubjectId('mathe'), + design: Design.fromData('795548'), + name: 'Mathe', + abbreviation: 'M', + connectedCourses: const IListConst( + [ + ConnectedCourse( + id: CourseId('connected-mathe-course'), + name: 'Mathe 8a', + abbreviation: 'M', + subjectName: 'Mathe') + ], + ), + ), + Subject( + id: const SubjectId('englisch'), + design: Design.fromData('000000'), + name: 'Englisch', + abbreviation: 'E', + connectedCourses: const IListConst( + [ + ConnectedCourse( + id: CourseId('connected-englisch-course'), + name: 'Englisch 8a', + abbreviation: 'E', + subjectName: 'Englisch', + ) + ], + ), + ), + ])); + + final expectedTerms = [ + TermModel( + id: const TermId('02-10-term'), + subjects: IListConst( + [ + SubjectModel( + id: const SubjectId('mathe'), + name: 'Mathe', + termId: const TermId('02-10-term'), + gradingSystem: GradingSystemModel.zeroToFifteenPoints, + grades: IListConst([ + GradeModel( + id: GradeId('grade-1'), + subjectId: const SubjectId('mathe'), + termId: const TermId('02-10-term'), + originalInput: '13', + value: const GradeValue( + asNum: 13, + gradingSystem: GradingSystem.zeroToFifteenPoints, + displayableGrade: null, + suffix: null), + gradingSystem: GradingSystemModel.zeroToFifteenPoints, + gradeType: const GradeTypeId('my-custom-grade-type'), + takenIntoAccount: true, + weight: const Weight.factor(0.5), + date: Date('2024-10-02'), + title: 'hallo', + details: 'hello', + ), + GradeModel( + id: GradeId('grade-2'), + subjectId: const SubjectId('mathe'), + termId: const TermId('02-10-term'), + originalInput: 3, + value: const GradeValue( + asNum: 3, + gradingSystem: GradingSystem.zeroToFifteenPoints, + displayableGrade: null, + suffix: null, + ), + gradingSystem: GradingSystemModel.zeroToFifteenPoints, + gradeType: const GradeTypeId('vocabulary-test'), + takenIntoAccount: true, + weight: const Weight.factor(1), + // weight: const Weight.factor(0.5), + // date: Date('2024-10-02'), + date: Date('2024-10-03'), + title: 'abcdef', + details: 'ghijkl', + ), + ]), + finalGradeType: const GradeTypeId('school-report-grade'), + isFinalGradeTypeOverridden: false, + weightingForTermGrade: const Weight.factor(2.5), + gradeTypeWeightings: IMapConst({ + const GradeTypeId('my-custom-grade-type'): + const Weight.factor(2.0) + }), + gradeTypeWeightingsFromTerm: IMapConst({ + const GradeTypeId('vocabulary-test'): const Weight.factor(1.5) + }), + weightType: WeightType.perGrade, + abbreviation: 'M', + design: Design.fromData('795548'), + connectedCourses: const IListConst( + [ + ConnectedCourse( + id: CourseId('connected-mathe-course'), + name: 'Mathe 8a', + abbreviation: 'M', + subjectName: 'Mathe', + ) + ], + ), + ) + ], + ), + gradeTypeWeightings: IMapConst( + {const GradeTypeId('vocabulary-test'): const Weight.factor(1.5)}), + gradingSystem: GradingSystemModel.zeroToFifteenPoints, + finalGradeType: const GradeTypeId('school-report-grade'), + isActiveTerm: true, + name: '02/10', + ), + TermModel( + id: const TermId('01-10-term'), + subjects: IListConst([ + SubjectModel( + id: const SubjectId('englisch'), + name: 'Englisch', + termId: const TermId('01-10-term'), + gradingSystem: GradingSystemModel.oneToSixWithPlusAndMinus, + grades: IListConst( + [ + GradeModel( + id: GradeId('grade-3'), + subjectId: const SubjectId('englisch'), + termId: const TermId('01-10-term'), + originalInput: '2-', + value: const GradeValue( + asNum: 2.25, + gradingSystem: GradingSystem.oneToSixWithPlusAndMinus, + displayableGrade: '2-', + suffix: null, + ), + gradingSystem: GradingSystemModel.oneToSixWithPlusAndMinus, + gradeType: const GradeTypeId('my-custom-grade-type'), + takenIntoAccount: false, + weight: const Weight.factor(1), + date: Date('2024-10-16'), + title: 'hallo', + details: 'ollah', + ), + GradeModel( + id: GradeId('grade-4'), + subjectId: const SubjectId('englisch'), + termId: const TermId('01-10-term'), + originalInput: 'Sehr zufriedenstellend', + value: const GradeValue( + asNum: 1, + gradingSystem: GradingSystem.austrianBehaviouralGrades, + displayableGrade: 'Sehr zufriedenstellend', + suffix: null), + gradingSystem: GradingSystemModel.austrianBehaviouralGrades, + gradeType: const GradeTypeId('oral-participation'), + takenIntoAccount: true, + weight: const Weight.factor(1), + date: Date('2024-10-18'), + title: 'Beep boop', + details: 'robot noises', + ), + ], + ), + finalGradeType: const GradeTypeId('oral-participation'), + isFinalGradeTypeOverridden: true, + weightingForTermGrade: const Weight.factor(1), + gradeTypeWeightings: const IMapConst({}), + gradeTypeWeightingsFromTerm: const IMapConst({}), + weightType: WeightType.inheritFromTerm, + abbreviation: 'E', + design: Design.fromData('000000'), + connectedCourses: const IListConst( + [ + ConnectedCourse( + id: CourseId('connected-englisch-course'), + name: 'Englisch 8a', + abbreviation: 'E', + subjectName: 'Englisch', + ) + ], + ), + ) + ]), + gradeTypeWeightings: const IMapConst({}), + gradingSystem: GradingSystemModel.oneToSixWithPlusAndMinus, + finalGradeType: const GradeTypeId('my-custom-grade-type'), + isActiveTerm: false, + name: '01/10', + ) + ]; + + expect(state.terms.length, expectedTerms.length); + + // This does not work and I couldn't figure out why + // So we have to do it manually + // expect(state.terms, expectedTerms); + + for (var actual in state.terms) { + final expected = expectedTerms.firstWhere( + (element) => element.id == actual.id, + ); + + expect(actual.id, expected.id); + expect(actual.gradeTypeWeightings, expected.gradeTypeWeightings); + expect(actual.gradingSystem, expected.gradingSystem); + expect(actual.finalGradeType, expected.finalGradeType); + expect(actual.isActiveTerm, expected.isActiveTerm); + expect(actual.name, expected.name); + + // This does not work and I couldn't figure out why + // So we have to do it manually + // expect(actual.subjects, expected.subjects); + for (var actualSub in actual.subjects) { + final expectedSub = expected.subjects.firstWhere( + (element) => element.id == actualSub.id, + ); + expect(actualSub.id, expectedSub.id); + + expect(actualSub.id, expectedSub.id); + expect(actualSub.name, expectedSub.name); + expect(actualSub.termId, expectedSub.termId); + expect(actualSub.gradingSystem, expectedSub.gradingSystem); + expect(actualSub.grades, expectedSub.grades); + expect(actualSub.finalGradeType, expectedSub.finalGradeType); + expect(actualSub.isFinalGradeTypeOverridden, + expectedSub.isFinalGradeTypeOverridden); + expect(actualSub.weightingForTermGrade, + expectedSub.weightingForTermGrade); + expect( + actualSub.gradeTypeWeightings, expectedSub.gradeTypeWeightings); + expect(actualSub.gradeTypeWeightingsFromTerm, + expectedSub.gradeTypeWeightingsFromTerm); + expect(actualSub.weightType, expectedSub.weightType); + expect(actualSub.abbreviation, expectedSub.abbreviation); + expect(actualSub.design, expectedSub.design); + expect(actualSub.connectedCourses, expectedSub.connectedCourses); + } + } + }); + }); +} + +/// We use the Firestore (de-)serialization methods to test the repository. +/// This lets us not depend on Firestore here (and having to mock it). +class TestFirestoreGradesStateRepository extends GradesStateRepository { + Map data = {}; + + @override + BehaviorSubject state = BehaviorSubject.seeded( + ( + terms: const IListConst([]), + customGradeTypes: const IListConst([]), + subjects: const IListConst([]), + ), + ); + + @override + void updateState(GradesState state) { + data = FirestoreGradesStateRepository.toDto(state); + final newState = FirestoreGradesStateRepository.fromData(data); + this.state.add(newState); + } +} diff --git a/app/test/grades/grades_test.dart b/app/test/grades/grades_test.dart index bdd0a22bd..4895a36d0 100644 --- a/app/test/grades/grades_test.dart +++ b/app/test/grades/grades_test.dart @@ -1360,82 +1360,4 @@ void main() { throwsA(const SubjectNotFoundException(SubjectId('Unknown')))); }); }); - group('basic repository tests', () { - late GradesStateRepository repository; - late GradesService service; - late GradesTestController controller; - - setUp(() { - repository = InMemoryGradesStateRepository(); - service = GradesService(repository: repository); - controller = GradesTestController(gradesService: service); - }); - - void replaceGradesServiceWithSameRepository() { - controller.service = GradesService(repository: repository); - } - - test( - 'A term is still saved when deleting the Grade service as long as the repository is the same', - () { - var term = termWith(name: 'term1'); - controller.createTerm(term); - - replaceGradesServiceWithSameRepository(); - - expect(controller.terms, hasLength(1)); - }); - test( - 'A gradeType is still saved when deleting the Grade service as long as the repository is the same', - () { - const gradeType = GradeType(id: GradeTypeId('foo'), displayName: 'Foo'); - controller.createCustomGradeType(gradeType); - - replaceGradesServiceWithSameRepository(); - - expect(controller.getPossibleGradeTypes(), contains(gradeType)); - }); - test( - 'A subject is still saved when deleting the Grade service as long as the repository is the same', - () { - var subject = - subjectWith(id: const SubjectId('foo'), name: 'Foo Subject'); - controller.addSubject(subject); - - replaceGradesServiceWithSameRepository(); - - expect( - controller.getSubjects(), - contains( - predicate( - (sub) => sub.id == subject.id && sub.name == subject.name, - ), - ), - ); - }); - test( - 'If the $GradesService is replaced without the same $GradesStateRepository then old values wont be there anymore', - () { - final term = termWith( - subjects: [ - subjectWith( - id: const SubjectId('Philosophie'), - grades: [ - gradeWith( - id: GradeId('grade1'), - value: 4.0, - ), - ], - ), - ], - ); - controller.createTerm(term); - - // We don't reuse the repository here, so the data will be lost - controller.service = - GradesService(repository: InMemoryGradesStateRepository()); - - expect(controller.terms, isEmpty); - }); - }); } diff --git a/lib/design/lib/src/design.dart b/lib/design/lib/src/design.dart index c0c3e90a1..4c2e401a2 100644 --- a/lib/design/lib/src/design.dart +++ b/lib/design/lib/src/design.dart @@ -70,6 +70,9 @@ class Design { @override int get hashCode => Object.hash(hex, type); + @override + String toString() => 'Design($hex, $type)'; + /// A list of designs that are available for free and don't require a /// Sharezone Plus subscription. static List freeDesigns = [