From 0f7d50cadea3b536a9d4cf0fc54f69996ba324b7 Mon Sep 17 00:00:00 2001 From: czh <2565523901@qq.com> Date: Mon, 9 Mar 2026 19:08:11 +0800 Subject: [PATCH] test: extend test coverage with comprehensive test suite Add extensive tests covering: - All rating transitions (:again, :hard, :good, :easy) - State transitions (learning -> review, review -> relearning) - Scheduler option validation - Card creation and defaults - Round-trip serialization for all structs - Retrievability calculation behavior - Reschedule card functionality - Enum helper functions - Performance benchmarks Increases test coverage from basic single test to 20+ test cases ensuring robustness of the FSRS implementation. --- test/lulucat/fsrs_test.exs | 286 +++++++++++++++++++++++++++++++++++-- 1 file changed, 278 insertions(+), 8 deletions(-) diff --git a/test/lulucat/fsrs_test.exs b/test/lulucat/fsrs_test.exs index b589527..ecad9b0 100644 --- a/test/lulucat/fsrs_test.exs +++ b/test/lulucat/fsrs_test.exs @@ -2,15 +2,285 @@ defmodule FsrsTest do use ExUnit.Case doctest Fsrs - test "review_card/3 returns updated card and review log" do - scheduler = Fsrs.new_scheduler(enable_fuzzing: false) - card = Fsrs.new_card() + describe "review_card/3" do + test "returns updated card and review log" do + scheduler = Fsrs.new_scheduler(enable_fuzzing: false) + card = Fsrs.new_card() - {updated_card, review_log} = Fsrs.review_card(scheduler, card, :good) + {updated_card, review_log} = Fsrs.review_card(scheduler, card, :good) - assert updated_card.last_review != nil - assert DateTime.compare(updated_card.due, updated_card.last_review) == :gt - assert review_log.card_id == card.card_id - assert review_log.rating == :good + assert updated_card.last_review != nil + assert DateTime.compare(updated_card.due, updated_card.last_review) == :gt + assert review_log.card_id == card.card_id + assert review_log.rating == :good + end + + test "all rating transitions work correctly" do + scheduler = Fsrs.new_scheduler(enable_fuzzing: false) + + for rating <- Fsrs.Rating.all() do + card = Fsrs.new_card() + {updated_card, review_log} = Fsrs.review_card(scheduler, card, rating) + + assert updated_card.last_review != nil + assert review_log.rating == rating + assert is_number(updated_card.stability) + assert is_number(updated_card.difficulty) + end + end + + test "learning to review state transition" do + scheduler = Fsrs.new_scheduler(enable_fuzzing: false) + card = Fsrs.new_card() + + # First review with :good should graduate card to review state + {card, _} = Fsrs.review_card(scheduler, card, :good) + assert card.state == :review + assert card.step == nil + end + + test "again rating moves card to relearning" do + scheduler = Fsrs.new_scheduler( + enable_fuzzing: false, + relearning_steps: [600] + ) + + card = Fsrs.new_card( + state: :review, + stability: 5.0, + difficulty: 5.0, + last_review: DateTime.add(DateTime.utc_now(), -1, :day) + ) + + {card, _} = Fsrs.review_card(scheduler, card, :again) + assert card.state == :relearning + assert card.step == 0 + end + end + + describe "scheduler options" do + test "custom desired_retention" do + scheduler = Fsrs.new_scheduler(desired_retention: 0.85, enable_fuzzing: false) + assert scheduler.desired_retention == 0.85 + end + + test "custom learning steps" do + scheduler = Fsrs.new_scheduler( + learning_steps: [{:minutes, 1}, {:seconds, 300}], + enable_fuzzing: false + ) + assert scheduler.learning_steps == [60, 300] + end + + test "custom maximum_interval" do + scheduler = Fsrs.new_scheduler(maximum_interval: 1000, enable_fuzzing: false) + assert scheduler.maximum_interval == 1000 + end + + test "invalid desired_retention raises error" do + assert_raise ArgumentError, fn -> + Fsrs.new_scheduler(desired_retention: 1.5) + end + + assert_raise ArgumentError, fn -> + Fsrs.new_scheduler(desired_retention: -0.1) + end + end + + test "invalid maximum_interval raises error" do + assert_raise ArgumentError, fn -> + Fsrs.new_scheduler(maximum_interval: 0) + end + + assert_raise ArgumentError, fn -> + Fsrs.new_scheduler(maximum_interval: -100) + end + end + end + + describe "card creation" do + test "new card defaults" do + card = Fsrs.new_card() + assert card.state == :learning + assert card.step == 0 + assert card.stability == nil + assert card.difficulty == nil + end + + test "new card with custom values" do + card = Fsrs.new_card( + card_id: 12345, + state: :review, + stability: 10.0, + difficulty: 7.5 + ) + assert card.card_id == 12345 + assert card.state == :review + assert card.stability == 10.0 + assert card.difficulty == 7.5 + end + + test "card IDs are unique" do + cards = for _ <- 1..100, do: Fsrs.new_card() + ids = Enum.map(cards, &&1.card_id) + assert length(Enum.uniq(ids)) == 100 + end + end + + describe "serialization" do + test "card round-trip serialization" do + card = Fsrs.new_card( + card_id: 12345, + state: :review, + stability: 10.0, + difficulty: 7.5 + ) + + json = Fsrs.Card.to_json(card) + restored = Fsrs.Card.from_json(json) + + assert restored.card_id == card.card_id + assert restored.state == card.state + assert restored.stability == card.stability + assert restored.difficulty == card.difficulty + end + + test "scheduler round-trip serialization" do + scheduler = Fsrs.new_scheduler( + desired_retention: 0.85, + enable_fuzzing: false, + maximum_interval: 5000 + ) + + json = Fsrs.Scheduler.to_json(scheduler) + restored = Fsrs.Scheduler.from_json(json) + + assert restored.desired_retention == scheduler.desired_retention + assert restored.enable_fuzzing == scheduler.enable_fuzzing + assert restored.maximum_interval == scheduler.maximum_interval + end + + test "review log round-trip serialization" do + log = Fsrs.new_review_log( + card_id: 123, + rating: :good, + review_datetime: ~U[2024-01-01 12:00:00Z], + review_duration: 5000 + ) + + json = Fsrs.ReviewLog.to_json(log) + restored = Fsrs.ReviewLog.from_json(json) + + assert restored.card_id == log.card_id + assert restored.rating == log.rating + assert restored.review_duration == log.review_duration + end + end + + describe "retrievability calculation" do + test "retrievability decreases over time" do + scheduler = Fsrs.new_scheduler(enable_fuzzing: false) + + card = Fsrs.new_card( + state: :review, + stability: 5.0, + last_review: ~U[2024-06-01 00:00:00Z] + ) + + r1 = Fsrs.get_card_retrievability(scheduler, card, ~U[2024-06-01 12:00:00Z]) + r2 = Fsrs.get_card_retrievability(scheduler, card, ~U[2024-06-02 00:00:00Z]) + r3 = Fsrs.get_card_retrievability(scheduler, card, ~U[2024-06-03 00:00:00Z]) + + assert r1 > r2 + assert r2 > r3 + assert r1 <= 1.0 + assert r3 >= 0.0 + end + + test "retrievability is 0.0 for card without last_review" do + scheduler = Fsrs.new_scheduler(enable_fuzzing: false) + card = Fsrs.new_card() + + assert Fsrs.get_card_retrievability(scheduler, card) == 0.0 + end + end + + describe "reschedule_card/3" do + test "replaying logs restores correct card state" do + scheduler = Fsrs.new_scheduler(enable_fuzzing: false) + + now = DateTime.utc_now() + logs = [ + Fsrs.new_review_log(card_id: 1, rating: :good, review_datetime: DateTime.add(now, -2, :day)), + Fsrs.new_review_log(card_id: 1, rating: :good, review_datetime: DateTime.add(now, -1, :day)), + Fsrs.new_review_log(card_id: 1, rating: :hard, review_datetime: now) + ] + + card = Fsrs.new_card(card_id: 1) + result = Fsrs.reschedule_card(scheduler, card, logs) + + assert result.state == :review + assert is_number(result.stability) + assert is_number(result.difficulty) + end + + test "reschedule_card validates card_id consistency" do + scheduler = Fsrs.new_scheduler(enable_fuzzing: false) + + logs = [ + Fsrs.new_review_log(card_id: 2, rating: :good, review_datetime: DateTime.utc_now()) + ] + + card = Fsrs.new_card(card_id: 1) + + assert_raise ArgumentError, fn -> + Fsrs.reschedule_card(scheduler, card, logs) + end + end + end + + describe "enum helpers" do + test "Rating conversions" do + assert Fsrs.Rating.to_int(:again) == 1 + assert Fsrs.Rating.to_int(:easy) == 4 + assert Fsrs.Rating.from_int!(1) == :again + assert Fsrs.Rating.from_int!(4) == :easy + + assert {:ok, :good} == Fsrs.Rating.from_int(3) + assert :error == Fsrs.Rating.from_int(5) + + assert Fsrs.Rating.valid?(:good) + assert not Fsrs.Rating.valid?(5) + end + + test "State conversions" do + assert Fsrs.State.to_int(:learning) == 1 + assert Fsrs.State.to_int(:relearning) == 3 + assert Fsrs.State.from_int!(1) == :learning + assert Fsrs.State.from_int!(3) == :relearning + + assert {:ok, :review} == Fsrs.State.from_int(2) + assert :error == Fsrs.State.from_int(0) + + assert Fsrs.State.valid?(:review) + assert not Fsrs.State.valid?(:invalid) + end + end + + describe "performance" do + @tag timeout: 5000 + test "handles batch card creation efficiently" do + scheduler = Fsrs.new_scheduler(enable_fuzzing: false) + + cards = for _ <- 1..1000, do: Fsrs.new_card() + + results = Enum.map(cards, fn card -> + {updated_card, _} = Fsrs.review_card(scheduler, card, :good) + updated_card + end) + + assert length(results) == 1000 + assert Enum.all?(results, &&(&1.state == :review)) + end end end