Formatting Strings in Scheme

Most of the time I use primitive display, or print functions:

;; displays a series of args to stdout
(define (print . args) (for-each display args) (flush-output-port) )

;; displays a series of args to stdout, then newline
(define (println . args) (for-each display args) (newline) (flush-output-port) )

;; displays a series of args to given port
(define (fprint port . args) (for-each (λ (x) (display x port)) args) (flush-output-port port) )

;; displays a series of args to given port, then newline
(define (fprintln port . args) (for-each (λ (x) (display x port)) args) (newline port) (flush-output-port port) )

;; displays a series of args to stderr, then newline
(define (errprintln . args)  (let [ (port (current-error-port)) ]
    (for-each (λ (x) (display x port)) args) (newline port) (flush-output-port port)
))

but sometimes I actually need to format things:

(import (prefix (srfi s19 time) tm: ))

(format  "~8a ~10:d ~20a" name score
    (tm:date->string (tm:current-date) "~Y-~m-~d ~H:~M:~S") )

Common Lisp format works as described in Chez Scheme, using /#f for destination, and some other Schemes as well; but most Schemes only have the nearly-useless SRFI 28. I'm aware of cat/fox/etc combinatorial formatters, but they're very verbose.

Chez also has date/time functions, but no formatter, so using SRFI 19 - nicely, SRFI 19 mostly does sane things, it's not like C's strftime.

Gambit hits a mark

