mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-02 03:15:36 +00:00
tools/run_tests.sh defaults QEMU_SRC to a sibling of the checkout, which is where setup_qemu.sh puts it; the two defaults disagreed, so a fresh clone rebuilt nothing and reported a build that was not there. The interpreter list was also reading an empty $PY. tools/test_bk4819_readback.sh was the last test with the author's paths, and the only one that could not run elsewhere. It now takes QEMU, GDB and ELF from the environment or PATH like the python tests, skips with a reason when one is missing, and says so on a platform whose QEMU cannot make the unix socket it uses. tools/check_docs.py points UVK5_FW_DIR at a sibling and skips the file:line checks, with a message, when there is no firmware tree -- a fresh clone used to see thirteen failures it could do nothing about. Added .github/workflows/unit.yml (the fast half of run_tests.sh on every push and PR), requirements-dev.txt for the one pip dependency, and a Dockerfile. Flask is not always installed, so test_webui now skips through setUpModule rather than erroring.
228 lines
8.7 KiB
Python
Executable File
228 lines
8.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Check the documentation's factual claims against the code.
|
|
|
|
Documentation rots quietly. Translating the docs into Chinese turned up four claims
|
|
that had already drifted -- a missing endpoint, an omitted peripheral, a table still
|
|
calling TIM a stub after TIM2 was modelled, and library modules absent from the layout.
|
|
None of those were caught by reading; they were caught by comparing against the source.
|
|
So compare mechanically, and keep doing it.
|
|
|
|
What is checked:
|
|
1. every tool named in a README exists
|
|
2. every test in run_tests.sh is documented in both READMEs
|
|
3. every internal .md link resolves
|
|
4. the English/Chinese pairs have matching section structure
|
|
5. memory-map addresses match the model's #defines
|
|
6. every long flag a doc passes to a tool exists in that tool
|
|
7. firmware file:line references point at what the docs say they do
|
|
|
|
Run it after touching docs or renaming anything:
|
|
|
|
python3 tools/check_docs.py
|
|
|
|
One caution learned while writing this. An early version compared firmware constants
|
|
with a regex that grabbed the first number on the line, so `key_debounce_10ms = 20 / 10`
|
|
read as 20 and the check reported the docs wrong when they said 2. The docs were right
|
|
and the checker was broken. A checker that cries wolf gets ignored, so anything it
|
|
cannot verify unambiguously is left out rather than guessed at.
|
|
"""
|
|
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
SIM = pathlib.Path(__file__).resolve().parent.parent
|
|
# Where the firmware sources are. Override when the tree is not at the default
|
|
# path -- without that the checker cannot run anywhere but the machine it was
|
|
# written on, and the file:line checks are the ones that catch drifting prose.
|
|
# Firmware sources are not in this repository (see the README), so the default is a
|
|
# sibling of this checkout and the checks that need the tree are skipped without it.
|
|
# A fresh clone used to see thirteen failures it could do nothing about.
|
|
_fw_env = os.environ.get("UVK5_FW_DIR")
|
|
FW = pathlib.Path(_fw_env) if _fw_env else (SIM.parent / "uvk5-port" / "uvk5-sat" / "App")
|
|
|
|
PAIRS = [
|
|
("README.md", "README.zh-CN.md"),
|
|
("AGENTS.md", "AGENTS.zh-CN.md"),
|
|
("docs/reverse-proxy.md", "docs/reverse-proxy.zh-CN.md"),
|
|
]
|
|
|
|
# Addresses the READMEs state, against the model's own #defines.
|
|
MEMORY_MAP = {
|
|
"PY32_FLASH_BASE": "0x08000000",
|
|
"PY32_SRAM_BASE": "0x20000000",
|
|
"PY32_RCC_BASE": "0x40021000",
|
|
"PY32_SPI1_BASE": "0x40013000",
|
|
"PY32_SPI2_BASE": "0x40003800",
|
|
"PY32_ADC1_BASE": "0x40012400",
|
|
"PY32_APP_OFFSET": "0x2800",
|
|
}
|
|
|
|
# A documented file:line and a word that must appear near it. The window is a few
|
|
# lines wide on purpose: a reference drifting by a line or two is still useful, and
|
|
# failing on that would make the check noise.
|
|
LINE_REFS = {
|
|
("app/app.c", 1697): "CheckRadioInterrupts",
|
|
("app/app.c", 910): "REG_0C",
|
|
("app/app.c", 1417): "REG_0C",
|
|
("app/app.c", 915): "uint16_t",
|
|
("app/app.c", 1027): "SquelchLost",
|
|
("app/app.c", 482): "StartListening",
|
|
("app/app.c", 1374): "BATTERY_SAVE",
|
|
("app/app.c", 1700): "TRANSMIT",
|
|
("driver/gpio.h", 31): "PTT",
|
|
("driver/gpio.h", 34): "AUDIO_PATH",
|
|
("driver/bk4819.c", 743): "SetFrequency",
|
|
("settings.c", 263): "KEY_1_SHORT",
|
|
("settings.c", 423): "mic_bar",
|
|
("ui/main.c", 2370): "Rx",
|
|
("app/menu.c", 2311): "Direction",
|
|
("app/menu.c", 1826): "gMenuListCount",
|
|
}
|
|
|
|
WINDOW = 4
|
|
|
|
problems = []
|
|
|
|
|
|
def fail(msg):
|
|
problems.append(msg)
|
|
print(f" FAIL {msg}")
|
|
|
|
|
|
def resolve_fw(name):
|
|
name = name.replace("App/", "")
|
|
for cand in (FW / name, FW / "app" / name, FW / "driver" / name,
|
|
FW / "helper" / name, FW / "ui" / name):
|
|
if cand.exists():
|
|
return cand
|
|
return None
|
|
|
|
|
|
def check_tools_exist():
|
|
print("tools named in a README must exist")
|
|
for doc in ("README.md", "README.zh-CN.md"):
|
|
text = (SIM / doc).read_text(encoding="utf-8")
|
|
for tool in sorted(set(re.findall(r"tools/([a-z0-9_]+\.(?:py|sh))", text))):
|
|
if not (SIM / "tools" / tool).exists():
|
|
fail(f"{doc} names tools/{tool}, which does not exist")
|
|
|
|
|
|
def check_tests_documented():
|
|
print("every test in run_tests.sh must be documented")
|
|
runner = (SIM / "tools" / "run_tests.sh").read_text(encoding="utf-8")
|
|
in_runner = set(re.findall(r"tools/([a-z0-9_]+\.(?:py|sh))", runner))
|
|
for doc in ("README.md", "README.zh-CN.md"):
|
|
text = (SIM / doc).read_text(encoding="utf-8")
|
|
for tool in sorted(in_runner):
|
|
if tool not in text:
|
|
fail(f"{doc} does not mention {tool}, which run_tests.sh runs")
|
|
|
|
|
|
def check_links():
|
|
print("internal .md links must resolve")
|
|
for doc in [d for pair in PAIRS for d in pair]:
|
|
path = SIM / doc
|
|
for target in re.findall(r"\]\(([^)]+\.md)\)", path.read_text(encoding="utf-8")):
|
|
if target.startswith("http"):
|
|
continue
|
|
if not (path.parent / target).exists():
|
|
fail(f"{doc} links to {target}, which does not exist")
|
|
|
|
|
|
def check_pairs():
|
|
print("translation pairs must have matching structure")
|
|
for en_name, zh_name in PAIRS:
|
|
en = re.findall(r"^(#+) (.+)$", (SIM / en_name).read_text(encoding="utf-8"), re.M)
|
|
zh = re.findall(r"^(#+) (.+)$", (SIM / zh_name).read_text(encoding="utf-8"), re.M)
|
|
if len(en) != len(zh):
|
|
fail(f"{en_name} has {len(en)} headings, {zh_name} has {len(zh)}")
|
|
continue
|
|
for i, ((en_lvl, en_txt), (zh_lvl, _)) in enumerate(zip(en, zh)):
|
|
if en_lvl != zh_lvl:
|
|
fail(f"{zh_name} heading {i + 1} is at a different depth than "
|
|
f"{en_name}'s ({en_txt!r})")
|
|
|
|
|
|
def check_memory_map():
|
|
print("memory-map addresses must match the model")
|
|
model = (SIM / "qemu" / "py32f071.c").read_text(encoding="utf-8")
|
|
for sym, documented in MEMORY_MAP.items():
|
|
m = re.search(rf"#define {sym}\s+(\S+)", model)
|
|
if not m:
|
|
fail(f"{sym} is documented but not defined in the model")
|
|
elif documented.lower() not in m.group(1).lower():
|
|
fail(f"docs say {sym} is {documented}, model says {m.group(1)}")
|
|
|
|
|
|
def check_documented_flags():
|
|
"""Every long flag a doc attributes to a tool must exist in that tool.
|
|
|
|
A renamed or removed option is the classic form of command rot, and it is the one
|
|
that wastes a reader's time most directly: they paste the line and it fails.
|
|
|
|
Backslash continuations are joined first. Without that, the regex stops at the
|
|
newline and only sees the first flag of a wrapped command -- which checked 4 of the
|
|
9 flags here and reported a clean run. A check that silently covers a quarter of
|
|
what it claims is worse than no check.
|
|
"""
|
|
print("documented tool flags must exist")
|
|
text = "\n".join(
|
|
(SIM / doc).read_text(encoding="utf-8") for pair in PAIRS for doc in pair)
|
|
joined = re.sub(r"\\\s*\n\s*", " ", text)
|
|
|
|
claims = {}
|
|
for m in re.finditer(r"tools/([a-z0-9_]+\.(?:py|sh))([^\n]*)", joined):
|
|
for flag in re.findall(r"(--[a-z][a-z-]+)", m.group(2)):
|
|
claims.setdefault(m.group(1), set()).add(flag)
|
|
|
|
for tool, flags in sorted(claims.items()):
|
|
path = SIM / "tools" / tool
|
|
if not path.exists():
|
|
continue # already reported by check_tools_exist
|
|
src = path.read_text(encoding="utf-8")
|
|
for flag in sorted(flags):
|
|
if flag not in src:
|
|
fail(f"docs pass {flag} to {tool}, which does not accept it")
|
|
|
|
|
|
def check_line_refs():
|
|
print("firmware file:line references must point at what the docs claim")
|
|
if not FW.is_dir():
|
|
print(f" SKIP no firmware tree at {FW}; set UVK5_FW_DIR to check "
|
|
f"{len(LINE_REFS)} file:line references")
|
|
return
|
|
|
|
for (name, line), needle in sorted(LINE_REFS.items()):
|
|
path = resolve_fw(name)
|
|
if path is None:
|
|
fail(f"{name} is referenced but not found in the firmware tree")
|
|
continue
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
if line > len(lines):
|
|
fail(f"{name}:{line} is past the end of the file ({len(lines)} lines)")
|
|
continue
|
|
window = "\n".join(lines[max(0, line - WINDOW - 1):line + WINDOW])
|
|
if needle.lower() not in window.lower():
|
|
fail(f"{name}:{line} has no {needle!r} nearby -- "
|
|
f"the reference has drifted")
|
|
|
|
|
|
def main():
|
|
for check in (check_tools_exist, check_tests_documented, check_links,
|
|
check_pairs, check_memory_map, check_documented_flags,
|
|
check_line_refs):
|
|
check()
|
|
|
|
print()
|
|
if problems:
|
|
print(f"{len(problems)} documentation problem(s)")
|
|
return 1
|
|
print("documentation matches the code")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|