refactor(test): hoist duplicated test helpers into ailang-test-support

closes #60

The fixture-corpus filter (NON_PARSEABLE_FIXTURES + list_ail_fixtures)
was copy-pasted across three test crates and drifted out of sync as
new reject fixtures landed; the examples_dir / workspace_root path
walk was reimplemented ~30 more times across the suite, under two
spellings (workspace_root / ws_root).

Introduce crates/ailang-test-support — a zero-dependency, dev-only
leaf crate — as the single home for these helpers:
- NON_PARSEABLE_FIXTURES, list_ail_fixtures
- workspace_root, examples_dir
- canonical_workspace_root (symlink-resolved variant, for the tsan/
  race tests that feed the root into native build steps)

All integration-test copies across ailang-core, ailang-surface,
ailang-prose, ailang-check, and ail now import from the shared crate;
the local definitions and their now-orphaned path imports are removed.
Path helpers resolve via the support crate's own CARGO_MANIFEST_DIR,
so they return the correct absolute paths from any caller.

ail_bin helpers are intentionally left in place: they depend on
env!("CARGO_BIN_EXE_ail"), which Cargo only defines when compiling the
ail crate's own integration tests, so they cannot move to a shared
crate.

Behaviour-identical: same paths, same fixture lists; full workspace
test suite green. Net -177 LOC.
This commit is contained in:
2026-06-02 01:54:37 +02:00
parent ac9171ebf0
commit 7ae92d3e60
40 changed files with 139 additions and 316 deletions
+1
View File
@@ -21,3 +21,4 @@ anyhow.workspace = true
[dev-dependencies]
tempfile.workspace = true
blake3.workspace = true
ailang-test-support.workspace = true
+1 -9
View File
@@ -11,6 +11,7 @@
//! someone reverts the migration (or breaks the canonical-form
//! acceptance path in the registry), that test goes red.
use ailang_test_support::examples_dir;
use std::path::PathBuf;
use std::process::Command;
@@ -19,15 +20,6 @@ fn ail_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_ail"))
}
fn examples_dir() -> PathBuf {
// CARGO_MANIFEST_DIR points at `crates/ail`; examples/ sits at the
// workspace root, two levels up.
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("examples")
}
/// Property: a workspace whose entry module contains a bare
/// cross-module Type::Con / Term::Ctor reference — here `Mystery_Type`,
/// a name no module in the workspace declares (post-prep.1: in-scope
+1 -9
View File
@@ -3,6 +3,7 @@
//! This test guards the most important property of the entire pipeline:
//! AST → typecheck → LLVM IR → clang → binary → correct stdout.
use ailang_test_support::workspace_root;
use std::path::Path;
use std::process::Command;
@@ -2801,15 +2802,6 @@ fn merge_prose_prints_framed_prompt() {
// ---------- ext-cli.1: CLI accepts `.ail` (Form A) source files ----------
fn workspace_root() -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf()
}
/// Property: `ail check` accepts a Form-A `.ail` source file (not just
/// the Form-B `.ail.json` canonical form) and exits zero on a
/// well-formed program. Guards against the post-rename misleading-JSON
+2 -4
View File
@@ -4,18 +4,16 @@
//! + `libailang_rt.a`, run it, assert the `s == 25` assertion holds
//! (exit 0). This is the whole-milestone existence proof.
use ailang_test_support::workspace_root;
use std::path::PathBuf;
use std::process::Command;
fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") }
fn manifest() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) }
fn ws_root() -> PathBuf {
manifest().parent().unwrap().parent().unwrap().to_path_buf()
}
#[test]
fn c_host_calls_exported_scalar_kernel() {
let fixture = ws_root().join("examples").join("embed_backtest_step.ail");
let fixture = workspace_root().join("examples").join("embed_backtest_step.ail");
let host_c = manifest().join("tests").join("embed").join("host.c");
let outdir = std::env::temp_dir()
.join(format!("ail-embed-e2e-{}", std::process::id()));
@@ -9,18 +9,13 @@
//!
//! Pure reader: writes nothing to the repo.
use std::path::{Path, PathBuf};
use ailang_test_support::workspace_root;
use std::process::Command;
fn ail_bin() -> &'static str {
env!("CARGO_BIN_EXE_ail")
}
fn workspace_root() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
Path::new(manifest_dir).parent().unwrap().parent().unwrap().to_path_buf()
}
#[test]
fn ail_build_on_main_free_module_is_rejected_at_cli() {
let fixture = workspace_root()
+2 -6
View File
@@ -4,11 +4,7 @@
//! Per-thread-ctx build: tsan-clean + exit 0. RED until rc.c gains
//! ailang_ctx_* + __ail_tls_ctx (link error: undefined symbols).
use std::process::Command;
use std::path::PathBuf;
fn ws_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
}
fn cc(args: &[&str], ctx: &str) {
let st = Command::new("clang").args(args).status().expect("spawn clang");
@@ -17,7 +13,7 @@ fn cc(args: &[&str], ctx: &str) {
#[test]
fn rc_accounting_per_ctx_is_tsan_clean() {
let root = ws_root();
let root = ailang_test_support::canonical_workspace_root();
let tmp = std::env::temp_dir().join("ail_m2_rcacct");
std::fs::create_dir_all(&tmp).unwrap();
let rc_o = tmp.join("rc.o");
@@ -48,7 +44,7 @@ fn rc_accounting_shared_ctx_is_flagged_by_tsan() {
// calling ailang_rc_alloc/ailang_rc_dec → concurrent
// ctx->alloc_count++ is a genuine data race tsan MUST report.
// Proves the de-globalisation property is not vacuous.
let root = ws_root();
let root = ailang_test_support::canonical_workspace_root();
let tmp = std::env::temp_dir().join("ail_m2_rcacct_neg");
std::fs::create_dir_all(&tmp).unwrap();
let rc_o = tmp.join("rc.o");
@@ -28,12 +28,8 @@
//! build of any kernel emits `libailang_rt.a`; the C host links the
//! runtime archive directly and never calls a kernel.
use std::path::PathBuf;
use std::process::Command;
fn ws_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
}
fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") }
/// 8 threads * 2_000_000 cycles — must match the `#define`s in
@@ -47,7 +43,7 @@ fn global_rc_stats_counters_are_exact_under_thread_contention() {
let outdir = std::env::temp_dir().join(format!(
"ail-rc-global-stats-race-{}", std::process::id()));
std::fs::create_dir_all(&outdir).unwrap();
let root = ws_root();
let root = ailang_test_support::canonical_workspace_root();
// Any kernel staticlib build emits libailang_rt.a alongside it.
let st = Command::new(ail_bin())
+2 -4
View File
@@ -23,14 +23,12 @@
//! `e2e.rs::build_and_run_with_rc_stats` (the runtime's fixed
//! `ailang_rc_stats: allocs=N frees=M live=K` shape).
use ailang_test_support::workspace_root;
use std::path::PathBuf;
use std::process::Command;
fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") }
fn manifest() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) }
fn ws_root() -> PathBuf {
manifest().parent().unwrap().parent().unwrap().to_path_buf()
}
#[derive(Debug)]
struct RcStats {
@@ -44,7 +42,7 @@ struct RcStats {
/// `AILANG_RC_STATS=1`, and return the `ailang_ctx_free` readback.
fn build_link_run_embed(fixture: &str, host_rel: &str, extra_cc: &[&str]) -> RcStats {
let module = fixture.strip_suffix(".ail").expect("fixture is a .ail file");
let fixture_path = ws_root().join("examples").join(fixture);
let fixture_path = workspace_root().join("examples").join(fixture);
let host_c = manifest().join("tests").join(host_rel);
let outdir = std::env::temp_dir().join(format!(
"ail-embed-record-e2e-{}-{}",
+3 -7
View File
@@ -2,18 +2,14 @@
//! produces `lib<entry>.a` (program-only) + a separate
//! `libailang_rt.a`, and emits NO executable / NO `@main`.
use std::path::{Path, PathBuf};
use ailang_test_support::workspace_root;
use std::process::Command;
fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") }
fn ws_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent().unwrap().parent().unwrap().to_path_buf()
}
#[test]
fn staticlib_emit_produces_two_archives() {
let fixture = ws_root().join("examples").join("embed_backtest_step.ail");
let fixture = workspace_root().join("examples").join("embed_backtest_step.ail");
let outdir = std::env::temp_dir()
.join(format!("ail-embed-cli-{}", std::process::id()));
std::fs::create_dir_all(&outdir).unwrap();
@@ -39,7 +35,7 @@ fn staticlib_emit_produces_two_archives() {
#[test]
fn staticlib_emit_requires_an_export() {
// embed_noentry_baseline has no `(export …)` fn.
let fixture = ws_root().join("examples")
let fixture = workspace_root().join("examples")
.join("embed_noentry_baseline.ail");
let outdir = std::env::temp_dir()
.join(format!("ail-embed-noexp-{}", std::process::id()));
+2 -5
View File
@@ -6,9 +6,6 @@
use std::process::Command;
use std::path::PathBuf;
fn ws_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
}
fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") }
fn build_staticlib(outdir: &PathBuf) {
@@ -16,7 +13,7 @@ fn build_staticlib(outdir: &PathBuf) {
let st = Command::new(ail_bin())
.args(["build", "examples/embed_backtest_step.ail",
"--emit=staticlib", "--alloc=rc", "-o", outdir.to_str().unwrap()])
.current_dir(ws_root()).status().expect("ail build");
.current_dir(ailang_test_support::canonical_workspace_root()).status().expect("ail build");
assert!(st.success(), "ail build --emit=staticlib failed");
}
@@ -24,7 +21,7 @@ fn build_staticlib(outdir: &PathBuf) {
fn scalar_swarm_per_ctx_is_tsan_clean_and_matches_oracle() {
let outdir = std::env::temp_dir().join("ail_m2_swarm_ok");
build_staticlib(&outdir);
let root = ws_root();
let root = ailang_test_support::canonical_workspace_root();
let bin = outdir.join("swarm_ok");
let st = Command::new("clang").args([
"-O1", "-g", "-fsanitize=thread", "-pthread",
+2 -4
View File
@@ -28,14 +28,12 @@
//! `g_rc_*`, not ctx; the invariant is GLOBAL Σallocs == Σfrees
//! across *all* stat lines, not per-ctx-line balance).
use ailang_test_support::workspace_root;
use std::path::PathBuf;
use std::process::Command;
fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") }
fn manifest() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) }
fn ws_root() -> PathBuf {
manifest().parent().unwrap().parent().unwrap().to_path_buf()
}
#[derive(Debug)]
struct RcStats {
@@ -49,7 +47,7 @@ struct RcStats {
/// `AILANG_RC_STATS=1`, and return the summed RC-stats readback.
fn build_link_run_embed(fixture: &str, host_rel: &str, extra_cc: &[&str]) -> RcStats {
let module = fixture.strip_suffix(".ail").expect("fixture is a .ail file");
let fixture_path = ws_root().join("examples").join(fixture);
let fixture_path = workspace_root().join("examples").join(fixture);
let host_c = manifest().join("tests").join(host_rel);
let outdir = std::env::temp_dir().join(format!(
"ail-embed-tick-e2e-{}-{}",
+4 -8
View File
@@ -4,18 +4,14 @@
//! executable-path `MissingEntryMain` rejection — the Decision-5
//! IR-readability affordance for the artefact M1 introduced.
use std::path::{Path, PathBuf};
use ailang_test_support::workspace_root;
use std::process::Command;
fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") }
fn ws_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent().unwrap().parent().unwrap().to_path_buf()
}
#[test]
fn emit_ir_staticlib_prints_kernel_forwarder_no_main() {
let fixture = ws_root().join("examples").join("embed_backtest_step.ail");
let fixture = workspace_root().join("examples").join("embed_backtest_step.ail");
let out = Command::new(ail_bin())
.args(["emit-ir", fixture.to_str().unwrap(), "--emit=staticlib"])
.output().expect("ail emit-ir --emit=staticlib");
@@ -37,7 +33,7 @@ fn emit_ir_staticlib_prints_kernel_forwarder_no_main() {
fn emit_ir_staticlib_requires_an_export() {
// embed_noentry_baseline has no `(export …)` fn — symmetric with
// build's zero-export guard (embed_staticlib_cli.rs).
let fixture = ws_root().join("examples").join("embed_noentry_baseline.ail");
let fixture = workspace_root().join("examples").join("embed_noentry_baseline.ail");
let out = Command::new(ail_bin())
.args(["emit-ir", fixture.to_str().unwrap(), "--emit=staticlib"])
.output().expect("ail emit-ir --emit=staticlib");
@@ -54,7 +50,7 @@ fn emit_ir_default_exe_still_requires_main() {
// Regression: --emit defaults to "exe"; emit-ir on a main-free
// module WITHOUT --emit=staticlib must still hit MissingEntryMain
// (the new staticlib branch must not alter the default path).
let fixture = ws_root().join("examples").join("embed_backtest_step.ail");
let fixture = workspace_root().join("examples").join("embed_backtest_step.ail");
let out = Command::new(ail_bin())
.args(["emit-ir", fixture.to_str().unwrap()])
.output().expect("ail emit-ir");
+1 -8
View File
@@ -9,16 +9,9 @@
//! `float_to_str`, libc `%g` plus the runtime `.0`-fallback for
//! whole-valued Float results, per Gitea #7).
use std::path::PathBuf;
use ailang_test_support::workspace_root;
use std::process::Command;
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent().unwrap()
.parent().unwrap()
.to_path_buf()
}
#[test]
fn floats_example_prints_expected_stdout() {
let root = workspace_root();
+1 -9
View File
@@ -2,6 +2,7 @@
//! changes to the LLVM IR. Snapshots in tests/snapshots/. Update with
//! UPDATE_SNAPSHOTS=1 cargo test ir_snapshot_.
use ailang_test_support::examples_dir;
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -44,15 +45,6 @@ fn snapshots_dir() -> PathBuf {
Path::new(manifest_dir).join("tests").join("snapshots")
}
fn examples_dir() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
Path::new(manifest_dir)
.parent()
.unwrap()
.parent()
.unwrap()
.join("examples")
}
fn first_diff_line(a: &str, b: &str) -> Option<(usize, String, String)> {
let mut la = a.lines();
+1 -8
View File
@@ -28,14 +28,7 @@
//! `workspace_has_typeclasses` gate so every workspace exercises the
//! mono pass.
use std::path::PathBuf;
fn examples_dir() -> PathBuf {
let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
d.pop();
d.pop();
d.join("examples")
}
use ailang_test_support::examples_dir;
/// Property: `monomorphise_workspace` must succeed on a workspace that
/// (a) flips the typeclass gate (any `Def::Class` + `Def::Instance` is
+1 -7
View File
@@ -17,6 +17,7 @@
//! diagnostic stream's `code` field. Stdout / runtime behaviour is
//! out of scope here — the dispatch path is a check-time concern.
use ailang_test_support::examples_dir;
use std::path::PathBuf;
use std::process::Command;
@@ -24,13 +25,6 @@ fn ail_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_ail"))
}
fn examples_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("examples")
}
fn run_ail_check_json(entry: &str) -> std::process::Output {
Command::new(ail_bin())
.args(["check", "--json", examples_dir().join(entry).to_str().unwrap()])
+1 -46
View File
@@ -20,58 +20,13 @@
//! were the only consumers of `.ail.json` walks and are retired
//! alongside.
use std::path::{Path, PathBuf};
use ailang_test_support::{examples_dir, list_ail_fixtures};
use std::process::Command;
fn ail_bin() -> &'static str {
env!("CARGO_BIN_EXE_ail")
}
fn workspace_root() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
Path::new(manifest_dir).parent().unwrap().parent().unwrap().to_path_buf()
}
fn examples_dir() -> PathBuf {
workspace_root().join("examples")
}
/// Fixtures that intentionally do NOT parse — the `#55` cutover
/// reject corpus. These are negative fixtures whose whole point is
/// that the parser rejects them, so `parse∘render∘parse` is not a
/// meaningful property for them and the harness must skip them.
///
/// Scope is deliberately minimal: only fixtures that fail at the
/// PARSE stage belong here. The other `*reject*.ail` cutover
/// fixtures (`borrow_return_reject`, `borrow_value_reject`,
/// `own_return_provenance_reject`, the `embed_export_*_rejected`
/// set, …) parse cleanly and are rejected only later at CHECK time,
/// so they round-trip fine and are NOT excluded.
///
/// - `bare_slot_reject.ail`: a bare fn-type slot with no mode. The
/// binary-ParamMode parser rejects it ("fn-type slot requires a
/// mode: write (own T) or (borrow T)").
const NON_PARSEABLE_FIXTURES: &[&str] = &["bare_slot_reject.ail"];
fn list_ail_fixtures() -> Vec<PathBuf> {
let dir = examples_dir();
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("read_dir({}): {e}", dir.display()))
.filter_map(|entry| entry.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| {
n.ends_with(".ail") && !NON_PARSEABLE_FIXTURES.contains(&n)
})
.unwrap_or(false)
})
.collect();
paths.sort();
paths
}
/// CLI-pipeline idempotency (post-form-a-default-authoring §C3): for
/// every `.ail` fixture, the user-facing pipeline
/// `ail parse <ail> | ail render | ail parse` is byte-identical
+1 -11
View File
@@ -6,17 +6,7 @@
//! Tasks 9 (`missing-constraint`) and 10 (`no-instance`).
use ailang_core::ast::Type;
use std::path::{Path, PathBuf};
fn examples_dir() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
Path::new(manifest_dir)
.parent()
.unwrap()
.parent()
.unwrap()
.join("examples")
}
use ailang_test_support::examples_dir;
/// Property protected: a class method declared in a `Def::Class` must be
/// reachable as a name in the module's `ModuleGlobals` so that pass-2
+1 -8
View File
@@ -6,16 +6,9 @@
//! the fixtures live in `<repo>/examples/`.
use ailang_core::ast::Type;
use ailang_test_support::examples_dir;
use std::path::PathBuf;
fn examples_dir() -> PathBuf {
// crates/ail/ → repo root → examples/
let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
d.pop();
d.pop();
d.join("examples")
}
#[test]
fn monomorphise_workspace_is_identity_on_class_free_workspace() {
// Pick any class-free fixture; `examples/hello.ail.json` is