Merge branch 'master' into m7-trial
This commit is contained in:
+59
-21
@@ -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([
|
|
||||||
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([
|
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",
|
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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user