;;; faith.fnl --- The Fennel Advanced Interactive Test Helper ;; https://git.sr.ht/~technomancy/faith ;; SPDX-License-Identifier: MIT ;; SPDX-FileCopyrightText: Scott Vokes, Phil Hagelberg, and contributors ;; To use Faith, create a test runner file which calls the `run` function with ;; a list of module names. The modules should export functions whose ;; names start with `test-` and which call the assertion functions in the ;; `faith` module. ;; Copyright © 2009-2013 Scott Vokes and contributors ;; Copyright © 2023-2024 Phil Hagelberg and contributors ;; Permission is hereby granted, free of charge, to any person obtaining a copy ;; of this software and associated documentation files (the "Software"), to deal ;; in the Software without restriction, including without limitation the rights ;; to use, copy, modify, merge, publish, distribute, sublicense, and/or sell ;; copies of the Software, and to permit persons to whom the Software is ;; furnished to do so, subject to the following conditions: ;; The above copyright notice and this permission notice shall be included in ;; all copies or substantial portions of the Software. ;; THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR ;; IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, ;; FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE ;; AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER ;; LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, ;; OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE ;; SOFTWARE. (local fennel (require :fennel)) ;;; helper functions (local unpack (or table.unpack _G.unpack)) (fn now [] {:real (or (and (pcall require :socket) (package.loaded.socket.gettime)) (and (pcall require :posix) (package.loaded.posix.gettimeofday) (let [t (package.loaded.posix.gettimeofday)] (+ t.sec (/ t.usec 1000000)))) nil) :approx (os.time) :cpu (os.clock)}) (fn fn? [v] (= (type v) :function)) (fn fail->string [{: where : reason : msg} module-name name] (string.format "FAIL: %s / %s:\n%s: %s%s\n" module-name name where (or reason "") (or (and msg (.. " - " (tostring msg))) ""))) (fn err->string [{: msg} module-name name] (or msg (string.format "ERROR (in %s / %s, couldn't get traceback)" (or module-name "(unknown)") (or name "(unknown)")))) (fn get-where [start] (let [traceback (fennel.traceback nil start) (_ _ where) (traceback:find "\n\t*([^:]+:[0-9]+):")] (or where "?"))) ;;; assertions ;; while I'd prefer to remove all top-level state, this one is difficult ;; because it has to be set by every assertion, and the assertion functions ;; themselves do not have access to any stateful arguments given that they ;; are called directly from user code. (var checked 0) (var diff-cmd (or (os.getenv "FAITH_DIFF") (if (os.getenv "NO_COLOR") "diff -u %s %s" "diff -u --color=always %s %s"))) (macro wrap [flag msg ...] (let [reason (if (<= (length ...) 1) ... `(string.format ,...))] `(do (set ,(sym :checked) (+ ,(sym :checked) 1)) (when (not ,flag) (error {:char "F" :type :fail :tostring fail->string :reason ,reason :msg ,msg :where (get-where 4)}))))) (fn pass [] {:char "." :type :pass}) (fn error-result [msg] {:char "E" :type :err :tostring err->string :msg msg}) (fn skip [] (error {:char :s :type :skip})) (fn is [got ?msg] (wrap got ?msg "Expected truthy value")) (fn error* [pat f ?msg] (case (pcall f) (true ?val) (wrap false ?msg "Expected an error, got %s" (fennel.view ?val)) (_ err) (let [err-string (if (= (type err) :string) err (fennel.view err))] (wrap (err-string:match pat) ?msg "Expected error to match pattern %s, was %s" pat err-string)))) (fn extra-fields? [t keys] (or (accumulate [extra? false k (pairs t) &until extra?] (if (= nil (. keys k)) true (tset keys k nil))) (next keys))) (fn table= [x y equal?] (let [keys {}] (and (accumulate [same? true k v (pairs x) &until (not same?)] (do (tset keys k true) (equal? v (. y k)))) (not (extra-fields? y keys))))) (fn equal? [x y] (or (= x y) (and (= (type x) :table (type y)) (table= x y equal?)))) (fn diff-report [expv gotv] (let [exp-file (os.tmpname "faithdiff1") got-file (os.tmpname "faithdiff2")] (with-open [f (io.open exp-file :w)] (f:write expv)) (with-open [f (io.open got-file :w)] (f:write gotv)) (let [diff (doto (io.popen (diff-cmd:format exp-file got-file)) (: :read) (: :read) (: :read)) ; omit header lines out (diff:read :*all)] (os.remove exp-file) (os.remove got-file) (let [(closed _ code) (diff:close)] (if (or closed (= 1 code)) (.. "\n" out) (string.format "Expected:\n%s\nGot:\n%s" expv gotv)))))) (fn =* [exp got ?msg] (let [expv (fennel.view exp) gotv (fennel.view got) report (if (and (not= expv gotv) (or (expv:find "\n") (gotv:find "\n"))) (diff-report expv gotv) (string.format "Expected %s, got %s" expv gotv))] (wrap (equal? exp got) ?msg report))) (fn not=* [exp got ?msg] (wrap (not (equal? exp got)) ?msg "Expected something other than %s" (fennel.view exp))) (fn <* [...] (let [args [...] msg (if (= :string (type (. args (length args)))) (table.remove args)) correct? (faccumulate [ok? true i 2 (length args) &until (not ok?)] (< (. args (- i 1)) (. args i)))] (wrap correct? msg "Expected arguments in strictly increasing order, got %s" (fennel.view args)))) (fn <=* [...] (let [args [...] msg (if (= :string (type (. args (length args)))) (table.remove args)) correct? (faccumulate [ok? true i 2 (length args) &until (not ok?)] (<= (. args (- i 1)) (. args i)))] (wrap correct? msg "Expected arguments in increasing/equal order, got %s" (fennel.view args)))) (fn almost= [exp got tolerance ?msg] (wrap (<= (math.abs (- exp got)) tolerance) ?msg "Expected %s +/- %s, got %s" exp tolerance got)) (fn identical [exp got ?msg] (wrap (rawequal exp got) ?msg "Expected %s, got %s" (fennel.view exp) (fennel.view got))) (fn match* [pat s ?msg] (wrap (: (tostring s) :match pat) ?msg "Expected string to match pattern %s, was\n%s" pat s)) (fn not-match [pat s ?msg] (wrap (or (not= (type s) :string) (not (s:match pat))) ?msg "Expected string not to match pattern %s, was\n %s" pat s)) ;;; running (fn dot [char total-count] (io.write char) (when (= 0 (math.fmod total-count 76)) (io.write "\n")) (io.stdout:flush)) (fn print-totals [report] (let [{: started-at : ended-at : results} report duration (fn [start end] (let [decimal-places 2] (: (.. "%." (tonumber decimal-places) "f") :format (math.max (- end start) (^ 10 (- decimal-places)))))) counts (accumulate [counts {:pass 0 :fail 0 :err 0 :skip 0} _ {:type type*} (ipairs results)] (doto counts (tset type* (+ (. counts type*) 1))))] (print (: (.. "Testing finished %s with %d assertion(s)\n" "%d passed, %d failed, %d error(s), %d skipped\n" "%.2f second(s) of CPU time used") :format (if started-at.real (: "in %s second(s)" :format (duration started-at.real ended-at.real)) (: "in approximately %s second(s)" :format (- ended-at.approx started-at.approx))) checked counts.pass counts.fail counts.err counts.skip (duration started-at.cpu ended-at.cpu))))) (fn begin-module [report tests] (print (string.format "\nStarting module %s with %d test(s)" report.module-name (accumulate [count 0 _ (pairs tests)] (+ count 1))))) (fn done [report] (print "\n") (each [_ result (ipairs report.results)] (when result.tostring (print (result:tostring report.module-name result.name)))) (print-totals report)) (local default-hooks {:begin false : done : begin-module :end-module false :begin-test false :end-test (fn [_name result total-count] (dot result.char total-count))}) (fn test-key? [k] (and (= (type k) :string) (k:match :^test.*))) (local ok-types {:fail true :pass true :skip true}) (fn err-handler [name] (fn [e] (if (and (= (type e) :table) (. ok-types e.type)) e (error-result (-> (string.format "\nERROR: %s:\n%s\n" name e) (fennel.traceback 4)))))) (fn run-test [name ?setup test ?teardown report hooks context] (when (fn? hooks.begin-test) (hooks.begin-test name)) (let [result (case-try (if ?setup (xpcall ?setup (err-handler name)) true) true (xpcall #(test (unpack context)) (err-handler name)) true (pass) (catch (_ err) err))] (when ?teardown (pcall ?teardown (unpack context))) (table.insert report.results (doto result (tset :name name))) (when (fn? hooks.end-test) (hooks.end-test name result (length report.results))))) (fn run-setup-all [setup-all report module-name] (if (fn? setup-all) (case [(pcall setup-all)] [true & context] context [false err] (let [msg (: "ERROR in test module %s setup-all: %s" :format module-name err)] (table.insert report.results (doto (error-result msg) (tset :name module-name))) (values nil err))) [])) (fn run-module [hooks report module-name test-module] (assert (= :table (type test-module)) (.. "test module must be table: " module-name)) (let [module-report {: module-name :started-at (now) :results []}] (case (run-setup-all test-module.setup-all report module-name) context (do (when hooks.begin-module (hooks.begin-module module-report test-module)) (each [_ {: name : test} (ipairs (doto (icollect [name test (pairs test-module)] (if (test-key? name) {:line (. (debug.getinfo test :S) :linedefined) : name : test})) (table.sort #(< $1.line $2.line))))] (run-test name test-module.setup test test-module.teardown module-report hooks context)) (case test-module.teardown-all teardown (pcall teardown (unpack context))) (when hooks.end-module (hooks.end-module module-report)) (icollect [_ value (ipairs module-report.results) &into report.results] value))))) (fn exit [hooks] (if hooks.exit (hooks.exit 1) _G.___replLocals___ :failed (and os os.exit) (os.exit 1))) (fn run [module-names ?opts] (set (checked diff-cmd) (values 0 (or (and ?opts ?opts.diff-cmd) diff-cmd))) (io.stdout:setvbuf :line) ;; don't count load time against the test runtime (each [_ m (ipairs module-names)] (require m)) (let [hooks (setmetatable (or (?. ?opts :hooks) {}) {:__index default-hooks}) report {:module-name :main :started-at (now) :results []}] (when hooks.begin (hooks.begin report module-names)) (each [_ module-name (ipairs module-names)] (case (pcall require module-name) (true test-module) (run-module hooks report module-name test-module) (false err) (let [error (: "ERROR: Cannot load %q:\n%s" :format module-name err)] (table.insert report.results (doto (error-result error) (tset :name module-name)))))) (set report.ended-at (now)) (when hooks.done (hooks.done report)) (when (accumulate [red false _ {:type type*} (ipairs report.results) &until red] (or (= type* :fail) (= type* :err))) (exit hooks)))) (when (= ... "--tests") (run (doto [...] (table.remove 1))) (os.exit 0)) {: run : skip :version "0.2.0" : is :error error* := =* :not= not=* :< <* :<= <=* : almost= : identical :match match* : not-match}