Skip to content

Commit 641a3c8

Browse files
hyperpolymathclaude
andcommitted
feat(affinescript-deno-test): v0.2.0 multi-test-per-file
Upstream AffineScript compiler commit ce324fa made both codegen.ml and codegen_gc.ml honour `fd_vis = Public` in addition to the hardcoded game-hook allowlist, so `pub fn test_<name>() -> Bool` now produces a named WASM export. Harness changes: - runner.ts switches from the MVP's single-`main` lookup to iterating all `test_*` exports, wrapping each as a distinct Deno.test() case. - caseName(): reports "<file-stem> / <case-stem>", e.g. hello_test.affine + test_addition → "hello / addition". - mod.ts re-exports the restored TEST_PREFIX constant. - hello_test.affine expanded to 4 `pub fn test_*` cases plus one non-`pub` helper to verify exports are visibility-gated, not name-gated. - deno.json bumped to 0.2.0. - README.adoc: "one test per file" moved to RESOLVED; the remaining two upstream-pending constraints (unconditional WASI fd_write import; affine-js env-only imports) are retained. Smoke test: 4 passed / 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a4d7175 commit 641a3c8

5 files changed

Lines changed: 142 additions & 98 deletions

File tree

affinescript-ecosystem/affinescript-deno-test/README.adoc

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ component: `affinescript/` (the compiler — actually mirrored from upstream),
1515

1616
== Status
1717

18-
*MVP v0.1.0.* Minimal working harness, one test per file. See
19-
<<mvp-constraints>> below for the specific limits and planned follow-ups.
18+
*v0.2.0.* Multi-test-per-file harness. The "one test per file" MVP
19+
constraint was resolved same-day via an upstream compiler change (see
20+
<<constraints>>).
2021

2122
== Quick start
2223

@@ -29,20 +30,39 @@ deno task smoke:test
2930

3031
== Writing a test
3132

32-
Each test is a single `.affine` file. The entry point is `fn main() -> Bool`;
33-
return `true` to pass, `false` to fail. Filename ends in `_test.affine` or
34-
`.test.affine`.
33+
Each test case is a top-level `pub fn test_<name>() -> Bool`. Return `true`
34+
to pass, `false` to fail. One `.affine` file may contain as many test cases
35+
as you like. Non-`pub` helpers stay internal to the module. Filename ends
36+
in `_test.affine` or `.test.affine`.
3537

