aboutsummaryrefslogtreecommitdiff
path: root/tests/test-org-drill-session-record.el
blob: d5c32adeff73b6f401322a02e9311900255a0d80 (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
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
;;; test-org-drill-session-record.el --- Tests for the stats session log  -*- lexical-binding: t; -*-

;;; Commentary:
;; Tests for the persist + recording layer that powers the stats
;; dashboard (see docs/design/stats-dashboard.org).
;;
;; The contract:
;;
;; - Every completed (non-suspended) drill session contributes one
;;   `org-drill-session-record' to `org-drill-session-log'.
;; - The log persists via `persist-defvar' between Emacs runs.
;; - A corrupt persist file is renamed to a dated `.corrupt-...'
;;   sibling and the log starts fresh, matching the SM5-matrix
;;   recovery pattern in `test-org-drill-persist-recovery.el'.
;; - Suspended sessions (end-pos set) do NOT record — the abort path
;;   discards, mirroring the `org-drill-on-timeout-action'
;;   `discard-current' semantics.

;;; Code:

(require 'ert)
(require 'cl-lib)
(require 'persist)
(require 'org-drill)

;;;; Helpers

(defun test-session-record--marker-at (pos)
  "Return a marker pointing at POS (an integer)."
  (let ((m (make-marker)))
    (set-marker m pos)
    m))

(defmacro test-session-record--with-empty-log (&rest body)
  "Run BODY with a fresh, empty `org-drill-session-log' and a stub
`persist-save' so tests never touch the real persist file."
  (declare (indent 0))
  `(let ((org-drill-session-log nil))
     (cl-letf (((symbol-function 'persist-save) #'ignore))
       ,@body)))

(cl-defun test-session-record--populated-session (&key qualities new mature failed
                                                       cram-mode
                                                       (scope-at-start 'file)
                                                       (algorithm-at-start 'simple8))
  "Return an `org-drill-session' with QUALITIES (a list of 0-5 integers)
and the requested entry counts.  NEW / MATURE / FAILED are integers.
SCOPE-AT-START and ALGORITHM-AT-START populate the session slots that
`org-drill--prepare-fresh-session' would set in production; pass nil to
exercise the not-prepared path."
  (let ((session (org-drill-session)))
    (oset session qualities qualities)
    (oset session new-entries
          (cl-loop for i from 1 to (or new 0)
                   collect (test-session-record--marker-at i)))
    ;; Mature = young + old; we put all on `old-mature-entries' for
    ;; the count assertion — sum is what the record stores.
    (oset session old-mature-entries
          (cl-loop for i from 1 to (or mature 0)
                   collect (test-session-record--marker-at i)))
    (oset session failed-entries
          (cl-loop for i from 1 to (or failed 0)
                   collect (test-session-record--marker-at i)))
    (oset session cram-mode cram-mode)
    (oset session scope-at-start scope-at-start)
    (oset session algorithm-at-start algorithm-at-start)
    session))

;;;; A. Struct construction (Normal)

(ert-deftest test-session-record-struct-construction-all-slots ()
  "`make-org-drill-session-record' accepts every documented slot and
the accessors read them back unchanged."
  (let ((rec (make-org-drill-session-record
              :start-time 1700000000.0
              :end-time   1700001800.0
              :scope 'file
              :algorithm 'simple8
              :qualities [5 4 3 2 1 0]
              :pass-percent 50
              :new-count 4
              :mature-count 10
              :failed-count 2
              :cram-mode nil)))
    (should (= 1700000000.0 (org-drill-session-record-start-time rec)))
    (should (= 1700001800.0 (org-drill-session-record-end-time rec)))
    (should (eq 'file (org-drill-session-record-scope rec)))
    (should (eq 'simple8 (org-drill-session-record-algorithm rec)))
    (should (equal [5 4 3 2 1 0] (org-drill-session-record-qualities rec)))
    (should (= 50 (org-drill-session-record-pass-percent rec)))
    (should (= 4 (org-drill-session-record-new-count rec)))
    (should (= 10 (org-drill-session-record-mature-count rec)))
    (should (= 2 (org-drill-session-record-failed-count rec)))
    (should-not (org-drill-session-record-cram-mode rec))))

;;;; B. Pass-percent (Normal / Boundary / Error)

(ert-deftest test-compute-pass-percent-shared-helper-handles-empty ()
  "`org-drill--compute-pass-percent' is the single source of truth shared
by `org-drill-final-report' and the dashboard record.  Empty qualities
must yield 0 (no div-by-zero).  Pinned here so future drift between
the two consumers gets caught."
  (let ((org-drill-failure-quality 2))
    (should (= 0 (org-drill--compute-pass-percent nil)))
    (should (= 0 (org-drill--compute-pass-percent '())))))

(ert-deftest test-compute-pass-percent-shared-helper-rounds-mixed ()
  "Helper rounds (count above failure-quality) / total * 100."
  (let ((org-drill-failure-quality 2))
    (should (= 60 (org-drill--compute-pass-percent '(5 4 3 2 1))))
    (should (= 100 (org-drill--compute-pass-percent '(5 5 5))))
    (should (= 0 (org-drill--compute-pass-percent '(0 1 2))))))



(ert-deftest test-session-record-pass-percent-mixed-qualities ()
  "Pass percent rounds (count of qualities > failure-quality) / total.

With the default failure-quality of 2 and qualities (5 4 3 2 1), 3 of
5 are above threshold => 60%."
  (test-session-record--with-empty-log
    (let* ((org-drill-failure-quality 2)
           (session (test-session-record--populated-session
                     :qualities '(5 4 3 2 1)))
           (rec (org-drill-session-record-from-session
                 session 0.0 1.0)))
      (should (= 60 (org-drill-session-record-pass-percent rec))))))

(ert-deftest test-session-record-pass-percent-all-pass ()
  "Every quality above failure-quality => 100%."
  (test-session-record--with-empty-log
    (let* ((org-drill-failure-quality 2)
           (session (test-session-record--populated-session
                     :qualities '(5 5 5 4 3)))
           (rec (org-drill-session-record-from-session
                 session 0.0 1.0)))
      (should (= 100 (org-drill-session-record-pass-percent rec))))))

(ert-deftest test-session-record-pass-percent-all-fail ()
  "Every quality at-or-below failure-quality => 0%."
  (test-session-record--with-empty-log
    (let* ((org-drill-failure-quality 2)
           (session (test-session-record--populated-session
                     :qualities '(2 1 0 2 1)))
           (rec (org-drill-session-record-from-session
                 session 0.0 1.0)))
      (should (= 0 (org-drill-session-record-pass-percent rec))))))

(ert-deftest test-session-record-pass-percent-empty-qualities-is-zero ()
  "An empty qualities list must not divide by zero — returns 0%."
  (test-session-record--with-empty-log
    (let* ((session (test-session-record--populated-session :qualities nil))
           (rec (org-drill-session-record-from-session
                 session 0.0 1.0)))
      (should (= 0 (org-drill-session-record-pass-percent rec))))))

;;;; C. Builder from session (Normal / Boundary)

(ert-deftest test-session-record-from-session-copies-counts ()
  "The builder reads new / mature / failed counts directly off the session."
  (test-session-record--with-empty-log
    (let* ((session (test-session-record--populated-session
                     :qualities '(4 4 4)
                     :new 7
                     :mature 12
                     :failed 1))
           (rec (org-drill-session-record-from-session
                 session 100.0 200.0)))
      (should (= 7 (org-drill-session-record-new-count rec)))
      (should (= 12 (org-drill-session-record-mature-count rec)))
      (should (= 1 (org-drill-session-record-failed-count rec))))))

(ert-deftest test-session-record-mature-count-sums-young-and-old ()
  "Mature count = young-mature-entries + old-mature-entries."
  (test-session-record--with-empty-log
    (let ((session (org-drill-session)))
      (oset session qualities '(3))
      (oset session young-mature-entries
            (list (test-session-record--marker-at 1)
                  (test-session-record--marker-at 2)))
      (oset session old-mature-entries
            (list (test-session-record--marker-at 3)
                  (test-session-record--marker-at 4)
                  (test-session-record--marker-at 5)))
      (let ((rec (org-drill-session-record-from-session session 0.0 1.0)))
        (should (= 5 (org-drill-session-record-mature-count rec)))))))

(ert-deftest test-session-record-records-cram-mode-flag ()
  "The session's cram-mode value lands on the record."
  (test-session-record--with-empty-log
    (let* ((session (test-session-record--populated-session
                     :qualities '(4) :cram-mode t))
           (rec (org-drill-session-record-from-session session 0.0 1.0)))
      (should (org-drill-session-record-cram-mode rec)))))

(ert-deftest test-session-record-stores-algorithm-captured-at-start ()
  "The record carries the algorithm captured at session start
(via the session's `algorithm-at-start' slot), not the global at
record-build time.  This protects against a mid-session defcustom flip
misrepresenting what was actually drilled."
  (test-session-record--with-empty-log
    (let* ((org-drill-spaced-repetition-algorithm 'simple8)  ; mid-session value
           (session (test-session-record--populated-session
                     :qualities '(4)
                     :algorithm-at-start 'sm5))               ; captured at start
           (rec (org-drill-session-record-from-session session 0.0 1.0)))
      (should (eq 'sm5 (org-drill-session-record-algorithm rec))))))

(ert-deftest test-session-record-stores-scope-captured-at-start ()
  "The record carries the scope captured at session start, not the
global at record-build time.  Same rationale as the algorithm capture."
  (test-session-record--with-empty-log
    (let* ((org-drill-scope 'directory)                       ; mid-session value
           (session (test-session-record--populated-session
                     :qualities '(3)
                     :scope-at-start 'file))                  ; captured at start
           (rec (org-drill-session-record-from-session session 0.0 1.0)))
      (should (eq 'file (org-drill-session-record-scope rec))))))

(ert-deftest test-session-record-stores-timestamps ()
  "Start and end timestamps land on the record verbatim."
  (test-session-record--with-empty-log
    (let* ((session (test-session-record--populated-session :qualities '(3)))
           (rec (org-drill-session-record-from-session
                 session 1234567890.0 1234571490.0)))
      (should (= 1234567890.0 (org-drill-session-record-start-time rec)))
      (should (= 1234571490.0 (org-drill-session-record-end-time rec))))))

(ert-deftest test-session-record-qualities-stored-as-vector ()
  "Qualities are stored as a vector (the spec's chosen shape), not a list."
  (test-session-record--with-empty-log
    (let* ((session (test-session-record--populated-session
                     :qualities '(5 4 3 2 1)))
           (rec (org-drill-session-record-from-session session 0.0 1.0)))
      (should (vectorp (org-drill-session-record-qualities rec)))
      (should (equal [5 4 3 2 1]
                     (org-drill-session-record-qualities rec))))))

;;;; D. Log append (Normal / Boundary)

(ert-deftest test-record-session-prepends-newest-first ()
  "`org-drill-record-session' adds the new record at the head of the log."
  (test-session-record--with-empty-log
    (let ((session (test-session-record--populated-session :qualities '(4))))
      (org-drill-record-session session 0.0 1.0)
      (org-drill-record-session session 10.0 11.0)
      (should (= 2 (length org-drill-session-log)))
      ;; Most-recent at head.
      (should (= 10.0 (org-drill-session-record-start-time
                       (car org-drill-session-log)))))))

(ert-deftest test-record-session-handles-empty-log ()
  "First record on an empty log produces a single-element list."
  (test-session-record--with-empty-log
    (let ((session (test-session-record--populated-session :qualities '(4))))
      (org-drill-record-session session 0.0 1.0)
      (should (= 1 (length org-drill-session-log)))
      (should (org-drill-session-record-p (car org-drill-session-log))))))

(ert-deftest test-record-session-calls-persist-save ()
  "`org-drill-record-session' calls `persist-save' on the log symbol."
  (let ((org-drill-session-log nil)
        (saved nil))
    (cl-letf (((symbol-function 'persist-save)
               (lambda (sym) (setq saved sym))))
      (let ((session (test-session-record--populated-session :qualities '(4))))
        (org-drill-record-session session 0.0 1.0))
      (should (eq 'org-drill-session-log saved)))))

;;;; E. Persist round-trip smoke check

(ert-deftest test-session-log-symbol-is-bound ()
  "After org-drill loads, `org-drill-session-log' is bound (either to the
loaded value or, on persist failure, to the fallback nil — same recovery
pattern as `org-drill-sm5-optimal-factor-matrix')."
  (should (boundp 'org-drill-session-log)))

;;;; F. Corrupt-load recovery (Error)

(ert-deftest test-session-log-quarantine-renames-corrupt-file ()
  "`org-drill--session-log-quarantine' renames the live persist file to a
timestamped `.corrupt-...' sibling so the next save doesn't overwrite it.
The suffix uses seconds granularity (YYYY-MM-DDTHHMMSS) so a same-day
re-quarantine doesn't clobber the earlier one."
  (let* ((tmp (make-temp-file "org-drill-session-log-test"))
         (renamed-to nil))
    (unwind-protect
        (progn
          (with-temp-file tmp (insert "garbage"))
          (cl-letf (((symbol-function 'persist--file-location)
                     (lambda (_sym) tmp))
                    ;; Capture the destination path so the cleanup branch
                    ;; can remove it regardless of the exact timestamp.
                    ((symbol-function 'rename-file)
                     (lambda (from to &rest _)
                       (setq renamed-to to)
                       ;; Honor the call so the side effect actually happens.
                       (copy-file from to t)
                       (delete-file from))))
            (org-drill--session-log-quarantine))
          (should-not (file-exists-p tmp))
          (should renamed-to)
          (should (file-exists-p renamed-to))
          ;; Match the seconds-granularity suffix shape.
          (should (string-match-p
                   "\\.corrupt-[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}T[0-9]\\{6\\}\\'"
                   renamed-to)))
      (ignore-errors (delete-file tmp))
      (when renamed-to (ignore-errors (delete-file renamed-to))))))

(ert-deftest test-session-log-quarantine-uses-seconds-in-suffix ()
  "The quarantine uses `format-time-string' with a seconds-granularity
format, so a same-day second corruption gets a distinct suffix.  This
test pins the format string itself so a regression to date-only would
fail loudly."
  (let* ((tmp (make-temp-file "org-drill-session-log-test"))
         (format-arg nil)
         (renamed-to nil))
    (unwind-protect
        (cl-letf (((symbol-function 'persist--file-location)
                   (lambda (_sym) tmp))
                  ((symbol-function 'format-time-string)
                   (lambda (fmt &rest _)
                     (setq format-arg fmt)
                     "STAMP"))
                  ((symbol-function 'rename-file)
                   (lambda (_from to &rest _)
                     (setq renamed-to to))))
          (with-temp-file tmp (insert "garbage"))
          (org-drill--session-log-quarantine)
          (should format-arg)
          ;; Format string must include hours/minutes/seconds, not just date.
          (should (string-match-p "%H" format-arg))
          (should (string-match-p "%M" format-arg))
          (should (string-match-p "%S" format-arg))
          (should (string-suffix-p ".corrupt-STAMP" renamed-to)))
      (ignore-errors (delete-file tmp)))))

(ert-deftest test-session-log-quarantine-no-file-is-noop ()
  "Quarantine on a missing persist file is a quiet no-op (no error)."
  (let ((tmp (concat (make-temp-name "org-drill-no-such-file-") ".never")))
    (should-not (file-exists-p tmp))
    (cl-letf (((symbol-function 'persist--file-location)
               (lambda (_sym) tmp)))
      ;; Must not raise.
      (org-drill--session-log-quarantine))))

;;;; G. Hook integration

(ert-deftest test-show-end-message-records-on-normal-completion ()
  "A non-suspended session triggers `org-drill-record-session'."
  (test-session-record--with-empty-log
    (let* ((session (test-session-record--populated-session :qualities '(4 3 5)))
           (recorded nil))
      (oset session end-pos nil)
      (cl-letf (((symbol-function 'org-drill-final-report) #'ignore)
                ((symbol-function 'save-some-buffers) #'ignore)
                ((symbol-function 'sit-for) #'ignore)
                ((symbol-function 'message) #'ignore)
                ((symbol-function 'org-drill-record-session)
                 (lambda (&rest _) (setq recorded t))))
        (org-drill--show-end-message session))
      (should recorded))))

(ert-deftest test-show-end-message-logs-when-recorder-errors ()
  "A recorder failure (struct shape, persist-save IO, etc.) must surface
via `message' rather than silently disappearing — no silent data loss."
  (test-session-record--with-empty-log
    (let* ((session (test-session-record--populated-session :qualities '(4)))
           (logged nil))
      (oset session end-pos nil)
      (cl-letf (((symbol-function 'org-drill-final-report) #'ignore)
                ((symbol-function 'save-some-buffers) #'ignore)
                ((symbol-function 'sit-for) #'ignore)
                ((symbol-function 'message)
                 (lambda (fmt &rest args)
                   ;; Accumulate (newest first).  The flow after the recorder
                   ;; error also calls `(message "Drill session finished!")'
                   ;; and `(message nil)', so a single-binding capture would
                   ;; lose the failure message.  Nil FMT is the minibuffer-
                   ;; clear call and is ignored.
                   (when fmt (push (apply #'format fmt args) logged))))
                ((symbol-function 'org-drill-record-session)
                 (lambda (&rest _) (error "boom"))))
        (org-drill--show-end-message session))
      (should logged)
      (should (cl-some (lambda (m) (string-match-p "failed to record session" m))
                       logged)))))

(ert-deftest test-show-end-message-skips-record-on-suspend ()
  "A suspended session (end-pos set) must NOT record — discard semantics."
  (test-session-record--with-empty-log
    (let* ((session (test-session-record--populated-session :qualities '(4)))
           (recorded nil))
      (oset session end-pos (test-session-record--marker-at 1))
      (cl-letf (((symbol-function 'org-reveal) #'ignore)
                ((symbol-function 'org-fold-show-entry) #'ignore)
                ((symbol-function 'org-drill-goto-entry) #'ignore)
                ((symbol-function 'org-drill--show-resume-hint) #'ignore)
                ((symbol-function 'org-drill-record-session)
                 (lambda (&rest _) (setq recorded t))))
        (org-drill--show-end-message session))
      (should-not recorded))))

(provide 'test-org-drill-session-record)

;;; test-org-drill-session-record.el ends here