aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-31 10:46:18 -0500
committerCraig Jennings <c@cjennings.net>2026-05-31 10:46:18 -0500
commit27385697fbf60e3319a598bb509197f5579c9405 (patch)
treebf09d7f0fc32701794cdc29ef91deecf5bc96f10 /tests
parent26cc4472dea261a1ad13fbee8fb6a91b019f77bb (diff)
downloadorg-drill-27385697fbf60e3319a598bb509197f5579c9405.tar.gz
org-drill-27385697fbf60e3319a598bb509197f5579c9405.zip
feat: add statistics dashboard CSV export and docs
org-drill-statistics-export-csv, bound to e in the dashboard and now implemented, writes three files into a chosen directory honoring the active scope and range: sessions.csv (one row per recorded session), cards.csv (one row per drill card in scope with its scheduling properties and computed status), and daily.csv (per-day reviews, passes, fails, pass percent, and duration). Fields are quoted per RFC 4180 by a small csv-quote helper, since csv-mode isn't a dependency. This is the dashboard's last piece, step 3 of the spec. The row builders for the three views are pure and unit-tested with deterministic fixtures, and csv-quote covers the comma, quote, and newline cases. I documented the dashboard and the export in org-drill.org (a new section with the keymap, the CSV columns, and the settings table) and added a feature bullet to the README. I also removed the now-redundant declare-function forward reference for export-csv. It named this file as the source, so once the real defun landed the byte-compiler counted the function twice and warned.
Diffstat (limited to 'tests')
-rw-r--r--tests/test-org-drill-statistics-export.el180
1 files changed, 180 insertions, 0 deletions
diff --git a/tests/test-org-drill-statistics-export.el b/tests/test-org-drill-statistics-export.el
new file mode 100644
index 0000000..849f439
--- /dev/null
+++ b/tests/test-org-drill-statistics-export.el
@@ -0,0 +1,180 @@
+;;; test-org-drill-statistics-export.el --- Tests for stats CSV export -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Step 3 of the statistics dashboard: `org-drill-statistics-export-csv'
+;; writes sessions.csv, cards.csv, and daily.csv. The row builders are pure
+;; and tested with deterministic fixtures here; the command itself is exercised
+;; against a temp directory.
+
+;;; Code:
+
+(require 'ert)
+(require 'org-drill)
+(require 'cl-lib)
+
+;;;; Fixtures
+
+(defun test-org-drill-stats-export--record (day-offset qualities duration-min
+ &optional algorithm)
+ "Build a session record DAY-OFFSET days before now.
+QUALITIES is a list of ints, DURATION-MIN the session length in minutes,
+ALGORITHM a symbol (default simple8)."
+ (let* ((start (- (float-time) (* day-offset 86400.0)))
+ (end (+ start (* duration-min 60.0)))
+ (qv (vconcat qualities))
+ (passes (cl-count-if (lambda (q) (> q org-drill-failure-quality)) qv)))
+ (make-org-drill-session-record
+ :start-time start :end-time end :scope 'directory
+ :algorithm (or algorithm 'simple8)
+ :qualities qv
+ :pass-percent (if (> (length qv) 0)
+ (round (* 100.0 (/ (float passes) (length qv)))) 0)
+ :new-count 0 :mature-count 0 :failed-count 0 :cram-mode nil)))
+
+;;;; csv-quote
+
+(ert-deftest test-org-drill-statistics-export-csv-quote-plain ()
+ "A field with no special characters is returned unchanged."
+ (should (equal (org-drill-statistics--csv-quote "hello") "hello")))
+
+(ert-deftest test-org-drill-statistics-export-csv-quote-comma ()
+ "A field containing a comma is wrapped in double quotes."
+ (should (equal (org-drill-statistics--csv-quote "a,b") "\"a,b\"")))
+
+(ert-deftest test-org-drill-statistics-export-csv-quote-doubles-quotes ()
+ "Embedded double quotes are doubled and the field is wrapped."
+ (should (equal (org-drill-statistics--csv-quote "say \"hi\"")
+ "\"say \"\"hi\"\"\"")))
+
+(ert-deftest test-org-drill-statistics-export-csv-quote-newline ()
+ "A field containing a newline is wrapped in double quotes."
+ (should (equal (org-drill-statistics--csv-quote "a\nb") "\"a\nb\"")))
+
+(ert-deftest test-org-drill-statistics-export-csv-quote-coerces-non-string ()
+ "A non-string field is coerced to its printed form."
+ (should (equal (org-drill-statistics--csv-quote 42) "42")))
+
+;;;; csv-row
+
+(ert-deftest test-org-drill-statistics-export-csv-row-joins-and-quotes ()
+ "A row joins fields with commas, quoting only those that need it."
+ (should (equal (org-drill-statistics--csv-row '("a" "b,c" 7))
+ "a,\"b,c\",7")))
+
+;;;; sessions rows
+
+(ert-deftest test-org-drill-statistics-export-session-row-fields ()
+ "A session row carries the counts, the space-joined qualities, and cram."
+ (let* ((rec (test-org-drill-stats-export--record 0 '(5 4 1) 6 'sm5))
+ (row (org-drill-statistics--session-row rec)))
+ ;; qualities column is space-joined
+ (should (member "5 4 1" row))
+ ;; algorithm and cram render as printed symbols
+ (should (member "sm5" row))
+ (should (member "nil" row))
+ ;; pass-percent for 2 of 3 passing is 67
+ (should (member "67" row))))
+
+(ert-deftest test-org-drill-statistics-export-sessions-rows-one-per-record ()
+ "One row per record in the log."
+ (let ((log (list (test-org-drill-stats-export--record 0 '(5) 1)
+ (test-org-drill-stats-export--record 1 '(2) 1))))
+ (should (= (length (org-drill-statistics--sessions-rows log)) 2))))
+
+;;;; daily rows
+
+(ert-deftest test-org-drill-statistics-export-daily-rows-length ()
+ "Daily rows have one entry per day in the window."
+ (let ((log (list (test-org-drill-stats-export--record 0 '(5) 1))))
+ (should (= (length (org-drill-statistics--daily-rows log 7)) 7))))
+
+(ert-deftest test-org-drill-statistics-export-daily-rows-today-aggregates ()
+ "Today's row sums reviews, passes, fails, and duration across its records."
+ ;; failure-quality default 2: 5 and 4 pass, 1 fails.
+ (let* ((log (list (test-org-drill-stats-export--record 0 '(5 4 1) 6)))
+ (rows (org-drill-statistics--daily-rows log 7))
+ (today (car (last rows)))) ; oldest-first, so today is last
+ ;; row shape: (date reviews passes fails pass% duration)
+ (should (equal (nth 1 today) "3")) ; reviews
+ (should (equal (nth 2 today) "2")) ; passes
+ (should (equal (nth 3 today) "1")) ; fails
+ (should (equal (nth 4 today) "67")) ; pass percent
+ (should (equal (nth 5 today) "6")))) ; duration minutes
+
+(ert-deftest test-org-drill-statistics-export-daily-rows-empty-day-zeros ()
+ "A day with no records reports zero reviews and a zero pass percent."
+ (let* ((log (list (test-org-drill-stats-export--record 0 '(5) 1)))
+ (rows (org-drill-statistics--daily-rows log 7))
+ (yesterday (nth (- (length rows) 2) rows)))
+ (should (equal (nth 1 yesterday) "0"))
+ (should (equal (nth 2 yesterday) "0"))))
+
+;;;; cards rows
+
+(ert-deftest test-org-drill-statistics-export-cards-rows-from-scope ()
+ "Cards rows carry the heading, scheduling props, and computed status."
+ (with-temp-buffer
+ (insert "* Capital of France :drill:\n"
+ ":PROPERTIES:\n"
+ ":DRILL_TOTAL_REPEATS: 4\n"
+ ":DRILL_LAST_QUALITY: 5\n"
+ ":END:\n"
+ "Paris\n")
+ (org-mode)
+ (let* ((org-drill-question-tag "drill")
+ (rows (org-drill-statistics--cards-rows 'file)))
+ (should (= (length rows) 1))
+ (let ((row (car rows)))
+ (should (member "Capital of France" row))
+ (should (member "4" row)) ; DRILL_TOTAL_REPEATS
+ (should (member "5" row)))))) ; DRILL_LAST_QUALITY
+
+;;;; export command
+
+(ert-deftest test-org-drill-statistics-export-writes-three-files ()
+ "The export command writes sessions.csv, cards.csv, and daily.csv."
+ (let ((dir (make-temp-file "org-drill-stats-export" t)))
+ (unwind-protect
+ (with-temp-buffer
+ (insert "* A card :drill:\nQ [answer] A\n")
+ (org-mode)
+ (let ((org-drill-question-tag "drill")
+ (org-drill-scope 'file)
+ (org-drill-session-log
+ (list (test-org-drill-stats-export--record 0 '(5 4) 3)
+ (test-org-drill-stats-export--record 1 '(1) 2))))
+ (org-drill-statistics-export-csv dir)))
+ nil)
+ (unwind-protect
+ (dolist (name '("sessions.csv" "cards.csv" "daily.csv"))
+ (let ((path (expand-file-name name dir)))
+ (should (file-exists-p path))
+ ;; non-empty: at least a header line
+ (should (> (nth 7 (file-attributes path)) 0))))
+ (delete-directory dir t))))
+
+(ert-deftest test-org-drill-statistics-export-sessions-file-has-header-and-rows ()
+ "sessions.csv starts with the header and has one line per record."
+ (let ((dir (make-temp-file "org-drill-stats-export" t)))
+ (unwind-protect
+ (progn
+ (with-temp-buffer
+ (insert "* A card :drill:\nQ [answer] A\n")
+ (org-mode)
+ (let ((org-drill-question-tag "drill")
+ (org-drill-scope 'file)
+ (org-drill-session-log
+ (list (test-org-drill-stats-export--record 0 '(5 4) 3)
+ (test-org-drill-stats-export--record 1 '(1) 2))))
+ (org-drill-statistics-export-csv dir)))
+ (with-temp-buffer
+ (insert-file-contents (expand-file-name "sessions.csv" dir))
+ (let ((lines (split-string (buffer-string) "\n" t)))
+ ;; header + 2 data rows
+ (should (= (length lines) 3))
+ (should (string-match-p "pass_percent" (car lines))))))
+ (delete-directory dir t))))
+
+(provide 'test-org-drill-statistics-export)
+
+;;; test-org-drill-statistics-export.el ends here