From da082736cc6d7522d688b03d029da1a4fcc07d18 Mon Sep 17 00:00:00 2001 From: Samuel Verschelde Date: Sun, 26 Jul 2026 00:28:26 +0000 Subject: [PATCH 1/3] Add ** include: directive for external file inclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for including another test file at a point in the current test file with ** include: file.t The included file is parsed recursively — its tests, directives, and nested includes take effect as if pasted inline at the ** include: line. Paths are resolved relative to the including file's directory. Multiple ** include: lines are needed to include several files; each line includes exactly one file. - Circular include chains are detected and raise an error with the full cycle path (e.g. A -> B -> C -> A) - Duplicate test names across files raise an error showing both source files - All directives follow the same rules: ** game: overrides, ** precommand: appends, etc. --- regtest.html | 12 +++++++++++ regtest.py | 60 ++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/regtest.html b/regtest.html index 40acb1d..b4f182b 100644 --- a/regtest.html +++ b/regtest.html @@ -124,6 +124,18 @@

The Test File

A line beginning with "** checkclass:" specifies a (Python) file containing extra check classes. I won't get into the details here, but see this sample file.

+

+A line beginning with "** include:" specifies a file to include at that point in the test file. Like all ** directives, it must appear before the first test definition. The included file is parsed recursively; its tests, directives, and nested includes take effect as if the content were pasted inline. File paths are resolved relative to the directory of the file containing the ** include: directive. You can use multiple ** include: lines to include several files. +

+ +

+Because included content is parsed inline, directives in included files follow the same rules as in the main file: ** game: overrides any prior value, ** precommand: appends to the precommand list, and so on. Test names must be unique across all files. A duplicate test name will cause an error. Circular includes (A includes B which includes A) are also an error. +

+ +

+An included file can contain anything the main file can: top-level directives, test definitions, comments, and even more ** include: lines. +

+

The rest of the test file is a set of tests. Each test is a separate run through the game. A test contains a sequence of commands. A command can contain various checks, validating the output of that command.

