Files
EVEngine/scripts/resource_format_suite.py
T
西风逍遥游 31880f0021 feat(examples): gate examples in CI and make repository scripts UTF-8 on every host
`examples/<name>/` was a tested surface in name only. The source-quality job had
no examples check at all, and scripts/smoke_examples.sh accepted any process that
had not printed one of its error markers: a project that died during startup,
exited after a single frame, or crashed without a marker was reported as PASS.
Nothing stopped a malformed or unregistered example from reaching dev, which is
how 14 examples arrived without READMEs and 20 were never registered in the
overview table.

Source side
- scripts/check_examples.py classifies every directory under examples/ as either
  runnable (main.nut plus a config.nut that assigns the engine `config` table) or
  as a reviewed non-runnable sample listed in NON_RUNNABLE_EXAMPLES with a
  reason. Both kinds must own a README.md and appear in examples/README.md.
  Stale exemptions and overview links to missing directories fail as well.
- `make check/examples` and a new source-quality CI step run the gate.
- scripts/tests/test_check_examples.py covers it and pins each failure mode
  against fixtures.

Runtime side
- scripts/smoke_examples.sh now enforces liveness, not just marker absence: every
  runnable example must still be running after MIN_RUN_SECONDS (default 2s; CI
  passes 2s), and a process that dies inside that window fails whatever its exit
  code. A clean self-exit after the window passes with an explicit note instead
  of being silently accepted as a pass.
- The Linux CI step sets MIN_RUN_SECONDS=2 and keeps RUN_SECONDS=6, so the job's
  wall clock is unchanged (13.7 min of its 20 min budget on dev).