(see, the X-Men Gambit has perfect aim and a stupid accent, which still makes him more interesting than Hawkeye; and of course I'm Mark and so is Marc)

With much appreciated help from Marc Feeley, got maintest running.

A couple of lessons: I very much think include paths should include the path of the main source doing the including. Chibi's default is correct, Gambit's default is wrong and requires fixing in every user program. It's "more secure", but if you're running source code from a directory, you can probably trust whatever else is in that dir.

main was frustrating: Gambit manual 2.6 (highlighting mine)

After the script is loaded the procedure main is called with the command line arguments. The way this is done depends on the language specifying token. For scheme-r4rs, scheme-r5rs, scheme-ieee-1178-1990, and scheme-srfi-0, the main procedure is called with the equivalent of (main (cdr (command-line))) and main is expected to return a process exit status code in the range 0 to 255. This conforms to the “Running Scheme Scripts on Unix SRFI” (SRFI 22). For gsi-script and six-script the main procedure is called with the equivalent of (apply main (cdr (command-line))) and the process exit status code is 0 (main’s result is ignored). The Gambit system has a predefined main procedure which accepts any number of arguments and returns 0, so it is perfectly valid for a script to not define main and to do all its processing with top-level expressions (examples are given in the next section).

So your code that looks fine with 1 arg will break with 2, depending on the version. (main . argv) works. I'm in the process of making sure every one of my maintests parses args consistently, and every Scheme disagrees.

Gambit's compiler worked very simply once I got the library on the command line; it doesn't seek out & include them the way Chez does, even though it takes what looks like a search path.

The upside of all this is at least now there's one maintained, fast, R7-compatible Scheme compiler. I'm sticking with Chez (R6) for my code, but it's nice having something 100x faster (gut feeling, not benchmarked) than Chibi to test R7 code on.

Gambit is a risky scheme

(puns about "scheme" names are mandatory)

Neat: New version 4.9.4 of Gambit Scheme is out and they have a web site again after like 3 years.

OK: So I start adapting my little module/how do you run example: here

Bad: Not only does the R7 library system not work, their version of this hello example
will load code from fucking github at runtime! NPM viruses & sabotage are baked into the system. See Modules in Gambit at 30

SIGH.

FreePascal Building

I went to do a little code maintenance on a couple utils I'd written in FreePascal, and they wouldn't build. For a couple years, FPC just didn't work on 64-bit Mac OS, but they finally fixed that. Current fpc 3.2.0 is in MacPorts, I'm not sure what the state of Lazarus is, I quit using it. But the core Pascal is fine for many tasks, and I may do some things in CP/M Pascal when I get my SpecNext (longest wait ever until fall/whenever).

Anyway, the error I got was ld: library not found for -lc and nobody on the Internet has ever posted with that error, that I can find.

And eventually I tracked it down to the SDK not being linked at all. So here's an updated fpc.cfg which goes in your $PPC_CONFIG_PATH, note the DARWIN section. Also that's x86_64 only, I need to add an ARM64 branch at some point.

#IFDEF DEBUG
     DEBUG
    -gl
    -Crtoi

    # Strip debuginfo
    -O2
    -Xs


 DARWIN
     DARWIN
    # use pipes instead of temporary files for assembling
    -ap
     CPUX86_64
    -Px86_64
    -FD/Library/Developer/CommandLineTools/usr/bin
    -XR/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk


 OBJFPC
     OBJFPC
    -Mobjfpc

     FPC
    -Mfpc


# use ansistrings
-Sh

# stop after warnings
-Sew

# don't show Hint: (5023) Unit "X" not used in Y
-vm5023
# don't show Hint: (5024) Parameter "name" not used
-vm5024

#-Fl/usr/X11/lib
#-Fl/usr/local/lib

-Fu/opt/local/libexec/fpc/lib/fpc/$fpcversion/units/$fpctarget
-Fu/opt/local/libexec/fpc/lib/fpc/$fpcversion/units/$fpctarget/*

And a quick hello world/Unicode tester:

{ hello.pas
    Copyright ©2017 by Mark Damon Hughes. Do what thou wilt.
}

program hello;

uses crt, sysutils;

var
    name: utf8string;
    c: widechar;
    i: integer;
begin
    write('What is your name? ');
    readln(name);
    writeln('Hello, ', name, ' from code page ', stringCodePage(name), '!');
    for i := 1 to length(name) do begin
        c := name[i];
        writeln(i, ': ', c, '(', ord(c), ')');
    end;
    readkey();
end.

fpc -dDEBUG hello.pas produces a couple dozen warnings like: ld: warning: object file (/opt/local/libexec/fpc/lib/fpc/3.2.0/units/x86_64-darwin/rtl/baseunix.o) was built for newer macOS version (11.0) than being linked (10.8) and I'd love it if someone could tell me how to get FPC to tell ld to make that STFU.

But it works:

% ./hello
What is your name? Mark
Hello, Mark from code page 65001!
1: M(77)
2: a(97)
3: r(114)
4: k(107)
5: �(239)
6: �(163)
7: �(191)

Script the Scheme REPL with Expect

Routinely I want to open the Chez Scheme REPL in my code dir and load my standard libraries; I've been copy-pasting from a Stickies to get my setup each time, because you can't easily set a common prelude from command line. Finally solved that.

In ~/bin/scheme-repl, I put:

#!/usr/bin/env expect -f
log_user 0
cd "$env(HOME)/Code/CodeChez"
spawn scheme
expect "> "
send -- "(import (chezscheme) (marklib) (marklib-os)"
send -- "  (only (srfi s1 lists) delete drop-right last)"
send -- "  (only (srfi s13 strings) string-delete string-index string-index-right string-join string-tokenize) )\n"
log_user 1
interact

(make sure to change the path to wherever you keep your Scheme scripts, and whatever imports you like)

Added alias s=scheme-repl to my .zshrc

Now I can just hit one letter for shortcut:

% s
(import (chezscheme) (marklib) (marklib-os)  (only (srfi s1 lists) delete drop
-right last)  (only (srfi s13 strings) string-delete string-index string-index-r
ight string-join string-tokenize) )
> (os-path 'pwd)
"/Users/mdh/Code/CodeChez"
>

You can sort of do the same thing with a Chez boot file, but I wasn't able to get it to load libraries from thunderchez, even with --libdirs flag, so screw it.

I'd forgotten everything I ever knew about expect, and the only resources online are exact copies of the same "log into ssh with expect!" (which you should never do! Set up SSH keys for Cthulhu's sake!) tutorial over and over again, so had to read the man page to make even this trivial thing work.

Still Fixing Sublime

Minor notes on customization, all files are in Preferences > Browse Packages.

I fixed Dracula to be a little more pit of utter darkness:

UI: Customize Theme, file is User/Default Dark.sublime-theme:

// Documentation at https://www.sublimetext.com/docs/themes.html
{
    "variables": {
        "base_hue": "black",
        "base_tint": "#99ddff",
        "ui_bg": "#222222",
        "text_fg": "white",
        "sidebar_bg": "#111111",
        "sidebar_row_selected": "#666600",
    },
    "rules": [
    ]
}

UI: Customize Color Scheme, file is User/Dracula.sublime-color-scheme:

// Documentation at https://www.sublimetext.com/docs/color_schemes.html
{
    "variables": {
    },
    "globals": {
        "background": "black",
    },
    "rules": [
    ]
}

And some keys I use, file is User/Default (OSX).sublime-keymap: ^O as Open above is incredibly useful if you write "paren code enter paren open-above tab" over and over. Cmd-Y is lambda λ in a bunch of my editors and iTerm2; using the actual lambda symbol is awkward but cool, so of course I do it.

[
    { "keys": ["ctrl+o"], "command": "run_macro_file", "args": {"file": "res://Packages/Default/Add Line Before.sublime-macro"} },
    { "keys": ["shift+ctrl+o"], "command": "run_macro_file", "args": {"file": "res://Packages/Default/Add Line.sublime-macro"} },
    { "keys": ["super+y"], "command": "insert", "args": {"characters": "λ"} },
]

Obvious missing feature is piping thru shell. I'm going to tackle that fully later, but for now I just needed to number lines, so added a package Number, containing:

Number/Number.py:

import sublime
import sublime_plugin

def numberLines(s):
    out = []
    i = 0
    for line in s.splitlines():
        i += 1
        out.append("%d. %s" % (i, line))
    return "\n".join(out) + "\n"

class NumberCommand(sublime_plugin.TextCommand):
    def run(self, edit):
        for region in self.view.sel():
            if not region.empty():
                s = self.view.substr(region)
                out = numberLines(s)
                self.view.replace(edit, region, out)

and Number/Default (OSX).sublime-keymap:

[
    { "keys": ["shift+ctrl+n"], "command": "number" }
]

This makes a nice template for doing additional filters. Of course there's no damned examples for the current version, the old samples are missing arguments.

When developing a plugin, you can open the console with ^~ and type view.run_command("number") and see some print statement debugging, which helps.

It's very much more of a "developer's toolkit" than a real editor sometimes. It reminds me a lot of hacking on E/PM in REXX on OS/2, where the entire editor was just a big REXX script and it was trivial to make it circle all search results, or some such. But it's been better for doing my Scheme hacking than anything else so far.

Sublime or Sub-prime?

Because I'm endlessly evading responsibility^W^W looking for more efficient ways to work at nothin' all day^A^K look, I play with new editors.

Ugly themes, right away, install Package Control, and then the theme Dracula, and it looks like something I can stand. Not as good as Hackerman for Atom.

It's still ludicrously using stacks of JSON files for settings; 700+ lines in the default, OS overrides that, then your user prefs override that. Read lines, figure out if the default is useful, copy & modify in your own.

It does have the advantage it works reasonably fast, and runs on multiple platforms. It has a Lisp syntax highlighter that doesn't completely suck on Scheme.

But it has this terrible minimap, and no setting for it! You have to disable it from the menu every time you make a new window. OR, you can hack on it. Prefs, Browse Packages, make a Python file User/minimap.py (found on StackOverflow, ugh):

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import sublime
import sublime_plugin

class MinimapSetting(sublime_plugin.EventListener):
    def on_activated(self, view):
        show_minimap = view.settings().get("show_minimap")
        view.window().set_minimap_visible(show_minimap)

Then add "show_minimap": false, to the prefs file, and the stupid minimap never shows back up.

Problem 2, I wanted to insert timestamps in my headers. No datetime function or snippet or whatever.

Make a folder Datetime next to User, file Datetime/Datetime.py:

import sublime
import sublime_plugin
import datetime

class DatetimeCommand(sublime_plugin.TextCommand):
    def run(self, edit):
        self.view.insert(edit, self.view.sel()[0].b, datetime.datetime.now().isoformat())

Note all the name cases matter. See that horrible self.view.sel()[0].b? That's how you get the current cursor position. WTF!

Next to it, make Default (OSX).sublime-keymap:

[
    { "keys": ["shift+ctrl+t"], "command": "datetime" }
]

And now Sh-^T will insert a chunky ISO8601 datetime. Good enough. (originally I did Sh-Cmd-D, but turns out that's duplicate line which I use sometimes)

So, I'm giving Sublime 4 a ★★☆☆☆ out of the box, the API is ugly as hell and they no longer have example code up so ★★★☆☆, but it's pretty hackable if you don't mind getting your hands covered in code guts, so maybe ★★★★☆

I'll give it another week and see if it's worth using longer term. BBEdit's infinitely better for giant text files, but I do have needs other than that.


Man, that Bachman & Turner concert… they're super old, and that was 2012. But Paul Schaeffer, minus the World's Most Dangerous Band, jamming out on the keyboard! The audience are too old to dance. But they still rock out pretty good for guys who are even older than me.

Reinventing the Wheel

Sure, there's existing code. Somebody Else's Code. It works fine, maybe not as fast as you'd like, or the interface isn't quite right. That's how it often is with me and SRFI-13. Olin Shivers is a skilled Schemer, back when that wasn't cool (OK, it's still not cool), but some of his APIs enshrined in early SRFIs drive me a little nuts, and the implementation is slow because it's so generalized.

So after a few false starts and failed tests, I now have these pretty things: (updated 2020-11-10, enforced hstart, hend boundaries)

;; Returns index of `needle` in `haystack`, or  if not found.
;; `cmp`: Comparator. Default `char=?`, `char-ci=?` is the most useful alternate comparator.
;; `hstart`: Starting index, default 0.
;; `hend`: Ending index, default (- haystack-length needle-length)
(define string-find (case-lambda
    [(haystack needle)  (string-find haystack needle char=? 0 )]
    [(haystack needle cmp)  (string-find haystack needle cmp 0 )]
    [(haystack needle cmp hstart)  (string-find haystack needle cmp hstart ) ]
    [(haystack needle cmp hstart hend)
        (let* [ (hlen (string-length haystack))  (nlen (string-length needle)) ]
            (set! hstart (max 0 (min hstart (sub1 hlen))))
            (unless hend (set! hend (fx- hlen nlen)))
            (set! hend (max 0 (min hend hlen)) )
            (if (or (fxzero? hlen) (fxzero? nlen))
                
                (let loop [ (hi hstart)  (ni 0) ]
                    ;; assume (< ni nlen)
                    ;(errprintln "hi=" hi ", ni=" ni ", hsub=" (substr haystack hi hlen) ", bsub=" (substr needle ni nlen))
                    (cond
                        [(cmp (string-ref haystack (fx+ hi ni)) (string-ref needle ni))  (set! ni (fx+ ni 1))
                            ;; end of needle?
                            (if (fx>=? ni nlen)  hi  (loop hi ni) )
                        ]
                        [else  (set! hi (fx+ hi 1))
                            ;; end of haystack?
                            (if (fx>? hi hend)    (loop hi 0) )
                        ]
        ))))
    ]
))

;; Test whether 'haystack' starts with 'needle'.
(define (string-has-prefix? haystack needle)
    (let [ (i (string-find haystack needle char=? 0 0)) ]
        (and i (fxzero? i))
))

;; Test whether 'haystack' ends with 'needle'.
(define (string-has-suffix? haystack needle)
    (let* [ (hlen (string-length haystack))  (nlen (string-length needle))
            (i (string-find haystack needle char=? (fx- hlen nlen)))
        ]
        (and i (fx=? i (fx- hlen nlen)))
))

Written for Chez Scheme, caveat implementor. BSD license, do what thou wilt. If you find a bug, send me a failing test case.

I don't normally bother with fx (fixnum) operations, but in tight loops it makes a difference over generic numeric tower +, etc.

Retrospective: PortalWorlds

What worked, what sucked, lessons learned, The More You Know, and knowing is half the battle, go Cobra, etc.

JS: Test Your Libraries -1

Especially the hard-to-test parts. I had one "obviously correct" array shuffle function I've been using for maybe 10 years:

WRONG RIGHT
function arrayShuffle(arr) {
    for (let i = arr.length-1; i >>= 1; --i) {
        const j = Math.floor(Math.random() * i); // WRONG. NO.
        const tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }
    return arr;
}
/** Fisher-Yates */
function arrayShuffle(arr) {
    for (let i = arr.length-1; i >= 1; --i) {
        const j = Math.floor(Math.random() * (i+1)); // RIGHT. YES.
        const tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }
    return arr;
}

And because of that, I got a distorted shuffle. I only noticed because no mob using my dumb-as-rocks not-even-AI would ever choose to move north… everything ended up piled up at the bottom of the map.

It's hard to test randomness, but you can check that values are in a sane range. For a decade I've had wrong code just copy-pasted into new projects because I never checked my assumptions.

JS: UI Libraries are Great +1

I mean, I knew that. But how easy it is to throw something up is a bit of a surprise every time I do it. It's a fast, dynamic, functional/OOP language that also has good graphics and sound libraries (I didn't do sound in PortalWorlds, but I will before 1.0), and tolerable event handling these days.