3638
[source,affine]
3739
----
38-
// example/addition_test.affine
40+
// example/hello_test.affine
3941
// SPDX-License-Identifier: PMPL-1.0-or-later
40-
fn main() -> Bool {
42+
43+
pub fn test_addition() -> Bool {
4144
let result = 2 + 3;
4245
result == 5
4346
}
47+
48+
pub fn test_identity() -> Bool {
49+
let x = 42;
50+
x == 42
51+
}
52+
53+
fn helper_not_exported() -> Int {
54+
99
55+
}
56+
57+
pub fn test_uses_helper() -> Bool {
58+
helper_not_exported() == 99
59+
}
4460
----
4561

62+
The harness reports each test as `<filestem> / <casename>` — e.g. the file
63+
above produces `hello / addition`, `hello / identity`, `hello / uses_helper`
64+
under `deno test`.
65+
4666
== Running tests
4767

4868
The public API is `runAll(root)` in `mod.ts`:
@@ -93,29 +113,27 @@ deno run --allow-read --allow-run --allow-env cli.ts ./tests
93113
(the local dev build). Override when the compiler is installed elsewhere
94114
or on `$PATH`.
95115

96-
[[mvp-constraints]]
97-
== MVP constraints (v0.1.0) and planned follow-ups
98-
99-
Three limits exist only because of the current AffineScript compiler's
100-
state. All are resolvable with small upstream changes.
116+
[[constraints]]
117+
== Constraints and planned follow-ups
101118

102-
=== One test per file
119+
One constraint was resolved same-day. Two remain, both resolvable with
120+
small upstream changes.
103121

104-
The AffineScript codegen (`lib/codegen.ml` line 1725) hardcodes the
105-
exportable-name allowlist to
122+
=== RESOLVED (v0.2.0): one test per file
106123

107-
[source,ocaml]
108-
----
109-
let export_names = ["main"; "init_state"; "step_state"; "get_state"; "mission_active"]
110-
----
124+
The AffineScript codegen previously hardcoded an exportable-name allowlist
125+
to `["main"; "init_state"; "step_state"; "get_state"; "mission_active"]`
126+
with no `pub fn` / `@export` keyword — so only those five names appeared
127+
in the WASM export list, no matter the source.
111128

112-
with no `pub fn` / `@export` / `#[export]` keyword. Any function not on that
113-
list is internal to the module and cannot be invoked from JS. The harness
114-
therefore uses `main` as the one sanctioned test slot.
129+
The fix (AffineScript commit `ce324fa`) makes both `codegen.ml` and
130+
`codegen_gc.ml` honour the AST's `fd_vis = Public` field in addition to
131+
the game-hook allowlist. The allowlist is retained verbatim for backward
132+
compatibility (pre-`pub` programs and the IDApTIK CharacterSelect bridge
133+
continue to work unchanged).
115134

116-
*Planned follow-up:* extend the compiler with a `pub fn` keyword (parser +
117-
AST + codegen). Once in place, the harness switches to the original design
118-
of multi-test-per-file via a `test_*` naming convention.
135+
The harness now uses the expected multi-test convention: any top-level
136+
`pub fn test_<name>() -> Bool` becomes a separate `Deno.test()` case.
119137

120138
=== WASI `fd_write` imported unconditionally
121139

affinescript-ecosystem/affinescript-deno-test/deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"//": "SPDX-License-Identifier: PMPL-1.0-or-later",
33
"name": "@hyperpolymath/affinescript-deno-test",
4-
"version": "0.1.0",
4+
"version": "0.2.0",
55
"exports": {
66
".": "./mod.ts",
77
"./cli": "./cli.ts"
Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,33 @@
11
// SPDX-License-Identifier: PMPL-1.0-or-later
22
// Example AffineScript test file for affinescript-deno-test.
33
//
4-
// Convention (MVP v0.1.0):
5-
// - One test per `.affine` file.
6-
// - The test entry point is the function `main`, returning `Bool`.
7-
// - The harness uses the filename (without the `_test.affine` / `.test.affine`
8-
// suffix) as the Deno.test() case name.
4+
// Convention (v0.2.0+): each top-level `pub fn test_<name>() -> Bool`
5+
// becomes a separate Deno.test() case. The harness iterates WASM exports
6+
// matching `test_*` and wraps each as a test. Returns:
7+
// true → test passes
8+
// false → test fails
99
//
10-
// This single-test-per-file convention is an MVP constraint driven by the
11-
// current AffineScript codegen, which hardcodes the exportable-name
12-
// allowlist in lib/codegen.ml (only `main`, `init_state`, `step_state`,
13-
// `get_state`, `mission_active` are exported). Multi-test-per-file support
14-
// is a planned follow-up once the compiler gains a `pub fn` / `@export`
15-
// keyword.
16-
//
17-
// For now: each behaviour you want reported separately needs its own
18-
// `<name>_test.affine` file.
10+
// Non-`pub` helpers stay internal to the module and are not exposed to
11+
// the test runner.
12+
13+
pub fn test_addition() -> Bool {
14+
let result = 2 + 3;
15+
result == 5
16+
}
17+
18+
pub fn test_identity() -> Bool {
19+
let x = 42;
20+
x == 42
21+
}
22+
23+
pub fn test_inequality() -> Bool {
24+
1 != 2
25+
}
26+
27+
fn helper_not_exported() -> Int {
28+
99
29+
}
1930

20-
fn main() -> Bool {
21-
let two_plus_three = 2 + 3;
22-
let forty_two = 42;
23-
(two_plus_three == 5) && (forty_two == 42) && (1 != 2)
31+
pub fn test_uses_helper() -> Bool {
32+
helper_not_exported() == 99
2433
}

affinescript-ecosystem/affinescript-deno-test/lib/runner.ts

Lines changed: 70 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,25 @@
33
//
44
// affinescript-deno-test: runner.ts
55
//
6-
// Loads a compiled AffineScript WASM module and wraps its `main` export as
7-
// a Deno.test() case. A test passes when `main` returns `true`, fails when
8-
// it returns `false`.
6+
// Loads a compiled AffineScript WASM module and wraps every exported function
7+
// whose name starts with `test_` as a Deno.test() case. A test passes when
8+
// the function returns `true`, fails when it returns `false`.
99
//
10-
// MVP convention (v0.1.0): one test per .affine file. The export name is
11-
// always `main` because the current AffineScript codegen (lib/codegen.ml
12-
// line 1725) hardcodes the exportable-name allowlist to
13-
// ["main"; "init_state"; "step_state"; "get_state"; "mission_active"]
14-
// with no `pub fn` / `@export` keyword. Multi-test-per-file support is a
15-
// planned follow-up once the compiler gains arbitrary-export syntax.
10+
// Convention (v0.2.0): each `.affine` file may define multiple tests via
11+
// the `pub fn test_<name>() -> Bool` syntax. Every `pub fn test_*` export
12+
// becomes a separate Deno.test() case. Non-`pub` helpers stay internal to
13+
// the module. This relies on the AffineScript compiler honouring `fd_vis`
14+
// in its WASM-export decision (commit ce324fa, both codegen.ml and
15+
// codegen_gc.ml).
1616
//
1717
// Uses the existing @hyperpolymath/affine-js bridge for WASM loading and
1818
// value marshalling, plus a minimal WASI stub for `fd_write` (AffineScript
19-
// codegen always pulls this import even for programs that never print).
19+
// codegen imports this unconditionally even for programs that never print).
2020

2121
import { AffineModule } from "@hyperpolymath/affine-js";
2222

23-
/** Convention: the single test export per file is always named this. */
24-
export const TEST_EXPORT = "main";
23+
/** Convention: every `pub fn` whose name begins with this prefix is a test. */
24+
export const TEST_PREFIX = "test_";
2525

2626
/** Result shape returned by AffineScript Bool exports (via affine-js). */
2727
interface BoolValue {
@@ -30,13 +30,15 @@ interface BoolValue {
3030
}
3131

3232
/**
33-
* Derive the Deno.test() case name from the WASM path. Strips the directory
34-
* and the `.wasm` extension; if the filename ends in `_test` or `.test`,
35-
* strips that suffix too for readability.
33+
* Derive the Deno.test() case name from the file basename + export name.
34+
* For a wasm at `/path/to/math_test.wasm` with export `test_add`, yields
35+
* `math / add`.
3636
*/
37-
function caseName(wasmPath: string): string {
37+
function caseName(wasmPath: string, exportName: string): string {
3838
const base = wasmPath.split("/").pop() ?? wasmPath;
39-
return base.replace(/\.wasm$/, "").replace(/(_test|\.test)$/, "");
39+
const fileStem = base.replace(/\.wasm$/, "").replace(/(_test|\.test)$/, "");
40+
const caseStem = exportName.replace(/^test_/, "");
41+
return `${fileStem} / ${caseStem}`;
4042
}
4143

4244
/**
@@ -61,10 +63,12 @@ function makeWasiStub(): WebAssembly.ModuleImports {
6163
}
6264

6365
/**
64-
* Register a Deno.test() case for the `main` export in the WASM module at
65-
* `wasmPath`. Path should be absolute; relative paths resolve against CWD.
66+
* Register a Deno.test() case for every `test_*` export in the WASM module
67+
* at `wasmPath`. Path should be absolute; relative paths resolve against CWD.
6668
*
67-
* Side-effect: calls `Deno.test()` exactly once.
69+
* Returns the number of tests registered. Throws if no `test_*` exports
70+
* are found (indicating a misconfigured file — at least one `pub fn test_*`
71+
* is expected).
6872
*/
6973
export async function registerTestsFromWasm(wasmPath: string): Promise<number> {
7074
const absolute = wasmPath.startsWith("/")
@@ -84,27 +88,32 @@ export async function registerTestsFromWasm(wasmPath: string): Promise<number> {
8488
}
8589

8690
const mod = await AffineModule.fromBytes(bytes);
91+
const testExports = mod.functionExports.filter((name: string) =>
92+
name.startsWith(TEST_PREFIX)
93+
);
8794

88-
if (!mod.functionExports.includes(TEST_EXPORT)) {
95+
if (testExports.length === 0) {
8996
throw new Error(
90-
`affinescript-deno-test: no '${TEST_EXPORT}' export found in ${wasmPath}. ` +
97+
`affinescript-deno-test: no '${TEST_PREFIX}*' exports found in ${wasmPath}. ` +
9198
`Available: [${mod.functionExports.join(", ")}]. ` +
92-
`Each .affine test file must define 'fn main() -> Bool'.`,
99+
`Each test must be declared as 'pub fn test_<name>() -> Bool'.`,
93100
);
94101
}
95102

96-
Deno.test(caseName(wasmPath), () => {
97-
const result = mod.call(TEST_EXPORT, { returnType: "bool" }) as BoolValue;
98-
if (result.kind !== "bool") {
99-
throw new Error(
100-
`test '${caseName(wasmPath)}' returned non-bool value: ${JSON.stringify(result)}`,
101-
);
102-
}
103-
if (!result.value) {
104-
throw new Error(`test '${caseName(wasmPath)}' returned false`);
105-
}
106-
});
107-
return 1;
103+
for (const exportName of testExports) {
104+
Deno.test(caseName(wasmPath, exportName), () => {
105+
const result = mod.call(exportName, { returnType: "bool" }) as BoolValue;
106+
if (result.kind !== "bool") {
107+
throw new Error(
108+
`test '${exportName}' returned non-bool value: ${JSON.stringify(result)}`,
109+
);
110+
}
111+
if (!result.value) {
112+
throw new Error(`test '${exportName}' returned false`);
113+
}
114+
});
115+
}
116+
return testExports.length;
108117
}
109118

110119
/**
@@ -126,28 +135,36 @@ async function registerTestsWithWasi(
126135
wasi_snapshot_preview1: makeWasiStub(),
127136
});
128137

129-
const mainExport = instance.exports[TEST_EXPORT];
130-
if (typeof mainExport !== "function") {
138+
const testExports = Object.keys(instance.exports).filter(
139+
(name) =>
140+
typeof instance.exports[name] === "function" &&
141+
name.startsWith(TEST_PREFIX),
142+
);
143+
144+
if (testExports.length === 0) {
131145
const available = Object.keys(instance.exports).join(", ");
132146
throw new Error(
133-
`affinescript-deno-test: no '${TEST_EXPORT}' function export found in ${wasmPath}. ` +
147+
`affinescript-deno-test: no '${TEST_PREFIX}*' function exports found in ${wasmPath}. ` +
134148
`Available: [${available}]. ` +
135-
`Each .affine test file must define 'fn main() -> Bool'.`,
149+
`Each test must be declared as 'pub fn test_<name>() -> Bool'.`,
136150
);
137151
}
138152

139-
Deno.test(caseName(wasmPath), () => {
140-
const raw = (mainExport as () => number)();
141-
// AffineScript compiles Bool to i32 (0 = false, 1 = true).
142-
if (raw !== 0 && raw !== 1) {
143-
throw new Error(
144-
`test '${caseName(wasmPath)}' returned non-bool raw value ${raw}; ` +
145-
`'main' must have signature 'fn main() -> Bool'`,
146-
);
147-
}
148-
if (raw === 0) {
149-
throw new Error(`test '${caseName(wasmPath)}' returned false`);
150-
}
151-
});
152-
return 1;
153+
for (const exportName of testExports) {
154+
const fn = instance.exports[exportName] as () => number;
155+
Deno.test(caseName(wasmPath, exportName), () => {
156+
const raw = fn();
157+
// AffineScript compiles Bool to i32 (0 = false, 1 = true).
158+
if (raw !== 0 && raw !== 1) {
159+
throw new Error(
160+
`test '${exportName}' returned non-bool raw value ${raw}; ` +
161+
`'${exportName}' must have signature 'fn ${exportName}() -> Bool'`,
162+
);
163+
}
164+
if (raw === 0) {
165+
throw new Error(`test '${exportName}' returned false`);
166+
}
167+
});
168+
}
169+
return testExports.length;
153170
}

affinescript-ecosystem/affinescript-deno-test/mod.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { registerTestsFromWasm } from "./lib/runner.ts";
2222

2323
export { compileToWasm, resolveCompilerPath } from "./lib/compile.ts";
2424
export { DEFAULT_TEST_PATTERN, discoverTestFiles } from "./lib/discover.ts";
25-
export { TEST_EXPORT, registerTestsFromWasm } from "./lib/runner.ts";
25+
export { TEST_PREFIX, registerTestsFromWasm } from "./lib/runner.ts";
2626

2727
/**
2828
* Discover, compile, and register every AffineScript test file under `root`.

0 commit comments

Comments
 (0)