Skip to content

Commit 276aa33

Browse files
committed
feat(core): one spelling for a store holding one byte
`Cpu(model, fill=0)` everywhere. Three of the four clocked members did not have it and each needed a different keyword for the same request, so a check written against any one of them reported the other three as broken. That is what the family's own check hit when it started asking every member for a part it could run a few dozen instructions on. The rule the family holds is unchanged and now stated more precisely: the default is never a cleared store, because no machine hands one over. What changes is that asking for one is possible and has to be written down. Two members said outright that there was no way to ask, which was already untrue of them: an image of zeroes did it, less legibly. The readme runner now reports an example as skipped rather than broken when the member itself says why it cannot run here, which two members need because their part runs a program they are not allowed to carry, and which changes nothing for the fourteen that publish no such reason.
1 parent a97889a commit 276aa33

5 files changed

Lines changed: 204 additions & 31 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
<a href="https://github.com/gufranco/sony-spc700-python/issues">Issues</a>
2424
</p>
2525

26-
**256** opcodes · **256,000** conformance cases and **1,182,940** cycles compared, **0** failures · every cycle count checked against **Nintendo's own tables** · **655** tests · **100%** statement and branch coverage · no dependencies
26+
**256** opcodes · **256,000** conformance cases and **1,182,940** cycles compared, **0** failures · every cycle count checked against **Nintendo's own tables** · **670** tests · **100%** statement and branch coverage · no dependencies
2727

