;; Safsaf, a Guile web framework ;; Copyright (C) 2026 Christopher Baines ;; This program is free software: you can redistribute it and/or ;; modify it under the terms of the GNU Lesser General Public License ;; as published by the Free Software Foundation, either version 3 of ;; the License, or (at your option) any later version. ;; ;; This program is distributed in the hope that it will be useful, but ;; WITHOUT ANY WARRANTY; without even the implied warranty of ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU ;; Lesser General Public License for more details. ;; ;; You should have received a copy of the GNU Lesser General Public ;; License along with this program. If not, see ;; . (define-module (tests support) #:use-module (ice-9 format) #:use-module (srfi srfi-1) #:export (is test suite suite-thunk define-suite test-runner* test? suite? suite-thunk? run-tests)) ;;; ;;; Minimal SRFI-269 implementation for Guile. ;;; ;;; Three definition primitives — is, test, suite — construct first-class ;;; entities (alists) and deliver them to a pluggable test runner via ;;; message passing. Definition is separated from execution: the runner ;;; decides when and how to run things. ;;; ;;; --- Parameter --- (define test-runner* (make-parameter #f)) ;;; --- Predicates --- (define (test? obj) (and (pair? obj) (assq 'test/body-thunk obj) (assq 'test/description obj) #t)) (define (suite? obj) (and (pair? obj) (assq 'suite/body-thunk obj) (assq 'suite/description obj) #t)) (define (suite-thunk? obj) (procedure? obj)) ;;; --- is --- (define-syntax is (lambda (x) (syntax-case x () ;; Predicate form: (is (pred arg ...)) ((_ (pred arg ...)) (with-syntax ((src (datum->syntax x (syntax-source x)))) #'(%run-assert (lambda () (pred arg ...)) '(pred arg ...) 'src (lambda () (list arg ...))))) ;; Simple form: (is expr) ((_ expr) (with-syntax ((src (datum->syntax x (syntax-source x)))) #'(%run-assert (lambda () expr) 'expr 'src #f)))))) (define (%run-assert body-thunk body-datum source args-thunk) (let* ((entity `((assert/body-thunk . ,body-thunk) (assert/body . ,body-datum) (assert/location . ,source) ,@(if args-thunk `((assert/args-thunk . ,args-thunk)) '())))) ((test-runner*) `((type . runner/run-assert) (assert . ,entity))))) ;;; --- test --- (define-syntax test (syntax-rules (quote) ((_ desc (quote metadata) meta body ...) (%load-test desc 'meta (lambda () body ... (values)))) ((_ desc body ...) (%load-test desc '() (lambda () body ... (values)))))) (define (%load-test description metadata body-thunk) ((test-runner*) `((type . runner/load-test) (test . ((test/body-thunk . ,body-thunk) (test/description . ,description) (test/metadata . ,metadata)))))) ;;; --- suite --- (define-syntax suite (syntax-rules (quote) ((_ desc (quote metadata) meta body ...) (%load-suite desc 'meta (lambda () body ... (values)))) ((_ desc body ...) (%load-suite desc '() (lambda () body ... (values)))))) (define (%load-suite description metadata body-thunk) ((test-runner*) `((type . runner/load-suite) (suite . ((suite/body-thunk . ,body-thunk) (suite/description . ,description) (suite/metadata . ,metadata)))))) ;;; --- suite-thunk --- (define-syntax suite-thunk (syntax-rules (quote) ((_ desc (quote metadata) meta body ...) (lambda () (%load-suite desc 'meta (lambda () body ... (values))))) ((_ desc body ...) (lambda () (%load-suite desc '() (lambda () body ... (values))))))) ;;; --- define-suite --- (define-syntax define-suite (syntax-rules (quote) ((_ name (quote metadata) meta body ...) (define name (suite-thunk (symbol->string 'name) (quote metadata) meta body ...))) ((_ name body ...) (define name (suite-thunk (symbol->string 'name) body ...))))) ;;; ;;; Simple immediate-execution test runner. ;;; (define %depth 0) (define %pass-count 0) (define %fail-count 0) (define %error-count 0) (define %test-failed? #f) (define (indent) (make-string (* 2 %depth) #\space)) (define (format-location loc) "Return a string like \"file.scm:42\" from a source location alist, or #f if location info is unavailable." (and loc (let ((file (assq-ref loc 'filename)) (line (assq-ref loc 'line))) (and file line (format #f "~a:~a" file (+ line 1)))))) (define (simple-test-runner message) (let ((type (assq-ref message 'type))) (case type ((runner/load-suite) (let* ((s (assq-ref message 'suite)) (desc (assq-ref s 'suite/description)) (body (assq-ref s 'suite/body-thunk))) (format #t "~a~a~%" (indent) desc) (set! %depth (+ %depth 1)) (body) (set! %depth (- %depth 1)))) ((runner/load-test) (let* ((t (assq-ref message 'test)) (desc (assq-ref t 'test/description)) (body (assq-ref t 'test/body-thunk))) (set! %test-failed? #f) (with-exception-handler (lambda (exn) (set! %error-count (+ %error-count 1)) (format #t "~aERROR ~a~%" (indent) desc) (format #t "~a ~a~%" (indent) exn)) (lambda () (body) (if %test-failed? (begin (set! %fail-count (+ %fail-count 1)) (format #t "~aFAIL ~a~%" (indent) desc)) (begin (set! %pass-count (+ %pass-count 1)) (format #t "~aok ~a~%" (indent) desc)))) #:unwind? #t))) ((runner/run-assert) (let* ((a (assq-ref message 'assert)) (body-thunk (assq-ref a 'assert/body-thunk)) (body-datum (assq-ref a 'assert/body)) (loc (assq-ref a 'assert/location))) (let ((result (body-thunk))) (unless result (set! %test-failed? #t) (format #t "~a FAIL: ~s" (indent) body-datum) (let ((loc-str (format-location loc))) (when loc-str (format #t " at ~a" loc-str))) (newline) ;; Show evaluated arguments for predicate assertions. (let ((args-thunk (assq-ref a 'assert/args-thunk))) (when args-thunk (with-exception-handler (lambda (_) #f) (lambda () (let ((args (args-thunk))) (format #t "~a args: ~s~%" (indent) args))) #:unwind? #t)))) result)))))) (define (run-tests thunk) "Set up the simple test runner, call THUNK (typically a suite-thunk), print a summary, and exit with 0 on success or 1 on failure." (set! %depth 0) (set! %pass-count 0) (set! %fail-count 0) (set! %error-count 0) (parameterize ((test-runner* simple-test-runner)) (thunk)) (newline) (let ((total (+ %pass-count %fail-count %error-count))) (format #t "~a passed, ~a failed, ~a errors (of ~a)~%" %pass-count %fail-count %error-count total) (exit (if (and (zero? %fail-count) (zero? %error-count)) 0 1))))