Merge branch 'master' into m7-trial

This commit is contained in:
2026-08-16 23:02:43 +09:00
4 changed files with 147 additions and 26 deletions
+58 -20
View File
@@ -126,12 +126,40 @@ def resolve_tools() -> tuple[Path, Path]:
return dosbox, watcom 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 <key>`` 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: 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 = [ lines = [
"@echo off", "if not exist RESULTS md RESULTS", "if not exist OUT md OUT", "@echo off", "if not exist RESULTS md RESULTS", "if not exist OUT md OUT",
"set WATCOM=W:", "set INCLUDE=W:\\H", "set WATCOM=W:", "set INCLUDE=W:\\H",
"set LIB=W:\\LIB286\\DOS;W:\\LIB286;W:\\LIB386\\DOS;W:\\LIB386", "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", "echo PASS>RESULTS\\BUILD.RES",
] ]
for index, case in enumerate(cases): 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: if not trace_dos:
command += f" > RESULTS\\{key}.LOG" command += f" > RESULTS\\{key}.LOG"
lines.append(command)
if case.expect_success:
lines.extend([ lines.extend([
f"if errorlevel 1 goto {key}E", f"echo PASS>RESULTS\\{key}.RES", command,
f"goto {key}C", f":{key}E", f"echo FAIL>RESULTS\\{key}.RES", f"call RC.BAT {key}",
]) f"if exist *.ERR type *.ERR > RESULTS\\{key}.ERR",
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",
f"if not exist RESULTS\\{key}.ERR type NUL > RESULTS\\{key}.ERR", f"if not exist RESULTS\\{key}.ERR type NUL > RESULTS\\{key}.ERR",
]) ])
lines.extend([ lines.extend([
@@ -181,22 +200,40 @@ class SuiteRun:
def _key(self, case: Case) -> str: def _key(self, case: Case) -> str:
return f"C{self.cases.index(case):03d}" 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: def result(self, case: Case | None = None) -> str:
name = "BUILD" if case is None else self._key(case) if case is None:
path = self.fec / "RESULTS" / f"{name}.RES" path = self.fec / "RESULTS" / "BUILD.RES"
return path.read_text(encoding="ascii").strip() if path.is_file() else "MISSING" 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: def log(self, case: Case | None = None) -> str:
name = "BUILD" if case is None else self._key(case) name = "BUILD" if case is None else self._key(case)
path = self.fec / "RESULTS" / f"{name}.LOG" path = self.fec / "RESULTS" / f"{name}.LOG"
content = path.read_text(encoding="utf-8", errors="replace") if path.is_file() else "" content = path.read_text(encoding="utf-8", errors="replace") if path.is_file() else ""
if content: if content.strip():
return content return content
errors = sorted(self.fec.glob("*.ERR")) errors = sorted(self.fec.glob("*.ERR"))
if errors: joined = "\n".join(p.read_text(encoding="utf-8", errors="replace") for p in errors)
return "\n".join(p.read_text(encoding="utf-8", errors="replace") for p in errors) if joined.strip():
console = self.root / "CONSOLE.LOG" return joined
return console.read_text(encoding="utf-8", errors="replace") if console.is_file() else "" # 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: def err(self, case: Case) -> str:
path = self.fec / "RESULTS" / f"{self._key(case)}.ERR" 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), _batch(cases, show_dos=show_dos, trace_dos=trace_dos),
encoding="ascii", newline="", encoding="ascii", newline="",
) )
(fec / "RC.BAT").write_text(_rc_batch(), encoding="ascii", newline="")
command = [str(dosbox)] command = [str(dosbox)]
if not show_dos: if not show_dos:
command.append("-silent") command.append("-silent")
+12 -4
View File
@@ -35,7 +35,9 @@ def main() -> int:
help="print the captured DOS console after the run") help="print the captured DOS console after the run")
run.add_argument("--trace-dos", action="store_true", run.add_argument("--trace-dos", action="store_true",
help="do not redirect case command output") 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: try:
if args.command == "setup": if args.command == "setup":
dosbox, watcom = setup(accept_watcom_license=args.accept_watcom_license) dosbox, watcom = setup(accept_watcom_license=args.accept_watcom_license)
@@ -55,12 +57,18 @@ def main() -> int:
if enabled: if enabled:
os.environ[name] = "1" os.environ[name] = "1"
import pytest import pytest
test_file = os.fspath( from .paths import ROOT
Path(__file__).resolve().parents[2] / "tools" / "tests" / "test_milestones_dosboxx.py" # 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"] 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: if args.dos_log:
pytest_args.append("-s") pytest_args.append("-s")
pytest_args.extend(extra)
return int(pytest.main(pytest_args)) return int(pytest.main(pytest_args))
except (DosboxError, ValueError) as exc: except (DosboxError, ValueError) as exc:
print(f"ferro-test: {exc}", file=sys.stderr) print(f"ferro-test: {exc}", file=sys.stderr)
+72
View File
@@ -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)
+4 -1
View File
@@ -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()] warning_lines = [line for line in err.splitlines() if "warning" in line.lower()]
if warning_lines: if warning_lines:
warnings.warn("\n".join(warning_lines), stacklevel=1) warnings.warn("\n".join(warning_lines), stacklevel=1)
code = suite_run.rc(case)
assert result == "PASS", ( 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}" f"{suite_run.log(case)}\n{err}"
) )