I'm not releasing the full source, but in a week or two I'll add a few more things and release my common library under BSD license. This is mostly stuff from my Learn2JS project, and some from TTMS-76, but those have a giant framework for running scripts, this is just a few functions for a pure JS application, and greatly condensed.

Pulling in common code makes this stuff so much easier.

JS: Application Structure -1

There's no proper "run loop" in PortalWorlds, and late in the process that bit me in the ass.

What you should do is, collect events, put them in a queue. Every X milliseconds (I usually use 30 FPS, so 1000/30=33.333ms) have setInterval do an event-update-redraw loop. If something takes a long time or requires animation updates, it must be done in chunks or in a background thread (using Web Workers ). This is true of any application, not just games.

What I did wrong is having events immediately perform actions, and the setInterval just does update-redraw. This is much easier, but it's wrong. I have no control currently over blocking user actions, and animation has to all happen at once.

The trick I used is to keep a "frame" counter, and that chooses animation frames, moves floating text and the tracers of missiles and fireballs. Next turn invoked by user event just wipes those out. An amusing side-effect is missiles vaporize corpses as they fly past.

Switching to a correct run loop isn't super hard, but does require changes to all my timing and animation hacks, so at this point it's not worth it.

JS: Fonts -1

I used Oryx's "simplex" font, which looks fine and bitmappy in web pages, but in Canvas it gets antialiased, and I wasn't able to make it stop. So I have to make all fonts slightly larger than I'd like, and they're still kind of blurry.