2828
```python
2929
from spc700 import Cpu, SparseMemory

‎conformance/family.test.py‎

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -188,26 +188,6 @@ def store_attribute() -> str:
188188
return found[0]
189189

190190

191-
def a_store_of_zeroes() -> Any:
192-
"""A store the part will run through rather than halt in.
193-
194-
Taken off a part the member built and written through the part, so no store
195-
type has to be named and no constructor keyword has to be guessed. Left in
196-
scrambled memory a part reaches an undocumented opcode within a few dozen
197-
instructions and stops, which is correct behaviour and useless for testing a
198-
limit.
199-
200-
A page is enough: the checks that use this run a few dozen instructions from
201-
address zero, and zeroing the whole space on a part with a sixteen megabyte
202-
one costs seconds for nothing.
203-
"""
204-
part = PACKAGE.Cpu(PACKAGE.DEFAULT_MODEL)
205-
held = getattr(part, store_attribute())
206-
for address in range(0x100):
207-
part.write8(address, 0x00)
208-
return held
209-
210-
211191
def accounts_for_one_interrupt(node: Any) -> bool:
212192
"""Whether a record says anything about the part having one interrupt line.
213193
@@ -239,11 +219,14 @@ def at_the_start(part: Any) -> None:
239219
def a_running_part() -> Part:
240220
"""A part pointed at a field of no-operations, so a bound is what is tested.
241221
242-
Left in scrambled memory a part reaches an undocumented opcode within a few
243-
dozen instructions and halts, which is correct behaviour and useless for
244-
testing a limit.
222+
`fill` is the one spelling across this family for a store holding one byte
223+
everywhere. It exists for exactly this: left in scrambled memory a part
224+
reaches an undocumented opcode within a few dozen instructions and stops,
225+
which is correct behaviour and useless for testing a limit. Three of the four
226+
clocked members did not have it and each needed a different keyword, so a
227+
check written against any one of them reported the other three as broken.
245228
"""
246-
part = PACKAGE.Cpu(PACKAGE.DEFAULT_MODEL, a_store_of_zeroes())
229+
part = PACKAGE.Cpu(PACKAGE.DEFAULT_MODEL, fill=0)
247230
at_the_start(part)
248231
checked: Part = part
249232
return checked

‎conformance/readme.test.py‎

Lines changed: 140 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,80 @@
99
Each example runs in a fresh interpreter rather than in this one. An example that
1010
only works because a test already imported something is not an example a reader
1111
can paste, and running it here in-process would hide that.
12+
13+
An example that cannot run at all here is reported as skipped rather than as
14+
broken, and only when the member itself says why. Two members model a part that
15+
runs a program their repository is not allowed to carry, so on a machine without
16+
one every example that builds a part refuses. That refusal is the package working
17+
correctly, and counting it as a broken example would make a bare checkout look
18+
like a defect while hiding real ones behind it.
19+
20+
What decides is the member's own `why_not`, which is the same sentence its doctor
21+
prints. A member that publishes none skips nothing, and on a machine that has the
22+
files nothing is skipped either, so the check keeps its teeth exactly where it
23+
had them.
1224
"""
1325

1426
from __future__ import annotations
1527

28+
import importlib
1629
import re
1730
import subprocess
1831
import sys
1932
import unittest
2033
from pathlib import Path
34+
from types import ModuleType
2135

2236
ROOT = Path(__file__).resolve().parent.parent
2337

2438
README = ROOT / "README.md"
2539

40+
41+
def packages() -> list[str]:
42+
"""The importable package in this repository, which is the member itself.
43+
44+
The conformance directory is one too and is not the member, so it is left
45+
out by name rather than by position.
46+
"""
47+
return sorted(
48+
found.name
49+
for found in ROOT.iterdir()
50+
if (found / "__init__.py").is_file() and found.name != "conformance"
51+
)
52+
53+
54+
def cannot_run_here(named: list[str] | None = None) -> str | None:
55+
"""Why this member cannot build its part on this machine, or nothing.
56+
57+
Read off the package rather than guessed from a traceback, so the sentence
58+
an example is excused by is the sentence the member itself publishes.
59+
60+
The names are a parameter so this can be driven against a member that
61+
publishes a reason from a member that does not, and the other way round.
62+
Nine of the sixteen publish nothing, and a branch only two of them reach is
63+
a branch nobody has seen work.
64+
"""
65+
if str(ROOT) not in sys.path:
66+
sys.path.insert(0, str(ROOT))
67+
for name in packages() if named is None else named:
68+
asked = getattr(importlib.import_module(name), "why_not", None)
69+
if callable(asked):
70+
answer = asked()
71+
return str(answer) if answer else None
72+
return None
73+
74+
75+
def excused(failure: str, reason: str | None = None) -> bool:
76+
"""Whether that failure is the member saying it has no file to run.
77+
78+
Matched on a run of the member's own sentence rather than on an exception
79+
name, because the name differs per member and the sentence is the thing the
80+
member publishes for exactly this purpose.
81+
"""
82+
said = cannot_run_here() if reason is None else reason
83+
return bool(said) and str(said)[:40] in failure
84+
85+
2686
BLOCK = re.compile(r"^```(\w*)\n(.*?)^```$", re.M | re.S)
2787

2888

@@ -61,29 +121,38 @@ def ran(source: str) -> subprocess.CompletedProcess[str]:
61121
)
62122

63123

64-
def broken(found: list[tuple[str, str | None]]) -> list[str]:
124+
def broken(found: list[tuple[str, str | None]], reason: str | None = None) -> list[str]:
65125
"""The last line of the traceback of every example that will not run.
66126
67127
A process can exit non-zero and print nothing, so the reason falls back to
68128
the exit code rather than indexing an empty list. A checker that raises
69129
while collecting a fault reports neither that fault nor any after it.
70130
"""
71131
failed = []
132+
reason = cannot_run_here() if reason is None else reason
72133
for source, _ in found:
73134
finished = ran(source)
74-
if finished.returncode != 0:
75-
said = finished.stderr.strip().splitlines()
76-
failed.append(said[-1] if said else f"exited {finished.returncode} in silence")
135+
if finished.returncode == 0:
136+
continue
137+
if excused(finished.stderr, reason):
138+
continue
139+
said = finished.stderr.strip().splitlines()
140+
failed.append(said[-1] if said else f"exited {finished.returncode} in silence")
77141
return failed
78142

79143

80-
def mismatched(found: list[tuple[str, str | None]]) -> list[tuple[str, str]]:
144+
def mismatched(
145+
found: list[tuple[str, str | None]], reason: str | None = None
146+
) -> list[tuple[str, str]]:
81147
"""What the readme claims each example prints, beside what it printed."""
82148
wrong = []
149+
reason = cannot_run_here() if reason is None else reason
83150
for source, expected in found:
84151
if expected is None:
85152
continue
86153
finished = ran(source)
154+
if finished.returncode != 0 and excused(finished.stderr, reason):
155+
continue
87156
if finished.stdout != expected:
88157
wrong.append((expected.strip(), finished.stdout.strip()))
89158
return wrong
@@ -137,6 +206,72 @@ def test_and_a_later_example_is_still_checked_after_an_earlier_one_failed(self)
137206

138207
self.assertEqual(mismatched(examples(readme)), [("2", "1")])
139208

209+
def test_a_member_that_can_run_everything_excuses_nothing(self) -> None:
210+
"""The teeth stay where they were on every member that ships its own part."""
211+
self.assertFalse(excused("anything at all", None if cannot_run_here() else "x" * 60))
212+
213+
def test_a_failure_the_member_says_it_expects_is_excused(self) -> None:
214+
reason = "no firmware image was found: this backend runs the part's own microcode"
215+
216+
self.assertTrue(excused(f"Traceback\nNoFirmware: {reason}", reason))
217+
218+
def test_and_any_other_failure_is_not(self) -> None:
219+
"""Driven against the shape that would otherwise slip through."""
220+
reason = "no firmware image was found: this backend runs the part's own microcode"
221+
222+
self.assertFalse(excused("Traceback\nZeroDivisionError: division by zero", reason))
223+
224+
def test_and_a_member_that_publishes_no_reason_excuses_nothing(self) -> None:
225+
self.assertFalse(excused("Traceback\nNoFirmware: anything", ""))
226+
227+
def test_an_example_that_only_this_machine_can_run_is_reported_as_broken(self) -> None:
228+
"""So the excuse cannot be claimed by an example that simply does not work."""
229+
readme = "```python\nraise ValueError('nope')\n```\n"
230+
231+
self.assertEqual(broken(examples(readme)), ["ValueError: nope"])
232+
233+
def test_an_example_the_member_says_it_cannot_run_is_not_reported_as_broken(self) -> None:
234+
readme = "```python\nraise SystemExit('no image is here')\n```\n"
235+
236+
self.assertEqual(broken(examples(readme), "no image is here"), [])
237+
238+
def test_and_its_stated_output_is_not_compared_either(self) -> None:
239+
"""An example that never ran produced no output to compare."""
240+
readme = "```python\nraise SystemExit('no image is here')\n```\n\n```\n7\n```\n"
241+
242+
self.assertEqual(mismatched(examples(readme), "no image is here"), [])
243+
244+
def test_a_member_that_publishes_a_reason_is_read(self) -> None:
245+
"""Driven against a stand-in, because nine of the sixteen publish none."""
246+
speaking = ModuleType("speaking")
247+
speaking.why_not = lambda: "no image is here" # type: ignore[attr-defined]
248+
sys.modules["speaking"] = speaking
249+
try:
250+
self.assertEqual(cannot_run_here(["speaking"]), "no image is here")
251+
finally:
252+
del sys.modules["speaking"]
253+
254+
def test_and_one_that_publishes_nothing_to_say_says_nothing(self) -> None:
255+
quiet = ModuleType("quiet")
256+
quiet.why_not = lambda: None # type: ignore[attr-defined]
257+
sys.modules["quiet"] = quiet
258+
try:
259+
self.assertIsNone(cannot_run_here(["quiet"]))
260+
finally:
261+
del sys.modules["quiet"]
262+
263+
def test_and_one_that_publishes_no_such_call_is_passed_over(self) -> None:
264+
silent = ModuleType("silent")
265+
sys.modules["silent"] = silent
266+
try:
267+
self.assertIsNone(cannot_run_here(["silent"]))
268+
finally:
269+
del sys.modules["silent"]
270+
271+
def test_the_member_this_repository_holds_is_found_by_name(self) -> None:
272+
"""So the sweep cannot start reading the conformance directory instead."""
273+
self.assertNotIn("conformance", packages())
274+
140275
def test_an_example_with_no_stated_output_is_only_run(self) -> None:
141276
readme = "```python\nprint(1)\n```\n"
142277

‎spc700/__init__.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,27 @@
3636

3737

3838
def Cpu( # noqa: N802
39-
model: str = DEFAULT_MODEL, memory: Any = None, **options: Any
39+
model: str = DEFAULT_MODEL,
40+
memory: Any = None,
41+
fill: int | None = None,
42+
**options: Any,
4043
) -> Any:
4144
"""A processor of the named model, sharing one interface across the family.
4245
4346
The model comes first because it is the thing a caller always knows and
4447
memory is the thing they often do not care about yet. Omitting it hands back
4548
a part with memory of its own, scrambled rather than cleared, which is what a
4649
board holds before anything has written to it.
50+
51+
`fill` is the one way across this family to ask for a store holding one byte
52+
everywhere. It is not what a board hands over and it is not the default: a
53+
caller asking for zeroes is asking for something no machine does, so they
54+
have to say so. What it is for is a run that has to get through a few dozen
55+
instructions without meeting an opcode that stops the part, which is what
56+
every check of a cycle budget needs and what scrambled memory cannot give.
4757
"""
58+
if fill is not None and memory is None:
59+
memory = Memory(fill=fill)
4860
return describe(model).build(SparseMemory() if memory is None else memory, **options)
4961

5062

‎spc700/models.test.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,5 +66,48 @@ def test_a_model_the_family_does_not_have_is_refused_at_construction(self) -> No
6666
spc700.Cpu("6502", Memory(fill=0))
6767

6868

69+
class QuietStoreTest(unittest.TestCase):
70+
"""`fill`, which is the one spelling across this family for a store of one byte.
71+
72+
Not what a board hands over and not the default: a caller asking for zeroes
73+
is asking for something no machine does, so they have to say so. What it is
74+
for is a run that has to get through a few dozen instructions without meeting
75+
an opcode that stops the part, which is what every check of a cycle budget
76+
needs and what scrambled memory cannot give.
77+
"""
78+
79+
def test_a_fill_puts_that_byte_everywhere(self) -> None:
80+
part = spc700.Cpu(spc700.DEFAULT_MODEL, fill=0)
81+
82+
self.assertEqual({part.memory.read8(address) for address in range(0x40)}, {0})
83+
84+
def test_and_any_byte_works_rather_than_only_zero(self) -> None:
85+
part = spc700.Cpu(spc700.DEFAULT_MODEL, fill=0xAA)
86+
87+
self.assertEqual({part.memory.read8(address) for address in range(0x40)}, {0xAA})
88+
89+
def test_without_one_the_store_is_scrambled_rather_than_cleared(self) -> None:
90+
"""The default has to stay the thing a machine actually hands over.
91+
92+
Read address by address rather than off the store's own bytes, because
93+
the default store allocates nothing until it is asked and has no bytes
94+
to read.
95+
"""
96+
part = spc700.Cpu(spc700.DEFAULT_MODEL)
97+
98+
held = {part.memory.read8(address) for address in range(0x40)}
99+
100+
self.assertNotEqual(held, {0})
101+
102+
def test_and_a_store_handed_in_is_left_alone(self) -> None:
103+
"""So `fill` cannot quietly replace memory a caller already built."""
104+
own = spc700.Memory(fill=0xAA)
105+
106+
part = spc700.Cpu(spc700.DEFAULT_MODEL, own, fill=0)
107+
108+
self.assertIs(part.memory, own)
109+
self.assertEqual({part.memory.read8(address) for address in range(0x40)}, {0xAA})
110+
111+
69112
if __name__ == "__main__":
70113
unittest.main()

0 commit comments

Comments
 (0)