smartcli-toolkit 0.1.1__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/PKG-INFO +1 -1
  2. {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/pyproject.toml +1 -1
  3. {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_core/__init__.py +7 -3
  4. {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_core/pty_backend.py +8 -0
  5. {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_core/readiness.py +22 -2
  6. {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_core/session.py +13 -2
  7. {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_toolkit.egg-info/PKG-INFO +1 -1
  8. {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_toolkit.egg-info/SOURCES.txt +2 -0
  9. smartcli_toolkit-0.1.2/tests/test_degenerate_inputs.py +93 -0
  10. smartcli_toolkit-0.1.2/tests/test_fx_contract.py +267 -0
  11. {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/tests/test_readiness.py +116 -21
  12. {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/LICENSE +0 -0
  13. {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/README.md +0 -0
  14. {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/setup.cfg +0 -0
  15. {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_core/screen_model.py +0 -0
  16. {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_core/snapshot.py +0 -0
  17. {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_toolkit.egg-info/dependency_links.txt +0 -0
  18. {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_toolkit.egg-info/requires.txt +0 -0
  19. {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_toolkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smartcli-toolkit
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Pluggable-PTY + pyte screen model + semantic snapshot / readiness core for driving interactive terminal programs (SmartCLI shared core).
5
5
  Author: dwgx
6
6
  License-Expression: MIT
@@ -30,7 +30,7 @@ build-backend = "setuptools.build_meta"
30
30
 
31
31
  [project]
32
32
  name = "smartcli-toolkit"
33
- version = "0.1.1"
33
+ version = "0.1.2"
34
34
  description = "Pluggable-PTY + pyte screen model + semantic snapshot / readiness core for driving interactive terminal programs (SmartCLI shared core)."
35
35
  readme = "README.md"
36
36
  license = "MIT"
@@ -9,9 +9,13 @@ Quick start::
9
9
 
10
10
  with PtySession(cols=100, rows=30) as sess:
11
11
  sess.start("python")
12
- sess.wait_ready(marker=r">>> $")
12
+ # NOTE: pyte right-pads every screen line with spaces, so an end-anchored
13
+ # marker like r">>> $" never matches. Use an unanchored marker (or r">>> *$"
14
+ # with re.M). wait_ready also races screen-stability, so pass a real marker
15
+ # for a known first prompt rather than relying on STABLE on startup.
16
+ sess.wait_ready(marker=r">>> ")
13
17
  sess.send_line("print('hello')")
14
- reason, snap = sess.wait_ready(marker=r">>> $")
18
+ reason, snap = sess.wait_ready(marker=r">>> ")
15
19
  print(snap.to_text())
16
20
  """
17
21
 
@@ -50,4 +54,4 @@ __all__ = [
50
54
  "KEY_MAP",
51
55
  ]
52
56
 
53
- __version__ = "0.1.1"
57
+ __version__ = "0.1.2"
@@ -75,6 +75,14 @@ class WinptyBackend(PtyBackend):
75
75
  def spawn(self, cmd: Union[str, Sequence[str]], cols: int, rows: int) -> None:
76
76
  import winpty # imported lazily so non-Windows hosts don't require it
77
77
 
78
+ # Reset per-spawn state so a re-used backend (a second spawn on the same
79
+ # instance) does not inherit the previous child's EOF sentinel or latched
80
+ # _eof — otherwise read_nonblocking would stop draining at the stale None
81
+ # and consumers would see premature EOF. A fresh queue drops any leftover.
82
+ self._queue = queue.Queue()
83
+ self._eof = False
84
+ self._reader = None
85
+
78
86
  # winpty.spawn accepts a command string or an argv list; dimensions are
79
87
  # (rows, cols).
80
88
  self._proc = winpty.PtyProcess.spawn(cmd, dimensions=(rows, cols))
@@ -41,6 +41,7 @@ def wait_until_stable(
41
41
  max_wait_ms: int = 8000,
42
42
  grace_ms: int = 40,
43
43
  min_wait_ms: int = 0,
44
+ blank_hash: Optional[int] = None,
44
45
  ) -> bool:
45
46
  """Pump reads until the screen hash is unchanged for ``quiet_ms``.
46
47
 
@@ -71,22 +72,32 @@ def wait_until_stable(
71
72
  deadline = start + (max_wait_ms / 1000.0)
72
73
  last_hash: Optional[int] = None
73
74
  stable_since: Optional[float] = None
75
+ # Readiness gate: never declare stable on a never-painted BLANK screen. Only
76
+ # engages when the caller passes ``blank_hash`` (the construct-time all-blank
77
+ # baseline) AND no output has been seen this wait AND the screen still equals
78
+ # that baseline. Default (blank_hash=None) is byte-for-byte the old behavior,
79
+ # so an already-drawn screen that is genuinely static still settles.
80
+ seen_any = False
74
81
 
75
82
  while True:
76
83
  now = time.monotonic()
77
84
  data = read_fn()
85
+ if data:
86
+ seen_any = True
78
87
  h = get_screen_hash_fn()
79
88
  elapsed = now - start
89
+ blank = (not seen_any and blank_hash is not None and h == blank_hash)
80
90
 
81
91
  if not data and h == last_hash:
82
92
  if stable_since is None:
83
93
  stable_since = now
84
- elif (now - stable_since) >= quiet and elapsed >= min_wait:
94
+ elif (now - stable_since) >= quiet and elapsed >= min_wait and not blank:
85
95
  if grace > 0:
86
96
  time.sleep(grace)
87
97
  tail = read_fn()
88
98
  if tail:
89
99
  # late flush: resume waiting
100
+ seen_any = True
90
101
  stable_since = None
91
102
  last_hash = get_screen_hash_fn()
92
103
  continue
@@ -156,6 +167,7 @@ def wait_ready(
156
167
  min_wait_ms: int = 50,
157
168
  grace_ms: int = 40,
158
169
  flags: int = 0,
170
+ blank_hash: Optional[int] = None,
159
171
  ) -> Tuple[str, object]:
160
172
  """Unified wait: satisfy on ``marker`` OR screen stability, capped by max_wait.
161
173
 
@@ -177,10 +189,16 @@ def wait_ready(
177
189
  deadline = start + (max_wait_ms / 1000.0)
178
190
  last_hash: Optional[int] = None
179
191
  stable_since: Optional[float] = None
192
+ # Readiness gate (see wait_until_stable): a marker match is never gated —
193
+ # only the stability branch refuses to fire on a never-painted blank screen
194
+ # when the caller supplies the blank baseline. Default None = old behavior.
195
+ seen_any = False
180
196
 
181
197
  while True:
182
198
  now = time.monotonic()
183
199
  data = read_fn()
200
+ if data:
201
+ seen_any = True
184
202
  elapsed = now - start
185
203
 
186
204
  # 1) marker wins immediately (respect min_wait)
@@ -189,14 +207,16 @@ def wait_ready(
189
207
 
190
208
  # 2) stability
191
209
  h = get_screen_hash_fn()
210
+ blank = (not seen_any and blank_hash is not None and h == blank_hash)
192
211
  if not data and h == last_hash:
193
212
  if stable_since is None:
194
213
  stable_since = now
195
- elif (now - stable_since) >= quiet and elapsed >= min_wait:
214
+ elif (now - stable_since) >= quiet and elapsed >= min_wait and not blank:
196
215
  if grace > 0:
197
216
  time.sleep(grace)
198
217
  tail = read_fn()
199
218
  if tail:
219
+ seen_any = True
200
220
  stable_since = None
201
221
  last_hash = get_screen_hash_fn()
202
222
  continue
@@ -6,10 +6,12 @@
6
6
 
7
7
  sess = PtySession(cols=100, rows=30)
8
8
  sess.start("python")
9
- sess.wait_ready(marker=r">>> $")
9
+ # Use an unanchored marker: pyte space-pads every line, so r">>> $" never
10
+ # matches (use r">>> " or r">>> *$" with re.M).
11
+ sess.wait_ready(marker=r">>> ")
10
12
  sess.send_text("print('hi')")
11
13
  sess.send_keys(["Enter"])
12
- snap = sess.wait_ready(marker=r">>> $")[1]
14
+ snap = sess.wait_ready(marker=r">>> ")[1]
13
15
  print(snap.to_text())
14
16
  sess.close()
15
17
 
@@ -113,6 +115,10 @@ class PtySession:
113
115
  self.rows = rows
114
116
  self.backend: PtyBackend = backend or get_default_backend()
115
117
  self.model = ScreenModel(cols, rows)
118
+ # Baseline hash of the freshly-constructed (all-blank) screen. Passed to
119
+ # the readiness waits so they never declare STABLE on a never-painted
120
+ # screen during a startup quiet-gap. Recomputed on resize.
121
+ self._blank_hash = self.model.content_hash()
116
122
  self._started = False
117
123
 
118
124
  # -- lifecycle ---------------------------------------------------------
@@ -142,6 +148,9 @@ class PtySession:
142
148
  self.rows = rows
143
149
  self.backend.resize(cols, rows)
144
150
  self.model.resize(cols, rows)
151
+ # Blank baseline for the new dimensions (only used by the readiness gate
152
+ # before any output has been seen).
153
+ self._blank_hash = ScreenModel(cols, rows).content_hash()
145
154
 
146
155
  # -- io ----------------------------------------------------------------
147
156
 
@@ -200,6 +209,7 @@ class PtySession:
200
209
  min_wait_ms=min_wait_ms,
201
210
  grace_ms=grace_ms,
202
211
  flags=flags,
212
+ blank_hash=self._blank_hash,
203
213
  )
204
214
  return reason, snap # type: ignore[return-value]
205
215
 
@@ -220,6 +230,7 @@ class PtySession:
220
230
  max_wait_ms=max_wait_ms,
221
231
  grace_ms=grace_ms,
222
232
  min_wait_ms=min_wait_ms,
233
+ blank_hash=self._blank_hash,
223
234
  )
224
235
 
225
236
  def wait_for(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smartcli-toolkit
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Pluggable-PTY + pyte screen model + semantic snapshot / readiness core for driving interactive terminal programs (SmartCLI shared core).
5
5
  Author: dwgx
6
6
  License-Expression: MIT
@@ -12,4 +12,6 @@ smartcli_toolkit.egg-info/SOURCES.txt
12
12
  smartcli_toolkit.egg-info/dependency_links.txt
13
13
  smartcli_toolkit.egg-info/requires.txt
14
14
  smartcli_toolkit.egg-info/top_level.txt
15
+ tests/test_degenerate_inputs.py
16
+ tests/test_fx_contract.py
15
17
  tests/test_readiness.py
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env python
2
+ """test_degenerate_inputs.py — regression locks for the degenerate-input crashes
3
+ found by the fx/tui-ui defect review (all in skill code, not smartcli_core).
4
+
5
+ Each check reproduces an input that used to raise (ZeroDivisionError / IndexError
6
+ / ValueError) and asserts it now degrades gracefully instead of crashing. Fast,
7
+ no PTY, no animation loop.
8
+
9
+ Run: python tests/test_degenerate_inputs.py (exit 0 = pass)
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ _ROOT = Path(__file__).resolve().parents[1]
18
+ sys.path.insert(0, str(_ROOT / "skills" / "tui-ui"))
19
+ sys.path.insert(0, str(_ROOT / "skills" / "cmd-art"))
20
+
21
+ _fails = []
22
+
23
+
24
+ def check_no_raise(fn, name):
25
+ try:
26
+ fn()
27
+ print(f"[PASS] {name}")
28
+ except Exception as e: # noqa: BLE001 - we are asserting NO exception
29
+ print(f"[FAIL] {name} raised {type(e).__name__}: {e}")
30
+ _fails.append(name)
31
+
32
+
33
+ def check(cond, name, detail=""):
34
+ print(f"{'[PASS]' if cond else '[FAIL]'} {name} {detail}")
35
+ if not cond:
36
+ _fails.append(name)
37
+
38
+
39
+ def main() -> int:
40
+ from ui.field import Ripple
41
+ from ui.widgets_ext.slider_track import SliderTrack
42
+ from ui.widgets_ext.braille_chart import BrailleChart
43
+ from fx.base import Param
44
+
45
+ print("=" * 60)
46
+ print("degenerate-input regression locks")
47
+ print("=" * 60)
48
+
49
+ # field.Ripple degenerate params (were ZeroDivisionError / IndexError)
50
+ check_no_raise(lambda: Ripple(origin=(0, 0), wavelength=0.0, travel=10.0).sample(1, 1),
51
+ "Ripple wavelength=0 does not divide-by-zero")
52
+ check_no_raise(lambda: Ripple(origin=(0, 0), wavelength=20.0, travel=10.0,
53
+ falloff_radius=0.0).sample(1, 1),
54
+ "Ripple falloff_radius=0 does not divide-by-zero")
55
+ check_no_raise(lambda: Ripple(origin=(0, 0), wavelength=20.0, travel=10.0,
56
+ palette=[]).sample(0, 0),
57
+ "Ripple empty palette does not IndexError")
58
+
59
+ # SliderTrack empty positions list (was ValueError max()/IndexError)
60
+ check_no_raise(lambda: SliderTrack(["a", "b"], positions=[]).measure(20, 3),
61
+ "SliderTrack positions=[] measure does not crash")
62
+ check_no_raise(lambda: SliderTrack(["a", "b"], positions=[]).render(20, 3),
63
+ "SliderTrack positions=[] render does not crash")
64
+
65
+ # BrailleChart NaN in series (was ValueError NaN->int)
66
+ check_no_raise(lambda: BrailleChart([float("nan"), 1, 2]).render(10, 3),
67
+ "BrailleChart NaN in series does not crash")
68
+
69
+ # Param int coercion: zero-padded decimals work; hex kept; bad input clean err
70
+ p = Param("count", "int")
71
+ check(p.coerce("08") == 8, "Param int '08' -> 8 (not base-0 ValueError)", f"={p.coerce('08')}")
72
+ check(p.coerce("010") == 10, "Param int '010' -> 10", f"={p.coerce('010')}")
73
+ check(p.coerce("0x10") == 16, "Param int '0x10' -> 16 (hex preserved)", f"={p.coerce('0x10')}")
74
+ # sign symmetry: +0x/-0x both honored (a prior fix narrowed this by omitting +)
75
+ check(p.coerce("+0x10") == 16, "Param int '+0x10' -> 16 (plus-signed hex)", f"={p.coerce('+0x10')}")
76
+ check(p.coerce("-0x10") == -16, "Param int '-0x10' -> -16 (minus-signed hex)", f"={p.coerce('-0x10')}")
77
+ try:
78
+ p.coerce("abc")
79
+ check(False, "Param int 'abc' raises ValueError", "no error raised")
80
+ except ValueError as e:
81
+ check("count" in str(e) and "abc" in str(e),
82
+ "Param int 'abc' -> clean ValueError naming param+value", f"{e}")
83
+
84
+ print("-" * 60)
85
+ if _fails:
86
+ print(f"FAIL: {len(_fails)} check(s) failed: {_fails}")
87
+ return 1
88
+ print("PASS: all degenerate-input regression locks hold")
89
+ return 0
90
+
91
+
92
+ if __name__ == "__main__":
93
+ sys.exit(main())
@@ -0,0 +1,267 @@
1
+ """Frame-contract test for the cmd-art fx framework (deterministic, no TTY).
2
+
3
+ Every :class:`fx.base.Effect` is a PURE frame producer: ``render(ctx) -> str``
4
+ must return a frame of EXACTLY ``ctx.height`` rows, and no row may be wider than
5
+ ``ctx.width`` display cells (a row wider than the frame is the "overflows at N
6
+ cols" defect the terminal would wrap; a frame with the wrong row count is
7
+ under/overfill). "Field"-style effects (fire, plasma, donut, ...) fill EVERY
8
+ cell, so for those the width must equal ``ctx.width`` exactly.
9
+
10
+ The live harness (verify_fx.py) proves effects RUN and keep the alt-screen /
11
+ truecolor invariants, and probe.py checks row count + an *upper* width bound.
12
+ Neither asserts the EXACT per-line cell width for the solid-fill effects, nor
13
+ sweeps degenerate ctx sizes (tiny 1x1/2x2). This test closes that gap by
14
+ rendering each effect at several sizes / frames and asserting the contract with
15
+ the repo's AUTHORITATIVE display-width function (ui.core.width -- wcwidth-backed,
16
+ ANSI-stripping, CJK/emoji aware -- the same measure the engine and pyte use).
17
+
18
+ Contracts asserted (HARD -> non-zero exit):
19
+ C1 row count == ctx.height for ALL effects at ALL sizes.
20
+ C2 no row wider than ctx.width for ALL effects at REALISTIC sizes.
21
+ C3 every row width == ctx.width for SOLID (full-fill) effects, at
22
+ realistic + narrow + tiny sizes.
23
+ C4 render() never raises for ALL effects at tiny (1x1/2x2) sizes.
24
+ C5 no UNEXPECTED narrow overflow at (20,8): only the documented
25
+ allowlist below may exceed width.
26
+
27
+ SOFT (reported, never fails a correct shipped build):
28
+ * known narrow-width overflow of the sparse text effects (see allowlist),
29
+ * width defects at the degenerate tiny sizes (1x1/2x2), which some effects
30
+ legitimately cannot honour (e.g. image2ascii's 2-cell half-block glyph).
31
+
32
+ Usage: python tests/test_fx_contract.py [name ...] (default: everything)
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ ROOT = Path(__file__).resolve().parents[1]
40
+ sys.path.insert(0, str(ROOT))
41
+ sys.path.insert(0, str(ROOT / "skills" / "cmd-art"))
42
+ sys.path.insert(0, str(ROOT / "skills" / "tui-ui"))
43
+
44
+ import fx.registry as registry # noqa: E402
45
+ from fx.base import FrameCtx # noqa: E402
46
+ from fx.theme import get_theme # noqa: E402
47
+
48
+ # ---- authoritative display-cell width (same fn the engine + pyte agree on) ---
49
+ # Reuse ui.core.width: strips ANSI/SGR then sums per-codepoint wcwidth (wide
50
+ # glyphs = 2, combining/ZWJ = 0). Falling back to a matching local stripper only
51
+ # if the tui-ui skill is somehow unavailable keeps this test self-contained.
52
+ try:
53
+ from ui.core import width as visible_width # type: ignore
54
+ except Exception: # pragma: no cover - tui-ui should always be importable
55
+ import re
56
+ import unicodedata
57
+
58
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
59
+
60
+ def visible_width(s: str) -> int: # type: ignore
61
+ s = _ANSI_RE.sub("", s)
62
+ w = 0
63
+ for ch in s:
64
+ o = ord(ch)
65
+ if o == 0 or unicodedata.combining(ch) or o in (0x200D, 0xFE0E, 0xFE0F):
66
+ continue
67
+ if o < 32 or 0x7F <= o < 0xA0:
68
+ continue
69
+ w += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
70
+ return w
71
+
72
+
73
+ # --------------------------------------------------------------------------
74
+ # Test matrix
75
+ # --------------------------------------------------------------------------
76
+ # Realistic frame sizes: the contract is HARD here.
77
+ REALISTIC = [(80, 24), (120, 40), (40, 12)]
78
+ # Narrow but real terminal: HARD row-count + curated overflow allowlist (C5).
79
+ NARROW = [(20, 8)]
80
+ # Degenerate/tiny: HARD row-count + no-crash; width is SOFT (reported only).
81
+ TINY = [(2, 2), (1, 1)]
82
+
83
+ # Effects whose rows are known to exceed ctx.width at the (20,8) narrow size,
84
+ # because they emit text without clipping to ctx.width. This is a genuine
85
+ # frame-contract weakness in the shipped effect (documented as a FINDING in the
86
+ # report), NOT a test-measurement artifact -- the raw text really is wider than
87
+ # the frame. Listed here so the test still PASSES on today's shipped build while
88
+ # a hard gate guards against any *new* effect regressing the same way.
89
+ KNOWN_NARROW_OVERFLOW = {"decrypt", "typewriter"}
90
+
91
+ results: list[tuple[str, bool, str]] = []
92
+ findings: list[str] = []
93
+
94
+
95
+ def record(name: str, ok: bool, detail: str = "") -> None:
96
+ results.append((name, ok, detail))
97
+ print(f"{'PASS' if ok else 'FAIL'} {name}" + (f" -- {detail}" if detail else ""))
98
+
99
+
100
+ def frames_for(cls) -> list[tuple[float, int]]:
101
+ """(t, frame_index) samples to render. Animated effects get a few frames to
102
+ catch frame-dependent raggedness; static effects only ever draw one frame."""
103
+ if cls.is_animated(cls.param_defaults()):
104
+ return [(0.0, 0), (0.7, 5), (3.1, 93)]
105
+ return [(0.7, 0)]
106
+
107
+
108
+ def render(cls, w: int, h: int, t: float, fi: int) -> str:
109
+ """Build the ctx the CLI would and render ONE frame (mirrors probe.py /
110
+ fx.core.render_once: fresh instance, setup(), render(), teardown())."""
111
+ eff = cls()
112
+ eff.setup()
113
+ try:
114
+ ctx = FrameCtx(t=t, frame_index=fi, width=w, height=h,
115
+ theme=get_theme(cls.preferred_theme),
116
+ params=cls.param_defaults())
117
+ return eff.render(ctx)
118
+ finally:
119
+ try:
120
+ eff.teardown()
121
+ except Exception:
122
+ pass
123
+
124
+
125
+ def _rows(frame: str) -> list[str]:
126
+ """Split a frame into its rows -- the AUTHORITATIVE row measure, identical to
127
+ probe.py's ``frame.split("\\n")``. The contract is "ctx.height rows joined by
128
+ newlines, NO trailing newline" (see fx.base), so a plain split yields exactly
129
+ ctx.height rows. We deliberately do NOT strip a trailing newline: sparse
130
+ effects pad with empty rows, so a frame can legitimately END in "\\n" where
131
+ that newline is the separator before a final empty row -- stripping it would
132
+ drop a real row and under-count the frame."""
133
+ return frame.split("\n")
134
+
135
+
136
+ def _is_solid(cls) -> bool:
137
+ """A SOLID (full-fill) effect writes every cell, so every row is EXACTLY
138
+ ctx.width. Classified empirically at the canonical 80x24 across its frames:
139
+ if no row deviates from 80, it is a field/particle effect and must honour
140
+ the exact-width contract everywhere. Sparse text/banner/image effects (which
141
+ legitimately leave blank cells) are excluded from the exact-width gate."""
142
+ for (t, fi) in frames_for(cls):
143
+ for ln in _rows(render(cls, 80, 24, t, fi)):
144
+ if visible_width(ln) != 80:
145
+ return False
146
+ return True
147
+
148
+
149
+ # --------------------------------------------------------------------------
150
+ # Checks
151
+ # --------------------------------------------------------------------------
152
+ def check_rowcount(cls) -> None:
153
+ """C1: every rendered frame has EXACTLY ctx.height rows, at ALL sizes."""
154
+ for (w, h) in REALISTIC + NARROW + TINY:
155
+ for (t, fi) in frames_for(cls):
156
+ rows = _rows(render(cls, w, h, t, fi))
157
+ if len(rows) != h:
158
+ record(f"rowcount {cls.name}", False,
159
+ f"{w}x{h} t={t}: {len(rows)} rows != {h}")
160
+ return
161
+ record(f"rowcount {cls.name}", True, "exact rows at every size")
162
+
163
+
164
+ def check_no_overflow(cls) -> None:
165
+ """C2: no row wider than ctx.width at REALISTIC sizes (all effects)."""
166
+ for (w, h) in REALISTIC:
167
+ for (t, fi) in frames_for(cls):
168
+ for i, ln in enumerate(_rows(render(cls, w, h, t, fi))):
169
+ cw = visible_width(ln)
170
+ if cw > w:
171
+ record(f"no-overflow {cls.name}", False,
172
+ f"{w}x{h} t={t} row {i}: width {cw} > {w}")
173
+ return
174
+ record(f"no-overflow {cls.name}", True, "no row exceeds width")
175
+
176
+
177
+ def check_exact_width(cls) -> None:
178
+ """C3: SOLID effects fill every cell -> width == ctx.width, at realistic +
179
+ narrow + tiny sizes. Sparse effects are skipped (SOFT-reported elsewhere)."""
180
+ if not _is_solid(cls):
181
+ record(f"exact-width {cls.name}", True, "sparse effect (exact-width n/a)")
182
+ return
183
+ for (w, h) in REALISTIC + NARROW + TINY:
184
+ for (t, fi) in frames_for(cls):
185
+ for i, ln in enumerate(_rows(render(cls, w, h, t, fi))):
186
+ cw = visible_width(ln)
187
+ if cw != w:
188
+ record(f"exact-width {cls.name}", False,
189
+ f"{w}x{h} t={t} row {i}: width {cw} != {w}")
190
+ return
191
+ record(f"exact-width {cls.name}", True, "every row == width (solid fill)")
192
+
193
+
194
+ def check_narrow_allowlist(cls) -> None:
195
+ """C5: at the narrow (20,8) size, overflow is a HARD failure UNLESS the
196
+ effect is on the documented KNOWN_NARROW_OVERFLOW allowlist. This is the
197
+ regression gate that would catch a NEW effect emitting unclipped rows."""
198
+ for (w, h) in NARROW:
199
+ for (t, fi) in frames_for(cls):
200
+ for i, ln in enumerate(_rows(render(cls, w, h, t, fi))):
201
+ cw = visible_width(ln)
202
+ if cw > w:
203
+ if cls.name in KNOWN_NARROW_OVERFLOW:
204
+ findings.append(
205
+ f"narrow overflow (known) {cls.name} {w}x{h} "
206
+ f"t={t} row {i}: width {cw} > {w}")
207
+ record(f"narrow {cls.name}", True,
208
+ f"known overflow tolerated (row {i} width {cw}>{w})")
209
+ return
210
+ record(f"narrow {cls.name}", False,
211
+ f"UNEXPECTED overflow {w}x{h} t={t} row {i}: "
212
+ f"width {cw} > {w} (not in allowlist)")
213
+ return
214
+ if cls.name in KNOWN_NARROW_OVERFLOW:
215
+ # allowlisted but no longer overflowing -> the effect was fixed; the
216
+ # allowlist is now stale (report so it can be tightened).
217
+ findings.append(f"allowlist stale: {cls.name} no longer overflows at 20x8")
218
+ record(f"narrow {cls.name}", True, "fits 20x8 (or known)")
219
+
220
+
221
+ def check_tiny_no_crash(cls) -> None:
222
+ """C4: render() must not raise at degenerate 1x1 / 2x2 sizes. Width defects
223
+ at these sizes are SOFT (some effects legitimately can't honour 1x1)."""
224
+ for (w, h) in TINY:
225
+ for (t, fi) in frames_for(cls):
226
+ try:
227
+ rows = _rows(render(cls, w, h, t, fi))
228
+ except Exception as exc: # a crash IS a hard failure (finding)
229
+ record(f"tiny-safe {cls.name}", False,
230
+ f"raised at {w}x{h} t={t}: {exc!r}")
231
+ return
232
+ for i, ln in enumerate(rows):
233
+ cw = visible_width(ln)
234
+ if cw != w:
235
+ findings.append(
236
+ f"tiny width (soft) {cls.name} {w}x{h} row {i}: "
237
+ f"width {cw} != {w}")
238
+ record(f"tiny-safe {cls.name}", True, "no crash at 1x1/2x2")
239
+
240
+
241
+ def main(argv: list[str]) -> int:
242
+ registry.load_all()
243
+ only = set(argv)
244
+ effects = [c for c in registry.all_effects() if not only or c.name in only]
245
+ sizes = REALISTIC + NARROW + TINY
246
+ for cls in effects:
247
+ check_rowcount(cls)
248
+ check_no_overflow(cls)
249
+ check_exact_width(cls)
250
+ check_narrow_allowlist(cls)
251
+ check_tiny_no_crash(cls)
252
+
253
+ failed = [r for r in results if not r[1]]
254
+ if findings:
255
+ print("\nFINDINGS (soft / documented):")
256
+ for f in findings:
257
+ print(" - " + f)
258
+ checks_per = 5
259
+ print(f"\n{len(effects)} effects x {len(sizes)} sizes "
260
+ f"({checks_per} contracts each)")
261
+ print(f"{len(results) - len(failed)}/{len(results)} checks passed")
262
+ return 1 if failed else 0
263
+
264
+
265
+ if __name__ == "__main__":
266
+ sys.exit(main(sys.argv[1:]))
267
+
@@ -37,6 +37,7 @@ class VirtualClock:
37
37
 
38
38
  def __init__(self) -> None:
39
39
  self.t = 0.0
40
+ self.last_sleep = None # duration of the most recent sleep() (seconds)
40
41
 
41
42
  def monotonic(self) -> float:
42
43
  return self.t
@@ -44,6 +45,7 @@ class VirtualClock:
44
45
  def sleep(self, seconds: float) -> None:
45
46
  # Advance virtual time; also nudge forward on zero-sleep to guarantee
46
47
  # progress so no loop can spin forever in a test.
48
+ self.last_sleep = seconds
47
49
  self.t += seconds if seconds > 0 else 0.001
48
50
 
49
51
 
@@ -137,41 +139,55 @@ def test_stable_timeout():
137
139
 
138
140
 
139
141
  def test_late_flush_resume():
140
- """A late flush after grace resumes the wait instead of declaring done early.
141
-
142
- We inject exactly one late batch on the post-grace `tail = read_fn()` drain.
143
- readiness must consume it, reset stability, and only return True once the
144
- screen re-settles never return on the half-drawn (pre-flush) screen.
142
+ """A late flush delivered ONLY on the post-grace `tail = read_fn()` drain must
143
+ resume the wait — not let it return True on the half-drawn (pre-flush) screen.
144
+
145
+ This targets readiness.py lines 87-93 specifically. The flush is delivered on
146
+ the exact read that immediately follows a `grace` sleep (grace=40ms, distinct
147
+ from the 30ms poll sleep), so it can only land on the tail drain — the branch
148
+ the earlier version of this test never exercised (mutation-proven false-green).
149
+
150
+ Proof of resume: (a) the flush was delivered on a tail drain, and (b) the wait
151
+ kept polling AFTER the flush (reads continued) instead of returning immediately.
152
+ Under a mutant that drops the resume branch, the function returns True right
153
+ after the flush drain → no further reads → this test FAILS.
145
154
  """
146
- _install_clock()
155
+ clk = _install_clock()
147
156
  reads = {"n": 0}
148
- flush_at = {"call": None} # records which read_fn call delivered the flush
157
+ tail_flush = {"delivered_at": None}
158
+ reads_after_flush = {"n": 0}
149
159
 
150
160
  def read_fn():
151
161
  reads["n"] += 1
152
- # Deliver the late flush the first time we've gone quiet long enough that
153
- # a grace-drain read happens (n>=4 in this cadence), exactly once.
154
- if flush_at["call"] is None and reads["n"] >= 4:
155
- flush_at["call"] = reads["n"]
162
+ if tail_flush["delivered_at"] is not None:
163
+ reads_after_flush["n"] += 1
164
+ return b"" # quiet again so it can finally re-settle
165
+ # The tail drain is the read whose immediately-preceding sleep was `grace`
166
+ # (40ms), not a `poll` (30ms). Deliver the flush there, exactly once.
167
+ if clk.last_sleep is not None and abs(clk.last_sleep - 0.040) < 1e-9:
168
+ tail_flush["delivered_at"] = reads["n"]
156
169
  return b"late output"
157
170
  return b""
158
171
 
159
172
  def hash_fn():
160
- # screen changes once the flush has been delivered, then settles at 2
161
- return 2 if flush_at["call"] is not None else 1
173
+ # constant until the flush lands (so quiet accumulates and we reach the
174
+ # tail drain), then a new value that itself settles.
175
+ return 2 if tail_flush["delivered_at"] is not None else 1
162
176
 
163
177
  ok = readiness.wait_until_stable(
164
178
  read_fn=read_fn,
165
179
  get_screen_hash_fn=hash_fn,
166
180
  quiet_ms=60, poll_ms=30, max_wait_ms=5000, grace_ms=40, min_wait_ms=0,
167
181
  )
168
- # Main contract: it re-settled to True AFTER a late flush was consumed.
169
- check(ok is True, "wait_until_stable: resumes after late flush then settles", f"ret={ok}")
170
- # A late flush was actually delivered and consumed, and reads continued
171
- # past that point (so it did NOT return on the stale pre-flush screen).
172
- check(flush_at["call"] is not None, "wait_until_stable: late flush was delivered", f"flush_at={flush_at['call']}")
173
- check(reads["n"] > flush_at["call"], "wait_until_stable: kept reading past the late flush",
174
- f"reads={reads['n']} flush_at={flush_at['call']}")
182
+ check(ok is True, "wait_until_stable: resumes after tail-drain flush then settles", f"ret={ok}")
183
+ check(tail_flush["delivered_at"] is not None,
184
+ "wait_until_stable: flush landed on the post-grace tail drain",
185
+ f"at read #{tail_flush['delivered_at']}")
186
+ # The crux: after the tail-drain flush, the loop must have RESUMED (more reads),
187
+ # not returned. A mutant dropping the resume branch returns immediately → 0.
188
+ check(reads_after_flush["n"] > 0,
189
+ "wait_until_stable: kept polling after the tail-drain flush (resumed, not returned)",
190
+ f"reads_after_flush={reads_after_flush['n']}")
175
191
 
176
192
 
177
193
  def test_min_wait_guard():
@@ -253,6 +269,82 @@ def test_wait_ready_stable():
253
269
  check(reason == "STABLE", "wait_ready: settles to STABLE with no marker", f"reason={reason}")
254
270
 
255
271
 
272
+ def test_wait_ready_stable_timing_floor():
273
+ """wait_ready must NOT declare STABLE before quiet_ms + min_wait_ms elapse.
274
+
275
+ Mirrors test_min_wait_guard but for wait_ready's stability branch. Asserts a
276
+ virtual-time floor, so a mutant that declares STABLE on the first quiet poll
277
+ (ignoring quiet_ms/min_wait_ms) is caught (the false-green M7 gap).
278
+ """
279
+ clk = _install_clock()
280
+ reason, _ = readiness.wait_ready(
281
+ read_fn=seq_reader([]), # instantly quiet
282
+ get_screen_hash_fn=seq_hashes([5]), # constant hash
283
+ get_text_fn=seq_text(["idle"]),
284
+ get_snapshot_fn=lambda: "S",
285
+ marker=None, quiet_ms=200, poll_ms=30, max_wait_ms=8000,
286
+ min_wait_ms=300, grace_ms=0,
287
+ )
288
+ check(reason == "STABLE", "wait_ready: eventually STABLE past the floor", f"reason={reason}")
289
+ # Must have waited at least min_wait (0.3s); a no-quiet mutant returns near t=0.
290
+ check(clk.t >= 0.3, "wait_ready: honored quiet_ms + min_wait_ms floor before STABLE",
291
+ f"virt_t={clk.t:.3f}s (>=0.3)")
292
+
293
+
294
+ def test_blank_gate_refuses_stable_on_never_painted_screen():
295
+ """With blank_hash set, a never-painted blank screen must NOT declare STABLE.
296
+
297
+ Regression lock for core bug #1: the readiness gate. read_fn never yields
298
+ bytes and the hash stays at the blank baseline → stability is withheld →
299
+ TIMEOUT, not a false STABLE on a screen the program hasn't drawn yet.
300
+ """
301
+ BLANK = 1000
302
+ # wait_until_stable form
303
+ clk = _install_clock()
304
+ ok = readiness.wait_until_stable(
305
+ read_fn=seq_reader([]), get_screen_hash_fn=seq_hashes([BLANK]),
306
+ quiet_ms=200, poll_ms=30, max_wait_ms=1000, grace_ms=40, min_wait_ms=50,
307
+ blank_hash=BLANK,
308
+ )
309
+ check(ok is False, "wait_until_stable: blank_hash gate withholds stable on blank screen", f"ret={ok}")
310
+ # wait_ready form
311
+ _install_clock()
312
+ reason, _ = readiness.wait_ready(
313
+ read_fn=seq_reader([]), get_screen_hash_fn=seq_hashes([BLANK]),
314
+ get_text_fn=seq_text([""]), get_snapshot_fn=lambda: "B",
315
+ marker=r">>> ", quiet_ms=200, poll_ms=30, max_wait_ms=1000,
316
+ min_wait_ms=50, grace_ms=40, blank_hash=BLANK,
317
+ )
318
+ check(reason == "TIMEOUT", "wait_ready: blank_hash gate → TIMEOUT not false STABLE", f"reason={reason}")
319
+
320
+
321
+ def test_blank_gate_does_not_harm_drawn_static_screen():
322
+ """The gate must NOT harm a legitimately-drawn, static screen (hash != blank).
323
+
324
+ Regression lock: the naive first attempt broke this exact case. A drawn UI
325
+ whose hash differs from the blank baseline and then holds steady must still
326
+ settle to STABLE even with blank_hash supplied.
327
+ """
328
+ _install_clock()
329
+ reason, _ = readiness.wait_ready(
330
+ read_fn=seq_reader([]), get_screen_hash_fn=seq_hashes([5555]), # != blank
331
+ get_text_fn=seq_text(["drawn UI"]), get_snapshot_fn=lambda: "S",
332
+ marker=None, quiet_ms=100, poll_ms=30, max_wait_ms=5000,
333
+ min_wait_ms=0, grace_ms=0, blank_hash=1000,
334
+ )
335
+ check(reason == "STABLE", "wait_ready: drawn static screen still settles under gate", f"reason={reason}")
336
+ # And once output has been seen, a return to the blank hash still settles.
337
+ _install_clock()
338
+ reads = iter([b"data", b"", b"", b"", b"", b"", b""])
339
+ reason2, _ = readiness.wait_ready(
340
+ read_fn=lambda: next(reads, b""), get_screen_hash_fn=seq_hashes([1000]),
341
+ get_text_fn=seq_text([""]), get_snapshot_fn=lambda: "S",
342
+ marker=None, quiet_ms=100, poll_ms=30, max_wait_ms=5000,
343
+ min_wait_ms=0, grace_ms=0, blank_hash=1000,
344
+ )
345
+ check(reason2 == "STABLE", "wait_ready: seen_any overrides blank gate", f"reason={reason2}")
346
+
347
+
256
348
  def test_wait_ready_timeout():
257
349
  """wait_ready returns TIMEOUT when marker never matches and screen churns."""
258
350
  clk = _install_clock()
@@ -284,7 +376,10 @@ def main() -> int:
284
376
  test_stable_reached, test_stable_timeout, test_late_flush_resume,
285
377
  test_min_wait_guard, test_regex_match, test_regex_timeout_returns_snapshot,
286
378
  test_regex_min_wait, test_wait_ready_marker_beats_stability,
287
- test_wait_ready_stable, test_wait_ready_timeout,
379
+ test_wait_ready_stable, test_wait_ready_stable_timing_floor,
380
+ test_blank_gate_refuses_stable_on_never_painted_screen,
381
+ test_blank_gate_does_not_harm_drawn_static_screen,
382
+ test_wait_ready_timeout,
288
383
  ):
289
384
  fn()
290
385
  wall = _real_time.perf_counter() - t0