;; Copyright (c) 2010-2025 Alex Shinn. All rights reserved.
;; BSD-style license: http://synthcode.com/license.txt

;;> Simple but extensible testing framework with advanced reporting.

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; list utilities

;; Simplified version of SRFI-1 any.
(define (any pred ls)
  (and (pair? ls)
       (or (pred (car ls))
           (any pred (cdr ls)))))

(define (safe-any pred ls . o)
  (let ((desc (or (and (pair? o) (car o)) "error in any")))
    (guard (exn
            (else
             (warning desc)
             (print-exception exn (current-error-port))
             #f))
      (any pred ls))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; exception utilities

(define (warning msg . args)
  (display msg (current-error-port))
  (for-each (lambda (x)
              (write-char #\space (current-error-port))
              (write x (current-error-port)))
            args)
  (newline (current-error-port)))

(define (exception-message exc)
  (let* ((s (let ((p (open-output-string)))
              (print-exception exc p)
              (get-output-string p)))
         (n (- (string-length s) 1)))
    ;; Strip the “ERROR: ” prefix if present
    (let loop ((i 0))
      (if (>= (+ i 2) n)
          (substring s 0 n)
          (if (and (char=? (string-ref s i) #\:)
                   (char=? (string-ref s (+ i 1)) #\space))
              (substring s (+ i 2) n)
              (loop (+ i 1)))))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; test interface

;;> \section{Testing}

;;> \macro{(test [name] expect expr)}

;;> The primary interface to testing.  Evaluate \var{expr} and check
;;> that it is equal to \var{expect}, and report the result, using
;;> \var{name} or a printed summary of \var{expr}.
;;>
;;> If used inside a group this will contribute to the overall group
;;> reporting, but can be used standalone:
;;>
;;> \example{(test 4 (+ 2 2))}
;;> \example{(test "add two and two" 4 (+ 2 2))}
;;> \example{(test 3 (+ 2 2))}
;;> \example{(test 4 (+ 2 "2"))}
;;>
;;> The equality comparison is made with
;;> \scheme{current-test-comparator}, defaulting to
;;> \scheme{test-equal?}, which is the same as \scheme{equal?} but
;;> more permissive on floating point comparisons).  Returns the
;;> status of the test (one of the symbols \scheme{'PASS},
;;> \scheme{'FAIL}, \scheme{'SKIP}, \scheme{'ERROR}).

(define-syntax test
  (syntax-rules (quote)
    ((test expect expr)
     (test #f expect expr))
    ((test name expect (expr ...))
     (test-propagate-info name expect (expr ...) ()))
    ((test name 'expect expr)
     (test-propagate-info name 'expect expr ()))
    ((test name (expect ...) expr)
     (test-syntax-error
      'test
      "the test expression should come last: (test <expected> (<expr> ...)) "
      (test name (expect ...) expr)))
    ((test name expect expr)
     (test-propagate-info name expect expr ()))
    ((test a ...)
     (test-syntax-error 'test "test requires 2 or 3 arguments" (test a ...)))))

;;> \macro{(test-equal equal [name] expect expr)}

;;> Equivalent to test, using \var{equal} for comparison instead of
;;> \scheme{equal?}.

(define-syntax test-equal
  (syntax-rules ()
    ((test-equal equal . args)
     (parameterize ((current-test-comparator equal))
       (test . args)))))

;;> \macro{(test-assert [name] expr)}

;;> Like \scheme{test} but evaluates \var{expr} and checks that it's true.

(define-syntax test-assert
  (syntax-rules ()
    ((_ expr)
     (test-assert #f expr))
    ((_ name expr)
     (test-propagate-info name #f expr ((assertion . #t))))
    ((test a ...)
     (test-syntax-error 'test-assert "1 or 2 arguments required"
                        (test a ...)))))

;;> \macro{(test-not [name] expr)}

;;> Like \scheme{test} but evaluates \var{expr} and checks that it's false.

(define-syntax test-not
  (syntax-rules ()
    ((_ expr) (test-assert (not expr)))
    ((_ name expr) (test-assert name (not expr)))))

;;> \macro{(test-values [name] expect expr)}

;;> Like \scheme{test} but \var{expect} and \var{expr} can both
;;> return multiple values.

(define-syntax test-values
  (syntax-rules ()
    ((_ expect expr)
     (test-values #f expect expr))
    ((_ name expect expr)
     (test name (call-with-values (lambda () expect) (lambda results results))
       (call-with-values (lambda () expr) (lambda results results))))))

;;> \macro{(test-error [name [pred]] expr)}

;;> Like \scheme{test} but evaluates \var{expr} and checks that it
;;> raises an error. If \var{pred} is provided, the raised error
;;> object must additionally satisfy the given type test.

(define-syntax test-error
  (syntax-rules ()
    ((_ expr)
     (test-error #f expr))
    ((_ name expr)
     (test-propagate-info name #f expr ((expect-error . #t))))
    ((_ name pred expr)
     (test-propagate-info name #f expr ((expect-error . #t)
                                        (error-type-test . ,pred)
                                        (error-type-test-expr . pred))))
    ((test a ...)
     (test-syntax-error 'test-error "1, 2, or 3 arguments required"
                        (test a ...)))))

;;> Low-level macro to pass alist info to the underlying \var{test-run}.

(define-syntax test-propagate-info
  (syntax-rules ()
    ;; TODO: Extract interesting variables so we can show their values
    ;; on failure.  Vars are empty for now.
    ((test-propagate-info name expect expr info)
     (test-vars () name expect expr info))))

(define-syntax test-vars
  (syntax-rules ()
    ((_ (vars ...) n expect expr ((key . val) ...))
     (test-run (lambda () expect)
               (lambda () expr)
               `((name . ,n)
                 (expect-source . expect)
                 (source . expr)
                 (var-names . (vars ...))
                 (var-values . ,(list vars ...))
                 (key . val) ...)))))

;;> The procedural interface to testing.  \var{expect} and \var{expr}
;;> should be thunks, and \var{info} is an alist of properties used in
;;> test reporting.

(define (test-run expect expr info)
  (let ((info (test-expand-info info)))
    ((current-test-reporter) 'BEGIN info)
    (if (and (cond ((current-test-group)
                    => (lambda (g) (not (test-group-ref g 'skip-group?))))
                   (else #t))
             (or (and (not (safe-any (lambda (f) (f info)) (current-test-removers)))
                      (or (pair? (current-test-removers))
                          (null? (current-test-filters))))
                 (safe-any (lambda (f) (f info)) (current-test-filters))))
        ((current-test-applier) expect expr info)
        ((current-test-skipper) info))))

;;> Convenience form to skip all enclosed tests.

(define-syntax test-skip
  (syntax-rules ()
    ((test-skip reason body ...)
     (parameterize ((current-test-removers (lambda (info) #t))
                    (current-test-filters (lambda (info) #f)))
       body ...))))

;;> Returns true if either \scheme{(equal? expect res)}, or
;;> \var{expect} is inexact and \var{res} is within
;;> \scheme{current-test-epsilon} of \var{expect}.

(define (test-equal? expect res)
  (or (equal? expect res)
      (if (real? expect)
          (and (inexact? expect)
               (real? res)
               ;; tests which expect an inexact value can
               ;; accept an equivalent exact value
               ;; (inexact? res)
               (approx-equal? expect res (current-test-epsilon)))
          (and (complex? res)
               (complex? expect)
               (test-equal? (real-part expect) (real-part res))
               (test-equal? (imag-part expect) (imag-part res))))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; group interface

;;> \section{Test Groups}

;;> Tests can be collected in groups for separate reporting, filtering
;;> and for catching exceptions outside of a test case.

;;> Wraps \var{body} as a single test group, which can be filtered and
;;> summarized separately.  The \var{body} is arbitrary Scheme code,
;;> and tests run within its dynamic extent will be associated with
;;> the group.  If an uncaught exception is raised outside of a test
;;> case, it will cause the whole group to fail with an error status.

;;> \example{
;;> (test-group "pi"
;;>   (test 3.14159 (acos -1))
;;>   (test 3 (acos -1))
;;>   (test 3.14159 (acos "-1")))
;;> }

(define-syntax test-group
  (syntax-rules ()
    ((_ name-expr body ...)
     (let ((name name-expr)
           (old-group (current-test-group)))
       (when (not (string? name))
         (error "a name is required, got " 'name-expr name))
       (test-begin name)
       (guard
           (exn
            (else
             (warning "error in group outside of tests")
             (print-exception exn (current-error-port))
             (test-group-inc! (current-test-group) 'count)
             (test-group-inc! (current-test-group) 'ERROR)
             (test-failure-count (+ 1 (test-failure-count)))))
         body ...)
       (test-end name)
       (current-test-group old-group)))))

;;> Begin testing a new group until the closing \scheme{(test-end)}.

(define-opt (test-begin (name ""))
  (let* ((parent (current-test-group))
         (group (make-test-group name parent)))
    ((current-test-group-reporter) group parent)
    (current-test-group group)))

;;> Ends testing group introduced with \scheme{(test-begin)}, and
;;> summarizes the results.  The \var{name} is optional, but if
;;> present should match the corresponding \scheme{test-begin} name,
;;> or a warning is printed.

(define-opt (test-end (name #f))
  (let ((group (current-test-group)))
    (when group
      (when (and name (not (equal? name (test-group-name group))))
        (warning "mismatched test-end:" name (test-group-name group)))
      ((current-test-group-reporter) group)
      (let ((parent (test-group-ref group 'parent)))
        (when parent
          (test-group-inc! parent 'subgroups-count)
          (cond
           ((test-group-ref group 'skip-group?)
            (test-group-inc! parent 'subgroups-skip))
           ((and (zero? (test-group-ref group 'FAIL 0))
                 (zero? (test-group-ref group 'ERROR 0))
                 (= (test-group-ref group 'subgroups-pass 0)
                    (test-group-ref group 'subgroups-count 0)))
            (test-group-inc! parent 'subgroups-pass))))
        (current-test-group parent)))))

;;> Exits with a failure status if any tests have failed,
;;> and a successful status otherwise.

(define (test-exit)
  (when (current-test-group)
    (warning "calling test-exit with unfinished test group:"
             (test-group-name (current-test-group))))
  (exit (zero? (test-failure-count))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; utilities

(define-syntax test-syntax-error
  (syntax-rules ()
    ((_) (syntax-error "invalid use of test-syntax-error"))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; test-group representation

;;> \section{Accessors}

;; (name (prop . value) ...)
(define (make-test-group name parent)
  (let* ((g (list name))
         (! (lambda (k v) (test-group-set! g k v))))
    (! 'start-time (current-second))
    (! 'parent parent)
    (! 'verbose
       (if parent
           (test-group-ref parent 'verbose)
           (current-test-verbosity)))
    (! 'level
       (if parent
           (+ 1 (test-group-ref parent 'level 0))
           0))
    (! 'skip-group?
       (and (or (and parent
                     (test-group-ref parent 'skip-group?))
                (safe-any (lambda (f) (f g))
                          (current-test-group-removers))
                (and (null? (current-test-group-removers))
                     (pair? (current-test-group-filters))))
            (not (safe-any (lambda (f) (f g))
                           (current-test-group-filters)))))
    g))

;;> Returns the name of a test group info object.

(define (test-group-name group) (car group))

;;> Returns the value of a \var{field} in a test var{group} info
;;> object.  \var{field} should be a symbol, and predefined fields
;;> include \scheme{parent}, \scheme{verbose}, \scheme{level},
;;> \scheme{start-time}, \scheme{skip-group?}, \scheme{count},
;;> \scheme{total-pass}, \scheme{total-fail}, \scheme{total-error}.

(define (test-group-ref group field . o)
  (if group
      (apply assq-ref (cdr group) field o)
      (and (pair? o) (car o))))

;;> Sets the value of a \var{field} in a test \var{group} info object.

(define (test-group-set! group field value)
  (cond
   ((assq field (cdr group))
    => (lambda (x) (set-cdr! x value)))
   (else (set-cdr! group (cons (cons field value) (cdr group))))))

;;> Increments the value of a \var{field} in a test \var{group} info
;;> object by \var{amount}, defaulting to 1.

(define (test-group-inc! group field . o)
  (let ((amount (if (pair? o) (car o) 1)))
    (cond
     ((assq field (cdr group))
      => (lambda (x) (set-cdr! x (+ amount (cdr x)))))
     (else (set-cdr! group (cons (cons field amount) (cdr group)))))))

;;> Updates a \var{field} in a test group info object by consing
;;> \var{value} onto it.

(define (test-group-push! group field value)
  (cond
   ((assq field (cdr group))
    => (lambda (x) (set-cdr! x (cons value (cdr x)))))
   (else (set-cdr! group (cons (cons field (list value)) (cdr group))))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; utilities

(define (assq-ref ls key . o)
  (cond ((assq key ls) => cdr)
        ((pair? o) (car o))
        (else #f)))

(define (approx-equal? a b epsilon)
  (cond
   ((> (abs a) (abs b))
    (approx-equal? b a epsilon))
   ((zero? a)
    (< (abs b) epsilon))
   (else
    (< (abs (/ (- a b) b)) epsilon))))

(define (call-with-output-string proc)
  (let ((out (open-output-string)))
    (proc out)
    (get-output-string out)))

;; partial pretty printing to abbreviate `quote' forms and the like
(define (write-to-string x)
  (call-with-output-string
    (lambda (out)
      (let wr ((x x))
        (if (pair? x)
            (cond
             ((and (symbol? (car x)) (pair? (cdr x)) (null? (cddr x))
                   (assq (car x)
                         '((quote . "'") (quasiquote . "`")
                           (unquote . ",") (unquote-splicing . ",@"))))
              => (lambda (s) (display (cdr s) out) (wr (cadr x))))
             (else
              (display "(" out)
              (wr (car x))
              (let lp ((ls (cdr x)))
                (cond ((pair? ls)
                       (display " " out)
                       (wr (car ls))
                       (lp (cdr ls)))
                      ((not (null? ls))
                       (display " . " out)
                       (write ls out))))
              (display ")" out)))
            (write x out))))))

(define (display-to-string x)
  (if (string? x) x (call-with-output-string (lambda (out) (display x out)))))

;; if we need to truncate, try first dropping let's to get at the
;; heart of the expression
(define (truncate-source x width . o)
  (let* ((str (write-to-string x))
         (len (string-length str)))
    (cond
     ((<= len width)
      str)
     ((and (pair? x) (eq? 'let (car x)))
      (if (and (pair? o) (car o))
          (truncate-source (car (reverse x)) width #t)
          (string-append "..."
                         (truncate-source (car (reverse x)) (- width 3) #t))))
     ((and (pair? x) (eq? 'call-with-current-continuation (car x)))
      (truncate-source (cons 'call/cc (cdr x)) width (and (pair? o) (car o))))
     ((and (pair? x) (eq? 'call-with-values (car x)))
      (string-append
       "..."
       (truncate-source (if (and (pair? (cadr x)) (eq? 'lambda (car (cadr x))))
                            (car (reverse (cadr x)))
                            (cadr x))
                        (- width 3)
                        #t)))
     (else
      (string-append
       (substring str 0 (min (max 0 (- width 3)) (string-length str)))
       "...")))))

(define (test-get-name! info)
  (or
   (assq-ref info 'name)
   (assq-ref info 'gen-name)
   (let ((name
          (cond
           ((assq 'source info)
            => (lambda (src)
                 (truncate-source (cdr src) (- (current-column-width) 12))))
           ((current-test-group)
            => (lambda (g)
                 (display "no source in: " (current-error-port))
                 (write info (current-error-port))
                 (display "\n" (current-error-port))
                 (string-append
                  "test-"
                  (number->string (test-group-ref g 'count 0)))))
           (else ""))))
     (if (pair? info)
         (set-cdr! info (cons (cons 'gen-name name) (cdr info))))
     name)))

(define (test-print-name info indent)
  (let* ((width (- (current-column-width) indent))
         (name (test-get-name! info))
         (diff (- width 9 (string-length name))))
    (display
     (if (positive? diff)
         name
         (string-append
          (substring name 0 (+ (string-length name) diff -1))
          (string (integer->char #x2026)))))
    (display " ")
    (if (positive? diff)
        (display (make-string diff (integer->char #x2024))))
    (display " ")
    (flush-output-port)))

(define (test-group-indent-width group)
  (let ((level (max 0 (+ 1 (- (test-group-ref group 'level 0)
                              (test-first-indentation))))))
    (* (current-group-indent) (min level (test-max-indentation)))))

;; Terminate the current and indent the next line with the given number
;; of spaces.  The very first string does not terminate a line.  There
;; should be a way to reset first? when creating more than one report
;; in a session.
(define indent-string
  (let ((first? #t))
    (lambda (indent)
      (string-append
       (if first?
           (begin
             (set! first? #f) "")
           "\n")
       (make-string indent #\space)))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(define (test-expand-info info)
  (let ((expr (assq-ref info 'source)))
    (if (and (pair? expr)
             (pair-source expr)
             (not (assq-ref info 'line-number)))
        `((file-name . ,(car (pair-source expr)))
          (line-number . ,(cdr (pair-source expr)))
          (format . ,(current-test-value-formatter))
          ,@info)
        info)))

(define (test-default-applier expect expr info)
  (let ((expect-val
         (guard
             (exn
              (else
               (warning "bad expect value" (assq-ref info 'expect-source))
               (print-exception exn (current-error-port))
               #f))
           (expect))))
    (guard
        (exn
         ((and (assq-ref info 'expect-error)
               (assq-ref info 'error-type-test))
          => (lambda (pred)
               ((current-test-reporter)
                (if (pred exn) 'PASS 'FAIL)
                (append `((exception . ,exn)) info))))
         (else
          ((current-test-reporter)
           (if (assq-ref info 'expect-error) 'PASS 'ERROR)
           (append `((exception . ,exn)) info))))
      (let ((res (expr)))
        (let ((status
               (if (and (not (assq-ref info 'expect-error))
                        (if (assq-ref info 'assertion)
                            res
                            ((current-test-comparator) expect-val res)))
                   'PASS
                   'FAIL))
              (info `((result . ,res) (expected . ,expect-val) ,@info)))
          ((current-test-reporter) status info))))))

(define (test-default-skipper info)
  ((current-test-reporter) 'SKIP info))

(define (test-status-color status)
  (case status
    ((ERROR) (lambda (x) (underline (red x))))
    ((FAIL) red)
    ((SKIP) yellow)
    (else (lambda (x) x))))

(define (test-status-message status)
  ((test-status-color status) (symbol->string status)))

(define (test-status-code status)
  ((test-status-color status)
   ;; alternatively: ❗, ✗, ‒, ✓
   ;; unfortunately, these have ambiguous width
   (case status
     ((ERROR) "!")
     ((FAIL) "x")
     ((SKIP) "-")
     (else "."))))

(define (display-expected/actual expected actual format)
  (let ((e-str (format expected))
        (a-str (format actual)))
    (if (and (equal? e-str a-str)
             (not (eqv? format write-to-string)))
        ;; If the formatter can't display any difference, fall back to
        ;; write-to-string.
        (display-expected/actual expected actual write-to-string)
        (let ((diff (diff e-str a-str read-char)))
          (write-string "expected ")
          (write-string (edits->string/color (car diff) (car (cddr diff)) 1))
          (write-string " but got ")
          (write-string (edits->string/color (cadr diff) (car (cddr diff)) 2))
          ))))

(define (test-print-explanation indent status info)
  (cond
   ((eq? status 'ERROR)
    (cond ((assq 'exception info)
           => (lambda (exc)
                (display indent)
                (display "Exception: ")
                (display (exception-message (cdr exc)))))))
   ((and (eq? status 'FAIL) (assq-ref info 'assertion))
    (display indent)
    (display "assertion failed"))
   ((and (eq? status 'FAIL) (assq-ref info 'expect-error))
    (display indent)
    (if (assq-ref info 'exception)
        (begin
          (display "error should satisfy ")
          (write (assq-ref info 'error-type-test-expr))
          (display " but raised ")
          (write (assq-ref info 'exception)))
        (begin
          (display "expected an error but got ")
          (write (assq-ref info 'result)))))
   ((eq? status 'FAIL)
    (display indent)
    (display-expected/actual (assq-ref info 'expected)
                             (assq-ref info 'result)
                             (or (assq-ref info 'format) write-to-string))))
  ;; print variables
  (cond
   ((and (memq status '(FAIL ERROR)) (assq-ref info 'var-names))
    => (lambda (names)
         (let ((values (assq-ref info 'var-values)))
           (if (and (pair? names)
                    (pair? values)
                    (= (length names) (length values)))
               (let ((indent2
                      (string-append indent (string #\space #\space))))
                 (for-each
                  (lambda (name value)
                    (display indent2)
                    (write name) (display ": ") (write value))
                  names values))))))))

(define (test-print-source indent status info)
  (case status
    ((FAIL ERROR)
     (cond
      ((assq-ref info 'line-number)
       => (lambda (line)
            (display indent)
            (display "on line ")
            (write line)
            (cond ((assq-ref info 'file-name)
                   => (lambda (file) (display " of file ") (write file)))))))
     (cond
      ((assq-ref info 'source)
       => (lambda (s)
            (cond
             ((or (assq-ref info 'name)
                  (> (string-length (write-to-string s))
                     (current-column-width)))
              (display indent)
              (display (write-to-string s)))))))
     (cond
      ((assq-ref info 'values)
       => (lambda (v)
            (for-each
             (lambda (v)
               (display indent) (display (car v))
               (display ": ") (write (cdr v)))
             v)))))))

(define (test-print-failure indent status info)
  ;; display status explanation
  (test-print-explanation indent status info)
  ;; display line, source and values info
  (test-print-source indent status info))

(define (test-group-line group open?)
  (let* ((name (test-group-name group))
         (spaces (test-group-indent-width group))
         (indent (indent-string spaces)))
    (if (test-group-ref group 'verbose)
        (let ((text (string-append
                     (if open? "" "done ")
                     (if (test-group-ref group 'skip-group?)
                         "skipping "
                         "testing ")
                     name)))
          (string-append
           indent
           "-- "
           (bold text)
           " "
           (make-string
            (max 0 (- (current-column-width)
                      (string-length text) spaces 4))
            #\-)))
        (string-append
         indent
         (bold (string-append name ": "))))))

(define (start-test info)
  (let ((group (current-test-group)))
    (when (or (not group) (test-group-ref group 'verbose))
      (let ((indent (and group (test-group-indent-width group))))
        (when (and indent (positive? indent))
          (display (indent-string indent)))
        (test-print-name info (or indent 4))))))

(define (stop-test status info)
  (define indent
    (indent-string
     (+ (current-group-indent)
        (cond ((current-test-group)
               => test-group-indent-width)
              (else 0)))))
  ;; update global failure count for exit status
  (cond
   ((or (eq? status 'FAIL) (eq? status 'ERROR))
    (test-failure-count (+ 1 (test-failure-count)))))
  (cond
   ((or (not (current-test-group))
        (test-group-ref (current-test-group) 'verbose))
    ;; display status
    (display "[")
    (if (not (eq? status 'ERROR)) (display " ")) ; pad
    (display (test-status-message status))
    (display "]")
    (test-print-failure indent status info)
    (newline))
   ;; ((eq? status 'SKIP))
   (else
    (display (test-status-code status))
    (cond
     ((and (memq status '(FAIL ERROR)) (current-test-group))
      => (lambda (group)
           (test-group-push! group 'failures (list indent status info)))))
    (cond ((current-test-group)
           => (lambda (group) (test-group-set! group 'trailing #t))))))
  ;; update group info
  (cond
   ((current-test-group)
    => (lambda (group)
         (test-group-inc! group 'count)
         (test-group-inc! group status)
         ;; maybe wrap long status lines
         (let* ((width (max (- (current-column-width)
                               (test-group-indent-width group))
                            (current-group-indent)))
                (column (test-group-ref group 'count 0)))
           (when (and (zero? (modulo column width))
                      (not (test-group-ref group 'verbose)))
             (newline)
             (display (string-copy indent (current-group-indent))))))))
  (flush-output-port)
  status)

(define (test-default-reporter status info)
  (if (eq? status 'BEGIN)
      (start-test info)
      (stop-test status info)))

(define (close-group group)
  (define (plural word n)
    (if (= n 1) word (string-append word "s")))
  (define (percent n d)
    (string-append " (" (number->string (/ (round (* 1000.0 (/ n d))) 10))
                   "%)"))
  (let* ((end-time (current-second))
         (start-time (test-group-ref group 'start-time))
         (duration (- end-time start-time))
         (skip (test-group-ref group 'SKIP 0))
         (base-count (- (test-group-ref group 'count 0) skip))
         (base-pass (test-group-ref group 'PASS 0))
         (base-fail (test-group-ref group 'FAIL 0))
         (base-err (test-group-ref group 'ERROR 0))
         (pass (+ base-pass (test-group-ref group 'total-pass 0)))
         (fail (+ base-fail (test-group-ref group 'total-fail 0)))
         (err (+ base-err (test-group-ref group 'total-error 0)))
         (count (+ pass fail err))
         (subgroups-count (test-group-ref group 'subgroups-count 0))
         (subgroups-skip (test-group-ref group 'subgroups-skip 0))
         (subgroups-run (- subgroups-count subgroups-skip))
         (subgroups-pass (test-group-ref group 'subgroups-pass 0))
         (indent (indent-string (test-group-indent-width group))))
    (when (or (positive? count) (positive? subgroups-count))
      (if (not (= base-count (+ base-pass base-fail base-err)))
          (warning "inconsistent count:"
                   base-count base-pass base-fail base-err))
      (when (positive? count)
        (display indent)
        (display
         ((if (= pass count) green (lambda (x) x))
          (string-append
           (number->string pass) " out of " (number->string count)
           (percent pass count))))
        (display
         (string-append
          (plural " test" pass) " passed in "
          (number->string duration) " seconds"
          (cond
           ((zero? skip) "")
           (else (string-append " (" (number->string skip)
                                (plural " test" skip) " skipped)")))
          ".")))
      (when (positive? fail)
        (display indent)
        (display
         (red
          (string-append
           (number->string fail) (plural " failure" fail)
           (percent fail count) "."))))
      (when (positive? err)
        (display indent)
        (display
         ((lambda (x) (underline (red x)))
          (string-append
           (number->string err) (plural " error" err)
           (percent err count) "."))))
      (unless (test-group-ref group 'verbose)
        (for-each
         (lambda (failure)
           (display indent)
           (display (red
                     (string-append (display-to-string (cadr failure)) ": ")))
           (display (test-get-name! (car (cddr failure))))
           (apply test-print-failure failure))
         (reverse (or (test-group-ref group 'failures) '()))))
      (when (positive? subgroups-run)
        (display indent)
        (display
         ((if (= subgroups-pass subgroups-run)
              green (lambda (x) x))
          (string-append
           (number->string subgroups-pass) " out of "
           (number->string subgroups-run)
           (percent subgroups-pass subgroups-run))))
        (display (plural " subgroup" subgroups-pass))
        (display " passed.")))
    (when (test-group-ref group 'verbose)
      (display (test-group-line group #f))
      (newline))
    (cond
     ((test-group-ref group 'parent)
      => (lambda (parent)
           (test-group-set! parent 'trailing #f)
           (test-group-inc! parent 'total-pass pass)
           (test-group-inc! parent 'total-fail fail)
           (test-group-inc! parent 'total-error err)))
     (else
      (when (zero? (test-group-ref group 'level))
        (newline))))))

(define test-default-group-reporter
  (case-lambda
    ((group)
     (close-group group))
    ((group parent)
     (display (test-group-line group 'open))
     (if (test-group-ref group 'verbose)
         (newline)
         (display (indent-string (+ 1 (test-group-indent-width group)))))
     )))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; parameters

;;> \section{Parameters}

;;> If specified, takes a single object as input (the expected or
;;> actual value of a test) and returns the string representation
;;> (default \scheme{write-to-string}).
(define current-test-value-formatter (make-parameter #f))

;;> The current test group as started by \scheme{test-group} or
;;> \scheme{test-begin}.

(define current-test-group (make-parameter #f))

;;> If true, show more verbose output per test.  Inferred from the
;;> environment variable TEST_VERBOSE.

(define current-test-verbosity
  (make-parameter
   (cond ((get-environment-variable "TEST_VERBOSE")
          => (lambda (s) (not (member s '("" "0")))))
         (else #f))))

;;> The epsilon used for floating point comparisons.

(define current-test-epsilon (make-parameter 1e-5))

;;> The underlying comparator used in testing, defaults to
;;> \scheme{test-equal?}.

(define current-test-comparator (make-parameter test-equal?))

;;> The test applier - what we do with non-skipped tests.  Takes the
;;> same signature as \scheme{test-run}, should be responsible for
;;> evaluating the thunks, determining the status of the test, and
;;> passing this information to \scheme{current-test-reporter}.

(define current-test-applier (make-parameter test-default-applier))

;;> The test skipper - what we do with non-skipped tests.  This should
;;> not evaluate the thunks and simply pass off to
;;> \scheme{current-test-reporter}.

(define current-test-skipper (make-parameter test-default-skipper))

;;> Takes two arguments, the symbol status of the test and the info
;;> alist.  The status is one of \scheme{'BEGIN}, \scheme{'PASS},
;;> \scheme{'FAIL}, \scheme{'ERROR}, or \scheme{'SKIP}.  For each test
;;> a reporter is called twice: once with symbol \scheme{'BEGIN} to
;;> indicate that handling of the test begins and a second time when
;;> the result was determined.  A test reporter returns the test’s
;;> result and updates bookkeeping in the current test group for
;;> reporting.

(define current-test-reporter (make-parameter test-default-reporter))

;;> Takes one argument, a test group, and prints a summary of the test
;;> results for that group.

(define current-test-group-reporter
  (make-parameter test-default-group-reporter))

;;> A running count of all test failures and errors across all groups
;;> (and threads).  Used by \scheme{test-exit}.

(define test-failure-count (make-parameter 0))

(define test-first-indentation
  (make-parameter
   (or (cond ((get-environment-variable "TEST_FIRST_INDENTATION")
              => string->number)
             (else #f))
       1)))

(define test-max-indentation
  (make-parameter
   (or (cond ((get-environment-variable "TEST_MAX_INDENTATION")
              => string->number)
             (else #f))
       5)))

(define (string->info-matcher str)
  (lambda (info)
    (cond ((test-get-name! info)
           => (lambda (name) (and (string? name) (string-contains name str))))
          (else #f))))

(define (string->group-matcher str)
  (lambda (group)
    (cond ((test-group-name group)
           => (lambda (name)
                (and (string? name) (string-contains name str))))
          (else #f))))

;; simplified version from SRFI 130
(define (string-split str ch)
  (let ((end (string-length str)))
    (let lp ((from 0) (to 0) (res '()))
      (cond
       ((>= to end)
        (reverse (if (> to from) (cons (substring str from to) res) res)))
       ((eqv? ch (string-ref str to))
        (lp (+ to 1) (+ to 1) (cons (substring str from to) res)))
       (else
        (lp from (+ to 1) res))))))

(define (getenv-filter-list proc name)
  (cond
   ((get-environment-variable name)
    => (lambda (s)
         (let lp ((ls (string-split s #\,))
                  (res '()))
           (cond
            ((null? ls) (reverse res))
            (else
             (let* ((s (car ls))
                    (f (guard
                           (exn
                            (else
                             (warning
                              (string-append "invalid filter '" s
                                             "' from environment variable: "
                                             name))
                             (print-exception exn (current-error-port))
                             #f))
                         (proc s))))
               (lp (cdr ls) (if f (cons f res) res))))))))
   (else '())))

(define current-test-group-filters
  (make-parameter
   (getenv-filter-list string->group-matcher "TEST_GROUP_FILTER")))

(define current-test-group-removers
  (make-parameter
   (getenv-filter-list string->group-matcher "TEST_GROUP_REMOVE")))

;;> Parameters controlling which test groups are skipped.  Each
;;> parameter is a list of procedures of one argument, a test group
;;> info, which can be queried with \var{test-group-name} and
;;> \var{test-group-ref}.  Analogous to SRFI 1, a filter selects a
;;> group for inclusion and a removers for exclusion.  The defaults
;;> are set automatically from the environment variables
;;> TEST_GROUP_FILTER and TEST_GROUP_REMOVE, which should be
;;> comma-delimited lists of strings which are checked for a substring
;;> match in the test group name.  A test group is skipped if it does
;;> not match any filter and:
;;> \itemlist[
;;> \item{its parent group is skipped, or}
;;> \item{it matches a remover, or}
;;> \item{no removers are specified but some filters are}
;;> ]
;;/

(define current-test-filters
  (make-parameter (getenv-filter-list string->info-matcher "TEST_FILTER")))

(define current-test-removers
  (make-parameter (getenv-filter-list string->info-matcher "TEST_REMOVE")))

;;> Parameters controlling which tests are skipped.  Each parameter is
;;> a list of procedures of one argument, a test info alist, which can
;;> be queried with \scheme{test-get-name!} or \scheme{assq}.
;;> Analogous to SRFI 1, a filter selects a test for inclusion and a
;;> removers for exclusion.  The defaults are set automatically from
;;> the environment variables TEST_FILTER and TEST_REMOVE, which
;;> should be comma-delimited lists of strings which are checked for a
;;> substring match in the test name.  A test is skipped if its group
;;> is skipped, or if it does not match a filter and:
;;> \itemlist[
;;> \item{it matches a remover, or}
;;> \item{no removers are specified but some filters are}
;;> ]
;;/

;;> Parameter controlling the current column width for test output,
;;> can be set from the environment variable TEST_COLUMN_WIDTH,
;;> otherwise defaults to 78.  For portability of implementation (and
;;> resulting output), does not attempt to use termios to determine
;;> the actual available width.

(define current-column-width
  (make-parameter
   (or (cond ((get-environment-variable "TEST_COLUMN_WIDTH")
              => string->number)
             (else #f))
       78)))

;;> Parameter controlling the indent in spaces for a group in test
;;> output, can be set from the environment variable TEST_GROUP_INDENT,
;;> otherwise defaults to 4.

(define current-group-indent
  (make-parameter
   (or (cond ((get-environment-variable "TEST_GROUP_INDENT")
              => string->number)
             (else #f))
       4)))