diff --git a/regtest.py b/regtest.py index 3e4276c..8d92813 100644 --- a/regtest.py +++ b/regtest.py @@ -96,8 +96,9 @@ class RegTest: A test is one session of the game, from the beginning. (Not necessarily to the end.) After every game command, tests can be run. """ - def __init__(self, name): + def __init__(self, name, testfile): self.name = name + self.testfile = testfile self.gamefile = None # use global gamefile self.terp = None # global terppath, terpargs self.precmd = None @@ -1111,12 +1112,42 @@ def parse_checkfile(filename): finally: fl.close() -def parse_tests(filename): - """Parse the test file. This fills out the testls array, and the - other globals which will be used during testing. +def parse_file(filename, base_dir, testfile, visiting): + """Parse a test file, handling ** include directives recursively. + + filename: the file path to parse + base_dir: parent directory — used to resolve the given filename if it + is not absolute (for the top-level file this is CWD, for an + included file this is the directory of the including file) + testfile: file name shown in error messages + visiting: ordered list of (filename, absolute-path) tuples of all + files currently in the include chain, from outermost to most + recent. Used to detect circular includes: if the file we're + about to parse is already in the chain, we raise an error + showing the full cycle path. + + Note on path resolution: + - The file being parsed (filename) is resolved relative to base_dir. + - Files listed in that file's ** include: are resolved relative to + the including file's directory (abspath below), not base_dir. + This lets you nest includes in subdirectories without having to + rewrite paths on every level. """ global gamefile, terppath, terpargs, terpformat + if not os.path.isabs(filename): + filename = os.path.join(base_dir, filename) + abspath = os.path.abspath(filename) + + # Check for circular includes + visited_paths = [p for _, p in visiting] + if abspath in visited_paths: + cycle = ' -> '.join([sf for sf, _ in visiting] + [testfile]) + raise Exception('Circular include chain: ' + cycle) + + # Push self onto the chain so nested includes can detect cycles + visiting.append((testfile, abspath)) + fl = open(filename) curtest = None curcmd = None @@ -1153,8 +1184,14 @@ def parse_tests(filename): terpformat = 'rem' if (val.lower() > 'og') else 'cheap' elif (key == 'checkclass'): parse_checkfile(val) + elif (key == 'include'): + # Resolve included file relative to THIS file's + # directory (not the outer base_dir), so that nested + # includes in subdirectories work naturally. + inc_base = os.path.dirname(abspath) + parse_file(val, inc_base, val, list(visiting)) else: - raise Exception('Unknown option: ** ' + key) + raise Exception('Unknown option: ** ' + key + ' (in ' + testfile + ')') else: if (key == 'game'): curtest.gamefile = val @@ -1168,8 +1205,11 @@ def parse_tests(filename): if (ln.startswith('*')): ln = ln[1:].strip() if (ln in testmap): + existing = testmap[ln] + if existing.testfile != testfile: + raise Exception('Test name used twice: ' + ln + ' (in ' + testfile + ' and ' + existing.testfile + ')') raise Exception('Test name used twice: ' + ln) - curtest = RegTest(ln) + curtest = RegTest(ln, testfile=testfile) testls.append(curtest) testmap[curtest.name] = curtest curcmd = Command('(init)') @@ -1186,6 +1226,14 @@ def parse_tests(filename): fl.close() +def parse_tests(filename): + """Parse the test file. This fills out the testls array, and the + other globals which will be used during testing. + """ + base_dir = os.getcwd() + parse_file(filename, base_dir, filename, []) + + def list_commands(ls, res=None, nested=()): """Given a list of commands, replace any {include} commands with the commands in the named subtests. This works recursively. From 3ddcf16e9a1894af31b60ccc4083735041ae37d8 Mon Sep 17 00:00:00 2001 From: Samuel Verschelde Date: Thu, 30 Jul 2026 23:49:12 +0000 Subject: [PATCH 2/3] Add a {include:silent} modifier for {include} >{include:silent} TESTNAME runs the included test's commands as usual but keeps their output out of the verbose transcript, in order to avoid repetition in the test transcripts. The included test's checks still run, and their failures are still reported. Pass -vv to see everything anyway. In -v mode a marker line, [silently included: TESTNAME], is printed in place of the hidden block, so the transcript still shows where the included test ran. Signed-off-by: Samuel Verschelde --- regtest.html | 21 ++++++++++++++++++++- regtest.py | 52 +++++++++++++++++++++++++++++++++------------------- 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/regtest.html b/regtest.html index b4f182b..b4b2310 100644 --- a/regtest.html +++ b/regtest.html @@ -276,7 +276,23 @@

Partial Tests

-(No space between the ">" and the "{". Checks after an >{include} line are meaningless; they are ignored.) +To silence the output of an included test in verbose mode, use the :silent modifier: +

+ +

+>{include:silent} TESTNAME +

+ +

+This executes all of the included test's commands normally, but suppresses their output from the verbose transcript. The included test's checks still run and will still report failures. To override the silencing and see everything, pass -vv instead of -v. +

+ +

+In -v mode, a marker line [silently included: TESTNAME] is printed in place of the hidden commands, so the transcript still shows where the included test ran. This marker is not printed at -vv, since everything is already visible there. +

+ +

+(No space between the ">" and the "{". Checks after an >{include} line are meaningless; they are ignored.)

@@ -389,6 +405,9 @@

Dictionary of Inputs

>{include} testname
Performs all the commands and checks in the named test. +
>{include:silent} testname +
Same as {include}, but suppresses the included commands' output from the verbose transcript. Their checks still run. Use -vv to override this silencing. In -v mode, a [silently included: testname] marker line is printed in place of the hidden commands. +

Dictionary of Check Modifiers

