aboutsummaryrefslogtreecommitdiff
path: root/tests/test-org-drill-item-data-roundtrip.el
blob: dc445a17d98a9ea89fba06f0da66abc1b87feec6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
;;; test-org-drill-item-data-roundtrip.el --- Tests for item-data save/load  -*- lexical-binding: t; -*-

;;; Commentary:
;; Tests for the core persistence pair `org-drill-get-item-data' and
;; `org-drill-store-item-data'.  These two functions are the boundary
;; between in-memory drill state and the on-disk org buffer — every rating
;; passes through them, so the user-facing contract is:
;;
;;   "When I rate a card, my progress is saved.  When I open the file
;;    tomorrow, my progress is still there."
;;
;; get-item-data returns an `org-drill-card-state' struct; store-item-data
;; takes one.  The tests below view the struct as a field list (via
;; `test-roundtrip--state-as-list') so the expected values read at a glance.
;;
;; The contract has three branches:
;;
;; 1. *Modern format*: store writes six DRILL_* properties; get reads
;;    them back.  The default path.
;;
;; 2. *Legacy LEARN_DATA*: older org-drill files stored interval,
;;    repeats, and ease as a single LEARN_DATA s-expression.  get
;;    transparently reads either format.  Backward compat — a 2015-era
;;    deck should still drill.
;;
;; 3. *Virgin item*: a card that's never been rated returns a state of
;;    `(0 0 0 0 nil nil)' so the scheduler knows to treat it as new.

;;; Code:

(require 'ert)
(require 'org)
(require 'org-drill)

;;;; Helpers

(defun test-roundtrip--state-as-list (state)
  "Return STATE's fields as a list, in the canonical
(LAST-INTERVAL REPETITIONS FAILURES TOTAL-REPEATS MEANQ EASE) order, so the
struct can be compared against a literal expected list."
  (list (org-drill-card-state-last-interval state)
        (org-drill-card-state-repetitions state)
        (org-drill-card-state-failures state)
        (org-drill-card-state-total-repeats state)
        (org-drill-card-state-meanq state)
        (org-drill-card-state-ease state)))

(defmacro with-fresh-drill-entry (&rest body)
  "Run BODY at point on a fresh drill entry with no DRILL_* or LEARN_DATA properties."
  (declare (indent 0))
  `(with-temp-buffer
     (let ((org-startup-folded nil))
       (insert "* Question :drill:\n")
       (org-mode)
       (goto-char (point-min))
       ,@body)))

(defmacro with-modern-drill-entry (props &rest body)
  "Run BODY at point on a drill entry with DRILL_* PROPS set.
PROPS is a list of (NAME . STRING-VALUE) cons cells."
  (declare (indent 1))
  `(with-temp-buffer
     (let ((org-startup-folded nil))
       (insert "* Question :drill:\n")
       (org-mode)
       (goto-char (point-min))
       (dolist (p ,props)
         (org-set-property (car p) (cdr p)))
       (goto-char (point-min))
       ,@body)))

;;;; Virgin items

(ert-deftest test-org-drill-get-item-data-virgin-returns-zero-list ()
  "A drill entry with no persisted state returns the virgin sentinel.
Six fields: zero interval, zero repeats, zero failures, zero total,
nil meanq, nil ease.  This is the value the scheduler reads on a
never-rated card."
  (with-fresh-drill-entry
    (should (equal '(0 0 0 0 nil nil)
                   (test-roundtrip--state-as-list (org-drill-get-item-data))))))

;;;; Modern format — read-side

(ert-deftest test-org-drill-get-item-data-modern-reads-all-six-fields ()
  "When all DRILL_* properties are set, get-item-data reads all six."
  (with-modern-drill-entry '(("DRILL_LAST_INTERVAL" . "10")
                             ("DRILL_REPEATS_SINCE_FAIL" . "3")
                             ("DRILL_FAILURE_COUNT" . "1")
                             ("DRILL_TOTAL_REPEATS" . "5")
                             ("DRILL_AVERAGE_QUALITY" . "3.8")
                             ("DRILL_EASE" . "2.4"))
    (should (equal '(10 3 1 5 3.8 2.4)
                   (test-roundtrip--state-as-list (org-drill-get-item-data))))))

(ert-deftest test-org-drill-get-item-data-modern-partial-properties-fall-back-to-defaults ()
  "Missing DRILL_* properties take their per-field defaults — but the
presence of any DRILL_* property still puts the function in modern mode."
  (with-modern-drill-entry '(("DRILL_TOTAL_REPEATS" . "3"))
    (let ((result (test-roundtrip--state-as-list (org-drill-get-item-data))))
      ;; total-repeats → 3 (set), others take their defaults
      (should (equal 0 (nth 0 result)))   ; last-interval default 0
      (should (equal 0 (nth 1 result)))   ; repeats default 0
      (should (equal 0 (nth 2 result)))   ; failures default 0
      (should (equal 3 (nth 3 result)))   ; total-repeats from property
      (should (null (nth 4 result)))      ; meanq default nil
      (should (null (nth 5 result))))))   ; ease default nil

;;;; Modern format — write-side

(ert-deftest test-org-drill-store-item-data-writes-all-six-properties ()
  "store-item-data sets all six DRILL_* properties on the entry at point."
  (with-fresh-drill-entry
    (org-drill-store-item-data (make-org-drill-card-state :last-interval 10 :repetitions 3 :failures 1 :total-repeats 5 :meanq 3.8 :ease 2.4))
    (should (equal "10.0" (org-entry-get (point) "DRILL_LAST_INTERVAL")))
    (should (equal "3" (org-entry-get (point) "DRILL_REPEATS_SINCE_FAIL")))
    (should (equal "1" (org-entry-get (point) "DRILL_FAILURE_COUNT")))
    (should (equal "5" (org-entry-get (point) "DRILL_TOTAL_REPEATS")))
    (should (equal "3.8" (org-entry-get (point) "DRILL_AVERAGE_QUALITY")))
    (should (equal "2.4" (org-entry-get (point) "DRILL_EASE")))))

(ert-deftest test-org-drill-store-item-data-rounds-floats ()
  "Floating-point fields are rounded — interval to 4dp, meanq/ease to 3dp.
Keeps the buffer tidy and avoids stray precision noise like 2.4999999998."
  (with-fresh-drill-entry
    (org-drill-store-item-data (make-org-drill-card-state :last-interval 10.123456789 :repetitions 3 :failures 1 :total-repeats 5 :meanq 3.8765432 :ease 2.4567899))
    (should (equal "10.1235" (org-entry-get (point) "DRILL_LAST_INTERVAL")))
    (should (equal "3.877" (org-entry-get (point) "DRILL_AVERAGE_QUALITY")))
    (should (equal "2.457" (org-entry-get (point) "DRILL_EASE")))))

(ert-deftest test-org-drill-store-item-data-deletes-legacy-LEARN_DATA ()
  "Storing modern format wipes any legacy LEARN_DATA on the entry.
Otherwise get-item-data would still read the stale legacy value first."
  (with-modern-drill-entry '(("LEARN_DATA" . "(2.5 1 0.5)"))
    (org-drill-store-item-data (make-org-drill-card-state :last-interval 10 :repetitions 3 :failures 1 :total-repeats 5 :meanq 3.8 :ease 2.4))
    (should (null (org-entry-get (point) "LEARN_DATA")))))

;;;; Round-trip — the core user-facing assertion

(ert-deftest test-org-drill-item-data-roundtrip-preserves-values ()
  "Storing then reading returns equivalent values — the save/load cycle is lossless.
This is the assertion users actually care about: rate a card today, see
the same state tomorrow.

Note on types: rounded fields (LAST-INTERVAL, MEANQ, EASE) come back as
floats because `org-drill-round-float' returns float; counters (REPEATS,
FAILURES, TOTAL-REPEATS) come back as ints.  Numerically the round-trip
is lossless; the scheduler accepts both."
  (with-fresh-drill-entry
    (org-drill-store-item-data (make-org-drill-card-state :last-interval 10 :repetitions 3 :failures 1 :total-repeats 5 :meanq 3.8 :ease 2.4))
    (should (equal '(10.0 3 1 5 3.8 2.4)
                   (test-roundtrip--state-as-list (org-drill-get-item-data))))))

