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.
- {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/PKG-INFO +1 -1
- {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/pyproject.toml +1 -1
- {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_core/__init__.py +7 -3
- {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_core/pty_backend.py +8 -0
- {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_core/readiness.py +22 -2
- {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_core/session.py +13 -2
- {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_toolkit.egg-info/PKG-INFO +1 -1
- {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_toolkit.egg-info/SOURCES.txt +2 -0
- smartcli_toolkit-0.1.2/tests/test_degenerate_inputs.py +93 -0
- smartcli_toolkit-0.1.2/tests/test_fx_contract.py +267 -0
- {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/tests/test_readiness.py +116 -21
- {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/LICENSE +0 -0
- {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/README.md +0 -0
- {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/setup.cfg +0 -0
- {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_core/screen_model.py +0 -0
- {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_core/snapshot.py +0 -0
- {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_toolkit.egg-info/dependency_links.txt +0 -0
- {smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_toolkit.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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">>>
|
|
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.
|
|
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
|
|
@@ -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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
#
|
|
161
|
-
|
|
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
|
-
|
|
169
|
-
check(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{smartcli_toolkit-0.1.1 → smartcli_toolkit-0.1.2}/smartcli_toolkit.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|