diff --git a/regtest.py b/regtest.py index 8d92813..30cc22c 100644 --- a/regtest.py +++ b/regtest.py @@ -126,14 +126,13 @@ class Command: def __init__(self, cmd, type=None): if type is None: # Peel off the "{...}" prefix, if found. - match = re.match('{([a-z_]*)}', cmd) + match = re.match('{([a-z_:]*)}', cmd) if not match: type = 'line' cmd = cmd.strip() else: type = match.group(1) cmd = cmd[match.end() : ].strip() - self.type = type if self.type == 'line': self.cmd = cmd @@ -184,7 +183,9 @@ def __init__(self, cmd, type=None): self.height = int(ls[1]) except: pass - elif self.type == 'include': + elif self.type in ['include', 'include:silent']: + self.silent = self.type.endswith(':silent') + self.type = 'include' self.cmd = cmd elif self.type == 'fileref_prompt': self.cmd = cmd @@ -583,7 +584,7 @@ def initialize(self): def perform_input(self, cmd): raise Exception('perform_input not implemented') - def accept_output(self): + def accept_output(self, silenced=False): raise Exception('accept_output not implemented') class GameStateCheap(GameState): @@ -598,7 +599,7 @@ def perform_input(self, cmd): self.infile.write((cmd.cmd+'\n').encode()) self.infile.flush() - def accept_output(self): + def accept_output(self, silenced=False): self.storywin = [] output = bytearray() @@ -617,7 +618,7 @@ def accept_output(self): dat = output.decode('utf-8') res = dat.split('\n') - if (opts.verbose): + if opts.verbose and not silenced: for ln in res: if (ln == '>'): continue @@ -715,7 +716,7 @@ def perform_input(self, cmd): self.infile.write((cmd+'\n').encode()) self.infile.flush() - def accept_output(self): + def accept_output(self, silenced=False): import json output = bytearray() update = None @@ -749,7 +750,7 @@ def accept_output(self): if time.time() >= timeout_time: raise Exception('Timed out awaiting output') - self.parse_remglk_update(update) + self.parse_remglk_update(update, silenced=silenced) def construct_remglk_input(self, cmd): if cmd.type == 'line': @@ -805,7 +806,7 @@ def construct_remglk_input(self, cmd): print() return update - def parse_remglk_update(self, update): + def parse_remglk_update(self, update, silenced=False): # Parse the update object. This is complicated. For the format, # see http://eblong.com/zarf/glk/glkote/docs.html @@ -852,7 +853,7 @@ def parse_remglk_update(self, update): if text: for line in text: dat = self.extract_text(line) - if (opts.verbose == 1): + if opts.verbose == 1 and not silenced: if (dat != '>'): print(dat) if line.get('append') and len(self.storywin): @@ -956,14 +957,14 @@ def perform_input(self, cmd): (outdat, errdat) = proc.communicate((cmd+'\n').encode(), timeout=opts.timeout_secs) self.pendingupdate = outdat.decode() - def accept_output(self): + def accept_output(self, silenced=False): import json dat = self.pendingupdate self.assert_json(dat) update = json.loads(dat) self.pendingupdate = None - self.parse_remglk_update(update) + self.parse_remglk_update(update, silenced=silenced) class ObjPrint: NoneType = type(None) @@ -1234,9 +1235,12 @@ def parse_tests(filename): parse_file(filename, base_dir, filename, []) -def list_commands(ls, res=None, nested=()): +def list_commands(ls, res=None, nested=(), silenced=False): """Given a list of commands, replace any {include} commands with the - commands in the named subtests. This works recursively. + commands in the named subtests. This works recursively. The result is + a list of (command, silenced) pairs; a command is silenced when it + comes from a {include:silent}, which also appends the include command + itself (marked silenced) to stand in for the hidden block. """ if res is None: res = [] @@ -1247,9 +1251,11 @@ def list_commands(ls, res=None, nested=()): test = testmap.get(cmd.cmd) if not test: raise Exception('Included test not found: %s' % (cmd.cmd,)) - list_commands(test.cmds, res, nested+(cmd.cmd,)) + if cmd.silent: + res.append((cmd, True)) + list_commands(test.cmds, res, nested+(cmd.cmd,), silenced=silenced or cmd.silent) continue - res.append(cmd) + res.append((cmd, silenced)) return res class VitalCheckException(Exception): @@ -1303,8 +1309,16 @@ def run(test): if check.vital: raise VitalCheckException() - for cmd in cmdlist: - if (opts.verbose): + for cmd, silenced in cmdlist: + # An 'include' Command only ever reaches this list as a marker for a + # silenced block (list_commands() always continues past it otherwise). + if cmd.type == 'include': + if opts.verbose == 1: + print('[silently included: %s]' % (cmd.cmd,)) + print() + continue + suppressed = silenced and opts.verbose < 2 + if opts.verbose and not suppressed: if cmd.type == 'line': if terpformat == 'cheap': print('> %s' % (cmd.cmd,)) @@ -1314,7 +1328,7 @@ def run(test): else: print('> {%s} %s' % (cmd.type, repr(cmd.cmd),)) gamestate.perform_input(cmd) - gamestate.accept_output() + gamestate.accept_output(silenced=suppressed) for check in cmd.checks: res = check.eval(gamestate) if (res): From c8f9faf995a63784c15bc9d1651e3ca2e6958f45 Mon Sep 17 00:00:00 2001 From: Samuel Verschelde Date: Thu, 30 Jul 2026 23:49:12 +0000 Subject: [PATCH 3/3] Silence the setup that precedes a silent first action When the first thing a test does is silenced, whatever runs before it is setup you have already seen too: the game's initial output, and any precommands ahead of it. Those are now hidden as well, so the transcript of each test begins where the test itself does. A test starts silently when its first command is >{include:silent}, when it plainly includes a wrapper test which itself starts silently, or when the silent include is a precommand, in which case it is the first thing every test in the file does. --- regtest.html | 8 ++++++++ regtest.py | 22 ++++++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/regtest.html b/regtest.html index b4b2310..e0db3b0 100644 --- a/regtest.html +++ b/regtest.html @@ -291,6 +291,14 @@