(ert-deftest test-org-drill-item-data-roundtrip-preserves-zero-values ()
  "A first-rating round-trip with mostly zeros survives intact.
Same type-mixing pattern as the non-zero round-trip — see that test's
note for why LAST-INTERVAL is 0.0 and the counters are integer 0."
  (with-fresh-drill-entry
    (org-drill-store-item-data (make-org-drill-card-state :last-interval 0 :repetitions 0 :failures 0 :total-repeats 1 :meanq 5.0 :ease 2.5))
    (should (equal '(0.0 0 0 1 5.0 2.5)
                   (test-roundtrip--state-as-list (org-drill-get-item-data))))))

;;;; Legacy LEARN_DATA — backward compat

(ert-deftest test-org-drill-get-item-data-legacy-LEARN_DATA-takes-precedence ()
  "When LEARN_DATA exists, it wins over the modern fields.
Layout: (LAST-INTERVAL REPEATS EASE).  failures and last-quality come
from their separate DRILL_* properties for the legacy path."
  (with-modern-drill-entry '(("LEARN_DATA" . "(7 2 2.6)")
                             ("DRILL_FAILURE_COUNT" . "0")
                             ("DRILL_LAST_QUALITY" . "4"))
    ;; Returned: (interval, repeats, failures, repeats-again, last-quality, ease)
    (should (equal '(7 2 0 2 4 2.6)
                   (test-roundtrip--state-as-list (org-drill-get-item-data))))))

(ert-deftest test-org-drill-get-item-data-invalid-LEARN_DATA-falls-through-to-modern ()
  "If LEARN_DATA is malformed, fall through to the modern DRILL_* fields.
Defends against a corrupted legacy entry from breaking a session."
  (with-modern-drill-entry '(("LEARN_DATA" . "((((not-a-list")
                             ("DRILL_LAST_INTERVAL" . "12")
                             ("DRILL_REPEATS_SINCE_FAIL" . "4")
                             ("DRILL_FAILURE_COUNT" . "2")
                             ("DRILL_TOTAL_REPEATS" . "6")
                             ("DRILL_AVERAGE_QUALITY" . "3.5")
                             ("DRILL_EASE" . "2.1"))
    (should (equal '(12 4 2 6 3.5 2.1)
                   (test-roundtrip--state-as-list (org-drill-get-item-data))))))

(ert-deftest test-org-drill-get-item-data-invalid-LEARN_DATA-and-no-modern-returns-virgin ()
  "Malformed LEARN_DATA on an entry with no DRILL_* fallback returns virgin sentinel.
This matters: a corrupted-only legacy entry shouldn't crash the session,
just be treated as never rated."
  (with-modern-drill-entry '(("LEARN_DATA" . "garbage"))
    (should (equal '(0 0 0 0 nil nil)
                   (test-roundtrip--state-as-list (org-drill-get-item-data))))))

(provide 'test-org-drill-item-data-roundtrip)

;;; test-org-drill-item-data-roundtrip.el ends here