The smarter way would be to use a bitmap font, and a very simple bitmap text renderer. That's what I do in Perilar: Dark Weaver, with an ATASCII-inspired fantasy font. But in 7 days I didn't have time to write and test that.

That's on a "maybe later" list for 1.0. Alternately, I could figure out how to get them antialiased in Canvas?

JS: Minimizer -1

I used to use yuicompressor, which renames variables and aggressively minimizes your code into unreadable but very compact line noise. But with the long-drawn-out death of Yahoo!, that hasn't been updated in a decade, and it doesn't handle ES2020.

I could probably have run everything thru Babel and then yuicompressor, but it's time to move on? So I just used Crockford's jsmin which only removes whitespace. It might be a good time to look into compiling JS to WASM binaries.

My build.zsh script:

#!/bin/zsh
rm -rf portalworlds*.zip
rm -rf build
mkdir -p build
mkdir -p build/js

# *.js -> .min.js
for f in js/*; do
    n=`basename $f .js`
    perl -ne 's/const DEBUG = true;/const DEBUG = false;/; s/^\s*DLOG.*$//; print;' $f >build/$f
    jsmin-crockford <build/$f >build/js/$n.min.js "Copyright (c) 2020 by Mark Damon Hughes. All Rights Reserved."
    rm build/$f
done

# index.html
perl -ne "s/\.js'/.min.js'/; print;" index.html >build/index.html

cp -R favicon.* style.css i ttf build
cd build
zname=portalworlds-$(date "+%Y%m%d_%H%M%S").zip
zip -r9Xq ../$zname * -x "*.DS_Store"
cd ..
echo $zname

Game: Combat Design +1

So I knew I wanted combat to be very swingy (allowing anything from instant death to instant kill), but not have levels or many stats; I didn't have time or inclination to make another detailed RPG!

Instead, combat works by adding your current strength and enemy's current strength, rolling 2 dice in half that range, so central results are more likely, but it's easy to get results at either far end. Then apply the difference from your strength as damage to you or the monster, depending on which side of the line it's on:

const roll = Math.floor(dice(2, this.strength + mob.strength)/2);
if (roll <= this.strength) {
    const dmg = Math.floor( (this.strength - roll) * (100 + this.getDamage()) / 100 );
    mob.takeDamage(dmg, true);
} else {
    const dmg = Math.floor( (roll - this.strength) * (100 - this.getDefense()) / 100 );
    message(mob.toString()+" hits you for "+dmg+" damage");
    this.takeDamage(dmg);
}

Kind of ridiculously simple, but it makes for symmetric combat, so player-attacks-monster and monster-attacks-player have the same results. It's not practical for a tabletop game, but this is a really fun mechanic in a computer game.

Damage and Defense from gear just modify the result by percentiles, they don't affect attack roll at all. If your strength + defense % is less than the monster's strength, you can be one-hit-killed, and vice versa.

Experience adds to current strength, and increases base strength if you're near max; you need to finish fights against slightly superior foes unharmed to grind up strength.

Game: Spells +1/-1

I knew I didn't have time for a lot of magic, and I never feel like I'm finished with magic systems anyway. So here I just picked 4 types: AOE damage, AOE control, escape, and heal; or as the game knows them, Fireball, Sleep, Invisible, and Heal. Then rather than have MP and worry about regeneration, I just give you "base" spells in each based on class, and you can pick up scrolls to add new points to current spell total. When you heal with mana potions or going thru a portal, you get back your base spells.

Last three were trivial: Sleep just searches a radius and has a die(100) > strength chance to give humanoid or animal targets a sleep condition for 2d4 turns; if they're sleeping, they skip the turn. Invis just sets an invis condition for 2d4 turns; mobs ignore you if you have that condition. Couple places like melee and taking damage I manually clear the Invis and Sleep conditions. Heal just heals 50% of base strength, woo.

Even with just 4, doing Fireball turned out to be quite challenging (the Application Structure problem); how do I show it go across the board, maybe have a turn or two delay, then explode? Well, it does it by moving 4 steps every turn, and only having the fake animation for a tracer of where it's been.

Game: Permadeath +0

Permadeath happened more by lack of save state than any intention. I much prefer to have save slots, and then you can choose to save your game and reload last or earlier, or you can play hardcore and never reload. It puts the moral burden of choosing permadeath on the player, not the developer.

But JS localStorage doesn't really have room to stash a huge amount of data. In Reaper's Crypt I had a very aggressive compression for the map (which looks like a giant grid of tiles but it's really about 64 rooms per level), and it's still problematic, sometimes maps just can't be saved.

I could save the character as of last portal you entered, and regenerate maps. But that encourages "stair-scumming", which I do have a problem with. So instead this is very arcade-like, you just play until you die, then insert another quarter.

I do plan to add a scoreboard, both local and maybe server-based for a copy hosted on mysticdungeon.club.

Personal: Shipping is Awesome +1

Actually finishing something and making it public without a decade-long process is amazing. It's not perfect, and I know nothing is perfect, I should just ship things, but I can't normally do that. Having a hard deadline and meeting it was the best feeling.

Personal: Drone-Slack Balance -1

I was desperately exhausted afterwards. Bone tired all day Sunday, and I'm still feeling it on Monday. 7 days in a row, even with a couple shorter days, is about 3 or 4 days too many.

I'm nocturnal. I like to wake up ideally just before midnight (but sometimes backslide to evening if Real Life interferes), eat and coffee, then work until dawn, walk the dog at sunrise, maybe get my own walk in (tiny dog cannot keep up for a mile+), then finish up and goof off until early afternoon when I can sleep. The schedule weirds out some daywalkers, but it's quieter and more compatible with "morning people" (ugh) than waking in afternoon and sleeping at dawn, which I did for decades.

But normally that work is 3 days of code, 1-2 days of writing or art, 2-3 days of just playing videogames or going Outside, doing Real Life AFK stuff.

Drones who can work all the time frighten me, they're basically Terminators. People who Slack all the time frighten me, they're a waste of precious oxygen & water, and I may foolishly try to rely on these people and get nothing. You need to be in the middle area.

Scheme-Test-Unit

Moving my library from Chicken to Chez (when I moved it from Chez to Chicken originally, it was much smaller and I only used a few asserts to test), I discovered that:

  1. Chicken's test egg is pretty nice but non-standard.
  2. SRFI-64 (from Thunderchez ) is OK as far as it goes, but has an inadequate test runner (the default just lists PASS/FAIL with no explanation for each test, and has one total). Ridiculous when you have dozens or hundreds of tests.
  3. There's no good alternative. There's a SchemeUnit which is for PLT Scheme née Racket, and a couple others which aren't SRFI-64 and aren't for Chez.

So I ended up writing my own:

Here's how it works:

#!/usr/bin/env scheme-script
;; example-test.ss

(import (chezscheme)
    (srfi s64 testing) ;; thunderchez
    (scheme-test-unit)
)

(define (all-tests)
(test-group "All Tests"

(test-group "Math"
    (test-equal "add" 4 (+ 2 3))
)

(test-group "Strings"
    (test-equal "append" "foobar" (string-append "foo" "bar"))
)

) ;; test group "All Tests"
) ;; all-tests

(define (main argv)
    (scheme-test-configure argv)
    (all-tests)
)

(main (command-line-arguments))

----
% chmod 755 example-test.ss
% ./example-test.ss --help
Usage: scheme-test-configure [-v|--verbose|-q|--quiet|-o FILENAME|--output FILENAME]
% ./example-test.ss
*** All Tests START
*** Math START
- add [(+ 2 3)] expected <<<5>>> but got <<<4>>>
*** Math END, PASS: 0 / FAIL: 1
*** Strings START
+ append [(string-append foo bar)]
*** Strings END, PASS: 1 / FAIL: 0
*** All Tests END, PASS: 0 / FAIL: 0
FINAL PASS: 1 / FAIL: 1

My own test cases come out:

% ./marklib-test.ss -q
*** Control END, PASS: 4 / FAIL: 0
*** Logic END, PASS: 12 / FAIL: 0
*** Math END, PASS: 18 / FAIL: 0
*** Strings END, PASS: 29 / FAIL: 0
*** Any END, PASS: 31 / FAIL: 0
*** Hashtable END, PASS: 9 / FAIL: 0
*** List END, PASS: 6 / FAIL: 0
*** Maybe END, PASS: 6 / FAIL: 0
*** Stack END, PASS: 10 / FAIL: 0
*** Vector END, PASS: 8 / FAIL: 0
*** Data Structures END, PASS: 0 / FAIL: 0
*** Dice END, PASS: 7 / FAIL: 0
*** All Tests END, PASS: 0 / FAIL: 0
FINAL PASS: 140 / FAIL: 0