Windows / GBK
- scripts/utf8_stdio.py re-encodes stdout/stderr as UTF-8 and every scripts/*.py
  program calls it as the first statement of its __main__ block. On a Chinese
  Windows a redirected stream falls back to the ANSI code page (cp936), and
  printing a character that code page cannot encode (arrows, superscripts, bullet
  separators, emoji - all common in this repository) raised UnicodeEncodeError
  and turned a checker into a traceback.
- The mirror image is input: read_text() without encoding= decoded UTF-8
  repository text as cp936 and raised UnicodeDecodeError.
- scripts/tests/test_utf8_stdio.py enforces both conventions for every script and
  test module; text file I/O and child-process decoding now pass encoding="utf-8"
  explicitly.
- Unit-test invocations run with `python3 -X utf8` so the runner's own output
  never follows the host code page.

Violations the new gates found on top of dev, fixed here: 14 examples shipped
without a README (fluid3d, mesh-impact-lab, mesh-modifier-lab, spline-tube-lab on
the earlier dev tip, plus pcg-depth-of-field, pcg-detail-overwrite,
pcg-layer-culling, pcg-location-system, pcg-runtime-orchestration,
pcg-scene-player-culling, pcg-snow-wind, pcg-thunder-strike, pcg-ui-scaler and
pcg-underwater added by #397), 20 were missing from the overview table (those 14
plus archspace-editor, geometry-stroke-lab, interior-mapping, layered-animation,
triplanar-decal, terrain-stamping), and scripts/export_meshret_onnx.py had no
UTF-8 stdio guard.

Verified locally: `python -m ruff check scripts` (All checks passed);
scripts/check_test_manifest.py; scripts/check_quality_metadata.py;
scripts/check_architecture_contracts.py --base HEAD; scripts/check_nodiscard.py;
`make -n check/examples`; scripts/check_examples.py (125 examples: 118 runnable,
7 reviewed non-runnable); and `python -X utf8 -m unittest discover -s scripts/tests
-p "test_*.py"` (155 tests, OK). The runtime half of the contract needs a Linux
headless build, so it is verified by the CI examples smoke step rather than
locally.
2026-09-15 20:20:23 +08:00

199 lines
9.8 KiB
Python

#!/usr/bin/env python3
"""Inventory resource tests; optionally run exact process-isolated CTest cases.
Source discovery is evidence of test existence, never evidence of format support
or successful execution. Missing CTest registrations fail execution closed.
"""
import argparse
import hashlib
import json
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
import utf8_stdio
ROOT = Path(__file__).resolve().parent.parent
FAMILIES = {
"image": ["resource_format_image.cpp", "image.cpp", "image_script.cpp", "asset_graphics_loader.cpp"],
"sound": ["resource_decoder_lifetime.cpp", "resource_format_sound.cpp", "sound.cpp", "audio.cpp", "editor_audio_import_diagnostics.cpp"],
"model": ["resource_format_model.cpp", "model3d.cpp", "model3d_normals.cpp", "medialoader_model_link.cpp", "avatar_vrm_vulkan.cpp"],
"font": ["font.cpp", "graphics_font.cpp"],
"map": ["map.cpp", "resource_format_map.cpp"],
"asset": [p.name for p in sorted((ROOT / "test").glob("asset_*.cpp"))],
"resource": ["filesystem_cpp.cpp", "resource.cpp", "resource_uri.cpp", "resource_lifetime.cpp"],
}
def inventory(root=ROOT):
result = {}
for family, files in FAMILIES.items():
cases = {}
for name in files:
path = root / "test" / name
for case in re.findall(r'TEST_CASE\(\s*"([^"\n]+)"', path.read_text(encoding="utf-8-sig")):
if case in cases:
raise ValueError("duplicate test: " + case)
cases[case] = "test/" + name
extensions = []
loader = {"image": "image/ImageLoader.cpp", "sound": "sound/SoundLoader.cpp", "model": "model3d/ModelLoader.cpp"}.get(family)
if loader:
source = (root / "src/modules" / loader).read_text(encoding="utf-8")
extensions = sorted(set(re.findall(r'ext == "(\.[^"]+)"', source)))
result[family] = {"advertised_resource_extensions": extensions, "tests": cases}
return result
def fixture_errors(root=ROOT):
directory = root / "test/fixtures/resource_formats"
manifest = json.loads((directory / "manifest.json").read_text(encoding="utf-8"))
if manifest.get("schema") != "evengine.resource-format-fixtures" or manifest.get("version") != 1:
raise ValueError("unsupported fixture manifest schema/version")
errors = []
for name, expected in manifest["sha256"].items():
if Path(name).name != name:
raise ValueError("fixture path must be a basename")
path = directory / name
if not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != expected:
errors.append(name)
return errors
def format_coverage(groups, results, root=ROOT):
contracts_path = root / "scripts/resource_format_contracts.json"
contract = json.loads(contracts_path.read_text(encoding="utf-8"))
if (set(contract) != {"schema", "version", "unknownFields", "formats"}
or contract["schema"] != "evengine.resource-format-contracts"
or contract["version"] != 1 or contract["unknownFields"] != "reject"):
raise ValueError("unsupported format contract schema/version or unknown fields")
known_tests = {name for group in inventory(root).values() for name in group["tests"]}
statuses = {test["name"]: test["status"] for test in results}
rows = []
seen = set()
for row in contract["formats"]:
if set(row) != {"id", "family", "extensions", "importTests", "exportTests", "exportMode", "scope"}:
raise ValueError("invalid format contract fields")
if row["id"] in seen:
raise ValueError("duplicate format contract: " + row["id"])
seen.add(row["id"])
for name in row["importTests"] + row["exportTests"]:
if name not in known_tests:
raise ValueError("stale format test reference: " + name)
if row["family"] not in groups:
continue
entry = dict(row)
for operation in ["import", "export"]:
names = row[operation + "Tests"]
entry[operation + "Status"] = ("coverage-missing" if not names else
"passed" if all(statuses.get(name) == "passed" for name in names) else
"failed" if any(name in statuses and statuses[name] != "passed" for name in names) else "not-run")
rows.append(entry)
for family, group in groups.items():
for extension in group["advertised_resource_extensions"]:
if not any(row["family"] == family and extension in row["extensions"] for row in rows):
raise ValueError("resource extension missing from contract: " + family + extension)
return rows
def run_cases(build_dir, selected):
listing = subprocess.run(["ctest", "--test-dir", str(build_dir), "--show-only=json-v1"],
check=True, capture_output=True, text=True, errors="replace")
registered = {t["name"]: t for t in json.loads(listing.stdout)["tests"]}
missing = sorted(set(selected) - registered.keys())
disabled = sorted(name for name in selected if name in registered and any(
p["name"] == "DISABLED" and p["value"] for p in registered[name].get("properties", [])))
if missing or disabled or not selected:
return 1, {"status": "failed", "missing_registrations": missing, "disabled_tests": disabled}
results = []
outputs = []
code = 0
# Keep the command under Windows' process command-line limit as suites grow.
with tempfile.TemporaryDirectory(prefix="eve-resource-ctest-") as directory:
for offset in range(0, len(selected), 40):
batch = selected[offset:offset + 40]
junit = Path(directory) / "results.xml"
junit.unlink(missing_ok=True)
pattern = "^(" + "|".join(re.escape(name) for name in batch) + ")$"
run = subprocess.run(["ctest", "--test-dir", str(build_dir), "--output-on-failure",
"--no-tests=error", "--output-junit", str(junit), "-R", pattern],
capture_output=True, text=True, errors="replace")
outputs.append(run.stdout + run.stderr)
code = code or run.returncode
if not junit.is_file():
code = code or 1
results.extend({"name": name, "status": "missing-result"} for name in batch)
continue
observed = set()
for case in ET.parse(junit).getroot().iter("testcase"):
name = case.attrib["name"]
status = "passed"
if (case.find("failure") is not None or case.find("error") is not None
or "Assertion Failed" in case.findtext("system-out", "")):
status = "failed"
elif case.find("skipped") is not None or case.get("status") in ("notrun", "disabled"):
status = "skipped"
if name not in batch or name in observed:
status = "unexpected-result"
observed.add(name)
result = {"name": name, "status": status, "seconds": case.get("time")}
if status != "passed":
result["output"] = case.findtext("system-out", "")[:16000]
results.append(result)
if status != "passed":
code = code or 1
for name in sorted(set(batch) - observed):
results.append({"name": name, "status": "missing-result"})
code = code or 1
return code, {"status": "passed" if code == 0 else "failed", "results": results,
"output": "\n".join(outputs)}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--build-dir", type=Path, help="Run tests from this configured CTest build")
parser.add_argument("--family", choices=sorted(FAMILIES), action="append")
parser.add_argument("--new-contracts-only", action="store_true", help="Select resourceFormats.* cases only")
parser.add_argument("--report", type=Path, required=True)
args = parser.parse_args()
groups = {k: v for k, v in inventory().items() if not args.family or k in args.family}
if args.new_contracts_only:
for group in groups.values():
group["tests"] = {k: v for k, v in group["tests"].items() if k.startswith("resourceFormats.")}
report = {"schema": "evengine.resource-test-report", "version": 1,
"status": "not-run", "families": groups,
"limitation": "Extension routing and test names do not prove per-format coverage or export support."}
code = 0
try:
report["fixture_errors"] = fixture_errors()
if report["fixture_errors"]:
code, report["status"] = 1, "failed"
elif args.build_dir:
selected = sorted({name for group in groups.values() for name in group["tests"]})
code, result = run_cases(args.build_dir, selected)
report.update(result)
except (subprocess.SubprocessError, OSError, ValueError, KeyError, ET.ParseError) as error:
report.update(status="failed", error=str(error))
code = 1
try:
report["format_coverage"] = format_coverage(groups, report.get("results", []))
except (OSError, ValueError, KeyError) as error:
report.update(status="failed", error=str(error))
code = 1
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(f"{report['status']}: {sum(len(g['tests']) for g in groups.values())} source tests; {args.report}")
if report.get("error"):
print(report["error"], file=sys.stderr)
if report.get("fixture_errors"):
print("Fixture hash mismatch: " + ", ".join(report["fixture_errors"]), file=sys.stderr)
return code
if __name__ == "__main__":
utf8_stdio.enable_utf8_stdio()
sys.exit(main())