Partial Tests

In -v mode, a marker line [silently included: TESTNAME] is printed in place of the hidden commands, so the transcript still shows where the included test ran. This marker is not printed at -vv, since everything is already visible there.

+

+When the first thing a test does is silenced, whatever comes before it is silenced with it: the game's initial output (before any command has been sent), and any ** precommand: lines or -p command-line precommands running ahead of it. That whole span is the boilerplate setup you already saw when the included test ran on its own, so the transcript of each test starts where the test itself does. This happens automatically; there's no separate way to mark it as silent, and -vv still shows everything. +

+ +

+A test starts silently when its first command is >{include:silent}, when its first command is a plain >{include} of a "wrapper" test which itself starts silently (RegTest follows the chain of leading includes), or when the silent include is a precommand (** precommand: {include:silent} TESTNAME, or the same through -p) and therefore the first thing every test in the file does. If something you can see runs first instead, such as a plain precommand ahead of the silent include, the initial output stays visible along with it. +

+

(No space between the ">" and the "{". Checks after an >{include} line are meaningless; they are ignored.)

diff --git a/regtest.py b/regtest.py index 30cc22c..41901a2 100644 --- a/regtest.py +++ b/regtest.py @@ -1258,6 +1258,14 @@ def list_commands(ls, res=None, nested=(), silenced=False): res.append((cmd, silenced)) return res +def starts_silenced(cmdlist): + """Whether the first action of a flattened command list is silenced. + Whatever gets printed before that action (the game's initial output, + or precommands running ahead of it) is part of the same silenced + span, since it is all setup leading up to it. + """ + return bool(cmdlist) and cmdlist[0][1] + class VitalCheckException(Exception): pass class NotJSONException(Exception): @@ -1294,11 +1302,21 @@ def run(test): else: raise Exception('Unrecognized format: %s' % (terpformat,)) - cmdlist = list_commands(precommands + test.cmds) + # The test's own commands, flattened. When its first action turns out to + # be silenced (a leading {include:silent}, possibly reached through a + # chain of plain {include}s), the global precommands running ahead of it + # are just as much setup you have already seen, so silence those too. + tail = list_commands(test.cmds) + cmdlist = list_commands(precommands, silenced=starts_silenced(tail)) + tail try: gamestate.initialize() - gamestate.accept_output() + # The initial output comes before the first action of all, so it is + # silenced whenever that action is, whether the silence originates + # in the test's own commands or in a precommand which is itself a + # silent include. + initsuppressed = starts_silenced(cmdlist) and opts.verbose < 2 + gamestate.accept_output(silenced=initsuppressed) if (test.precmd): for check in test.precmd.checks: res = check.eval(gamestate)