diff --git a/src/ferrolang_vm/dosboxx.py b/src/ferrolang_vm/dosboxx.py index 58b5ca8..ac94cfd 100644 --- a/src/ferrolang_vm/dosboxx.py +++ b/src/ferrolang_vm/dosboxx.py @@ -126,12 +126,40 @@ def resolve_tools() -> tuple[Path, Path]: return dosbox, watcom +# ``if errorlevel N`` in DOS tests ``>= N``, so an exact code needs a descending +# ladder. Small values get their own rung because they carry the meaning -- 1 is +# an ordinary compiler error, 3 is Watcom's abort() -- while anything above 8 is +# bucketed to a lower bound, which is enough to tell a crash from a diagnostic. +_RC_LADDER = (255, 128, 64, 32, 16, 8, 7, 6, 5, 4, 3, 2, 1) + + +def _rc_batch() -> str: + """Batch helper that records the previous command's exit code. + + Called as ``call RC.BAT `` right after a case command, since anything + else -- including writing a file -- would clobber ERRORLEVEL first. Note the + space before each ``>``: ``echo 0>FILE`` would parse as a redirect of handle + 0 rather than an echo of "0", so the value is written with a trailing space + and stripped on the host. + """ + lines = ["@echo off"] + lines.extend(f"if errorlevel {value} goto R{value}" for value in _RC_LADDER) + lines.extend(["echo 0 >RESULTS\\%1.RC", "goto END"]) + for value in _RC_LADDER: + lines.extend([f":R{value}", f"echo {value} >RESULTS\\%1.RC", "goto END"]) + lines.extend([":END", ""]) + return "\r\n".join(lines) + + def _batch(cases: list[Case], *, show_dos: bool, trace_dos: bool) -> str: + # The compiler build is the step that fails first and blocks everything after + # it, so its output is captured exactly like a case command's. + build = "call BUILD.BAT" if trace_dos else "call BUILD.BAT > RESULTS\\BUILD.LOG" lines = [ "@echo off", "if not exist RESULTS md RESULTS", "if not exist OUT md OUT", "set WATCOM=W:", "set INCLUDE=W:\\H", "set LIB=W:\\LIB286\\DOS;W:\\LIB286;W:\\LIB386\\DOS;W:\\LIB386", - "call BUILD.BAT", "if not exist BUILD.OK goto BUILDFAIL", + build, "if not exist BUILD.OK goto BUILDFAIL", "echo PASS>RESULTS\\BUILD.RES", ] for index, case in enumerate(cases): @@ -145,19 +173,10 @@ def _batch(cases: list[Case], *, show_dos: bool, trace_dos: bool) -> str: ]) if not trace_dos: command += f" > RESULTS\\{key}.LOG" - lines.append(command) - if case.expect_success: - lines.extend([ - f"if errorlevel 1 goto {key}E", f"echo PASS>RESULTS\\{key}.RES", - f"goto {key}C", f":{key}E", f"echo FAIL>RESULTS\\{key}.RES", - ]) - else: - lines.extend([ - f"if errorlevel 1 goto {key}E", f"echo FAIL>RESULTS\\{key}.RES", - f"goto {key}C", f":{key}E", f"echo PASS>RESULTS\\{key}.RES", - ]) lines.extend([ - f":{key}C", f"if exist *.ERR type *.ERR > RESULTS\\{key}.ERR", + command, + f"call RC.BAT {key}", + f"if exist *.ERR type *.ERR > RESULTS\\{key}.ERR", f"if not exist RESULTS\\{key}.ERR type NUL > RESULTS\\{key}.ERR", ]) lines.extend([ @@ -181,22 +200,40 @@ class SuiteRun: def _key(self, case: Case) -> str: return f"C{self.cases.index(case):03d}" + def rc(self, case: Case) -> int | None: + """Exit code the DOS command reported, or None if it was never recorded. + + Values above 8 are a lower bound; see ``_RC_LADDER``. + """ + path = self.fec / "RESULTS" / f"{self._key(case)}.RC" + if not path.is_file(): + return None + text = path.read_text(encoding="ascii", errors="replace").strip() + return int(text) if text.isdigit() else None + def result(self, case: Case | None = None) -> str: - name = "BUILD" if case is None else self._key(case) - path = self.fec / "RESULTS" / f"{name}.RES" - return path.read_text(encoding="ascii").strip() if path.is_file() else "MISSING" + if case is None: + path = self.fec / "RESULTS" / "BUILD.RES" + return path.read_text(encoding="ascii").strip() if path.is_file() else "MISSING" + code = self.rc(case) + if code is None: + return "MISSING" + return "PASS" if (code == 0) == case.expect_success else "FAIL" def log(self, case: Case | None = None) -> str: name = "BUILD" if case is None else self._key(case) path = self.fec / "RESULTS" / f"{name}.LOG" content = path.read_text(encoding="utf-8", errors="replace") if path.is_file() else "" - if content: + if content.strip(): return content errors = sorted(self.fec.glob("*.ERR")) - if errors: - return "\n".join(p.read_text(encoding="utf-8", errors="replace") for p in errors) - console = self.root / "CONSOLE.LOG" - return console.read_text(encoding="utf-8", errors="replace") if console.is_file() else "" + joined = "\n".join(p.read_text(encoding="utf-8", errors="replace") for p in errors) + if joined.strip(): + return joined + # Deliberately not falling back to CONSOLE.LOG: that is the emulator's own + # log (display enumeration, INT15 chatter) and burying one useful line in + # it reads as output when there was none. Use --dos-log to see it. + return "(no DOS output captured; the command wrote nothing before exiting)" def err(self, case: Case) -> str: path = self.fec / "RESULTS" / f"{self._key(case)}.ERR" @@ -229,6 +266,7 @@ def run_suite(cases: list[Case], *, keep: bool = False, show_dos: bool = False, _batch(cases, show_dos=show_dos, trace_dos=trace_dos), encoding="ascii", newline="", ) + (fec / "RC.BAT").write_text(_rc_batch(), encoding="ascii", newline="") command = [str(dosbox)] if not show_dos: command.append("-silent") diff --git a/src/ferrolang_vm/test_cli.py b/src/ferrolang_vm/test_cli.py index e20f744..075a525 100644 --- a/src/ferrolang_vm/test_cli.py +++ b/src/ferrolang_vm/test_cli.py @@ -35,7 +35,9 @@ def main() -> int: help="print the captured DOS console after the run") run.add_argument("--trace-dos", action="store_true", help="do not redirect case command output") - args = parser.parse_args() + run.add_argument("-k", dest="select", metavar="EXPR", + help="run only cases whose id matches this pytest -k expression") + args, extra = parser.parse_known_args() try: if args.command == "setup": dosbox, watcom = setup(accept_watcom_license=args.accept_watcom_license) @@ -55,12 +57,18 @@ def main() -> int: if enabled: os.environ[name] = "1" import pytest - test_file = os.fspath( - Path(__file__).resolve().parents[2] / "tools" / "tests" / "test_milestones_dosboxx.py" - ) + from .paths import ROOT + # The package can be imported from a different checkout than the one the + # shell is sitting in -- an editable install plus a git worktree is enough + # to silently build and test the wrong tree. Say which tree this is. + print(f"ferro-test: building {ROOT}", file=sys.stderr) + test_file = os.fspath(ROOT / "tools" / "tests" / "test_milestones_dosboxx.py") pytest_args = [test_file, "--tb=short", "-v" if args.verbose else "-q"] + if args.select: + pytest_args.extend(["-k", args.select]) if args.dos_log: pytest_args.append("-s") + pytest_args.extend(extra) return int(pytest.main(pytest_args)) except (DosboxError, ValueError) as exc: print(f"ferro-test: {exc}", file=sys.stderr) diff --git a/tools/tests/test_dos_names.py b/tools/tests/test_dos_names.py new file mode 100644 index 0000000..2daf1d5 --- /dev/null +++ b/tools/tests/test_dos_names.py @@ -0,0 +1,72 @@ +"""Host-side checks for constraints the DOS toolchain enforces far too late. + +Everything here runs without DOSBox-X. The point is to fail in a tenth of a +second with the offending name, instead of after a DOSBox-X boot and ten +successful object builds -- and with a message that says what is actually wrong. +A 9-character source name reaches the DOS build as ``Unable to open "src\\x.c"``, +which reads as a missing file rather than a name that cannot be represented. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from ferrolang_vm.paths import ROOT +from ferrolang_vm.registry import CASES + +# The runner copies these onto a FAT filesystem, where a name is at most eight +# characters plus a three-character extension. +COPIED_TREES = ("fec/src", "fec/std", "fec/tests") + + +def _offenders(root: Path) -> list[str]: + bad = [] + for path in sorted(root.rglob("*")): + name = path.name + if name.startswith("."): + continue + stem, _, suffix = name.rpartition(".") if "." in name else (name, "", "") + if len(stem) > 8 or len(suffix) > 3: + bad.append(f"{path.relative_to(ROOT).as_posix()} (stem {len(stem)}, ext {len(suffix)})") + return bad + + +@pytest.mark.parametrize("tree", COPIED_TREES) +def test_copied_files_fit_dos_8_3(tree: str) -> None: + root = ROOT / tree + if not root.is_dir(): + pytest.skip(f"{tree} is absent") + bad = _offenders(root) + assert not bad, ( + f"{len(bad)} name(s) under {tree} cannot be represented on the DOS side.\n" + "The DOS build will report them as missing files, not as long names:\n " + + "\n ".join(bad) + ) + + +def test_registry_paths_exist_on_the_host() -> None: + """Every ``.FE`` a case names must exist, matched case-insensitively. + + DOS is case-insensitive, so a registry typo survives until the command runs + inside the VM and fails with a message about the wrong thing. + """ + available = { + path.relative_to(ROOT / "fec").as_posix().upper() + for path in (ROOT / "fec").rglob("*.fe") + } + available |= { + path.relative_to(ROOT / "fec").as_posix().upper() + for path in (ROOT / "fec").rglob("*.FE") + } + missing = [] + for case in CASES: + for token in case.command.split(): + if not token.upper().endswith(".FE"): + continue + wanted = token.replace("\\", "/").upper() + if wanted.startswith("STD/"): + wanted = f"STD/{wanted[4:]}" + if not any(entry.endswith(wanted) for entry in available): + missing.append(f"{case.id}: {token}") + assert not missing, "registry names fixtures that do not exist:\n " + "\n ".join(missing) diff --git a/tools/tests/test_milestones_dosboxx.py b/tools/tests/test_milestones_dosboxx.py index c386b62..8b8ae3f 100644 --- a/tools/tests/test_milestones_dosboxx.py +++ b/tools/tests/test_milestones_dosboxx.py @@ -46,7 +46,10 @@ def test_milestone_case(case: Case, suite_run: SuiteRun) -> None: warning_lines = [line for line in err.splitlines() if "warning" in line.lower()] if warning_lines: warnings.warn("\n".join(warning_lines), stacklevel=1) + code = suite_run.rc(case) assert result == "PASS", ( - f"DOS command: {case.command}\nExpected success: {case.expect_success}\n" + f"DOS command: {case.command}\n" + f"Expected success: {case.expect_success}\n" + f"Exit code: {'not recorded' if code is None else code}\n" f"{suite_run.log(case)}\n{err}" )