diff --git a/packages/practice_engine b/packages/practice_engine deleted file mode 160000 index 3735b43..0000000 --- a/packages/practice_engine +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3735b434dc2160c1f323e2a925f9e87b3b9dfee8 diff --git a/packages/practice_engine/coverage/lcov.info b/packages/practice_engine/coverage/lcov.info new file mode 100644 index 0000000..6fe81b1 --- /dev/null +++ b/packages/practice_engine/coverage/lcov.info @@ -0,0 +1,284 @@ +SF:/Users/znup/projects/decky/packages/practice_engine/lib/models/deck.dart +DA:24,4 +DA:34,4 +DA:42,4 +DA:43,4 +DA:44,3 +DA:45,4 +DA:46,1 +DA:47,4 +DA:48,3 +DA:53,3 +DA:56,6 +DA:59,1 +DA:60,2 +DA:61,5 +DA:64,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:70,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:76,0 +DA:78,0 +DA:79,0 +DA:80,0 +DA:81,0 +DA:82,0 +DA:83,0 +LF:30 +LH:14 +end_of_record +SF:/Users/znup/projects/decky/packages/practice_engine/lib/models/deck_config.dart +DA:18,7 +DA:27,1 +DA:34,1 +DA:36,0 +DA:37,1 +DA:39,1 +DA:41,1 +DA:43,0 +DA:47,2 +DA:50,1 +DA:51,3 +DA:52,3 +DA:53,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:58,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +LF:22 +LH:10 +end_of_record +SF:/Users/znup/projects/decky/packages/practice_engine/lib/models/question.dart +DA:33,9 +DA:47,6 +DA:59,6 +DA:60,6 +DA:61,6 +DA:62,6 +DA:63,6 +DA:64,5 +DA:65,4 +DA:66,4 +DA:67,5 +DA:68,5 +DA:69,5 +DA:74,4 +DA:75,8 +DA:78,3 +DA:81,2 +DA:82,6 +DA:83,6 +DA:84,0 +DA:85,0 +DA:86,0 +DA:87,0 +DA:88,0 +DA:89,0 +DA:90,0 +DA:91,0 +DA:92,0 +DA:94,0 +DA:96,0 +DA:97,0 +DA:98,0 +DA:99,0 +DA:100,0 +DA:101,0 +DA:102,0 +DA:103,0 +DA:104,0 +DA:105,0 +LF:39 +LH:19 +end_of_record +SF:/Users/znup/projects/decky/packages/practice_engine/lib/logic/deck_service.dart +DA:11,1 +DA:15,3 +DA:16,2 +DA:17,1 +DA:18,2 +DA:23,1 +DA:25,1 +DA:31,1 +DA:35,3 +DA:36,2 +DA:37,1 +DA:44,1 +DA:46,1 +DA:53,1 +DA:57,3 +DA:58,1 +DA:62,1 +DA:63,1 +DA:64,1 +DA:66,1 +DA:68,1 +DA:77,3 +DA:87,6 +DA:88,3 +DA:91,6 +DA:92,2 +DA:96,3 +DA:103,3 +DA:104,6 +DA:105,6 +DA:109,2 +DA:115,2 +DA:122,2 +DA:123,4 +DA:128,3 +DA:137,1 +DA:148,1 +DA:149,2 +DA:154,1 +LF:39 +LH:39 +end_of_record +SF:/Users/znup/projects/decky/packages/practice_engine/lib/models/attempt_result.dart +DA:24,2 +DA:52,2 +DA:62,2 +DA:66,8 +DA:67,2 +DA:68,2 +DA:69,4 +DA:72,8 +DA:74,2 +LF:9 +LH:9 +end_of_record +SF:/Users/znup/projects/decky/packages/practice_engine/lib/algorithms/priority_manager.dart +DA:7,4 +DA:13,8 +DA:14,4 +DA:15,4 +DA:16,4 +DA:17,4 +DA:19,3 +DA:20,6 +DA:21,3 +DA:27,1 +DA:28,1 +LF:11 +LH:11 +end_of_record +SF:/Users/znup/projects/decky/packages/practice_engine/lib/algorithms/weighted_selector.dart +DA:10,4 +DA:16,2 +DA:21,4 +DA:22,1 +DA:25,4 +DA:26,2 +DA:29,2 +DA:30,2 +DA:32,6 +DA:33,2 +DA:37,2 +DA:38,2 +DA:45,2 +DA:49,4 +DA:50,0 +DA:54,4 +DA:55,2 +DA:56,0 +DA:57,0 +DA:62,6 +DA:64,2 +DA:67,2 +DA:69,4 +DA:70,2 +DA:71,2 +DA:75,6 +DA:78,6 +DA:79,4 +DA:80,2 +DA:85,0 +LF:30 +LH:26 +end_of_record +SF:/Users/znup/projects/decky/packages/practice_engine/lib/algorithms/spaced_repetition.dart +DA:16,1 +DA:20,1 +DA:25,1 +DA:29,2 +DA:30,1 +DA:31,2 +DA:38,1 +DA:43,1 +DA:45,1 +DA:46,1 +DA:48,1 +DA:52,3 +LF:12 +LH:12 +end_of_record +SF:/Users/znup/projects/decky/packages/practice_engine/lib/logic/attempt_service.dart +DA:14,2 +DA:20,1 +DA:24,2 +DA:25,2 +DA:26,1 +DA:28,1 +DA:31,1 +DA:32,1 +DA:34,2 +DA:43,1 +DA:53,1 +DA:54,2 +DA:55,2 +DA:57,1 +DA:58,1 +DA:61,2 +DA:62,2 +DA:68,2 +DA:69,2 +DA:72,1 +DA:73,1 +DA:77,0 +DA:81,0 +DA:82,0 +DA:84,1 +DA:87,1 +DA:88,1 +DA:96,1 +DA:98,2 +DA:104,0 +DA:106,1 +DA:113,2 +DA:120,1 +DA:124,1 +DA:125,5 +DA:128,2 +DA:129,2 +DA:132,3 +DA:133,2 +DA:134,1 +DA:136,1 +DA:138,2 +DA:141,1 +DA:153,1 +DA:154,5 +LF:45 +LH:41 +end_of_record +SF:/Users/znup/projects/decky/packages/practice_engine/lib/models/attempt.dart +DA:14,1 +DA:21,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:34,0 +LF:7 +LH:1 +end_of_record diff --git a/packages/practice_engine/coverage/test/attempt_flow_test.dart.vm.json b/packages/practice_engine/coverage/test/attempt_flow_test.dart.vm.json new file mode 100644 index 0000000..72945e1 --- /dev/null +++ b/packages/practice_engine/coverage/test/attempt_flow_test.dart.vm.json @@ -0,0 +1 @@ +{"type":"CodeCoverage","coverage":[{"source":"package:practice_engine/models/deck.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fdeck.dart","uri":"package:practice_engine/models/deck.dart","_kind":"library"},"hits":[24,1,34,1,42,1,43,1,44,1,45,1,47,1,48,1,46,0,53,0,56,0,59,0,60,0,61,0,64,0,67,0,68,0,69,0,70,0,71,0,72,0,73,0,74,0,76,0,78,0,79,0,80,0,81,0,82,0,83,0]},{"source":"package:practice_engine/models/deck_config.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fdeck_config.dart","uri":"package:practice_engine/models/deck_config.dart","_kind":"library"},"hits":[18,1,27,0,34,0,36,0,37,0,39,0,41,0,43,0,47,0,50,0,51,0,52,0,53,0,54,0,55,0,56,0,58,0,60,0,61,0,62,0,63,0,64,0]},{"source":"package:practice_engine/models/question.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fquestion.dart","uri":"package:practice_engine/models/question.dart","_kind":"library"},"hits":[33,1,47,1,59,1,60,1,61,1,62,1,63,1,64,1,65,1,66,1,67,1,68,1,69,1,74,1,75,2,78,1,81,1,82,3,83,3,84,0,85,0,86,0,87,0,88,0,89,0,90,0,91,0,92,0,94,0,96,0,97,0,98,0,99,0,100,0,101,0,102,0,103,0,104,0,105,0]},{"source":"package:practice_engine/logic/attempt_service.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Flogic%2Fattempt_service.dart","uri":"package:practice_engine/logic/attempt_service.dart","_kind":"library"},"hits":[14,2,20,1,24,2,25,2,26,1,28,1,31,1,32,1,34,2,43,1,53,1,54,2,55,2,57,1,58,1,61,2,62,2,68,2,69,2,72,1,73,1,84,1,87,1,88,1,96,1,98,2,106,1,113,2,120,1,124,1,125,5,128,2,129,2,132,3,134,1,136,1,138,2,141,1,77,0,81,0,82,0,104,0,153,1,154,5,133,2]},{"source":"package:practice_engine/models/attempt_result.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fattempt_result.dart","uri":"package:practice_engine/models/attempt_result.dart","_kind":"library"},"hits":[24,1,52,1,62,1,66,4,67,1,68,1,69,2,72,4,74,1]},{"source":"package:practice_engine/algorithms/spaced_repetition.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Falgorithms%2Fspaced_repetition.dart","uri":"package:practice_engine/algorithms/spaced_repetition.dart","_kind":"library"},"hits":[16,0,20,0,25,0,29,0,30,0,31,0,38,0,43,0,45,0,46,0,48,0,52,0]},{"source":"package:practice_engine/algorithms/weighted_selector.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Falgorithms%2Fweighted_selector.dart","uri":"package:practice_engine/algorithms/weighted_selector.dart","_kind":"library"},"hits":[10,2,16,1,21,2,25,2,26,1,29,1,30,1,32,3,33,1,37,1,38,1,22,0,45,1,49,2,54,2,64,1,67,1,69,2,70,1,71,1,75,3,78,3,79,2,80,1,50,0,85,0,55,1,62,3,56,0,57,0]},{"source":"package:practice_engine/models/attempt.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fattempt.dart","uri":"package:practice_engine/models/attempt.dart","_kind":"library"},"hits":[14,1,21,0,26,0,27,0,28,0,29,0,34,0]},{"source":"package:practice_engine/logic/deck_service.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Flogic%2Fdeck_service.dart","uri":"package:practice_engine/logic/deck_service.dart","_kind":"library"},"hits":[11,0,15,0,23,0,25,0,31,0,35,0,44,0,46,0,53,0,57,0,66,0,68,0,77,1,87,2,88,1,91,2,92,1,96,1,103,1,104,2,105,2,109,1,115,1,122,1,123,2,128,1,137,0,148,0,149,0,154,0,16,0,17,0,18,0,36,0,37,0,58,0,62,0,63,0,64,0]},{"source":"package:practice_engine/algorithms/priority_manager.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Falgorithms%2Fpriority_manager.dart","uri":"package:practice_engine/algorithms/priority_manager.dart","_kind":"library"},"hits":[7,1,13,2,14,1,15,1,16,1,17,1,19,1,20,2,21,1,27,0,28,0]}]} \ No newline at end of file diff --git a/packages/practice_engine/coverage/test/attempt_result_test.dart.vm.json b/packages/practice_engine/coverage/test/attempt_result_test.dart.vm.json new file mode 100644 index 0000000..b65a05f --- /dev/null +++ b/packages/practice_engine/coverage/test/attempt_result_test.dart.vm.json @@ -0,0 +1 @@ +{"type":"CodeCoverage","coverage":[{"source":"package:practice_engine/models/question.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fquestion.dart","uri":"package:practice_engine/models/question.dart","_kind":"library"},"hits":[33,1,47,0,59,0,60,0,61,0,62,0,63,0,64,0,65,0,66,0,67,0,68,0,69,0,74,0,75,0,78,1,81,0,82,0,83,0,84,0,85,0,86,0,87,0,88,0,89,0,90,0,91,0,92,0,94,0,96,0,97,0,98,0,99,0,100,0,101,0,102,0,103,0,104,0,105,0]},{"source":"package:practice_engine/models/attempt_result.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fattempt_result.dart","uri":"package:practice_engine/models/attempt_result.dart","_kind":"library"},"hits":[24,1,52,1,62,1,66,4,67,1,68,1,69,2,72,4,74,1]}]} \ No newline at end of file diff --git a/packages/practice_engine/coverage/test/config_test.dart.vm.json b/packages/practice_engine/coverage/test/config_test.dart.vm.json new file mode 100644 index 0000000..5568458 --- /dev/null +++ b/packages/practice_engine/coverage/test/config_test.dart.vm.json @@ -0,0 +1 @@ +{"type":"CodeCoverage","coverage":[{"source":"package:practice_engine/models/deck_config.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fdeck_config.dart","uri":"package:practice_engine/models/deck_config.dart","_kind":"library"},"hits":[18,1,27,1,34,1,37,1,39,1,41,1,36,0,43,0,47,1,50,1,51,3,52,3,53,0,54,0,55,0,56,0,58,0,60,0,61,0,62,0,63,0,64,0]},{"source":"package:practice_engine/models/attempt_result.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fattempt_result.dart","uri":"package:practice_engine/models/attempt_result.dart","_kind":"library"},"hits":[24,0,52,0,62,0,66,0,67,0,68,0,69,0,72,0,74,0]},{"source":"package:practice_engine/models/question.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fquestion.dart","uri":"package:practice_engine/models/question.dart","_kind":"library"},"hits":[33,0,47,0,59,0,60,0,61,0,62,0,63,0,64,0,65,0,66,0,67,0,68,0,69,0,74,0,75,0,78,0,81,0,82,0,83,0,84,0,85,0,86,0,87,0,88,0,89,0,90,0,91,0,92,0,94,0,96,0,97,0,98,0,99,0,100,0,101,0,102,0,103,0,104,0,105,0]},{"source":"package:practice_engine/algorithms/priority_manager.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Falgorithms%2Fpriority_manager.dart","uri":"package:practice_engine/algorithms/priority_manager.dart","_kind":"library"},"hits":[7,0,13,0,14,0,15,0,16,0,17,0,19,0,20,0,21,0,27,0,28,0]},{"source":"package:practice_engine/logic/deck_service.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Flogic%2Fdeck_service.dart","uri":"package:practice_engine/logic/deck_service.dart","_kind":"library"},"hits":[11,0,15,0,23,0,25,0,31,0,35,0,44,0,46,0,53,0,57,0,66,0,68,0,77,0,87,0,88,0,91,0,92,0,96,0,103,0,104,0,105,0,109,0,115,0,122,0,123,0,128,0,137,0,148,0,149,0,154,0,16,0,17,0,18,0,36,0,37,0,58,0,62,0,63,0,64,0]},{"source":"package:practice_engine/models/deck.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fdeck.dart","uri":"package:practice_engine/models/deck.dart","_kind":"library"},"hits":[24,0,34,0,42,0,43,0,44,0,45,0,46,0,47,0,48,0,53,0,56,0,59,0,60,0,61,0,64,0,67,0,68,0,69,0,70,0,71,0,72,0,73,0,74,0,76,0,78,0,79,0,80,0,81,0,82,0,83,0]}]} \ No newline at end of file diff --git a/packages/practice_engine/coverage/test/deck_test.dart.vm.json b/packages/practice_engine/coverage/test/deck_test.dart.vm.json new file mode 100644 index 0000000..0a1e2ae --- /dev/null +++ b/packages/practice_engine/coverage/test/deck_test.dart.vm.json @@ -0,0 +1 @@ +{"type":"CodeCoverage","coverage":[{"source":"package:practice_engine/models/deck.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fdeck.dart","uri":"package:practice_engine/models/deck.dart","_kind":"library"},"hits":[24,1,34,1,42,1,43,1,45,1,46,1,47,1,48,1,44,0,53,3,56,6,59,1,60,2,61,5,64,0,67,0,68,0,69,0,70,0,71,0,72,0,73,0,74,0,76,0,78,0,79,0,80,0,81,0,82,0,83,0]},{"source":"package:practice_engine/models/deck_config.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fdeck_config.dart","uri":"package:practice_engine/models/deck_config.dart","_kind":"library"},"hits":[18,1,27,0,34,0,36,0,37,0,39,0,41,0,43,0,47,0,50,0,51,0,52,0,53,0,54,0,55,0,56,0,58,0,60,0,61,0,62,0,63,0,64,0]},{"source":"package:practice_engine/models/question.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fquestion.dart","uri":"package:practice_engine/models/question.dart","_kind":"library"},"hits":[33,1,47,1,59,1,60,1,61,1,62,1,63,1,64,1,66,1,67,1,68,1,69,1,65,0,74,0,75,0,78,0,81,0,82,0,83,0,84,0,85,0,86,0,87,0,88,0,89,0,90,0,91,0,92,0,94,0,96,0,97,0,98,0,99,0,100,0,101,0,102,0,103,0,104,0,105,0]},{"source":"package:practice_engine/models/attempt_result.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fattempt_result.dart","uri":"package:practice_engine/models/attempt_result.dart","_kind":"library"},"hits":[24,0,52,0,62,0,66,0,67,0,68,0,69,0,72,0,74,0]},{"source":"package:practice_engine/algorithms/priority_manager.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Falgorithms%2Fpriority_manager.dart","uri":"package:practice_engine/algorithms/priority_manager.dart","_kind":"library"},"hits":[7,0,13,0,14,0,15,0,16,0,17,0,19,0,20,0,21,0,27,0,28,0]},{"source":"package:practice_engine/logic/deck_service.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Flogic%2Fdeck_service.dart","uri":"package:practice_engine/logic/deck_service.dart","_kind":"library"},"hits":[11,0,15,0,23,0,25,0,31,0,35,0,44,0,46,0,53,0,57,0,66,0,68,0,77,0,87,0,88,0,91,0,92,0,96,0,103,0,104,0,105,0,109,0,115,0,122,0,123,0,128,0,137,0,148,0,149,0,154,0,16,0,17,0,18,0,36,0,37,0,58,0,62,0,63,0,64,0]}]} \ No newline at end of file diff --git a/packages/practice_engine/coverage/test/manual_override_test.dart.vm.json b/packages/practice_engine/coverage/test/manual_override_test.dart.vm.json new file mode 100644 index 0000000..28e9745 --- /dev/null +++ b/packages/practice_engine/coverage/test/manual_override_test.dart.vm.json @@ -0,0 +1 @@ +{"type":"CodeCoverage","coverage":[{"source":"package:practice_engine/models/deck.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fdeck.dart","uri":"package:practice_engine/models/deck.dart","_kind":"library"},"hits":[24,1,34,1,42,1,43,1,44,1,45,1,47,1,48,1,46,0,53,0,56,0,59,0,60,0,61,0,64,0,67,0,68,0,69,0,70,0,71,0,72,0,73,0,74,0,76,0,78,0,79,0,80,0,81,0,82,0,83,0]},{"source":"package:practice_engine/models/deck_config.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fdeck_config.dart","uri":"package:practice_engine/models/deck_config.dart","_kind":"library"},"hits":[18,1,27,0,34,0,36,0,37,0,39,0,41,0,43,0,47,0,50,0,51,0,52,0,53,0,54,0,55,0,56,0,58,0,60,0,61,0,62,0,63,0,64,0]},{"source":"package:practice_engine/models/question.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fquestion.dart","uri":"package:practice_engine/models/question.dart","_kind":"library"},"hits":[33,1,47,1,59,1,60,1,61,1,62,1,63,1,64,1,65,1,66,1,67,1,68,1,69,1,74,1,75,2,78,0,81,0,82,0,83,0,84,0,85,0,86,0,87,0,88,0,89,0,90,0,91,0,92,0,94,0,96,0,97,0,98,0,99,0,100,0,101,0,102,0,103,0,104,0,105,0]},{"source":"package:practice_engine/logic/deck_service.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Flogic%2Fdeck_service.dart","uri":"package:practice_engine/logic/deck_service.dart","_kind":"library"},"hits":[11,1,15,3,23,1,25,1,31,1,35,3,44,1,46,1,53,0,57,0,66,0,68,0,77,1,87,2,88,1,91,2,96,1,103,1,104,2,105,2,128,1,92,0,109,0,115,0,122,0,123,0,137,1,148,1,149,2,154,1,16,2,17,1,18,2,36,2,37,1,58,0,62,0,63,0,64,0]},{"source":"package:practice_engine/models/attempt_result.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fattempt_result.dart","uri":"package:practice_engine/models/attempt_result.dart","_kind":"library"},"hits":[24,0,52,0,62,0,66,0,67,0,68,0,69,0,72,0,74,0]},{"source":"package:practice_engine/algorithms/priority_manager.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Falgorithms%2Fpriority_manager.dart","uri":"package:practice_engine/algorithms/priority_manager.dart","_kind":"library"},"hits":[7,1,13,2,14,1,15,1,16,1,17,1,19,0,20,0,21,0,27,0,28,0]}]} \ No newline at end of file diff --git a/packages/practice_engine/coverage/test/priority_manager_test.dart.vm.json b/packages/practice_engine/coverage/test/priority_manager_test.dart.vm.json new file mode 100644 index 0000000..370469b --- /dev/null +++ b/packages/practice_engine/coverage/test/priority_manager_test.dart.vm.json @@ -0,0 +1 @@ +{"type":"CodeCoverage","coverage":[{"source":"package:practice_engine/models/question.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fquestion.dart","uri":"package:practice_engine/models/question.dart","_kind":"library"},"hits":[33,1,47,1,59,1,60,1,61,1,62,1,63,1,64,1,65,1,67,1,68,1,69,1,66,0,74,1,75,2,78,0,81,0,82,0,83,0,84,0,85,0,86,0,87,0,88,0,89,0,90,0,91,0,92,0,94,0,96,0,97,0,98,0,99,0,100,0,101,0,102,0,103,0,104,0,105,0]},{"source":"package:practice_engine/models/deck_config.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fdeck_config.dart","uri":"package:practice_engine/models/deck_config.dart","_kind":"library"},"hits":[18,1,27,0,34,0,36,0,37,0,39,0,41,0,43,0,47,0,50,0,51,0,52,0,53,0,54,0,55,0,56,0,58,0,60,0,61,0,62,0,63,0,64,0]},{"source":"package:practice_engine/algorithms/priority_manager.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Falgorithms%2Fpriority_manager.dart","uri":"package:practice_engine/algorithms/priority_manager.dart","_kind":"library"},"hits":[7,1,13,2,14,1,15,1,16,1,17,1,19,1,20,2,21,1,27,1,28,1]},{"source":"package:practice_engine/models/attempt_result.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fattempt_result.dart","uri":"package:practice_engine/models/attempt_result.dart","_kind":"library"},"hits":[24,0,52,0,62,0,66,0,67,0,68,0,69,0,72,0,74,0]},{"source":"package:practice_engine/logic/deck_service.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Flogic%2Fdeck_service.dart","uri":"package:practice_engine/logic/deck_service.dart","_kind":"library"},"hits":[11,0,15,0,23,0,25,0,31,0,35,0,44,0,46,0,53,0,57,0,66,0,68,0,77,0,87,0,88,0,91,0,92,0,96,0,103,0,104,0,105,0,109,0,115,0,122,0,123,0,128,0,137,0,148,0,149,0,154,0,16,0,17,0,18,0,36,0,37,0,58,0,62,0,63,0,64,0]},{"source":"package:practice_engine/models/deck.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fdeck.dart","uri":"package:practice_engine/models/deck.dart","_kind":"library"},"hits":[24,0,34,0,42,0,43,0,44,0,45,0,46,0,47,0,48,0,53,0,56,0,59,0,60,0,61,0,64,0,67,0,68,0,69,0,70,0,71,0,72,0,73,0,74,0,76,0,78,0,79,0,80,0,81,0,82,0,83,0]}]} \ No newline at end of file diff --git a/packages/practice_engine/coverage/test/question_state_test.dart.vm.json b/packages/practice_engine/coverage/test/question_state_test.dart.vm.json new file mode 100644 index 0000000..498105e --- /dev/null +++ b/packages/practice_engine/coverage/test/question_state_test.dart.vm.json @@ -0,0 +1 @@ +{"type":"CodeCoverage","coverage":[{"source":"package:practice_engine/models/question.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fquestion.dart","uri":"package:practice_engine/models/question.dart","_kind":"library"},"hits":[33,1,47,1,59,1,60,1,61,1,62,1,63,1,64,1,65,1,66,1,67,1,68,1,69,1,74,1,75,2,78,0,81,0,82,0,83,0,84,0,85,0,86,0,87,0,88,0,89,0,90,0,91,0,92,0,94,0,96,0,97,0,98,0,99,0,100,0,101,0,102,0,103,0,104,0,105,0]},{"source":"package:practice_engine/models/deck_config.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fdeck_config.dart","uri":"package:practice_engine/models/deck_config.dart","_kind":"library"},"hits":[18,1,27,0,34,0,36,0,37,0,39,0,41,0,43,0,47,0,50,0,51,0,52,0,53,0,54,0,55,0,56,0,58,0,60,0,61,0,62,0,63,0,64,0]},{"source":"package:practice_engine/logic/deck_service.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Flogic%2Fdeck_service.dart","uri":"package:practice_engine/logic/deck_service.dart","_kind":"library"},"hits":[11,0,15,0,23,0,25,0,31,0,35,0,44,0,46,0,53,0,57,0,66,0,68,0,77,1,87,2,88,1,91,2,92,1,96,1,103,1,104,2,105,2,109,1,115,1,122,1,123,2,128,1,137,0,148,0,149,0,154,0,16,0,17,0,18,0,36,0,37,0,58,0,62,0,63,0,64,0]},{"source":"package:practice_engine/models/attempt_result.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fattempt_result.dart","uri":"package:practice_engine/models/attempt_result.dart","_kind":"library"},"hits":[24,0,52,0,62,0,66,0,67,0,68,0,69,0,72,0,74,0]},{"source":"package:practice_engine/algorithms/priority_manager.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Falgorithms%2Fpriority_manager.dart","uri":"package:practice_engine/algorithms/priority_manager.dart","_kind":"library"},"hits":[7,1,13,2,14,1,15,1,16,1,17,1,19,1,20,2,21,1,27,0,28,0]},{"source":"package:practice_engine/models/deck.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fdeck.dart","uri":"package:practice_engine/models/deck.dart","_kind":"library"},"hits":[24,0,34,0,42,0,43,0,44,0,45,0,46,0,47,0,48,0,53,0,56,0,59,0,60,0,61,0,64,0,67,0,68,0,69,0,70,0,71,0,72,0,73,0,74,0,76,0,78,0,79,0,80,0,81,0,82,0,83,0]}]} \ No newline at end of file diff --git a/packages/practice_engine/coverage/test/reset_test.dart.vm.json b/packages/practice_engine/coverage/test/reset_test.dart.vm.json new file mode 100644 index 0000000..f104e45 --- /dev/null +++ b/packages/practice_engine/coverage/test/reset_test.dart.vm.json @@ -0,0 +1 @@ +{"type":"CodeCoverage","coverage":[{"source":"package:practice_engine/models/deck.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fdeck.dart","uri":"package:practice_engine/models/deck.dart","_kind":"library"},"hits":[24,1,34,1,42,1,43,1,44,1,45,1,47,1,46,0,48,0,53,0,56,0,59,0,60,0,61,0,64,0,67,0,68,0,69,0,70,0,71,0,72,0,73,0,74,0,76,0,78,0,79,0,80,0,81,0,82,0,83,0]},{"source":"package:practice_engine/models/deck_config.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fdeck_config.dart","uri":"package:practice_engine/models/deck_config.dart","_kind":"library"},"hits":[18,1,27,0,34,0,36,0,37,0,39,0,41,0,43,0,47,1,50,0,51,0,52,0,53,0,54,0,55,0,56,0,58,0,60,0,61,0,62,0,63,0,64,0]},{"source":"package:practice_engine/models/question.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fquestion.dart","uri":"package:practice_engine/models/question.dart","_kind":"library"},"hits":[33,1,47,1,59,1,60,1,61,1,62,1,63,1,64,0,65,0,66,0,67,0,68,0,69,0,74,0,75,0,78,0,81,0,82,0,83,0,84,0,85,0,86,0,87,0,88,0,89,0,90,0,91,0,92,0,94,0,96,0,97,0,98,0,99,0,100,0,101,0,102,0,103,0,104,0,105,0]},{"source":"package:practice_engine/logic/deck_service.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Flogic%2Fdeck_service.dart","uri":"package:practice_engine/logic/deck_service.dart","_kind":"library"},"hits":[11,0,15,0,23,0,25,0,31,0,35,0,44,0,46,0,53,1,57,3,66,1,68,1,77,0,87,0,88,0,91,0,92,0,96,0,103,0,104,0,105,0,109,0,115,0,122,0,123,0,128,0,137,0,148,0,149,0,154,0,58,1,62,1,63,1,64,1,16,0,17,0,18,0,36,0,37,0]},{"source":"package:practice_engine/models/attempt_result.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fattempt_result.dart","uri":"package:practice_engine/models/attempt_result.dart","_kind":"library"},"hits":[24,0,52,0,62,0,66,0,67,0,68,0,69,0,72,0,74,0]},{"source":"package:practice_engine/algorithms/priority_manager.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Falgorithms%2Fpriority_manager.dart","uri":"package:practice_engine/algorithms/priority_manager.dart","_kind":"library"},"hits":[7,0,13,0,14,0,15,0,16,0,17,0,19,0,20,0,21,0,27,0,28,0]}]} \ No newline at end of file diff --git a/packages/practice_engine/coverage/test/spaced_repetition_test.dart.vm.json b/packages/practice_engine/coverage/test/spaced_repetition_test.dart.vm.json new file mode 100644 index 0000000..85f5932 --- /dev/null +++ b/packages/practice_engine/coverage/test/spaced_repetition_test.dart.vm.json @@ -0,0 +1 @@ +{"type":"CodeCoverage","coverage":[{"source":"package:practice_engine/models/question.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fquestion.dart","uri":"package:practice_engine/models/question.dart","_kind":"library"},"hits":[33,1,47,0,59,0,60,0,61,0,62,0,63,0,64,0,65,0,66,0,67,0,68,0,69,0,74,0,75,0,78,0,81,0,82,0,83,0,84,0,85,0,86,0,87,0,88,0,89,0,90,0,91,0,92,0,94,0,96,0,97,0,98,0,99,0,100,0,101,0,102,0,103,0,104,0,105,0]},{"source":"package:practice_engine/algorithms/spaced_repetition.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Falgorithms%2Fspaced_repetition.dart","uri":"package:practice_engine/algorithms/spaced_repetition.dart","_kind":"library"},"hits":[16,1,20,1,25,1,29,2,30,1,31,2,38,1,43,1,45,1,46,1,48,1,52,3]},{"source":"package:practice_engine/algorithms/priority_manager.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Falgorithms%2Fpriority_manager.dart","uri":"package:practice_engine/algorithms/priority_manager.dart","_kind":"library"},"hits":[7,0,13,0,14,0,15,0,16,0,17,0,19,0,20,0,21,0,27,0,28,0]},{"source":"package:practice_engine/models/deck_config.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fdeck_config.dart","uri":"package:practice_engine/models/deck_config.dart","_kind":"library"},"hits":[18,0,27,0,34,0,36,0,37,0,39,0,41,0,43,0,47,0,50,0,51,0,52,0,53,0,54,0,55,0,56,0,58,0,60,0,61,0,62,0,63,0,64,0]},{"source":"package:practice_engine/logic/deck_service.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Flogic%2Fdeck_service.dart","uri":"package:practice_engine/logic/deck_service.dart","_kind":"library"},"hits":[11,0,15,0,23,0,25,0,31,0,35,0,44,0,46,0,53,0,57,0,66,0,68,0,77,0,87,0,88,0,91,0,92,0,96,0,103,0,104,0,105,0,109,0,115,0,122,0,123,0,128,0,137,0,148,0,149,0,154,0,16,0,17,0,18,0,36,0,37,0,58,0,62,0,63,0,64,0]},{"source":"package:practice_engine/models/deck.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fdeck.dart","uri":"package:practice_engine/models/deck.dart","_kind":"library"},"hits":[24,0,34,0,42,0,43,0,44,0,45,0,46,0,47,0,48,0,53,0,56,0,59,0,60,0,61,0,64,0,67,0,68,0,69,0,70,0,71,0,72,0,73,0,74,0,76,0,78,0,79,0,80,0,81,0,82,0,83,0]},{"source":"package:practice_engine/models/attempt_result.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fattempt_result.dart","uri":"package:practice_engine/models/attempt_result.dart","_kind":"library"},"hits":[24,0,52,0,62,0,66,0,67,0,68,0,69,0,72,0,74,0]},{"source":"package:practice_engine/algorithms/weighted_selector.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Falgorithms%2Fweighted_selector.dart","uri":"package:practice_engine/algorithms/weighted_selector.dart","_kind":"library"},"hits":[10,0,16,0,21,0,22,0,25,0,26,0,29,0,30,0,32,0,33,0,37,0,38,0,45,0,49,0,50,0,54,0,64,0,67,0,69,0,70,0,71,0,75,0,78,0,79,0,80,0,85,0,55,0,56,0,57,0,62,0]},{"source":"package:practice_engine/logic/attempt_service.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Flogic%2Fattempt_service.dart","uri":"package:practice_engine/logic/attempt_service.dart","_kind":"library"},"hits":[14,0,20,0,24,0,25,0,26,0,28,0,31,0,32,0,34,0,43,0,53,0,54,0,55,0,57,0,58,0,61,0,62,0,68,0,69,0,72,0,73,0,77,0,81,0,82,0,84,0,87,0,88,0,96,0,98,0,104,0,106,0,113,0,120,0,124,0,125,0,128,0,129,0,132,0,134,0,136,0,138,0,141,0,153,0,154,0,133,0]},{"source":"package:practice_engine/models/attempt.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fattempt.dart","uri":"package:practice_engine/models/attempt.dart","_kind":"library"},"hits":[14,0,21,0,26,0,27,0,28,0,29,0,34,0]}]} \ No newline at end of file diff --git a/packages/practice_engine/coverage/test/weighted_selector_test.dart.vm.json b/packages/practice_engine/coverage/test/weighted_selector_test.dart.vm.json new file mode 100644 index 0000000..c862830 --- /dev/null +++ b/packages/practice_engine/coverage/test/weighted_selector_test.dart.vm.json @@ -0,0 +1 @@ +{"type":"CodeCoverage","coverage":[{"source":"package:practice_engine/models/question.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fquestion.dart","uri":"package:practice_engine/models/question.dart","_kind":"library"},"hits":[33,1,47,0,59,0,60,0,61,0,62,0,63,0,64,0,65,0,66,0,67,0,68,0,69,0,74,0,75,0,78,1,81,1,82,3,83,3,84,0,85,0,86,0,87,0,88,0,89,0,90,0,91,0,92,0,94,0,96,0,97,0,98,0,99,0,100,0,101,0,102,0,103,0,104,0,105,0]},{"source":"package:practice_engine/algorithms/weighted_selector.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Falgorithms%2Fweighted_selector.dart","uri":"package:practice_engine/algorithms/weighted_selector.dart","_kind":"library"},"hits":[10,2,16,1,21,2,22,1,25,2,26,1,29,1,30,1,32,3,33,1,37,1,38,1,45,1,49,2,54,2,64,1,67,1,69,2,70,1,71,1,75,3,78,3,79,2,80,1,50,0,85,0,55,1,62,3,56,0,57,0]},{"source":"package:practice_engine/algorithms/priority_manager.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Falgorithms%2Fpriority_manager.dart","uri":"package:practice_engine/algorithms/priority_manager.dart","_kind":"library"},"hits":[7,0,13,0,14,0,15,0,16,0,17,0,19,0,20,0,21,0,27,0,28,0]},{"source":"package:practice_engine/models/deck_config.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fdeck_config.dart","uri":"package:practice_engine/models/deck_config.dart","_kind":"library"},"hits":[18,0,27,0,34,0,36,0,37,0,39,0,41,0,43,0,47,0,50,0,51,0,52,0,53,0,54,0,55,0,56,0,58,0,60,0,61,0,62,0,63,0,64,0]},{"source":"package:practice_engine/logic/deck_service.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Flogic%2Fdeck_service.dart","uri":"package:practice_engine/logic/deck_service.dart","_kind":"library"},"hits":[11,0,15,0,23,0,25,0,31,0,35,0,44,0,46,0,53,0,57,0,66,0,68,0,77,0,87,0,88,0,91,0,92,0,96,0,103,0,104,0,105,0,109,0,115,0,122,0,123,0,128,0,137,0,148,0,149,0,154,0,16,0,17,0,18,0,36,0,37,0,58,0,62,0,63,0,64,0]},{"source":"package:practice_engine/models/deck.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fdeck.dart","uri":"package:practice_engine/models/deck.dart","_kind":"library"},"hits":[24,0,34,0,42,0,43,0,44,0,45,0,46,0,47,0,48,0,53,0,56,0,59,0,60,0,61,0,64,0,67,0,68,0,69,0,70,0,71,0,72,0,73,0,74,0,76,0,78,0,79,0,80,0,81,0,82,0,83,0]},{"source":"package:practice_engine/models/attempt_result.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fattempt_result.dart","uri":"package:practice_engine/models/attempt_result.dart","_kind":"library"},"hits":[24,0,52,0,62,0,66,0,67,0,68,0,69,0,72,0,74,0]},{"source":"package:practice_engine/algorithms/spaced_repetition.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Falgorithms%2Fspaced_repetition.dart","uri":"package:practice_engine/algorithms/spaced_repetition.dart","_kind":"library"},"hits":[16,0,20,0,25,0,29,0,30,0,31,0,38,0,43,0,45,0,46,0,48,0,52,0]},{"source":"package:practice_engine/logic/attempt_service.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Flogic%2Fattempt_service.dart","uri":"package:practice_engine/logic/attempt_service.dart","_kind":"library"},"hits":[14,0,20,0,24,0,25,0,26,0,28,0,31,0,32,0,34,0,43,0,53,0,54,0,55,0,57,0,58,0,61,0,62,0,68,0,69,0,72,0,73,0,77,0,81,0,82,0,84,0,87,0,88,0,96,0,98,0,104,0,106,0,113,0,120,0,124,0,125,0,128,0,129,0,132,0,134,0,136,0,138,0,141,0,153,0,154,0,133,0]},{"source":"package:practice_engine/models/attempt.dart","script":{"type":"@Script","fixedId":true,"id":"libraries/1/scripts/package%3Apractice_engine%2Fmodels%2Fattempt.dart","uri":"package:practice_engine/models/attempt.dart","_kind":"library"},"hits":[14,0,21,0,26,0,27,0,28,0,29,0,34,0]}]} \ No newline at end of file diff --git a/packages/practice_engine/lib/algorithms/priority_manager.dart b/packages/practice_engine/lib/algorithms/priority_manager.dart new file mode 100644 index 0000000..c968298 --- /dev/null +++ b/packages/practice_engine/lib/algorithms/priority_manager.dart @@ -0,0 +1,31 @@ +import '../models/question.dart'; +import '../models/deck_config.dart'; + +/// Manages priority point calculations for questions. +class PriorityManager { + /// Applies priority changes based on answer correctness. + static Question applyAnswerResult({ + required Question question, + required bool isCorrect, + required DeckConfig config, + }) { + if (isCorrect) { + final newPriority = (question.priorityPoints - + config.priorityDecreaseOnCorrect) + .clamp(0, double.infinity) + .toInt(); + return question.withPriorityPoints(newPriority); + } else { + return question.copyWith( + priorityPoints: question.priorityPoints + + config.priorityIncreaseOnIncorrect, + ); + } + } + + /// Resets priority to 0. + static Question resetPriority(Question question) { + return question.withPriorityPoints(0); + } +} + diff --git a/packages/practice_engine/lib/algorithms/spaced_repetition.dart b/packages/practice_engine/lib/algorithms/spaced_repetition.dart new file mode 100644 index 0000000..145b0fe --- /dev/null +++ b/packages/practice_engine/lib/algorithms/spaced_repetition.dart @@ -0,0 +1,56 @@ +import '../models/question.dart'; + +/// Handles spaced repetition logic for known questions. +class SpacedRepetition { + /// Base probability for known questions (very low). + static const double baseKnownProbability = 0.01; + + /// Maximum probability for known questions. + static const double maxKnownProbability = 0.15; + + /// Calculates the probability weight for a known question based on + /// how long it's been since it was last seen. + /// + /// [lastAttemptIndex] is the last attempt where the question was seen. + /// [currentAttemptIndex] is the current global attempt index. + static double calculateKnownQuestionWeight({ + required int lastAttemptIndex, + required int currentAttemptIndex, + }) { + if (lastAttemptIndex < 0) { + // Never seen before, use base probability + return baseKnownProbability; + } + + final attemptsSinceLastSeen = currentAttemptIndex - lastAttemptIndex; + + // Probability increases with attempts since last seen + // Formula: base + (min(attemptsSinceLastSeen / 20, 1) * (max - base)) + final progress = (attemptsSinceLastSeen / 20.0).clamp(0.0, 1.0); + final probability = baseKnownProbability + + (progress * (maxKnownProbability - baseKnownProbability)); + + return probability; + } + + /// Gets the weight for a question considering its known status and + /// last attempt index. + static double getQuestionWeight({ + required Question question, + required int currentAttemptIndex, + required double basePriorityWeight, + }) { + if (question.isKnown) { + // Known questions use spaced repetition probability + return calculateKnownQuestionWeight( + lastAttemptIndex: question.lastAttemptIndex, + currentAttemptIndex: currentAttemptIndex, + ) * basePriorityWeight; + } else { + // Unknown questions use priority-based weight + // Priority points + 1 to ensure non-zero weight + return (question.priorityPoints + 1).toDouble(); + } + } +} + diff --git a/packages/practice_engine/lib/algorithms/weighted_selector.dart b/packages/practice_engine/lib/algorithms/weighted_selector.dart new file mode 100644 index 0000000..c5699a1 --- /dev/null +++ b/packages/practice_engine/lib/algorithms/weighted_selector.dart @@ -0,0 +1,88 @@ +import 'dart:math'; +import '../models/question.dart'; +import 'spaced_repetition.dart'; + +/// Selects questions using weighted random selection. +class WeightedSelector { + final Random _random; + + /// Creates a weighted selector with an optional random seed. + WeightedSelector({int? seed}) : _random = Random(seed); + + /// Selects [count] questions from [candidates] using weighted random selection. + /// + /// [currentAttemptIndex] is used for spaced repetition calculations. + /// Returns a list of selected questions (no duplicates). + List selectQuestions({ + required List candidates, + required int count, + required int currentAttemptIndex, + }) { + if (candidates.isEmpty || count <= 0) { + return []; + } + + if (count >= candidates.length) { + return List.from(candidates); + } + + final selected = []; + final available = List.from(candidates); + + while (selected.length < count && available.isNotEmpty) { + final question = _selectOne( + candidates: available, + currentAttemptIndex: currentAttemptIndex, + ); + selected.add(question); + available.remove(question); + } + + return selected; + } + + /// Selects a single question using weighted random selection. + Question _selectOne({ + required List candidates, + required int currentAttemptIndex, + }) { + if (candidates.length == 1) { + return candidates.first; + } + + // Calculate weights for all candidates + final weights = candidates.map((q) { + if (q.isKnown) { + return SpacedRepetition.calculateKnownQuestionWeight( + lastAttemptIndex: q.lastAttemptIndex, + currentAttemptIndex: currentAttemptIndex, + ); + } else { + // Unknown questions: priority + 1 to ensure non-zero weight + return (q.priorityPoints + 1).toDouble(); + } + }).toList(); + + // Calculate cumulative weights + final cumulativeWeights = []; + double sum = 0.0; + for (final weight in weights) { + sum += weight; + cumulativeWeights.add(sum); + } + + // Select random value in range [0, sum) + final randomValue = _random.nextDouble() * sum; + + // Find the index corresponding to the random value + for (int i = 0; i < cumulativeWeights.length; i++) { + if (randomValue < cumulativeWeights[i]) { + return candidates[i]; + } + } + + // Fallback to last item (shouldn't happen, but safety) + return candidates.last; + } +} + diff --git a/packages/practice_engine/lib/logic/attempt_service.dart b/packages/practice_engine/lib/logic/attempt_service.dart new file mode 100644 index 0000000..741bbc7 --- /dev/null +++ b/packages/practice_engine/lib/logic/attempt_service.dart @@ -0,0 +1,185 @@ +import 'dart:math'; +import '../models/deck.dart'; +import '../models/question.dart'; +import '../models/attempt.dart'; +import '../models/attempt_result.dart'; +import '../algorithms/weighted_selector.dart'; +import 'deck_service.dart'; + +/// Service for managing attempt/quiz operations. +class AttemptService { + final WeightedSelector _selector; + + /// Creates an attempt service with an optional random seed. + AttemptService({int? seed}) : _selector = WeightedSelector(seed: seed); + + /// Creates a new attempt by selecting questions from the deck. + /// + /// Selects [attemptSize] questions (defaults to deck config default). + /// Uses weighted random selection with no duplicates. + /// Excludes known questions unless [DeckConfig.includeKnownInAttempts] is true. + /// [includeKnown] can override the config setting for this attempt. + Attempt createAttempt({ + required Deck deck, + int? attemptSize, + bool? includeKnown, + }) { + final size = attemptSize ?? deck.config.defaultAttemptSize; + + // Filter candidates based on includeKnownInAttempts setting + // Use override parameter if provided, otherwise use config + final shouldIncludeKnown = includeKnown ?? deck.config.includeKnownInAttempts; + final candidates = shouldIncludeKnown + ? deck.questions + : deck.questions.where((q) => !q.isKnown).toList(); + + final selected = _selector.selectQuestions( + candidates: candidates, + count: size, + currentAttemptIndex: deck.currentAttemptIndex, + ); + + return Attempt( + id: _generateAttemptId(), + questions: selected, + startTime: DateTime.now().millisecondsSinceEpoch, + ); + } + + /// Processes an attempt result and updates the deck. + /// + /// [answers] is a map of questionId -> userAnswerIndex (for single answer) or questionId -> List (for multiple answers). + /// [manualOverrides] is an optional map of questionId -> bool (true if marked as needs practice). + /// Returns the updated deck and attempt result. + ({ + Deck updatedDeck, + AttemptResult result, + }) processAttempt({ + required Deck deck, + required Attempt attempt, + required Map answers, + Map? manualOverrides, + int? endTime, + }) { + final overrides = manualOverrides ?? {}; + final finishTime = endTime ?? DateTime.now().millisecondsSinceEpoch; + final timeSpent = finishTime - attempt.startTime; + + final answerResults = []; + final updatedQuestions = []; + + // Process each question in the attempt + for (final question in attempt.questions) { + final userAnswer = answers[question.id]; + if (userAnswer == null) { + // Question not answered, treat as incorrect + continue; + } + + // Handle both single answer (int) and multiple answers (List) + final List userAnswerIndices; + if (userAnswer is int) { + userAnswerIndices = [userAnswer]; + } else if (userAnswer is List) { + userAnswerIndices = userAnswer; + } else { + // Invalid format, treat as incorrect + continue; + } + + // Check if answer is correct + // For multiple correct answers: user must select all correct answers and no incorrect ones + final correctIndices = question.correctIndices; + final userSet = userAnswerIndices.toSet(); + final correctSet = correctIndices.toSet(); + final isCorrect = userSet.length == correctSet.length && + userSet.every((idx) => correctSet.contains(idx)); + final userMarkedNeedsPractice = overrides[question.id] ?? false; + + // Determine status change + final oldIsKnown = question.isKnown; + final oldStreak = question.consecutiveCorrect; + + // Update question + final updated = userMarkedNeedsPractice + ? DeckService.updateQuestionWithManualOverride( + question: question, + isCorrect: isCorrect, + userMarkedNeedsPractice: true, + config: deck.config, + currentAttemptIndex: deck.currentAttemptIndex, + ) + : DeckService.updateQuestionAfterAnswer( + question: question, + isCorrect: isCorrect, + config: deck.config, + currentAttemptIndex: deck.currentAttemptIndex, + ); + + // Determine status change + QuestionStatusChange statusChange; + if (userMarkedNeedsPractice) { + statusChange = QuestionStatusChange.unchanged; + } else if (isCorrect) { + if (!oldIsKnown && updated.isKnown) { + statusChange = QuestionStatusChange.improved; + } else if (updated.consecutiveCorrect > oldStreak) { + statusChange = QuestionStatusChange.improved; + } else { + statusChange = QuestionStatusChange.unchanged; + } + } else { + if (oldIsKnown && !updated.isKnown) { + statusChange = QuestionStatusChange.regressed; + } else if (oldStreak > 0 && updated.consecutiveCorrect == 0) { + statusChange = QuestionStatusChange.regressed; + } else { + statusChange = QuestionStatusChange.unchanged; + } + } + + answerResults.add(AnswerResult( + question: updated, + userAnswerIndices: userAnswerIndices, + isCorrect: isCorrect, + statusChange: statusChange, + )); + + updatedQuestions.add(updated); + } + + // Update deck with new question states + final questionMap = Map.fromEntries( + deck.questions.map((q) => MapEntry(q.id, q)), + ); + + for (final updated in updatedQuestions) { + questionMap[updated.id] = updated; + } + + final allUpdatedQuestions = deck.questions.map((q) { + return questionMap[q.id] ?? q; + }).toList(); + + final updatedDeck = deck.copyWith( + questions: allUpdatedQuestions, + currentAttemptIndex: deck.currentAttemptIndex + 1, + ); + + final result = AttemptResult.fromAnswers( + results: answerResults, + timeSpent: timeSpent, + ); + + return ( + updatedDeck: updatedDeck, + result: result, + ); + } + + /// Generates a unique attempt ID. + String _generateAttemptId() { + return 'attempt_${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; + } +} + diff --git a/packages/practice_engine/lib/logic/deck_service.dart b/packages/practice_engine/lib/logic/deck_service.dart new file mode 100644 index 0000000..513c0d8 --- /dev/null +++ b/packages/practice_engine/lib/logic/deck_service.dart @@ -0,0 +1,166 @@ +import '../models/deck.dart'; +import '../models/question.dart'; +import '../models/deck_config.dart'; +import '../algorithms/priority_manager.dart'; + +/// Service for managing deck operations. +class DeckService { + /// Marks a question as known (manual override). + /// + /// Sets streak to threshold and isKnown to true. + static Deck markQuestionAsKnown({ + required Deck deck, + required String questionId, + }) { + final updatedQuestions = deck.questions.map((q) { + if (q.id == questionId) { + return q.copyWith( + consecutiveCorrect: deck.config.requiredConsecutiveCorrect, + isKnown: true, + ); + } + return q; + }).toList(); + + return deck.copyWith(questions: updatedQuestions); + } + + /// Marks a question as needs practice (manual override). + /// + /// Sets isKnown to false and streak to 0, but keeps priority unchanged. + static Deck markQuestionAsNeedsPractice({ + required Deck deck, + required String questionId, + }) { + final updatedQuestions = deck.questions.map((q) { + if (q.id == questionId) { + return q.copyWith( + isKnown: false, + consecutiveCorrect: 0, + // priorityPoints remains unchanged + ); + } + return q; + }).toList(); + + return deck.copyWith(questions: updatedQuestions); + } + + /// Resets the entire deck. + /// + /// Resets all questions: streak = 0, isKnown = false, priority = 0. + /// Optionally resets totalAttempts if [resetAttemptCounts] is true. + /// Optionally clears attempt history if [clearAttemptHistory] is true. + static Deck resetDeck({ + required Deck deck, + bool resetAttemptCounts = false, + bool clearAttemptHistory = false, + }) { + final updatedQuestions = deck.questions.map((q) { + return q.copyWith( + consecutiveCorrect: 0, + isKnown: false, + priorityPoints: 0, + lastAttemptIndex: -1, + totalCorrectAttempts: resetAttemptCounts ? 0 : q.totalCorrectAttempts, + totalAttempts: resetAttemptCounts ? 0 : q.totalAttempts, + ); + }).toList(); + + return deck.copyWith( + questions: updatedQuestions, + currentAttemptIndex: 0, + attemptHistory: clearAttemptHistory ? [] : deck.attemptHistory, + ); + } + + /// Updates a question's state after an answer. + /// + /// Applies correctness rules and updates streak, priority, and isKnown. + static Question updateQuestionAfterAnswer({ + required Question question, + required bool isCorrect, + required DeckConfig config, + required int currentAttemptIndex, + }) { + Question updated = question; + + if (isCorrect) { + // Increment streak + final newStreak = question.consecutiveCorrect + 1; + updated = updated.copyWith(consecutiveCorrect: newStreak); + + // Update isKnown if streak reaches threshold + if (newStreak >= config.requiredConsecutiveCorrect) { + updated = updated.copyWith(isKnown: true); + } + + // Decrease priority + updated = PriorityManager.applyAnswerResult( + question: updated, + isCorrect: true, + config: config, + ); + + // Update totals + updated = updated.copyWith( + totalCorrectAttempts: question.totalCorrectAttempts + 1, + totalAttempts: question.totalAttempts + 1, + ); + } else { + // Reset streak + updated = updated.copyWith( + consecutiveCorrect: 0, + isKnown: false, + ); + + // Increase priority + updated = PriorityManager.applyAnswerResult( + question: updated, + isCorrect: false, + config: config, + ); + + // Update totals + updated = updated.copyWith( + totalAttempts: question.totalAttempts + 1, + ); + } + + // Update lastAttemptIndex + updated = updated.copyWith(lastAttemptIndex: currentAttemptIndex); + + return updated; + } + + /// Updates a question with manual override after an answer. + /// + /// If user marks as "Needs Practice" even after correct answer, + /// ignore correctness and don't increment streak or decrease priority. + static Question updateQuestionWithManualOverride({ + required Question question, + required bool isCorrect, + required bool userMarkedNeedsPractice, + required DeckConfig config, + required int currentAttemptIndex, + }) { + if (userMarkedNeedsPractice) { + // User marked as needs practice - ignore correctness + // Don't increment streak, don't decrease priority + // Just update attempt counts and lastAttemptIndex + return question.copyWith( + totalAttempts: question.totalAttempts + 1, + lastAttemptIndex: currentAttemptIndex, + ); + } else { + // Normal flow + return updateQuestionAfterAnswer( + question: question, + isCorrect: isCorrect, + config: config, + currentAttemptIndex: currentAttemptIndex, + ); + } + } +} + diff --git a/packages/practice_engine/lib/models/attempt.dart b/packages/practice_engine/lib/models/attempt.dart new file mode 100644 index 0000000..d048c67 --- /dev/null +++ b/packages/practice_engine/lib/models/attempt.dart @@ -0,0 +1,36 @@ +import '../models/question.dart'; + +/// A single attempt/quiz session. +class Attempt { + /// Unique identifier for this attempt. + final String id; + + /// List of questions included in this attempt. + final List questions; + + /// Timestamp when the attempt was started (milliseconds since epoch). + final int startTime; + + const Attempt({ + required this.id, + required this.questions, + required this.startTime, + }); + + /// Creates a copy of this attempt with the given fields replaced. + Attempt copyWith({ + String? id, + List? questions, + int? startTime, + }) { + return Attempt( + id: id ?? this.id, + questions: questions ?? this.questions, + startTime: startTime ?? this.startTime, + ); + } + + /// Number of questions in this attempt. + int get questionCount => questions.length; +} + diff --git a/packages/practice_engine/lib/models/attempt_history_entry.dart b/packages/practice_engine/lib/models/attempt_history_entry.dart new file mode 100644 index 0000000..f9ed74d --- /dev/null +++ b/packages/practice_engine/lib/models/attempt_history_entry.dart @@ -0,0 +1,95 @@ +import 'attempt_result.dart'; + +/// A historical record of a completed attempt. +class AttemptHistoryEntry { + /// Timestamp when the attempt was completed (milliseconds since epoch). + final int timestamp; + + /// Total number of questions in the attempt. + final int totalQuestions; + + /// Number of correct answers. + final int correctCount; + + /// Percentage of correct answers. + final double percentageCorrect; + + /// Time spent on the attempt in milliseconds. + final int timeSpent; + + /// Percentage of questions in the deck that are not yet known (0-100). + /// This represents progress - as more questions become known, this decreases. + final double unknownPercentage; + + const AttemptHistoryEntry({ + required this.timestamp, + required this.totalQuestions, + required this.correctCount, + required this.percentageCorrect, + required this.timeSpent, + this.unknownPercentage = 0.0, + }); + + /// Creates a history entry from an attempt result. + factory AttemptHistoryEntry.fromAttemptResult({ + required AttemptResult result, + required int totalQuestionsInDeck, + required int knownCount, + int? timestamp, + }) { + final unknownCount = totalQuestionsInDeck - knownCount; + final unknownPercentage = totalQuestionsInDeck > 0 + ? (unknownCount / totalQuestionsInDeck * 100.0) + : 0.0; + + return AttemptHistoryEntry( + timestamp: timestamp ?? DateTime.now().millisecondsSinceEpoch, + totalQuestions: result.totalQuestions, + correctCount: result.correctCount, + percentageCorrect: result.percentageCorrect, + timeSpent: result.timeSpent, + unknownPercentage: unknownPercentage, + ); + } + + /// Creates a copy of this entry with the given fields replaced. + AttemptHistoryEntry copyWith({ + int? timestamp, + int? totalQuestions, + int? correctCount, + double? percentageCorrect, + int? timeSpent, + double? unknownPercentage, + }) { + return AttemptHistoryEntry( + timestamp: timestamp ?? this.timestamp, + totalQuestions: totalQuestions ?? this.totalQuestions, + correctCount: correctCount ?? this.correctCount, + percentageCorrect: percentageCorrect ?? this.percentageCorrect, + timeSpent: timeSpent ?? this.timeSpent, + unknownPercentage: unknownPercentage ?? this.unknownPercentage, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AttemptHistoryEntry && + runtimeType == other.runtimeType && + timestamp == other.timestamp && + totalQuestions == other.totalQuestions && + correctCount == other.correctCount && + percentageCorrect == other.percentageCorrect && + timeSpent == other.timeSpent && + unknownPercentage == other.unknownPercentage; + + @override + int get hashCode => + timestamp.hashCode ^ + totalQuestions.hashCode ^ + correctCount.hashCode ^ + percentageCorrect.hashCode ^ + timeSpent.hashCode ^ + unknownPercentage.hashCode; +} + diff --git a/packages/practice_engine/lib/models/attempt_result.dart b/packages/practice_engine/lib/models/attempt_result.dart new file mode 100644 index 0000000..bf5ef7e --- /dev/null +++ b/packages/practice_engine/lib/models/attempt_result.dart @@ -0,0 +1,88 @@ +import 'question.dart'; + +/// Status change for a question after an attempt. +enum QuestionStatusChange { + improved, + regressed, + unchanged, +} + +/// Result of a single answer in an attempt. +class AnswerResult { + /// The question that was answered. + final Question question; + + /// The indices of answers the user selected. + final List userAnswerIndices; + + /// Deprecated: Use [userAnswerIndices] instead. Returns the first selected answer index. + @Deprecated('Use userAnswerIndices instead') + int get userAnswerIndex => userAnswerIndices.isNotEmpty ? userAnswerIndices.first : -1; + + /// Whether the answer was correct. + final bool isCorrect; + + /// The status change for this question. + final QuestionStatusChange statusChange; + + const AnswerResult({ + required this.question, + required this.userAnswerIndices, + required this.isCorrect, + required this.statusChange, + }); +} + +/// Result of a completed attempt. +class AttemptResult { + /// Total number of questions in the attempt. + final int totalQuestions; + + /// Number of correct answers. + final int correctCount; + + /// Percentage of correct answers. + final double percentageCorrect; + + /// Time spent on the attempt in milliseconds. + final int timeSpent; + + /// List of questions that were answered incorrectly. + final List incorrectQuestions; + + /// List of all answer results. + final List allResults; + + const AttemptResult({ + required this.totalQuestions, + required this.correctCount, + required this.percentageCorrect, + required this.timeSpent, + required this.incorrectQuestions, + required this.allResults, + }); + + /// Creates an attempt result from answer results. + factory AttemptResult.fromAnswers({ + required List results, + required int timeSpent, + }) { + final correctCount = results.where((r) => r.isCorrect).length; + final totalQuestions = results.length; + final percentageCorrect = totalQuestions > 0 + ? (correctCount / totalQuestions) * 100.0 + : 0.0; + final incorrectQuestions = + results.where((r) => !r.isCorrect).toList(); + + return AttemptResult( + totalQuestions: totalQuestions, + correctCount: correctCount, + percentageCorrect: percentageCorrect, + timeSpent: timeSpent, + incorrectQuestions: incorrectQuestions, + allResults: results, + ); + } +} + diff --git a/packages/practice_engine/lib/models/deck.dart b/packages/practice_engine/lib/models/deck.dart new file mode 100644 index 0000000..9520e23 --- /dev/null +++ b/packages/practice_engine/lib/models/deck.dart @@ -0,0 +1,125 @@ +import 'deck_config.dart'; +import 'question.dart'; +import 'attempt_history_entry.dart'; +import 'incomplete_attempt.dart'; + +/// A practice deck containing questions and configuration. +class Deck { + /// Unique identifier for this deck. + final String id; + + /// Title of the deck. + final String title; + + /// Description of the deck. + final String description; + + /// List of questions in this deck. + final List questions; + + /// Configuration for this deck. + final DeckConfig config; + + /// Current global attempt index (incremented with each attempt). + final int currentAttemptIndex; + + /// History of completed attempts. + final List attemptHistory; + + /// Incomplete attempt that can be resumed. + final IncompleteAttempt? incompleteAttempt; + + const Deck({ + required this.id, + required this.title, + required this.description, + required this.questions, + required this.config, + this.currentAttemptIndex = 0, + this.attemptHistory = const [], + this.incompleteAttempt, + }); + + /// Creates a copy of this deck with the given fields replaced. + Deck copyWith({ + String? id, + String? title, + String? description, + List? questions, + DeckConfig? config, + int? currentAttemptIndex, + List? attemptHistory, + IncompleteAttempt? incompleteAttempt, + bool clearIncompleteAttempt = false, + }) { + return Deck( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + questions: questions ?? this.questions, + config: config ?? this.config, + currentAttemptIndex: currentAttemptIndex ?? this.currentAttemptIndex, + attemptHistory: attemptHistory ?? this.attemptHistory, + incompleteAttempt: clearIncompleteAttempt ? null : (incompleteAttempt ?? this.incompleteAttempt), + ); + } + + /// Total number of questions in the deck. + int get numberOfQuestions => questions.length; + + /// Number of questions marked as known. + int get knownCount => questions.where((q) => q.isKnown).length; + + /// Practice percentage: (known / total) * 100 + double get practicePercentage { + if (questions.isEmpty) return 0.0; + return (knownCount / questions.length) * 100.0; + } + + /// Number of completed attempts. + int get attemptCount => attemptHistory.length; + + /// Average percentage correct across all attempts. + double get averagePercentageCorrect { + if (attemptHistory.isEmpty) return 0.0; + final sum = attemptHistory.fold( + 0.0, + (sum, entry) => sum + entry.percentageCorrect, + ); + return sum / attemptHistory.length; + } + + /// Total time spent on all attempts in milliseconds. + int get totalTimeSpent { + return attemptHistory.fold( + 0, + (sum, entry) => sum + entry.timeSpent, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Deck && + runtimeType == other.runtimeType && + id == other.id && + title == other.title && + description == other.description && + questions.toString() == other.questions.toString() && + config == other.config && + currentAttemptIndex == other.currentAttemptIndex && + attemptHistory.toString() == other.attemptHistory.toString() && + incompleteAttempt == other.incompleteAttempt; + + @override + int get hashCode => + id.hashCode ^ + title.hashCode ^ + description.hashCode ^ + questions.hashCode ^ + config.hashCode ^ + currentAttemptIndex.hashCode ^ + attemptHistory.hashCode ^ + (incompleteAttempt?.hashCode ?? 0); +} + diff --git a/packages/practice_engine/lib/models/deck_config.dart b/packages/practice_engine/lib/models/deck_config.dart new file mode 100644 index 0000000..542c4b8 --- /dev/null +++ b/packages/practice_engine/lib/models/deck_config.dart @@ -0,0 +1,85 @@ +/// Configuration for a practice deck. +class DeckConfig { + /// Number of consecutive correct answers required to mark a question as known. + final int requiredConsecutiveCorrect; + + /// Default number of questions to include in an attempt. + final int defaultAttemptSize; + + /// Priority points to add when a question is answered incorrectly. + final int priorityIncreaseOnIncorrect; + + /// Priority points to subtract when a question is answered correctly. + final int priorityDecreaseOnCorrect; + + /// Whether to provide immediate feedback after each answer. + final bool immediateFeedbackEnabled; + + /// Whether to include known questions in attempts. + /// If false, known questions will be excluded from attempts. + final bool includeKnownInAttempts; + + /// Optional time limit for attempts in seconds. + /// If null, no time limit is enforced. + final int? timeLimitSeconds; + + const DeckConfig({ + this.requiredConsecutiveCorrect = 3, + this.defaultAttemptSize = 10, + this.priorityIncreaseOnIncorrect = 5, + this.priorityDecreaseOnCorrect = 2, + this.immediateFeedbackEnabled = true, + this.includeKnownInAttempts = false, + this.timeLimitSeconds, + }); + + /// Creates a copy of this config with the given fields replaced. + DeckConfig copyWith({ + int? requiredConsecutiveCorrect, + int? defaultAttemptSize, + int? priorityIncreaseOnIncorrect, + int? priorityDecreaseOnCorrect, + bool? immediateFeedbackEnabled, + bool? includeKnownInAttempts, + int? timeLimitSeconds, + }) { + return DeckConfig( + requiredConsecutiveCorrect: + requiredConsecutiveCorrect ?? this.requiredConsecutiveCorrect, + defaultAttemptSize: defaultAttemptSize ?? this.defaultAttemptSize, + priorityIncreaseOnIncorrect: + priorityIncreaseOnIncorrect ?? this.priorityIncreaseOnIncorrect, + priorityDecreaseOnCorrect: + priorityDecreaseOnCorrect ?? this.priorityDecreaseOnCorrect, + immediateFeedbackEnabled: + immediateFeedbackEnabled ?? this.immediateFeedbackEnabled, + includeKnownInAttempts: + includeKnownInAttempts ?? this.includeKnownInAttempts, + timeLimitSeconds: timeLimitSeconds ?? this.timeLimitSeconds, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DeckConfig && + runtimeType == other.runtimeType && + requiredConsecutiveCorrect == other.requiredConsecutiveCorrect && + defaultAttemptSize == other.defaultAttemptSize && + priorityIncreaseOnIncorrect == other.priorityIncreaseOnIncorrect && + priorityDecreaseOnCorrect == other.priorityDecreaseOnCorrect && + immediateFeedbackEnabled == other.immediateFeedbackEnabled && + includeKnownInAttempts == other.includeKnownInAttempts && + timeLimitSeconds == other.timeLimitSeconds; + + @override + int get hashCode => + requiredConsecutiveCorrect.hashCode ^ + defaultAttemptSize.hashCode ^ + priorityIncreaseOnIncorrect.hashCode ^ + priorityDecreaseOnCorrect.hashCode ^ + immediateFeedbackEnabled.hashCode ^ + includeKnownInAttempts.hashCode ^ + (timeLimitSeconds?.hashCode ?? 0); +} + diff --git a/packages/practice_engine/lib/models/incomplete_attempt.dart b/packages/practice_engine/lib/models/incomplete_attempt.dart new file mode 100644 index 0000000..73dc11b --- /dev/null +++ b/packages/practice_engine/lib/models/incomplete_attempt.dart @@ -0,0 +1,134 @@ +import 'attempt.dart'; +import 'question.dart'; + +/// Represents an incomplete attempt that can be resumed later. +class IncompleteAttempt { + /// The attempt ID. + final String attemptId; + + /// List of question IDs in the attempt (in order). + final List questionIds; + + /// Timestamp when the attempt was started. + final int startTime; + + /// Current question index (0-based). + final int currentQuestionIndex; + + /// Map of questionId -> answer (int for single, List for multiple). + final Map answers; + + /// Map of questionId -> manual override (needs practice). + final Map manualOverrides; + + /// Timestamp when the attempt was paused. + final int pausedAt; + + /// Remaining time in seconds (if time limit was set). + final int? remainingSeconds; + + const IncompleteAttempt({ + required this.attemptId, + required this.questionIds, + required this.startTime, + required this.currentQuestionIndex, + required this.answers, + required this.manualOverrides, + required this.pausedAt, + this.remainingSeconds, + }); + + /// Creates an Attempt from this incomplete attempt using questions from the deck. + Attempt toAttempt(List deckQuestions) { + final questionMap = {for (var q in deckQuestions) q.id: q}; + final questions = questionIds + .map((id) => questionMap[id]) + .whereType() + .toList(); + + return Attempt( + id: attemptId, + questions: questions, + startTime: startTime, + ); + } + + /// Creates an incomplete attempt from JSON. + factory IncompleteAttempt.fromJson(Map json) { + return IncompleteAttempt( + attemptId: json['attemptId'] as String, + questionIds: List.from(json['questionIds'] as List), + startTime: json['startTime'] as int, + currentQuestionIndex: json['currentQuestionIndex'] as int, + answers: Map.from(json['answers'] as Map), + manualOverrides: Map.from( + (json['manualOverrides'] as Map?)?.map((k, v) => MapEntry(k.toString(), v as bool)) ?? {}, + ), + pausedAt: json['pausedAt'] as int, + remainingSeconds: json['remainingSeconds'] as int?, + ); + } + + /// Converts to JSON for storage. + Map toJson() { + return { + 'attemptId': attemptId, + 'questionIds': questionIds, + 'startTime': startTime, + 'currentQuestionIndex': currentQuestionIndex, + 'answers': answers, + 'manualOverrides': manualOverrides, + 'pausedAt': pausedAt, + if (remainingSeconds != null) 'remainingSeconds': remainingSeconds, + }; + } + + /// Creates a copy with updated fields. + IncompleteAttempt copyWith({ + String? attemptId, + List? questionIds, + int? startTime, + int? currentQuestionIndex, + Map? answers, + Map? manualOverrides, + int? pausedAt, + int? remainingSeconds, + }) { + return IncompleteAttempt( + attemptId: attemptId ?? this.attemptId, + questionIds: questionIds ?? this.questionIds, + startTime: startTime ?? this.startTime, + currentQuestionIndex: currentQuestionIndex ?? this.currentQuestionIndex, + answers: answers ?? this.answers, + manualOverrides: manualOverrides ?? this.manualOverrides, + pausedAt: pausedAt ?? this.pausedAt, + remainingSeconds: remainingSeconds ?? this.remainingSeconds, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is IncompleteAttempt && + runtimeType == other.runtimeType && + attemptId == other.attemptId && + questionIds.toString() == other.questionIds.toString() && + startTime == other.startTime && + currentQuestionIndex == other.currentQuestionIndex && + answers.toString() == other.answers.toString() && + manualOverrides.toString() == other.manualOverrides.toString() && + pausedAt == other.pausedAt && + remainingSeconds == other.remainingSeconds; + + @override + int get hashCode => + attemptId.hashCode ^ + questionIds.hashCode ^ + startTime.hashCode ^ + currentQuestionIndex.hashCode ^ + answers.hashCode ^ + manualOverrides.hashCode ^ + pausedAt.hashCode ^ + (remainingSeconds?.hashCode ?? 0); +} + diff --git a/packages/practice_engine/lib/models/question.dart b/packages/practice_engine/lib/models/question.dart new file mode 100644 index 0000000..4bf8eb2 --- /dev/null +++ b/packages/practice_engine/lib/models/question.dart @@ -0,0 +1,138 @@ +/// A question in a practice deck. +class Question { + /// Unique identifier for this question. + final String id; + + /// The question prompt. + final String prompt; + + /// List of possible answers. + final List answers; + + /// Indices of correct answers in [answers]. + /// For backward compatibility, if empty, falls back to [correctAnswerIndex] (deprecated). + final List correctAnswerIndices; + + /// Deprecated: Use [correctAnswerIndices] instead. + /// Kept for backward compatibility with existing data. + @Deprecated('Use correctAnswerIndices instead') + final int? correctAnswerIndex; + + /// Number of consecutive correct answers. + final int consecutiveCorrect; + + /// Whether this question is considered "known". + final bool isKnown; + + /// Priority points (higher = more likely to be selected). + final int priorityPoints; + + /// The last attempt index where this question was seen. + final int lastAttemptIndex; + + /// Total number of correct attempts. + final int totalCorrectAttempts; + + /// Total number of attempts. + final int totalAttempts; + + Question({ + required this.id, + required this.prompt, + required this.answers, + List? correctAnswerIndices, + @Deprecated('Use correctAnswerIndices instead') int? correctAnswerIndex, + this.consecutiveCorrect = 0, + this.isKnown = false, + this.priorityPoints = 0, + this.lastAttemptIndex = -1, + this.totalCorrectAttempts = 0, + this.totalAttempts = 0, + }) : correctAnswerIndices = correctAnswerIndices ?? + (correctAnswerIndex != null ? [correctAnswerIndex] : const []), + correctAnswerIndex = correctAnswerIndex; + + /// Creates a copy of this question with the given fields replaced. + Question copyWith({ + String? id, + String? prompt, + List? answers, + List? correctAnswerIndices, + @Deprecated('Use correctAnswerIndices instead') int? correctAnswerIndex, + int? consecutiveCorrect, + bool? isKnown, + int? priorityPoints, + int? lastAttemptIndex, + int? totalCorrectAttempts, + int? totalAttempts, + }) { + return Question( + id: id ?? this.id, + prompt: prompt ?? this.prompt, + answers: answers ?? this.answers, + correctAnswerIndices: correctAnswerIndices ?? this.correctAnswerIndices, + correctAnswerIndex: correctAnswerIndex ?? this.correctAnswerIndex, + consecutiveCorrect: consecutiveCorrect ?? this.consecutiveCorrect, + isKnown: isKnown ?? this.isKnown, + priorityPoints: priorityPoints ?? this.priorityPoints, + lastAttemptIndex: lastAttemptIndex ?? this.lastAttemptIndex, + totalCorrectAttempts: totalCorrectAttempts ?? this.totalCorrectAttempts, + totalAttempts: totalAttempts ?? this.totalAttempts, + ); + } + + /// Gets the correct answer indices, with backward compatibility. + List get correctIndices { + if (correctAnswerIndices.isNotEmpty) { + return correctAnswerIndices; + } + // Backward compatibility + if (correctAnswerIndex != null) { + return [correctAnswerIndex!]; + } + return []; + } + + /// Checks if an answer index is correct. + bool isCorrectAnswer(int index) { + return correctIndices.contains(index); + } + + /// Whether this question has multiple correct answers. + bool get hasMultipleCorrectAnswers => correctIndices.length > 1; + + /// Validates that priorityPoints is non-negative. + Question withPriorityPoints(int points) { + return copyWith(priorityPoints: points < 0 ? 0 : points); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Question && + runtimeType == other.runtimeType && + id == other.id && + prompt == other.prompt && + answers.toString() == other.answers.toString() && + correctAnswerIndices.toString() == other.correctAnswerIndices.toString() && + consecutiveCorrect == other.consecutiveCorrect && + isKnown == other.isKnown && + priorityPoints == other.priorityPoints && + lastAttemptIndex == other.lastAttemptIndex && + totalCorrectAttempts == other.totalCorrectAttempts && + totalAttempts == other.totalAttempts; + + @override + int get hashCode => + id.hashCode ^ + prompt.hashCode ^ + answers.hashCode ^ + correctAnswerIndices.hashCode ^ + consecutiveCorrect.hashCode ^ + isKnown.hashCode ^ + priorityPoints.hashCode ^ + lastAttemptIndex.hashCode ^ + totalCorrectAttempts.hashCode ^ + totalAttempts.hashCode; +} + diff --git a/packages/practice_engine/lib/practice_engine.dart b/packages/practice_engine/lib/practice_engine.dart new file mode 100644 index 0000000..fa8fcd3 --- /dev/null +++ b/packages/practice_engine/lib/practice_engine.dart @@ -0,0 +1,21 @@ +/// Practice Engine - A pure Dart package for spaced repetition and practice tracking. +library practice_engine; + +// Models +export 'models/deck.dart'; +export 'models/deck_config.dart'; +export 'models/question.dart'; +export 'models/attempt.dart'; +export 'models/attempt_result.dart'; +export 'models/attempt_history_entry.dart'; +export 'models/incomplete_attempt.dart'; + +// Services +export 'logic/deck_service.dart'; +export 'logic/attempt_service.dart'; + +// Algorithms +export 'algorithms/weighted_selector.dart'; +export 'algorithms/spaced_repetition.dart'; +export 'algorithms/priority_manager.dart'; + diff --git a/packages/practice_engine/pubspec.lock b/packages/practice_engine/pubspec.lock new file mode 100644 index 0000000..22f3086 --- /dev/null +++ b/packages/practice_engine/pubspec.lock @@ -0,0 +1,381 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e" + url: "https://pub.dev" + source: hosted + version: "92.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e" + url: "https://pub.dev" + source: hosted + version: "9.0.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + url: "https://pub.dev" + source: hosted + version: "0.12.18" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "77cc98ea27006c84e71a7356cf3daf9ddbde2d91d84f77dbfe64cf0e4d9611ae" + url: "https://pub.dev" + source: hosted + version: "1.28.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" + url: "https://pub.dev" + source: hosted + version: "0.7.8" + test_core: + dependency: transitive + description: + name: test_core + sha256: f1072617a6657e5fc09662e721307f7fb009b4ed89b19f47175d11d5254a62d4 + url: "https://pub.dev" + source: hosted + version: "0.6.14" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" + url: "https://pub.dev" + source: hosted + version: "1.1.4" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.9.0 <4.0.0" diff --git a/packages/practice_engine/pubspec.yaml b/packages/practice_engine/pubspec.yaml new file mode 100644 index 0000000..2582d4b --- /dev/null +++ b/packages/practice_engine/pubspec.yaml @@ -0,0 +1,10 @@ +name: practice_engine +description: A pure Dart package implementing spaced repetition, weighted selection, and practice tracking for question decks. +version: 1.0.0 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dev_dependencies: + test: ^1.24.0 + diff --git a/packages/practice_engine/test/attempt_flow_test.dart b/packages/practice_engine/test/attempt_flow_test.dart new file mode 100644 index 0000000..4ae02e5 --- /dev/null +++ b/packages/practice_engine/test/attempt_flow_test.dart @@ -0,0 +1,157 @@ +import 'package:test/test.dart'; +import 'package:practice_engine/models/deck.dart'; +import 'package:practice_engine/models/deck_config.dart'; +import 'package:practice_engine/models/question.dart'; +import 'package:practice_engine/logic/attempt_service.dart'; + +void main() { + group('Attempt Flow', () { + late Deck deck; + late AttemptService attemptService; + + setUp(() { + final config = const DeckConfig( + defaultAttemptSize: 5, + requiredConsecutiveCorrect: 3, + ); + + final questions = List.generate(10, (i) { + return Question( + id: 'q$i', + prompt: 'Question $i', + answers: ['A', 'B', 'C'], + correctAnswerIndices: [0], + priorityPoints: i, + ); + }); + + deck = Deck( + id: 'deck1', + title: 'Test Deck', + description: 'Test', + questions: questions, + config: config, + ); + + attemptService = AttemptService(seed: 42); + }); + + test('createAttempt selects correct number of questions', () { + final attempt = attemptService.createAttempt(deck: deck); + + expect(attempt.questions.length, equals(deck.config.defaultAttemptSize)); + }); + + test('createAttempt uses custom attempt size', () { + final attempt = attemptService.createAttempt( + deck: deck, + attemptSize: 3, + ); + + expect(attempt.questions.length, equals(3)); + }); + + test('createAttempt has no duplicate questions', () { + final attempt = attemptService.createAttempt(deck: deck); + + final ids = attempt.questions.map((q) => q.id).toList(); + final uniqueIds = ids.toSet(); + + expect(uniqueIds.length, equals(ids.length)); + }); + + test('processAttempt updates deck with results', () { + final attempt = attemptService.createAttempt(deck: deck); + final answers = {}; + + // Answer all questions correctly + for (final question in attempt.questions) { + answers[question.id] = question.correctIndices.first; + } + + final result = attemptService.processAttempt( + deck: deck, + attempt: attempt, + answers: answers, + ); + + expect(result.updatedDeck.currentAttemptIndex, + equals(deck.currentAttemptIndex + 1)); + }); + + test('processAttempt calculates correct percentage', () { + final attempt = attemptService.createAttempt( + deck: deck, + attemptSize: 3, + ); + final answers = {}; + + // Answer 2 out of 3 correctly + answers[attempt.questions[0].id] = + attempt.questions[0].correctIndices.first; + answers[attempt.questions[1].id] = + attempt.questions[1].correctIndices.first; + answers[attempt.questions[2].id] = 999; // Wrong answer + + final result = attemptService.processAttempt( + deck: deck, + attempt: attempt, + answers: answers, + ); + + expect(result.result.percentageCorrect, closeTo(66.67, 0.01)); + expect(result.result.correctCount, equals(2)); + }); + + test('processAttempt tracks incorrect questions', () { + final attempt = attemptService.createAttempt( + deck: deck, + attemptSize: 3, + ); + final answers = {}; + + // Answer first correctly, rest incorrectly + answers[attempt.questions[0].id] = + attempt.questions[0].correctIndices.first; + answers[attempt.questions[1].id] = 999; + answers[attempt.questions[2].id] = 999; + + final result = attemptService.processAttempt( + deck: deck, + attempt: attempt, + answers: answers, + ); + + expect(result.result.incorrectQuestions.length, equals(2)); + }); + + test('processAttempt updates question streaks correctly', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + consecutiveCorrect: 2, + ); + + final testDeck = deck.copyWith(questions: [question]); + final attempt = attemptService.createAttempt( + deck: testDeck, + attemptSize: 1, + ); + + final answers = {question.id: question.correctIndices.first}; + + final result = attemptService.processAttempt( + deck: testDeck, + attempt: attempt, + answers: answers, + ); + + final updated = result.updatedDeck.questions.first; + expect(updated.consecutiveCorrect, equals(3)); + expect(updated.isKnown, equals(true)); + }); + }); +} + diff --git a/packages/practice_engine/test/attempt_result_test.dart b/packages/practice_engine/test/attempt_result_test.dart new file mode 100644 index 0000000..1fc4be7 --- /dev/null +++ b/packages/practice_engine/test/attempt_result_test.dart @@ -0,0 +1,144 @@ +import 'package:test/test.dart'; +import 'package:practice_engine/models/question.dart'; +import 'package:practice_engine/models/attempt_result.dart'; + +void main() { + group('AttemptResult', () { + test('fromAnswers calculates correct percentage', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + ); + + final results = [ + AnswerResult( + question: question, + userAnswerIndices: [0], + isCorrect: true, + statusChange: QuestionStatusChange.unchanged, + ), + AnswerResult( + question: question, + userAnswerIndices: [1], + isCorrect: false, + statusChange: QuestionStatusChange.unchanged, + ), + AnswerResult( + question: question, + userAnswerIndices: [0], + isCorrect: true, + statusChange: QuestionStatusChange.unchanged, + ), + ]; + + final attemptResult = AttemptResult.fromAnswers( + results: results, + timeSpent: 1000, + ); + + expect(attemptResult.totalQuestions, equals(3)); + expect(attemptResult.correctCount, equals(2)); + expect(attemptResult.percentageCorrect, closeTo(66.67, 0.01)); + }); + + test('fromAnswers handles empty results', () { + final attemptResult = AttemptResult.fromAnswers( + results: [], + timeSpent: 0, + ); + + expect(attemptResult.totalQuestions, equals(0)); + expect(attemptResult.correctCount, equals(0)); + expect(attemptResult.percentageCorrect, equals(0.0)); + }); + + test('fromAnswers filters incorrect questions', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + ); + + final results = [ + AnswerResult( + question: question, + userAnswerIndices: [0], + isCorrect: true, + statusChange: QuestionStatusChange.unchanged, + ), + AnswerResult( + question: question, + userAnswerIndices: [1], + isCorrect: false, + statusChange: QuestionStatusChange.unchanged, + ), + AnswerResult( + question: question, + userAnswerIndices: [1], + isCorrect: false, + statusChange: QuestionStatusChange.unchanged, + ), + ]; + + final attemptResult = AttemptResult.fromAnswers( + results: results, + timeSpent: 1000, + ); + + expect(attemptResult.incorrectQuestions.length, equals(2)); + }); + + test('fromAnswers includes all results', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + ); + + final results = List.generate(5, (i) { + return AnswerResult( + question: question, + userAnswerIndices: [i % 2], + isCorrect: i % 2 == 0, + statusChange: QuestionStatusChange.unchanged, + ); + }); + + final attemptResult = AttemptResult.fromAnswers( + results: results, + timeSpent: 2000, + ); + + expect(attemptResult.allResults.length, equals(5)); + expect(attemptResult.timeSpent, equals(2000)); + }); + }); + + group('AnswerResult', () { + test('contains question and answer information', () { + final question = Question( + id: 'q1', + prompt: 'What is 2+2?', + answers: ['3', '4', '5'], + correctAnswerIndices: [1], + ); + + final result = AnswerResult( + question: question, + userAnswerIndices: [1], + isCorrect: true, + statusChange: QuestionStatusChange.improved, + ); + + expect(result.question, equals(question)); + expect(result.userAnswerIndices, equals([1])); + expect(result.isCorrect, equals(true)); + expect(result.statusChange, equals(QuestionStatusChange.improved)); + }); + }); +} + diff --git a/packages/practice_engine/test/config_test.dart b/packages/practice_engine/test/config_test.dart new file mode 100644 index 0000000..c58faa0 --- /dev/null +++ b/packages/practice_engine/test/config_test.dart @@ -0,0 +1,54 @@ +import 'package:test/test.dart'; +import 'package:practice_engine/models/deck_config.dart'; + +void main() { + group('DeckConfig', () { + test('has default values', () { + const config = DeckConfig(); + + expect(config.requiredConsecutiveCorrect, equals(3)); + expect(config.defaultAttemptSize, equals(10)); + expect(config.priorityIncreaseOnIncorrect, equals(5)); + expect(config.priorityDecreaseOnCorrect, equals(2)); + expect(config.immediateFeedbackEnabled, equals(true)); + }); + + test('can be created with custom values', () { + const config = DeckConfig( + requiredConsecutiveCorrect: 5, + defaultAttemptSize: 20, + priorityIncreaseOnIncorrect: 10, + priorityDecreaseOnCorrect: 3, + immediateFeedbackEnabled: false, + ); + + expect(config.requiredConsecutiveCorrect, equals(5)); + expect(config.defaultAttemptSize, equals(20)); + expect(config.priorityIncreaseOnIncorrect, equals(10)); + expect(config.priorityDecreaseOnCorrect, equals(3)); + expect(config.immediateFeedbackEnabled, equals(false)); + }); + + test('copyWith creates new config with updated fields', () { + const config = DeckConfig(); + final updated = config.copyWith( + requiredConsecutiveCorrect: 4, + immediateFeedbackEnabled: false, + ); + + expect(updated.requiredConsecutiveCorrect, equals(4)); + expect(updated.defaultAttemptSize, equals(config.defaultAttemptSize)); + expect(updated.immediateFeedbackEnabled, equals(false)); + }); + + test('equality works correctly', () { + const config1 = DeckConfig(); + const config2 = DeckConfig(); + const config3 = DeckConfig(requiredConsecutiveCorrect: 5); + + expect(config1, equals(config2)); + expect(config1, isNot(equals(config3))); + }); + }); +} + diff --git a/packages/practice_engine/test/deck_test.dart b/packages/practice_engine/test/deck_test.dart new file mode 100644 index 0000000..b7544c3 --- /dev/null +++ b/packages/practice_engine/test/deck_test.dart @@ -0,0 +1,115 @@ +import 'package:test/test.dart'; +import 'package:practice_engine/models/deck.dart'; +import 'package:practice_engine/models/deck_config.dart'; +import 'package:practice_engine/models/question.dart'; + +void main() { + group('Deck', () { + late DeckConfig defaultConfig; + late List sampleQuestions; + + setUp(() { + defaultConfig = const DeckConfig(); + sampleQuestions = [ + Question( + id: 'q1', + prompt: 'What is 2+2?', + answers: ['3', '4', '5'], + correctAnswerIndices: [1], + isKnown: true, + ), + Question( + id: 'q2', + prompt: 'What is 3+3?', + answers: ['5', '6', '7'], + correctAnswerIndices: [1], + isKnown: false, + ), + Question( + id: 'q3', + prompt: 'What is 4+4?', + answers: ['7', '8', '9'], + correctAnswerIndices: [1], + isKnown: true, + ), + ]; + }); + + test('calculates numberOfQuestions correctly', () { + final deck = Deck( + id: 'deck1', + title: 'Math Deck', + description: 'Basic math', + questions: sampleQuestions, + config: defaultConfig, + ); + + expect(deck.numberOfQuestions, equals(3)); + }); + + test('calculates knownCount correctly', () { + final deck = Deck( + id: 'deck1', + title: 'Math Deck', + description: 'Basic math', + questions: sampleQuestions, + config: defaultConfig, + ); + + expect(deck.knownCount, equals(2)); + }); + + test('calculates practicePercentage correctly', () { + final deck = Deck( + id: 'deck1', + title: 'Math Deck', + description: 'Basic math', + questions: sampleQuestions, + config: defaultConfig, + ); + + expect(deck.practicePercentage, closeTo(66.67, 0.01)); + }); + + test('practicePercentage is 0 for empty deck', () { + final deck = Deck( + id: 'deck1', + title: 'Empty Deck', + description: 'No questions', + questions: [], + config: defaultConfig, + ); + + expect(deck.practicePercentage, equals(0.0)); + }); + + test('practicePercentage is 100 when all questions are known', () { + final allKnown = sampleQuestions.map((q) => q.copyWith(isKnown: true)).toList(); + final deck = Deck( + id: 'deck1', + title: 'All Known', + description: 'All known', + questions: allKnown, + config: defaultConfig, + ); + + expect(deck.practicePercentage, equals(100.0)); + }); + + test('copyWith creates new deck with updated fields', () { + final deck = Deck( + id: 'deck1', + title: 'Math Deck', + description: 'Basic math', + questions: sampleQuestions, + config: defaultConfig, + ); + + final updated = deck.copyWith(title: 'Updated Title'); + expect(updated.title, equals('Updated Title')); + expect(updated.id, equals(deck.id)); + expect(updated.questions, equals(deck.questions)); + }); + }); +} + diff --git a/packages/practice_engine/test/manual_override_test.dart b/packages/practice_engine/test/manual_override_test.dart new file mode 100644 index 0000000..1a26a47 --- /dev/null +++ b/packages/practice_engine/test/manual_override_test.dart @@ -0,0 +1,125 @@ +import 'package:test/test.dart'; +import 'package:practice_engine/models/deck.dart'; +import 'package:practice_engine/models/deck_config.dart'; +import 'package:practice_engine/models/question.dart'; +import 'package:practice_engine/logic/deck_service.dart'; + +void main() { + group('Manual Override Logic', () { + late DeckConfig config; + late Deck deck; + + setUp(() { + config = const DeckConfig(requiredConsecutiveCorrect: 3); + deck = Deck( + id: 'deck1', + title: 'Test Deck', + description: 'Test', + questions: [ + Question( + id: 'q1', + prompt: 'Question 1', + answers: ['A', 'B'], + correctAnswerIndices: [0], + consecutiveCorrect: 1, + isKnown: false, + priorityPoints: 5, + ), + Question( + id: 'q2', + prompt: 'Question 2', + answers: ['A', 'B'], + correctAnswerIndices: [0], + consecutiveCorrect: 0, + isKnown: false, + priorityPoints: 10, + ), + ], + config: config, + ); + }); + + test('markQuestionAsKnown sets streak to threshold and isKnown to true', () { + final updated = DeckService.markQuestionAsKnown( + deck: deck, + questionId: 'q1', + ); + + final question = updated.questions.firstWhere((q) => q.id == 'q1'); + expect(question.consecutiveCorrect, + equals(config.requiredConsecutiveCorrect)); + expect(question.isKnown, equals(true)); + }); + + test('markQuestionAsNeedsPractice sets isKnown to false and streak to 0', () { + // First mark as known + var updated = DeckService.markQuestionAsKnown( + deck: deck, + questionId: 'q1', + ); + + // Then mark as needs practice + updated = DeckService.markQuestionAsNeedsPractice( + deck: updated, + questionId: 'q1', + ); + + final question = updated.questions.firstWhere((q) => q.id == 'q1'); + expect(question.isKnown, equals(false)); + expect(question.consecutiveCorrect, equals(0)); + }); + + test('markQuestionAsNeedsPractice preserves priority', () { + final updated = DeckService.markQuestionAsNeedsPractice( + deck: deck, + questionId: 'q1', + ); + + final question = updated.questions.firstWhere((q) => q.id == 'q1'); + expect(question.priorityPoints, equals(5)); + }); + + test('manual override with needs practice ignores correctness', () { + final question = deck.questions.first; + + // Answer correctly but mark as needs practice + final updated = DeckService.updateQuestionWithManualOverride( + question: question, + isCorrect: true, + userMarkedNeedsPractice: true, + config: config, + currentAttemptIndex: 0, + ); + + // Streak should not increment + expect(updated.consecutiveCorrect, equals(question.consecutiveCorrect)); + + // Priority should not decrease + expect(updated.priorityPoints, equals(question.priorityPoints)); + + // isKnown should not change + expect(updated.isKnown, equals(question.isKnown)); + + // But totalAttempts should increment + expect(updated.totalAttempts, equals(question.totalAttempts + 1)); + }); + + test('manual override without needs practice follows normal flow', () { + final question = deck.questions.first; + + final updated = DeckService.updateQuestionWithManualOverride( + question: question, + isCorrect: true, + userMarkedNeedsPractice: false, + config: config, + currentAttemptIndex: 0, + ); + + // Should behave like normal update + expect(updated.consecutiveCorrect, equals(question.consecutiveCorrect + 1)); + expect(updated.priorityPoints, + equals(question.priorityPoints - config.priorityDecreaseOnCorrect)); + }); + }); +} + diff --git a/packages/practice_engine/test/priority_manager_test.dart b/packages/practice_engine/test/priority_manager_test.dart new file mode 100644 index 0000000..6e92813 --- /dev/null +++ b/packages/practice_engine/test/priority_manager_test.dart @@ -0,0 +1,102 @@ +import 'package:test/test.dart'; +import 'package:practice_engine/models/question.dart'; +import 'package:practice_engine/models/deck_config.dart'; +import 'package:practice_engine/algorithms/priority_manager.dart'; + +void main() { + group('PriorityManager', () { + late DeckConfig config; + + setUp(() { + config = const DeckConfig( + priorityIncreaseOnIncorrect: 5, + priorityDecreaseOnCorrect: 2, + ); + }); + + test('increases priority on incorrect answer', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + priorityPoints: 10, + ); + + final updated = PriorityManager.applyAnswerResult( + question: question, + isCorrect: false, + config: config, + ); + + expect(updated.priorityPoints, + equals(10 + config.priorityIncreaseOnIncorrect)); + }); + + test('decreases priority on correct answer', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + priorityPoints: 10, + ); + + final updated = PriorityManager.applyAnswerResult( + question: question, + isCorrect: true, + config: config, + ); + + expect(updated.priorityPoints, + equals(10 - config.priorityDecreaseOnCorrect)); + }); + + test('priority cannot go below 0', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + priorityPoints: 1, + ); + + final updated = PriorityManager.applyAnswerResult( + question: question, + isCorrect: true, + config: config, + ); + + expect(updated.priorityPoints, equals(0)); + }); + + test('resetPriority sets priority to 0', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + priorityPoints: 100, + ); + + final updated = PriorityManager.resetPriority(question); + + expect(updated.priorityPoints, equals(0)); + }); + + test('withPriorityPoints enforces non-negative priority', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + priorityPoints: 10, + ); + + final updated = question.withPriorityPoints(-5); + + expect(updated.priorityPoints, equals(0)); + }); + }); +} + diff --git a/packages/practice_engine/test/question_state_test.dart b/packages/practice_engine/test/question_state_test.dart new file mode 100644 index 0000000..00a2a9a --- /dev/null +++ b/packages/practice_engine/test/question_state_test.dart @@ -0,0 +1,204 @@ +import 'package:test/test.dart'; +import 'package:practice_engine/models/question.dart'; +import 'package:practice_engine/models/deck_config.dart'; +import 'package:practice_engine/logic/deck_service.dart'; + +void main() { + group('Question State Transitions', () { + late DeckConfig config; + + setUp(() { + config = const DeckConfig(requiredConsecutiveCorrect: 3); + }); + + test('correct answer increments streak', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + consecutiveCorrect: 1, + ); + + final updated = DeckService.updateQuestionAfterAnswer( + question: question, + isCorrect: true, + config: config, + currentAttemptIndex: 0, + ); + + expect(updated.consecutiveCorrect, equals(2)); + }); + + test('incorrect answer resets streak', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + consecutiveCorrect: 2, + ); + + final updated = DeckService.updateQuestionAfterAnswer( + question: question, + isCorrect: false, + config: config, + currentAttemptIndex: 0, + ); + + expect(updated.consecutiveCorrect, equals(0)); + }); + + test('question becomes known when streak reaches threshold', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + consecutiveCorrect: 2, + isKnown: false, + ); + + final updated = DeckService.updateQuestionAfterAnswer( + question: question, + isCorrect: true, + config: config, + currentAttemptIndex: 0, + ); + + expect(updated.consecutiveCorrect, equals(3)); + expect(updated.isKnown, equals(true)); + }); + + test('incorrect answer sets isKnown to false', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + consecutiveCorrect: 3, + isKnown: true, + ); + + final updated = DeckService.updateQuestionAfterAnswer( + question: question, + isCorrect: false, + config: config, + currentAttemptIndex: 0, + ); + + expect(updated.isKnown, equals(false)); + expect(updated.consecutiveCorrect, equals(0)); + }); + + test('priority increases on incorrect answer', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + priorityPoints: 5, + ); + + final updated = DeckService.updateQuestionAfterAnswer( + question: question, + isCorrect: false, + config: config, + currentAttemptIndex: 0, + ); + + expect(updated.priorityPoints, + equals(5 + config.priorityIncreaseOnIncorrect)); + }); + + test('priority decreases on correct answer', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + priorityPoints: 10, + ); + + final updated = DeckService.updateQuestionAfterAnswer( + question: question, + isCorrect: true, + config: config, + currentAttemptIndex: 0, + ); + + expect(updated.priorityPoints, + equals(10 - config.priorityDecreaseOnCorrect)); + }); + + test('priority cannot go below 0', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + priorityPoints: 1, + ); + + final updated = DeckService.updateQuestionAfterAnswer( + question: question, + isCorrect: true, + config: config, + currentAttemptIndex: 0, + ); + + expect(updated.priorityPoints, equals(0)); + }); + + test('lastAttemptIndex is updated', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + lastAttemptIndex: 5, + ); + + final updated = DeckService.updateQuestionAfterAnswer( + question: question, + isCorrect: true, + config: config, + currentAttemptIndex: 10, + ); + + expect(updated.lastAttemptIndex, equals(10)); + }); + + test('totalAttempts increments on both correct and incorrect', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + totalAttempts: 5, + totalCorrectAttempts: 3, + ); + + final updatedCorrect = DeckService.updateQuestionAfterAnswer( + question: question, + isCorrect: true, + config: config, + currentAttemptIndex: 0, + ); + + expect(updatedCorrect.totalAttempts, equals(6)); + expect(updatedCorrect.totalCorrectAttempts, equals(4)); + + final updatedIncorrect = DeckService.updateQuestionAfterAnswer( + question: question, + isCorrect: false, + config: config, + currentAttemptIndex: 0, + ); + + expect(updatedIncorrect.totalAttempts, equals(6)); + expect(updatedIncorrect.totalCorrectAttempts, equals(3)); + }); + }); +} + diff --git a/packages/practice_engine/test/reset_test.dart b/packages/practice_engine/test/reset_test.dart new file mode 100644 index 0000000..f2c81d5 --- /dev/null +++ b/packages/practice_engine/test/reset_test.dart @@ -0,0 +1,99 @@ +import 'package:test/test.dart'; +import 'package:practice_engine/models/deck.dart'; +import 'package:practice_engine/models/deck_config.dart'; +import 'package:practice_engine/models/question.dart'; +import 'package:practice_engine/logic/deck_service.dart'; + +void main() { + group('Reset Logic', () { + late DeckConfig config; + late Deck deck; + + setUp(() { + config = const DeckConfig(requiredConsecutiveCorrect: 3); + deck = Deck( + id: 'deck1', + title: 'Test Deck', + description: 'Test', + questions: [ + Question( + id: 'q1', + prompt: 'Question 1', + answers: ['A', 'B'], + correctAnswerIndices: [0], + consecutiveCorrect: 5, + isKnown: true, + priorityPoints: 10, + lastAttemptIndex: 20, + totalCorrectAttempts: 15, + totalAttempts: 20, + ), + Question( + id: 'q2', + prompt: 'Question 2', + answers: ['A', 'B'], + correctAnswerIndices: [0], + consecutiveCorrect: 2, + isKnown: false, + priorityPoints: 5, + lastAttemptIndex: 15, + totalCorrectAttempts: 8, + totalAttempts: 12, + ), + ], + config: config, + currentAttemptIndex: 25, + ); + }); + + test('resetDeck resets all question states', () { + final reset = DeckService.resetDeck(deck: deck); + + for (final question in reset.questions) { + expect(question.consecutiveCorrect, equals(0)); + expect(question.isKnown, equals(false)); + expect(question.priorityPoints, equals(0)); + expect(question.lastAttemptIndex, equals(-1)); + } + + expect(reset.currentAttemptIndex, equals(0)); + }); + + test('resetDeck preserves attempt counts by default', () { + final reset = DeckService.resetDeck(deck: deck); + + expect(reset.questions[0].totalAttempts, equals(20)); + expect(reset.questions[0].totalCorrectAttempts, equals(15)); + expect(reset.questions[1].totalAttempts, equals(12)); + expect(reset.questions[1].totalCorrectAttempts, equals(8)); + }); + + test('resetDeck resets attempt counts when requested', () { + final reset = DeckService.resetDeck( + deck: deck, + resetAttemptCounts: true, + ); + + for (final question in reset.questions) { + expect(question.totalAttempts, equals(0)); + expect(question.totalCorrectAttempts, equals(0)); + } + }); + + test('resetDeck resets currentAttemptIndex to 0', () { + final reset = DeckService.resetDeck(deck: deck); + + expect(reset.currentAttemptIndex, equals(0)); + }); + + test('resetDeck preserves deck metadata', () { + final reset = DeckService.resetDeck(deck: deck); + + expect(reset.id, equals(deck.id)); + expect(reset.title, equals(deck.title)); + expect(reset.description, equals(deck.description)); + expect(reset.config, equals(deck.config)); + }); + }); +} + diff --git a/packages/practice_engine/test/spaced_repetition_test.dart b/packages/practice_engine/test/spaced_repetition_test.dart new file mode 100644 index 0000000..c294ff7 --- /dev/null +++ b/packages/practice_engine/test/spaced_repetition_test.dart @@ -0,0 +1,101 @@ +import 'package:test/test.dart'; +import 'package:practice_engine/models/question.dart'; +import 'package:practice_engine/algorithms/spaced_repetition.dart'; + +void main() { + group('SpacedRepetition', () { + test('baseKnownProbability for never-seen known question', () { + final weight = SpacedRepetition.calculateKnownQuestionWeight( + lastAttemptIndex: -1, + currentAttemptIndex: 0, + ); + + expect(weight, equals(SpacedRepetition.baseKnownProbability)); + }); + + test('probability increases with attempts since last seen', () { + final weight1 = SpacedRepetition.calculateKnownQuestionWeight( + lastAttemptIndex: 0, + currentAttemptIndex: 10, + ); + + final weight2 = SpacedRepetition.calculateKnownQuestionWeight( + lastAttemptIndex: 0, + currentAttemptIndex: 30, + ); + + expect(weight2, greaterThan(weight1)); + }); + + test('probability is capped at maxKnownProbability', () { + final weight = SpacedRepetition.calculateKnownQuestionWeight( + lastAttemptIndex: 0, + currentAttemptIndex: 1000, + ); + + expect(weight, lessThanOrEqualTo(SpacedRepetition.maxKnownProbability)); + }); + + test('probability increases gradually', () { + final weights = []; + for (int i = 0; i <= 30; i++) { + final weight = SpacedRepetition.calculateKnownQuestionWeight( + lastAttemptIndex: 0, + currentAttemptIndex: i, + ); + weights.add(weight); + } + + // Should be monotonically increasing + for (int i = 1; i < weights.length; i++) { + expect(weights[i], greaterThanOrEqualTo(weights[i - 1])); + } + }); + + test('getQuestionWeight uses priority for unknown questions', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + isKnown: false, + priorityPoints: 10, + ); + + final weight = SpacedRepetition.getQuestionWeight( + question: question, + currentAttemptIndex: 0, + basePriorityWeight: 1.0, + ); + + // Should be priority + 1 + expect(weight, equals(11.0)); + }); + + test('getQuestionWeight uses spaced repetition for known questions', () { + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + isKnown: true, + lastAttemptIndex: 5, + priorityPoints: 0, + ); + + final weight = SpacedRepetition.getQuestionWeight( + question: question, + currentAttemptIndex: 10, + basePriorityWeight: 1.0, + ); + + // Should use spaced repetition probability + final expected = SpacedRepetition.calculateKnownQuestionWeight( + lastAttemptIndex: 5, + currentAttemptIndex: 10, + ); + expect(weight, closeTo(expected, 0.001)); + }); + }); +} + diff --git a/packages/practice_engine/test/weighted_selector_test.dart b/packages/practice_engine/test/weighted_selector_test.dart new file mode 100644 index 0000000..76b7c9c --- /dev/null +++ b/packages/practice_engine/test/weighted_selector_test.dart @@ -0,0 +1,138 @@ +import 'package:test/test.dart'; +import 'package:practice_engine/models/question.dart'; +import 'package:practice_engine/algorithms/weighted_selector.dart'; + +void main() { + group('WeightedSelector', () { + test('selects correct number of questions', () { + final selector = WeightedSelector(seed: 42); + final questions = List.generate(10, (i) { + return Question( + id: 'q$i', + prompt: 'Question $i', + answers: ['A', 'B'], + correctAnswerIndices: [0], + priorityPoints: i, + ); + }); + + final selected = selector.selectQuestions( + candidates: questions, + count: 5, + currentAttemptIndex: 0, + ); + + expect(selected.length, equals(5)); + }); + + test('returns all questions if count >= candidates length', () { + final selector = WeightedSelector(seed: 42); + final questions = List.generate(5, (i) { + return Question( + id: 'q$i', + prompt: 'Question $i', + answers: ['A', 'B'], + correctAnswerIndices: [0], + ); + }); + + final selected = selector.selectQuestions( + candidates: questions, + count: 10, + currentAttemptIndex: 0, + ); + + expect(selected.length, equals(5)); + }); + + test('returns empty list for empty candidates', () { + final selector = WeightedSelector(seed: 42); + final selected = selector.selectQuestions( + candidates: [], + count: 5, + currentAttemptIndex: 0, + ); + + expect(selected, isEmpty); + }); + + test('returns empty list for count <= 0', () { + final selector = WeightedSelector(seed: 42); + final questions = List.generate(5, (i) { + return Question( + id: 'q$i', + prompt: 'Question $i', + answers: ['A', 'B'], + correctAnswerIndices: [0], + ); + }); + + final selected = selector.selectQuestions( + candidates: questions, + count: 0, + currentAttemptIndex: 0, + ); + + expect(selected, isEmpty); + }); + + test('no duplicates in selection', () { + final selector = WeightedSelector(seed: 42); + final questions = List.generate(10, (i) { + return Question( + id: 'q$i', + prompt: 'Question $i', + answers: ['A', 'B'], + correctAnswerIndices: [0], + priorityPoints: 5, + ); + }); + + final selected = selector.selectQuestions( + candidates: questions, + count: 5, + currentAttemptIndex: 0, + ); + + final ids = selected.map((q) => q.id).toSet(); + expect(ids.length, equals(selected.length)); + }); + + test('higher priority questions are more likely to be selected', () { + final selector = WeightedSelector(seed: 42); + final questions = [ + Question( + id: 'low', + prompt: 'Low priority', + answers: ['A', 'B'], + correctAnswerIndices: [0], + priorityPoints: 1, + ), + Question( + id: 'high', + prompt: 'High priority', + answers: ['A', 'B'], + correctAnswerIndices: [0], + priorityPoints: 100, + ), + ]; + + // Run multiple selections and count occurrences + int highSelected = 0; + for (int i = 0; i < 100; i++) { + final selected = selector.selectQuestions( + candidates: questions, + count: 1, + currentAttemptIndex: 0, + ); + if (selected.first.id == 'high') { + highSelected++; + } + } + + // High priority should be selected more often + expect(highSelected, greaterThan(50)); + }); + }); +} +