shrinkray 25.12.28.0__py3-none-any.whl → 25.12.29.0__py3-none-any.whl
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.
- shrinkray/state.py +42 -47
- shrinkray/subprocess/protocol.py +10 -1
- shrinkray/subprocess/worker.py +59 -13
- shrinkray/tui.py +605 -32
- {shrinkray-25.12.28.0.dist-info → shrinkray-25.12.29.0.dist-info}/METADATA +10 -2
- {shrinkray-25.12.28.0.dist-info → shrinkray-25.12.29.0.dist-info}/RECORD +10 -10
- {shrinkray-25.12.28.0.dist-info → shrinkray-25.12.29.0.dist-info}/WHEEL +0 -0
- {shrinkray-25.12.28.0.dist-info → shrinkray-25.12.29.0.dist-info}/entry_points.txt +0 -0
- {shrinkray-25.12.28.0.dist-info → shrinkray-25.12.29.0.dist-info}/licenses/LICENSE +0 -0
- {shrinkray-25.12.28.0.dist-info → shrinkray-25.12.29.0.dist-info}/top_level.txt +0 -0
shrinkray/state.py
CHANGED
|
@@ -57,7 +57,7 @@ def compute_dynamic_timeout(runtime: float) -> float:
|
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
@define
|
|
60
|
-
class
|
|
60
|
+
class OutputCaptureManager:
|
|
61
61
|
"""Manages temporary files for test output capture.
|
|
62
62
|
|
|
63
63
|
Allocates unique files for each test's stdout/stderr output,
|
|
@@ -74,7 +74,8 @@ class TestOutputManager:
|
|
|
74
74
|
|
|
75
75
|
_sequence: int = 0
|
|
76
76
|
_active_outputs: dict[int, str] = {}
|
|
77
|
-
|
|
77
|
+
# Completed outputs: (test_id, file_path, completion_time, return_code)
|
|
78
|
+
_completed_outputs: deque[tuple[int, str, float, int]] = deque()
|
|
78
79
|
|
|
79
80
|
def __attrs_post_init__(self) -> None:
|
|
80
81
|
# Initialize mutable defaults
|
|
@@ -89,11 +90,13 @@ class TestOutputManager:
|
|
|
89
90
|
self._active_outputs[test_id] = file_path
|
|
90
91
|
return test_id, file_path
|
|
91
92
|
|
|
92
|
-
def mark_completed(self, test_id: int) -> None:
|
|
93
|
+
def mark_completed(self, test_id: int, return_code: int = 0) -> None:
|
|
93
94
|
"""Mark a test as completed and move to completed queue."""
|
|
94
95
|
if test_id in self._active_outputs:
|
|
95
96
|
file_path = self._active_outputs.pop(test_id)
|
|
96
|
-
self._completed_outputs.append(
|
|
97
|
+
self._completed_outputs.append(
|
|
98
|
+
(test_id, file_path, time.time(), return_code)
|
|
99
|
+
)
|
|
97
100
|
self._cleanup_old_files()
|
|
98
101
|
|
|
99
102
|
def _cleanup_old_files(self) -> None:
|
|
@@ -104,65 +107,55 @@ class TestOutputManager:
|
|
|
104
107
|
self._completed_outputs
|
|
105
108
|
and now - self._completed_outputs[0][2] > self.max_age_seconds
|
|
106
109
|
):
|
|
107
|
-
_, file_path, _ = self._completed_outputs.popleft()
|
|
110
|
+
_, file_path, _, _ = self._completed_outputs.popleft()
|
|
108
111
|
self._safe_delete(file_path)
|
|
109
112
|
# Remove excess files beyond max_files
|
|
110
113
|
while len(self._completed_outputs) > self.max_files:
|
|
111
|
-
_, file_path, _ = self._completed_outputs.popleft()
|
|
114
|
+
_, file_path, _, _ = self._completed_outputs.popleft()
|
|
112
115
|
self._safe_delete(file_path)
|
|
113
116
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if not self._completed_outputs:
|
|
122
|
-
return None
|
|
123
|
-
test_id, file_path, completion_time = self._completed_outputs[-1]
|
|
124
|
-
elapsed = time.time() - completion_time
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _file_has_content(path: str) -> bool:
|
|
119
|
+
"""Check if a file exists and has non-zero size."""
|
|
120
|
+
try:
|
|
121
|
+
return os.path.getsize(path) > 0
|
|
122
|
+
except OSError:
|
|
123
|
+
return False
|
|
125
124
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return test_id, file_path
|
|
125
|
+
def get_current_output(self) -> tuple[str | None, int | None, int | None]:
|
|
126
|
+
"""Get the current output to display.
|
|
129
127
|
|
|
130
|
-
|
|
128
|
+
Returns (file_path, test_id, return_code) where:
|
|
129
|
+
- file_path: path to the output file to display
|
|
130
|
+
- test_id: the test ID (for display in header)
|
|
131
|
+
- return_code: the return code (None if test is still running)
|
|
131
132
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
Active tests always take priority. If no active test, shows
|
|
136
|
-
recently completed test output for min_display_seconds, plus
|
|
137
|
-
an additional grace_period if no new test has started.
|
|
133
|
+
Active tests take priority only if they have produced output.
|
|
134
|
+
Otherwise, shows recently completed test output for min_display_seconds,
|
|
135
|
+
plus an additional grace_period if no new test has started.
|
|
138
136
|
"""
|
|
139
|
-
# Active tests
|
|
137
|
+
# Active tests take priority only if they have content
|
|
140
138
|
if self._active_outputs:
|
|
141
139
|
max_id = max(self._active_outputs.keys())
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
140
|
+
active_path = self._active_outputs[max_id]
|
|
141
|
+
if self._file_has_content(active_path):
|
|
142
|
+
# Active test with output - no return code yet
|
|
143
|
+
return active_path, max_id, None
|
|
144
|
+
# Active test has no output yet - fall through to show previous output
|
|
145
|
+
|
|
146
|
+
# Check for recently completed test that should stay visible,
|
|
147
|
+
# or fall back to most recent completed (even if past display window)
|
|
148
148
|
if self._completed_outputs:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def get_active_test_id(self) -> int | None:
|
|
153
|
-
"""Get the currently running test ID, if any.
|
|
149
|
+
test_id, file_path, _, return_code = self._completed_outputs[-1]
|
|
150
|
+
return file_path, test_id, return_code
|
|
154
151
|
|
|
155
|
-
|
|
156
|
-
"""
|
|
157
|
-
if self._active_outputs:
|
|
158
|
-
return max(self._active_outputs.keys())
|
|
159
|
-
return None
|
|
152
|
+
return None, None, None
|
|
160
153
|
|
|
161
154
|
def cleanup_all(self) -> None:
|
|
162
155
|
"""Clean up all output files (called on shutdown)."""
|
|
163
156
|
for file_path in self._active_outputs.values():
|
|
164
157
|
self._safe_delete(file_path)
|
|
165
|
-
for _, file_path, _ in self._completed_outputs:
|
|
158
|
+
for _, file_path, _, _ in self._completed_outputs:
|
|
166
159
|
self._safe_delete(file_path)
|
|
167
160
|
self._active_outputs.clear()
|
|
168
161
|
self._completed_outputs.clear()
|
|
@@ -209,7 +202,7 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
209
202
|
_last_debug_output: str = ""
|
|
210
203
|
|
|
211
204
|
# Optional output manager for capturing test output (TUI mode)
|
|
212
|
-
output_manager:
|
|
205
|
+
output_manager: OutputCaptureManager | None = None
|
|
213
206
|
|
|
214
207
|
def __attrs_post_init__(self):
|
|
215
208
|
self.is_interesting_limiter = trio.CapacityLimiter(max(self.parallelism, 1))
|
|
@@ -298,6 +291,7 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
298
291
|
# Determine output handling
|
|
299
292
|
test_id: int | None = None
|
|
300
293
|
output_file_handle = None
|
|
294
|
+
exit_code: int | None = None # Track for output manager
|
|
301
295
|
|
|
302
296
|
if self.output_manager is not None:
|
|
303
297
|
# Capture output to a file for TUI display
|
|
@@ -366,6 +360,7 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
366
360
|
|
|
367
361
|
result: int | None = sp.returncode
|
|
368
362
|
assert result is not None
|
|
363
|
+
exit_code = result
|
|
369
364
|
|
|
370
365
|
return result
|
|
371
366
|
finally:
|
|
@@ -373,7 +368,7 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
373
368
|
if output_file_handle is not None:
|
|
374
369
|
output_file_handle.close()
|
|
375
370
|
if test_id is not None and self.output_manager is not None:
|
|
376
|
-
self.output_manager.mark_completed(test_id)
|
|
371
|
+
self.output_manager.mark_completed(test_id, exit_code or 0)
|
|
377
372
|
|
|
378
373
|
async def run_for_exit_code(self, test_case: TestCase, debug: bool = False) -> int:
|
|
379
374
|
# Lazy import
|
shrinkray/subprocess/protocol.py
CHANGED
|
@@ -57,8 +57,13 @@ class ProgressUpdate:
|
|
|
57
57
|
disabled_passes: list[str] = field(default_factory=list)
|
|
58
58
|
# Test output preview (last 4KB of current/recent test output)
|
|
59
59
|
test_output_preview: str = ""
|
|
60
|
-
#
|
|
60
|
+
# Test ID of the output being displayed (None if no output yet)
|
|
61
61
|
active_test_id: int | None = None
|
|
62
|
+
# Return code of the displayed test (None if test is still running)
|
|
63
|
+
last_test_return_code: int | None = None
|
|
64
|
+
# New size history entries since last update: list of (runtime_seconds, size)
|
|
65
|
+
# Client should accumulate these over time
|
|
66
|
+
new_size_history: list[tuple[float, int]] = field(default_factory=list)
|
|
62
67
|
|
|
63
68
|
|
|
64
69
|
@dataclass
|
|
@@ -120,6 +125,8 @@ def serialize(msg: Request | Response | ProgressUpdate) -> str:
|
|
|
120
125
|
"disabled_passes": msg.disabled_passes,
|
|
121
126
|
"test_output_preview": msg.test_output_preview,
|
|
122
127
|
"active_test_id": msg.active_test_id,
|
|
128
|
+
"last_test_return_code": msg.last_test_return_code,
|
|
129
|
+
"new_size_history": msg.new_size_history,
|
|
123
130
|
},
|
|
124
131
|
}
|
|
125
132
|
else:
|
|
@@ -169,6 +176,8 @@ def deserialize(line: str) -> Request | Response | ProgressUpdate:
|
|
|
169
176
|
disabled_passes=d.get("disabled_passes", []),
|
|
170
177
|
test_output_preview=d.get("test_output_preview", ""),
|
|
171
178
|
active_test_id=d.get("active_test_id"),
|
|
179
|
+
last_test_return_code=d.get("last_test_return_code"),
|
|
180
|
+
new_size_history=[tuple(x) for x in d.get("new_size_history", [])],
|
|
172
181
|
)
|
|
173
182
|
|
|
174
183
|
# Check for response (has "result" or "error" field)
|
shrinkray/subprocess/worker.py
CHANGED
|
@@ -58,6 +58,11 @@ class ReducerWorker:
|
|
|
58
58
|
self._output_stream = output_stream
|
|
59
59
|
# Output directory for test output capture (cleaned up on shutdown)
|
|
60
60
|
self._output_dir: str | None = None
|
|
61
|
+
# Size history for graphing: list of (runtime_seconds, size) tuples
|
|
62
|
+
self._size_history: list[tuple[float, int]] = []
|
|
63
|
+
self._last_sent_history_index: int = 0
|
|
64
|
+
self._last_recorded_size: int = 0
|
|
65
|
+
self._last_history_time: float = 0.0
|
|
61
66
|
|
|
62
67
|
async def emit(self, msg: Response | ProgressUpdate) -> None:
|
|
63
68
|
"""Write a message to the output stream."""
|
|
@@ -157,9 +162,9 @@ class ReducerWorker:
|
|
|
157
162
|
find_clang_delta,
|
|
158
163
|
)
|
|
159
164
|
from shrinkray.state import (
|
|
165
|
+
OutputCaptureManager,
|
|
160
166
|
ShrinkRayDirectoryState,
|
|
161
167
|
ShrinkRayStateSingleFile,
|
|
162
|
-
TestOutputManager,
|
|
163
168
|
)
|
|
164
169
|
from shrinkray.work import Volume
|
|
165
170
|
|
|
@@ -213,7 +218,7 @@ class ReducerWorker:
|
|
|
213
218
|
|
|
214
219
|
# Create output manager for test output capture (always enabled for TUI)
|
|
215
220
|
self._output_dir = tempfile.mkdtemp(prefix="shrinkray-output-")
|
|
216
|
-
self.state.output_manager =
|
|
221
|
+
self.state.output_manager = OutputCaptureManager(output_dir=self._output_dir)
|
|
217
222
|
|
|
218
223
|
self.problem = self.state.problem
|
|
219
224
|
self.reducer = self.state.reducer
|
|
@@ -304,17 +309,23 @@ class ReducerWorker:
|
|
|
304
309
|
return Response(id=request_id, result={"status": "skipped"})
|
|
305
310
|
return Response(id=request_id, error="Reducer does not support pass control")
|
|
306
311
|
|
|
307
|
-
def _get_test_output_preview(self) -> tuple[str, int | None]:
|
|
308
|
-
"""Get preview of current test output and
|
|
312
|
+
def _get_test_output_preview(self) -> tuple[str, int | None, int | None]:
|
|
313
|
+
"""Get preview of current test output, test ID, and return code.
|
|
314
|
+
|
|
315
|
+
Returns (content, test_id, return_code) where:
|
|
316
|
+
- content: the last 4KB of the output file
|
|
317
|
+
- test_id: the test ID being displayed
|
|
318
|
+
- return_code: None if test is still running, otherwise the exit code
|
|
319
|
+
"""
|
|
309
320
|
if self.state is None or self.state.output_manager is None:
|
|
310
|
-
return "", None
|
|
321
|
+
return "", None, None
|
|
311
322
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
323
|
+
output_path, test_id, return_code = (
|
|
324
|
+
self.state.output_manager.get_current_output()
|
|
325
|
+
)
|
|
315
326
|
|
|
316
327
|
if output_path is None:
|
|
317
|
-
return "",
|
|
328
|
+
return "", None, None
|
|
318
329
|
|
|
319
330
|
# Read last 4KB of file
|
|
320
331
|
try:
|
|
@@ -326,9 +337,13 @@ class ReducerWorker:
|
|
|
326
337
|
else:
|
|
327
338
|
f.seek(0)
|
|
328
339
|
data = f.read()
|
|
329
|
-
return
|
|
340
|
+
return (
|
|
341
|
+
data.decode("utf-8", errors="replace"),
|
|
342
|
+
test_id,
|
|
343
|
+
return_code,
|
|
344
|
+
)
|
|
330
345
|
except OSError:
|
|
331
|
-
return "",
|
|
346
|
+
return "", test_id, return_code
|
|
332
347
|
|
|
333
348
|
def _get_content_preview(self) -> tuple[str, bool]:
|
|
334
349
|
"""Get a preview of the current test case content."""
|
|
@@ -379,6 +394,29 @@ class ReducerWorker:
|
|
|
379
394
|
return None
|
|
380
395
|
|
|
381
396
|
stats = self.problem.stats
|
|
397
|
+
runtime = time.time() - stats.start_time
|
|
398
|
+
current_size = stats.current_test_case_size
|
|
399
|
+
|
|
400
|
+
# Record size history when size changes or periodically
|
|
401
|
+
# Use 200ms interval for first 5 minutes, then 1s (ticks are at 1-minute intervals)
|
|
402
|
+
history_interval = 1.0 if runtime >= 300 else 0.2
|
|
403
|
+
|
|
404
|
+
if not self._size_history:
|
|
405
|
+
# First sample: record initial size at time 0
|
|
406
|
+
self._size_history.append((0.0, stats.initial_test_case_size))
|
|
407
|
+
self._last_recorded_size = stats.initial_test_case_size
|
|
408
|
+
self._last_history_time = 0.0
|
|
409
|
+
|
|
410
|
+
if current_size != self._last_recorded_size:
|
|
411
|
+
# Size changed - always record
|
|
412
|
+
self._size_history.append((runtime, current_size))
|
|
413
|
+
self._last_recorded_size = current_size
|
|
414
|
+
self._last_history_time = runtime
|
|
415
|
+
elif runtime - self._last_history_time >= history_interval:
|
|
416
|
+
# No size change but interval passed - record periodic update
|
|
417
|
+
self._size_history.append((runtime, current_size))
|
|
418
|
+
self._last_history_time = runtime
|
|
419
|
+
|
|
382
420
|
content_preview, hex_mode = self._get_content_preview()
|
|
383
421
|
|
|
384
422
|
# Get parallel workers count and track average
|
|
@@ -433,7 +471,13 @@ class ReducerWorker:
|
|
|
433
471
|
disabled_passes = []
|
|
434
472
|
|
|
435
473
|
# Get test output preview
|
|
436
|
-
test_output_preview, active_test_id =
|
|
474
|
+
test_output_preview, active_test_id, last_return_code = (
|
|
475
|
+
self._get_test_output_preview()
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# Get new size history entries since last update
|
|
479
|
+
new_entries = self._size_history[self._last_sent_history_index :]
|
|
480
|
+
self._last_sent_history_index = len(self._size_history)
|
|
437
481
|
|
|
438
482
|
return ProgressUpdate(
|
|
439
483
|
status=self.reducer.status if self.reducer else "",
|
|
@@ -443,7 +487,7 @@ class ReducerWorker:
|
|
|
443
487
|
reductions=stats.reductions,
|
|
444
488
|
interesting_calls=stats.interesting_calls,
|
|
445
489
|
wasted_calls=stats.wasted_interesting_calls,
|
|
446
|
-
runtime=
|
|
490
|
+
runtime=runtime,
|
|
447
491
|
parallel_workers=parallel_workers,
|
|
448
492
|
average_parallelism=average_parallelism,
|
|
449
493
|
effective_parallelism=effective_parallelism,
|
|
@@ -455,6 +499,8 @@ class ReducerWorker:
|
|
|
455
499
|
disabled_passes=disabled_passes,
|
|
456
500
|
test_output_preview=test_output_preview,
|
|
457
501
|
active_test_id=active_test_id,
|
|
502
|
+
last_test_return_code=last_return_code,
|
|
503
|
+
new_size_history=new_entries,
|
|
458
504
|
)
|
|
459
505
|
|
|
460
506
|
async def emit_progress_updates(self) -> None:
|
shrinkray/tui.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""Textual-based TUI for Shrink Ray."""
|
|
2
2
|
|
|
3
|
+
import math
|
|
3
4
|
import os
|
|
4
5
|
import time
|
|
5
6
|
import traceback
|
|
6
7
|
from collections.abc import AsyncGenerator
|
|
7
8
|
from contextlib import aclosing
|
|
8
9
|
from datetime import timedelta
|
|
9
|
-
from typing import Literal, Protocol
|
|
10
|
+
from typing import Literal, Protocol, cast
|
|
10
11
|
|
|
11
12
|
import humanize
|
|
12
13
|
from rich.text import Text
|
|
@@ -17,7 +18,9 @@ from textual.reactive import reactive
|
|
|
17
18
|
from textual.screen import ModalScreen
|
|
18
19
|
from textual.theme import Theme
|
|
19
20
|
from textual.widgets import DataTable, Footer, Header, Label, Static
|
|
21
|
+
from textual_plotext import PlotextPlot
|
|
20
22
|
|
|
23
|
+
from shrinkray.formatting import try_decode
|
|
21
24
|
from shrinkray.subprocess.client import SubprocessClient
|
|
22
25
|
from shrinkray.subprocess.protocol import (
|
|
23
26
|
PassStatsData,
|
|
@@ -223,6 +226,219 @@ class StatsDisplay(Static):
|
|
|
223
226
|
return "\n".join(lines)
|
|
224
227
|
|
|
225
228
|
|
|
229
|
+
def _format_time_label(seconds: float) -> str:
|
|
230
|
+
"""Format a time value for axis labels."""
|
|
231
|
+
if seconds < 60:
|
|
232
|
+
return f"{int(seconds)}s"
|
|
233
|
+
elif seconds < 3600:
|
|
234
|
+
minutes = int(seconds / 60)
|
|
235
|
+
return f"{minutes}m"
|
|
236
|
+
else:
|
|
237
|
+
hours = int(seconds / 3600)
|
|
238
|
+
return f"{hours}h"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _get_time_axis_bounds(current_time: float) -> tuple[float, list[float], list[str]]:
|
|
242
|
+
"""Get stable x-axis bounds and labeled positions.
|
|
243
|
+
|
|
244
|
+
Returns (max_time, positions, labels) where:
|
|
245
|
+
- max_time: the stable right boundary of the axis
|
|
246
|
+
- positions: numeric positions where axis labels should appear (e.g., [0, 60, 120])
|
|
247
|
+
- labels: formatted strings for each position (e.g., ["0s", "1m", "2m"])
|
|
248
|
+
|
|
249
|
+
The axis only rescales when current_time exceeds the current boundary.
|
|
250
|
+
"""
|
|
251
|
+
if current_time <= 0:
|
|
252
|
+
ticks = [0.0, 10.0, 20.0, 30.0]
|
|
253
|
+
labels = [_format_time_label(t) for t in ticks]
|
|
254
|
+
return (30.0, ticks, labels)
|
|
255
|
+
|
|
256
|
+
# For the first 10 minutes, expand one minute at a time with 1-minute ticks
|
|
257
|
+
if current_time < 600:
|
|
258
|
+
# Round up to next minute
|
|
259
|
+
minutes = int(current_time / 60) + 1
|
|
260
|
+
max_time = float(minutes * 60)
|
|
261
|
+
interval = 60.0
|
|
262
|
+
else:
|
|
263
|
+
# After 10 minutes, use larger boundaries
|
|
264
|
+
# (boundary, tick_interval) - axis extends to boundary, ticks at interval
|
|
265
|
+
boundaries = [
|
|
266
|
+
(1800, 300), # 30m with 5m ticks
|
|
267
|
+
(3600, 600), # 1h with 10m ticks
|
|
268
|
+
(7200, 1200), # 2h with 20m ticks
|
|
269
|
+
(14400, 1800), # 4h with 30m ticks
|
|
270
|
+
(28800, 3600), # 8h with 1h ticks
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
# Find the first boundary that exceeds current_time
|
|
274
|
+
max_time = 1800.0
|
|
275
|
+
interval = 300.0
|
|
276
|
+
for boundary, tick_interval in boundaries:
|
|
277
|
+
if current_time < boundary:
|
|
278
|
+
max_time = float(boundary)
|
|
279
|
+
interval = float(tick_interval)
|
|
280
|
+
break
|
|
281
|
+
else:
|
|
282
|
+
# Beyond 8h: extend in 4h increments with 1h ticks
|
|
283
|
+
hours = int(current_time / 14400) + 1
|
|
284
|
+
max_time = float(hours * 14400)
|
|
285
|
+
interval = 3600.0
|
|
286
|
+
|
|
287
|
+
# Generate ticks from 0 to max_time
|
|
288
|
+
ticks = []
|
|
289
|
+
t = 0.0
|
|
290
|
+
while t <= max_time:
|
|
291
|
+
ticks.append(t)
|
|
292
|
+
t += interval
|
|
293
|
+
|
|
294
|
+
labels = [_format_time_label(t) for t in ticks]
|
|
295
|
+
return (max_time, ticks, labels)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _get_percentage_axis_bounds(
|
|
299
|
+
min_pct: float, max_pct: float
|
|
300
|
+
) -> tuple[float, list[float], list[str]]:
|
|
301
|
+
"""Get stable y-axis bounds for percentage values on log scale.
|
|
302
|
+
|
|
303
|
+
Returns (min_pct_bound, positions, labels) where:
|
|
304
|
+
- min_pct_bound: the stable lower boundary of the axis
|
|
305
|
+
- positions: log10 positions where axis labels should appear
|
|
306
|
+
- labels: formatted percentage strings for each position
|
|
307
|
+
|
|
308
|
+
The axis only rescales when min_pct gets close to the current lower boundary.
|
|
309
|
+
"""
|
|
310
|
+
# Standard percentage boundaries (log scale friendly)
|
|
311
|
+
# Extended to handle very small reductions (below 0.01%)
|
|
312
|
+
boundaries = [
|
|
313
|
+
100,
|
|
314
|
+
50,
|
|
315
|
+
20,
|
|
316
|
+
10,
|
|
317
|
+
5,
|
|
318
|
+
2,
|
|
319
|
+
1,
|
|
320
|
+
0.5,
|
|
321
|
+
0.2,
|
|
322
|
+
0.1,
|
|
323
|
+
0.05,
|
|
324
|
+
0.02,
|
|
325
|
+
0.01,
|
|
326
|
+
0.005,
|
|
327
|
+
0.002,
|
|
328
|
+
0.001,
|
|
329
|
+
0.0005,
|
|
330
|
+
0.0002,
|
|
331
|
+
0.0001,
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
# Find the appropriate lower bound - use the first boundary below min_pct * 0.5
|
|
335
|
+
# This gives us some room before we need to rescale
|
|
336
|
+
lower_bound = boundaries[-1] # Default to smallest boundary
|
|
337
|
+
for b in boundaries:
|
|
338
|
+
if b < min_pct * 0.5:
|
|
339
|
+
lower_bound = b
|
|
340
|
+
break
|
|
341
|
+
|
|
342
|
+
# Find which percentage values to show as ticks (between lower_bound and 100%)
|
|
343
|
+
# Since boundaries always includes 100 and lower_bound <= 100, this is never empty
|
|
344
|
+
tick_pcts = [p for p in boundaries if p >= lower_bound and p <= 100]
|
|
345
|
+
|
|
346
|
+
# Convert to log scale
|
|
347
|
+
ticks = [math.log10(max(0.0001, p)) for p in tick_pcts]
|
|
348
|
+
|
|
349
|
+
# Format labels
|
|
350
|
+
labels = []
|
|
351
|
+
for p in tick_pcts:
|
|
352
|
+
if p >= 1:
|
|
353
|
+
labels.append(f"{p:.0f}%")
|
|
354
|
+
else:
|
|
355
|
+
labels.append(f"{p}%")
|
|
356
|
+
|
|
357
|
+
return (lower_bound, ticks, labels)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class SizeGraph(PlotextPlot):
|
|
361
|
+
"""Widget to display test case size over time on a log scale."""
|
|
362
|
+
|
|
363
|
+
_size_history: list[tuple[float, int]]
|
|
364
|
+
_original_size: int
|
|
365
|
+
_current_runtime: float
|
|
366
|
+
|
|
367
|
+
def __init__(
|
|
368
|
+
self,
|
|
369
|
+
name: str | None = None,
|
|
370
|
+
id: str | None = None,
|
|
371
|
+
classes: str | None = None,
|
|
372
|
+
disabled: bool = False,
|
|
373
|
+
) -> None:
|
|
374
|
+
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
|
375
|
+
self._size_history = []
|
|
376
|
+
self._original_size = 0
|
|
377
|
+
self._current_runtime = 0.0
|
|
378
|
+
|
|
379
|
+
def update_graph(
|
|
380
|
+
self,
|
|
381
|
+
new_entries: list[tuple[float, int]],
|
|
382
|
+
original_size: int,
|
|
383
|
+
current_runtime: float,
|
|
384
|
+
) -> None:
|
|
385
|
+
"""Update the graph with new data."""
|
|
386
|
+
if new_entries:
|
|
387
|
+
self._size_history.extend(new_entries)
|
|
388
|
+
if original_size > 0:
|
|
389
|
+
self._original_size = original_size
|
|
390
|
+
self._current_runtime = current_runtime
|
|
391
|
+
self._setup_plot()
|
|
392
|
+
self.refresh()
|
|
393
|
+
|
|
394
|
+
def on_mount(self) -> None:
|
|
395
|
+
"""Set up the plot on mount."""
|
|
396
|
+
self._setup_plot()
|
|
397
|
+
|
|
398
|
+
def on_resize(self) -> None:
|
|
399
|
+
"""Redraw when resized."""
|
|
400
|
+
self._setup_plot()
|
|
401
|
+
|
|
402
|
+
def _setup_plot(self) -> None:
|
|
403
|
+
"""Configure and draw the plot."""
|
|
404
|
+
plt = self.plt
|
|
405
|
+
plt.clear_figure()
|
|
406
|
+
plt.theme("dark")
|
|
407
|
+
|
|
408
|
+
if len(self._size_history) < 2 or self._original_size == 0:
|
|
409
|
+
plt.xlabel("Time")
|
|
410
|
+
plt.ylabel("% of original")
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
times = [t for t, _ in self._size_history]
|
|
414
|
+
sizes = [s for _, s in self._size_history]
|
|
415
|
+
|
|
416
|
+
# Calculate percentages of original size
|
|
417
|
+
percentages = [(s / self._original_size) * 100 for s in sizes]
|
|
418
|
+
|
|
419
|
+
# Use log scale for y-axis (percentages)
|
|
420
|
+
log_percentages = [math.log10(max(0.01, p)) for p in percentages]
|
|
421
|
+
|
|
422
|
+
plt.plot(times, log_percentages, marker="braille")
|
|
423
|
+
|
|
424
|
+
# Get stable x-axis bounds
|
|
425
|
+
max_time, x_ticks, x_labels = _get_time_axis_bounds(self._current_runtime)
|
|
426
|
+
plt.xticks(x_ticks, x_labels)
|
|
427
|
+
plt.xlim(0, max_time)
|
|
428
|
+
|
|
429
|
+
# Get stable y-axis bounds
|
|
430
|
+
min_pct = min(percentages)
|
|
431
|
+
lower_bound, y_ticks, y_labels = _get_percentage_axis_bounds(min_pct, 100)
|
|
432
|
+
plt.yticks(y_ticks, y_labels)
|
|
433
|
+
plt.ylim(math.log10(max(0.01, lower_bound)), math.log10(100))
|
|
434
|
+
|
|
435
|
+
plt.xlabel("Time")
|
|
436
|
+
plt.ylabel("% of original")
|
|
437
|
+
|
|
438
|
+
# Build to apply the plot
|
|
439
|
+
_ = plt.build()
|
|
440
|
+
|
|
441
|
+
|
|
226
442
|
class ContentPreview(Static):
|
|
227
443
|
"""Widget to display the current test case content preview."""
|
|
228
444
|
|
|
@@ -313,21 +529,37 @@ class OutputPreview(Static):
|
|
|
313
529
|
|
|
314
530
|
output_content = reactive("")
|
|
315
531
|
active_test_id: reactive[int | None] = reactive(None)
|
|
532
|
+
last_return_code: reactive[int | None] = reactive(None)
|
|
316
533
|
_last_update_time: float = 0.0
|
|
317
|
-
|
|
534
|
+
# Pending updates that haven't been applied yet (due to throttling)
|
|
535
|
+
_pending_content: str = ""
|
|
536
|
+
_pending_test_id: int | None = None
|
|
537
|
+
_pending_return_code: int | None = None
|
|
538
|
+
# Track if we've ever seen any output (once true, never show "No test output yet...")
|
|
539
|
+
_has_seen_output: bool = False
|
|
318
540
|
|
|
319
|
-
def update_output(
|
|
320
|
-
|
|
541
|
+
def update_output(
|
|
542
|
+
self, content: str, test_id: int | None, return_code: int | None = None
|
|
543
|
+
) -> None:
|
|
544
|
+
# Only update pending content if there's actual content to show
|
|
545
|
+
# This prevents switching to empty output when we have previous output
|
|
546
|
+
if content:
|
|
547
|
+
self._pending_content = content
|
|
548
|
+
self._has_seen_output = True
|
|
549
|
+
self._pending_test_id = test_id
|
|
550
|
+
self._pending_return_code = return_code
|
|
551
|
+
|
|
552
|
+
# Throttle display updates to every 200ms
|
|
321
553
|
now = time.time()
|
|
322
554
|
if now - self._last_update_time < 0.2:
|
|
323
555
|
return
|
|
324
556
|
|
|
325
557
|
self._last_update_time = now
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
self.
|
|
558
|
+
# Only update output_content if we have new content
|
|
559
|
+
if self._pending_content:
|
|
560
|
+
self.output_content = self._pending_content
|
|
561
|
+
self.active_test_id = self._pending_test_id
|
|
562
|
+
self.last_return_code = self._pending_return_code
|
|
331
563
|
self.refresh(layout=True)
|
|
332
564
|
|
|
333
565
|
def _get_available_lines(self) -> int:
|
|
@@ -345,11 +577,15 @@ class OutputPreview(Static):
|
|
|
345
577
|
return 30
|
|
346
578
|
|
|
347
579
|
def render(self) -> str:
|
|
348
|
-
# Header line
|
|
349
|
-
|
|
580
|
+
# Header line - use return_code to determine if test is running
|
|
581
|
+
# (return_code is None means still running, has value means completed)
|
|
582
|
+
if self.active_test_id is not None and self.last_return_code is None:
|
|
350
583
|
header = f"[green]Test #{self.active_test_id} running...[/green]"
|
|
351
|
-
elif self.
|
|
352
|
-
header = f"[dim]Test #{self.
|
|
584
|
+
elif self.active_test_id is not None:
|
|
585
|
+
header = f"[dim]Test #{self.active_test_id} exited with code {self.last_return_code}[/dim]"
|
|
586
|
+
elif self._has_seen_output or self.output_content:
|
|
587
|
+
# Have seen output before - show without header
|
|
588
|
+
header = ""
|
|
353
589
|
else:
|
|
354
590
|
header = "[dim]No test output yet...[/dim]"
|
|
355
591
|
|
|
@@ -359,14 +595,17 @@ class OutputPreview(Static):
|
|
|
359
595
|
available_lines = self._get_available_lines()
|
|
360
596
|
lines = self.output_content.split("\n")
|
|
361
597
|
|
|
598
|
+
# Build prefix (header + newline, or empty if no header)
|
|
599
|
+
prefix = f"{header}\n" if header else ""
|
|
600
|
+
|
|
362
601
|
# Show tail of output (most recent lines)
|
|
363
602
|
if len(lines) <= available_lines:
|
|
364
|
-
return f"{
|
|
603
|
+
return f"{prefix}{self.output_content}"
|
|
365
604
|
|
|
366
605
|
# Truncate from the beginning
|
|
367
606
|
truncated_lines = lines[-(available_lines):]
|
|
368
607
|
skipped = len(lines) - available_lines
|
|
369
|
-
return f"{
|
|
608
|
+
return f"{prefix}... ({skipped} earlier lines)\n" + "\n".join(truncated_lines)
|
|
370
609
|
|
|
371
610
|
|
|
372
611
|
class HelpScreen(ModalScreen[None]):
|
|
@@ -424,6 +663,169 @@ class HelpScreen(ModalScreen[None]):
|
|
|
424
663
|
yield Static("[dim]Press any key to close[/dim]")
|
|
425
664
|
|
|
426
665
|
|
|
666
|
+
class ExpandedBoxModal(ModalScreen[None]):
|
|
667
|
+
"""Modal screen showing an expanded view of a content box."""
|
|
668
|
+
|
|
669
|
+
CSS = """
|
|
670
|
+
ExpandedBoxModal {
|
|
671
|
+
align: center middle;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
ExpandedBoxModal > Vertical {
|
|
675
|
+
width: 95%;
|
|
676
|
+
height: 90%;
|
|
677
|
+
background: $panel;
|
|
678
|
+
border: thick $primary;
|
|
679
|
+
padding: 0 1 1 1;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
ExpandedBoxModal #expanded-title {
|
|
683
|
+
text-align: center;
|
|
684
|
+
text-style: bold;
|
|
685
|
+
height: auto;
|
|
686
|
+
width: 100%;
|
|
687
|
+
border-bottom: solid $primary;
|
|
688
|
+
padding: 0;
|
|
689
|
+
margin-bottom: 1;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
ExpandedBoxModal VerticalScroll {
|
|
693
|
+
width: 100%;
|
|
694
|
+
height: 1fr;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
ExpandedBoxModal #expanded-content {
|
|
698
|
+
width: 100%;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
ExpandedBoxModal #expanded-graph {
|
|
702
|
+
width: 100%;
|
|
703
|
+
height: 1fr;
|
|
704
|
+
}
|
|
705
|
+
"""
|
|
706
|
+
|
|
707
|
+
BINDINGS = [
|
|
708
|
+
("escape,enter,q", "dismiss", "Close"),
|
|
709
|
+
]
|
|
710
|
+
|
|
711
|
+
def __init__(
|
|
712
|
+
self, title: str, content_widget_id: str, file_path: str | None = None
|
|
713
|
+
) -> None:
|
|
714
|
+
super().__init__()
|
|
715
|
+
self._title = title
|
|
716
|
+
self._content_widget_id = content_widget_id
|
|
717
|
+
self._file_path = file_path
|
|
718
|
+
|
|
719
|
+
def _read_file(self, file_path: str) -> str:
|
|
720
|
+
"""Read file content, decoding as text if possible."""
|
|
721
|
+
with open(file_path, "rb") as f:
|
|
722
|
+
raw_content = f.read()
|
|
723
|
+
# Try to decode as text, fall back to hex display if binary
|
|
724
|
+
encoding, text = try_decode(raw_content)
|
|
725
|
+
if encoding is not None:
|
|
726
|
+
return text
|
|
727
|
+
return "[Binary content - hex display]\n\n" + raw_content.hex()
|
|
728
|
+
|
|
729
|
+
def compose(self) -> ComposeResult:
|
|
730
|
+
with Vertical():
|
|
731
|
+
yield Label(self._title, id="expanded-title")
|
|
732
|
+
if self._content_widget_id == "graph-container":
|
|
733
|
+
# For graph, create a new SizeGraph widget
|
|
734
|
+
yield SizeGraph(id="expanded-graph")
|
|
735
|
+
else:
|
|
736
|
+
# For other content, use a scrollable static
|
|
737
|
+
with VerticalScroll():
|
|
738
|
+
yield Static("", id="expanded-content")
|
|
739
|
+
|
|
740
|
+
def _get_graph_content(self, app: "ShrinkRayApp") -> None:
|
|
741
|
+
"""Copy graph data from main graph to expanded graph."""
|
|
742
|
+
main_graphs = list(app.query("#size-graph").results(SizeGraph))
|
|
743
|
+
expanded_graphs = list(self.query("#expanded-graph").results(SizeGraph))
|
|
744
|
+
if not main_graphs or not expanded_graphs:
|
|
745
|
+
return
|
|
746
|
+
main_graph = main_graphs[0]
|
|
747
|
+
expanded_graph = expanded_graphs[0]
|
|
748
|
+
expanded_graph._size_history = main_graph._size_history.copy()
|
|
749
|
+
expanded_graph._original_size = main_graph._original_size
|
|
750
|
+
expanded_graph._current_runtime = main_graph._current_runtime
|
|
751
|
+
expanded_graph._setup_plot()
|
|
752
|
+
|
|
753
|
+
def _get_stats_content(self, app: "ShrinkRayApp") -> str:
|
|
754
|
+
"""Get stats content from the stats display widget."""
|
|
755
|
+
stats_displays = list(app.query("#stats-display").results(StatsDisplay))
|
|
756
|
+
if not stats_displays:
|
|
757
|
+
return "Statistics not available"
|
|
758
|
+
return stats_displays[0].render()
|
|
759
|
+
|
|
760
|
+
def _get_file_content(self, app: "ShrinkRayApp") -> str:
|
|
761
|
+
"""Get content from file or preview widget."""
|
|
762
|
+
if self._file_path:
|
|
763
|
+
return self._read_file(self._file_path)
|
|
764
|
+
content_previews = list(app.query("#content-preview").results(ContentPreview))
|
|
765
|
+
if not content_previews:
|
|
766
|
+
return "Content preview not available"
|
|
767
|
+
return content_previews[0].preview_content
|
|
768
|
+
|
|
769
|
+
def _get_output_content(self, app: "ShrinkRayApp") -> str:
|
|
770
|
+
"""Get output content from the output preview widget."""
|
|
771
|
+
output_previews = list(app.query("#output-preview").results(OutputPreview))
|
|
772
|
+
if not output_previews:
|
|
773
|
+
return "Output not available"
|
|
774
|
+
output_preview = output_previews[0]
|
|
775
|
+
|
|
776
|
+
# Use pending values (most recent) rather than throttled values
|
|
777
|
+
raw_content = output_preview._pending_content or output_preview.output_content
|
|
778
|
+
test_id = (
|
|
779
|
+
output_preview._pending_test_id
|
|
780
|
+
if output_preview._pending_test_id is not None
|
|
781
|
+
else output_preview.active_test_id
|
|
782
|
+
)
|
|
783
|
+
return_code = (
|
|
784
|
+
output_preview._pending_return_code
|
|
785
|
+
if output_preview._pending_return_code is not None
|
|
786
|
+
else output_preview.last_return_code
|
|
787
|
+
)
|
|
788
|
+
has_seen_output = output_preview._has_seen_output
|
|
789
|
+
|
|
790
|
+
# Build header - return_code is None means test is still running
|
|
791
|
+
if test_id is not None and return_code is None:
|
|
792
|
+
header = f"[green]Test #{test_id} running...[/green]\n\n"
|
|
793
|
+
elif test_id is not None:
|
|
794
|
+
header = f"[dim]Test #{test_id} exited with code {return_code}[/dim]\n\n"
|
|
795
|
+
else:
|
|
796
|
+
header = ""
|
|
797
|
+
|
|
798
|
+
if raw_content:
|
|
799
|
+
return header + raw_content
|
|
800
|
+
elif has_seen_output or test_id is not None:
|
|
801
|
+
# We've seen output before - show header only (no "No test output" message)
|
|
802
|
+
return header.rstrip("\n") if header else ""
|
|
803
|
+
else:
|
|
804
|
+
return "[dim]No test output yet...[/dim]"
|
|
805
|
+
|
|
806
|
+
def on_mount(self) -> None:
|
|
807
|
+
"""Populate content from the source widget."""
|
|
808
|
+
# Cast is safe because this modal is only used within ShrinkRayApp
|
|
809
|
+
app = cast("ShrinkRayApp", self.app)
|
|
810
|
+
|
|
811
|
+
if self._content_widget_id == "graph-container":
|
|
812
|
+
self._get_graph_content(app)
|
|
813
|
+
return
|
|
814
|
+
|
|
815
|
+
# For non-graph content, populate the static
|
|
816
|
+
# compose() always creates the #expanded-content widget for non-graph modals
|
|
817
|
+
if self._content_widget_id == "stats-container":
|
|
818
|
+
content = self._get_stats_content(app)
|
|
819
|
+
elif self._content_widget_id == "content-container":
|
|
820
|
+
content = self._get_file_content(app)
|
|
821
|
+
elif self._content_widget_id == "output-container":
|
|
822
|
+
content = self._get_output_content(app)
|
|
823
|
+
else:
|
|
824
|
+
content = ""
|
|
825
|
+
|
|
826
|
+
self.query_one("#expanded-content", Static).update(content)
|
|
827
|
+
|
|
828
|
+
|
|
427
829
|
class PassStatsScreen(ModalScreen[None]):
|
|
428
830
|
"""Modal screen showing pass statistics in a table."""
|
|
429
831
|
|
|
@@ -647,16 +1049,42 @@ class ShrinkRayApp(App[None]):
|
|
|
647
1049
|
height: 100%;
|
|
648
1050
|
}
|
|
649
1051
|
|
|
1052
|
+
#status-label {
|
|
1053
|
+
text-style: bold;
|
|
1054
|
+
margin: 0 1;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
#stats-area {
|
|
1058
|
+
height: 1fr;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
650
1061
|
#stats-container {
|
|
651
|
-
|
|
652
|
-
|
|
1062
|
+
border: solid $primary;
|
|
1063
|
+
margin: 0;
|
|
1064
|
+
padding: 1;
|
|
1065
|
+
width: 1fr;
|
|
1066
|
+
height: 100%;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
#stats-container:focus {
|
|
1070
|
+
border: thick $primary;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
#graph-container {
|
|
1074
|
+
border: solid $primary;
|
|
1075
|
+
margin: 0;
|
|
653
1076
|
padding: 1;
|
|
654
|
-
|
|
1077
|
+
width: 1fr;
|
|
1078
|
+
height: 100%;
|
|
655
1079
|
}
|
|
656
1080
|
|
|
657
|
-
#
|
|
658
|
-
|
|
659
|
-
|
|
1081
|
+
#graph-container:focus {
|
|
1082
|
+
border: thick $primary;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
#size-graph {
|
|
1086
|
+
width: 100%;
|
|
1087
|
+
height: 100%;
|
|
660
1088
|
}
|
|
661
1089
|
|
|
662
1090
|
#content-area {
|
|
@@ -664,27 +1092,27 @@ class ShrinkRayApp(App[None]):
|
|
|
664
1092
|
}
|
|
665
1093
|
|
|
666
1094
|
#content-container {
|
|
667
|
-
border: solid
|
|
668
|
-
margin:
|
|
1095
|
+
border: solid $primary;
|
|
1096
|
+
margin: 0;
|
|
669
1097
|
padding: 1;
|
|
670
1098
|
width: 1fr;
|
|
671
1099
|
height: 100%;
|
|
672
1100
|
}
|
|
673
1101
|
|
|
674
|
-
#content-container:
|
|
675
|
-
border:
|
|
1102
|
+
#content-container:focus {
|
|
1103
|
+
border: thick $primary;
|
|
676
1104
|
}
|
|
677
1105
|
|
|
678
1106
|
#output-container {
|
|
679
|
-
border: solid
|
|
680
|
-
margin:
|
|
1107
|
+
border: solid $primary;
|
|
1108
|
+
margin: 0;
|
|
681
1109
|
padding: 1;
|
|
682
1110
|
width: 1fr;
|
|
683
1111
|
height: 100%;
|
|
684
1112
|
}
|
|
685
1113
|
|
|
686
|
-
#output-container:
|
|
687
|
-
border:
|
|
1114
|
+
#output-container:focus {
|
|
1115
|
+
border: thick $primary;
|
|
688
1116
|
}
|
|
689
1117
|
"""
|
|
690
1118
|
|
|
@@ -693,6 +1121,11 @@ class ShrinkRayApp(App[None]):
|
|
|
693
1121
|
("p", "show_pass_stats", "Pass Stats"),
|
|
694
1122
|
("c", "skip_current_pass", "Skip Pass"),
|
|
695
1123
|
("h", "show_help", "Help"),
|
|
1124
|
+
("up", "focus_up", "Focus Up"),
|
|
1125
|
+
("down", "focus_down", "Focus Down"),
|
|
1126
|
+
("left", "focus_left", "Focus Left"),
|
|
1127
|
+
("right", "focus_right", "Focus Right"),
|
|
1128
|
+
("enter", "expand_box", "Expand"),
|
|
696
1129
|
]
|
|
697
1130
|
|
|
698
1131
|
ENABLE_COMMAND_PALETTE = False
|
|
@@ -737,6 +1170,14 @@ class ShrinkRayApp(App[None]):
|
|
|
737
1170
|
self._current_pass_name: str = ""
|
|
738
1171
|
self._disabled_passes: list[str] = []
|
|
739
1172
|
|
|
1173
|
+
# Box IDs in navigation order: [top-left, top-right, bottom-left, bottom-right]
|
|
1174
|
+
_BOX_IDS = [
|
|
1175
|
+
"stats-container",
|
|
1176
|
+
"graph-container",
|
|
1177
|
+
"content-container",
|
|
1178
|
+
"output-container",
|
|
1179
|
+
]
|
|
1180
|
+
|
|
740
1181
|
def compose(self) -> ComposeResult:
|
|
741
1182
|
yield Header()
|
|
742
1183
|
with Vertical(id="main-container"):
|
|
@@ -745,14 +1186,23 @@ class ShrinkRayApp(App[None]):
|
|
|
745
1186
|
id="status-label",
|
|
746
1187
|
markup=False,
|
|
747
1188
|
)
|
|
748
|
-
with
|
|
749
|
-
|
|
1189
|
+
with Horizontal(id="stats-area"):
|
|
1190
|
+
with VerticalScroll(id="stats-container") as stats_scroll:
|
|
1191
|
+
stats_scroll.border_title = "Statistics"
|
|
1192
|
+
stats_scroll.can_focus = True
|
|
1193
|
+
yield StatsDisplay(id="stats-display")
|
|
1194
|
+
with Vertical(id="graph-container") as graph_container:
|
|
1195
|
+
graph_container.border_title = "Size Over Time"
|
|
1196
|
+
graph_container.can_focus = True
|
|
1197
|
+
yield SizeGraph(id="size-graph")
|
|
750
1198
|
with Horizontal(id="content-area"):
|
|
751
1199
|
with VerticalScroll(id="content-container") as content_scroll:
|
|
752
1200
|
content_scroll.border_title = "Recent Reductions"
|
|
1201
|
+
content_scroll.can_focus = True
|
|
753
1202
|
yield ContentPreview(id="content-preview")
|
|
754
1203
|
with VerticalScroll(id="output-container") as output_scroll:
|
|
755
1204
|
output_scroll.border_title = "Test Output"
|
|
1205
|
+
output_scroll.can_focus = True
|
|
756
1206
|
yield OutputPreview(id="output-preview")
|
|
757
1207
|
yield Footer()
|
|
758
1208
|
|
|
@@ -772,8 +1222,81 @@ class ShrinkRayApp(App[None]):
|
|
|
772
1222
|
|
|
773
1223
|
self.title = "Shrink Ray"
|
|
774
1224
|
self.sub_title = self._file_path
|
|
1225
|
+
|
|
1226
|
+
# Set initial focus to first box
|
|
1227
|
+
self.query_one("#stats-container").focus()
|
|
1228
|
+
|
|
775
1229
|
self.run_reduction()
|
|
776
1230
|
|
|
1231
|
+
def _get_focused_box_index(self) -> int:
|
|
1232
|
+
"""Get the index of the currently focused box, or 0 if none."""
|
|
1233
|
+
for i, box_id in enumerate(self._BOX_IDS):
|
|
1234
|
+
boxes = list(self.query(f"#{box_id}"))
|
|
1235
|
+
if boxes and boxes[0].has_focus:
|
|
1236
|
+
return i
|
|
1237
|
+
return 0
|
|
1238
|
+
|
|
1239
|
+
def _focus_box(self, index: int) -> None:
|
|
1240
|
+
"""Focus the box at the given index (with wrapping)."""
|
|
1241
|
+
index = index % len(self._BOX_IDS)
|
|
1242
|
+
box_id = self._BOX_IDS[index]
|
|
1243
|
+
self.query_one(f"#{box_id}").focus()
|
|
1244
|
+
|
|
1245
|
+
def action_focus_up(self) -> None:
|
|
1246
|
+
"""Move focus to the box above."""
|
|
1247
|
+
current = self._get_focused_box_index()
|
|
1248
|
+
# Grid is 2x2: top row is 0,1; bottom row is 2,3
|
|
1249
|
+
# Moving up: 2->0, 3->1, 0->2, 1->3 (wraps)
|
|
1250
|
+
if current >= 2:
|
|
1251
|
+
self._focus_box(current - 2)
|
|
1252
|
+
else:
|
|
1253
|
+
self._focus_box(current + 2)
|
|
1254
|
+
|
|
1255
|
+
def action_focus_down(self) -> None:
|
|
1256
|
+
"""Move focus to the box below."""
|
|
1257
|
+
current = self._get_focused_box_index()
|
|
1258
|
+
# Moving down: 0->2, 1->3, 2->0, 3->1 (wraps)
|
|
1259
|
+
if current < 2:
|
|
1260
|
+
self._focus_box(current + 2)
|
|
1261
|
+
else:
|
|
1262
|
+
self._focus_box(current - 2)
|
|
1263
|
+
|
|
1264
|
+
def action_focus_left(self) -> None:
|
|
1265
|
+
"""Move focus to the box on the left."""
|
|
1266
|
+
current = self._get_focused_box_index()
|
|
1267
|
+
# Moving left within row: 0->1, 1->0, 2->3, 3->2 (wraps within row)
|
|
1268
|
+
if current % 2 == 0:
|
|
1269
|
+
self._focus_box(current + 1)
|
|
1270
|
+
else:
|
|
1271
|
+
self._focus_box(current - 1)
|
|
1272
|
+
|
|
1273
|
+
def action_focus_right(self) -> None:
|
|
1274
|
+
"""Move focus to the box on the right."""
|
|
1275
|
+
current = self._get_focused_box_index()
|
|
1276
|
+
# Moving right within row: 0->1, 1->0, 2->3, 3->2 (wraps within row)
|
|
1277
|
+
if current % 2 == 0:
|
|
1278
|
+
self._focus_box(current + 1)
|
|
1279
|
+
else:
|
|
1280
|
+
self._focus_box(current - 1)
|
|
1281
|
+
|
|
1282
|
+
def action_expand_box(self) -> None:
|
|
1283
|
+
"""Expand the currently focused box to a modal."""
|
|
1284
|
+
current = self._get_focused_box_index()
|
|
1285
|
+
box_id = self._BOX_IDS[current]
|
|
1286
|
+
|
|
1287
|
+
# Get the title from the container's border_title
|
|
1288
|
+
titles = {
|
|
1289
|
+
"stats-container": "Statistics",
|
|
1290
|
+
"graph-container": "Size Over Time",
|
|
1291
|
+
"content-container": "Current Test Case",
|
|
1292
|
+
"output-container": "Test Output",
|
|
1293
|
+
}
|
|
1294
|
+
title = titles.get(box_id, "Details")
|
|
1295
|
+
|
|
1296
|
+
# Pass file_path for content-container to enable full file reading
|
|
1297
|
+
file_path = self._file_path if box_id == "content-container" else None
|
|
1298
|
+
self.push_screen(ExpandedBoxModal(title, box_id, file_path=file_path))
|
|
1299
|
+
|
|
777
1300
|
@work(exclusive=True)
|
|
778
1301
|
async def run_reduction(self) -> None:
|
|
779
1302
|
"""Start the reduction subprocess and monitor progress."""
|
|
@@ -812,6 +1335,7 @@ class ShrinkRayApp(App[None]):
|
|
|
812
1335
|
stats_display = self.query_one("#stats-display", StatsDisplay)
|
|
813
1336
|
content_preview = self.query_one("#content-preview", ContentPreview)
|
|
814
1337
|
output_preview = self.query_one("#output-preview", OutputPreview)
|
|
1338
|
+
size_graph = self.query_one("#size-graph", SizeGraph)
|
|
815
1339
|
|
|
816
1340
|
async with aclosing(self._client.get_progress_updates()) as updates:
|
|
817
1341
|
async for update in updates:
|
|
@@ -820,8 +1344,22 @@ class ShrinkRayApp(App[None]):
|
|
|
820
1344
|
update.content_preview, update.hex_mode
|
|
821
1345
|
)
|
|
822
1346
|
output_preview.update_output(
|
|
823
|
-
update.test_output_preview,
|
|
1347
|
+
update.test_output_preview,
|
|
1348
|
+
update.active_test_id,
|
|
1349
|
+
update.last_test_return_code,
|
|
824
1350
|
)
|
|
1351
|
+
size_graph.update_graph(
|
|
1352
|
+
update.new_size_history,
|
|
1353
|
+
update.original_size,
|
|
1354
|
+
update.runtime,
|
|
1355
|
+
)
|
|
1356
|
+
# Also update expanded modals if they exist
|
|
1357
|
+
self._update_expanded_graph(
|
|
1358
|
+
update.new_size_history,
|
|
1359
|
+
update.original_size,
|
|
1360
|
+
update.runtime,
|
|
1361
|
+
)
|
|
1362
|
+
self._update_expanded_stats()
|
|
825
1363
|
self._latest_pass_stats = update.pass_stats
|
|
826
1364
|
self._current_pass_name = update.current_pass_name
|
|
827
1365
|
self._disabled_passes = update.disabled_passes
|
|
@@ -870,6 +1408,41 @@ class ShrinkRayApp(App[None]):
|
|
|
870
1408
|
except Exception:
|
|
871
1409
|
pass # Widget not yet mounted
|
|
872
1410
|
|
|
1411
|
+
def _update_expanded_graph(
|
|
1412
|
+
self,
|
|
1413
|
+
new_entries: list[tuple[float, int]],
|
|
1414
|
+
original_size: int,
|
|
1415
|
+
current_runtime: float,
|
|
1416
|
+
) -> None:
|
|
1417
|
+
"""Update the expanded graph if it exists in a modal screen."""
|
|
1418
|
+
# Check if there's an ExpandedBoxModal for the graph on the screen stack
|
|
1419
|
+
for screen in self.screen_stack:
|
|
1420
|
+
if isinstance(screen, ExpandedBoxModal):
|
|
1421
|
+
if screen._content_widget_id == "graph-container":
|
|
1422
|
+
expanded_graphs = list(
|
|
1423
|
+
screen.query("#expanded-graph").results(SizeGraph)
|
|
1424
|
+
)
|
|
1425
|
+
if expanded_graphs:
|
|
1426
|
+
expanded_graphs[0].update_graph(
|
|
1427
|
+
new_entries, original_size, current_runtime
|
|
1428
|
+
)
|
|
1429
|
+
break
|
|
1430
|
+
|
|
1431
|
+
def _update_expanded_stats(self) -> None:
|
|
1432
|
+
"""Update the expanded stats if it exists in a modal screen."""
|
|
1433
|
+
for screen in self.screen_stack:
|
|
1434
|
+
if isinstance(screen, ExpandedBoxModal):
|
|
1435
|
+
if screen._content_widget_id == "stats-container":
|
|
1436
|
+
stats_displays = list(
|
|
1437
|
+
self.query("#stats-display").results(StatsDisplay)
|
|
1438
|
+
)
|
|
1439
|
+
expanded_contents = list(
|
|
1440
|
+
screen.query("#expanded-content").results(Static)
|
|
1441
|
+
)
|
|
1442
|
+
if stats_displays and expanded_contents:
|
|
1443
|
+
expanded_contents[0].update(stats_displays[0].render())
|
|
1444
|
+
break
|
|
1445
|
+
|
|
873
1446
|
async def action_quit(self) -> None:
|
|
874
1447
|
"""Quit the application with graceful cancellation."""
|
|
875
1448
|
if self._client and not self._completed:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shrinkray
|
|
3
|
-
Version: 25.12.
|
|
3
|
+
Version: 25.12.29.0
|
|
4
4
|
Summary: Shrink Ray
|
|
5
5
|
Author-email: "David R. MacIver" <david@drmaciver.com>
|
|
6
6
|
License: MIT
|
|
@@ -16,6 +16,7 @@ Requires-Dist: click>=8.0.1
|
|
|
16
16
|
Requires-Dist: chardet>=5.2.0
|
|
17
17
|
Requires-Dist: trio>=0.28.0
|
|
18
18
|
Requires-Dist: textual>=0.47.0
|
|
19
|
+
Requires-Dist: textual-plotext>=0.2.0
|
|
19
20
|
Requires-Dist: humanize>=4.9.0
|
|
20
21
|
Requires-Dist: libcst>=1.1.0
|
|
21
22
|
Requires-Dist: exceptiongroup>=1.2.0
|
|
@@ -28,6 +29,8 @@ Requires-Dist: hypothesmith>=0.3.1; extra == "dev"
|
|
|
28
29
|
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
29
30
|
Requires-Dist: pytest-trio>=0.8.0; extra == "dev"
|
|
30
31
|
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-xdist>=3.5.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
|
|
31
34
|
Requires-Dist: syrupy>=5.0.0; extra == "dev"
|
|
32
35
|
Requires-Dist: jinja2>=3.0.0; extra == "dev"
|
|
33
36
|
Requires-Dist: coverage[toml]>=7.4.0; extra == "dev"
|
|
@@ -78,7 +81,12 @@ shrinkray (or any other test-case reducer) then systematically tries smaller and
|
|
|
78
81
|
|
|
79
82
|
While it runs, you will see the following user interface:
|
|
80
83
|
|
|
81
|
-
|
|
84
|
+
<video controls poster="gallery/enterprise-hello/hello.png">
|
|
85
|
+
<source src="https://drmaciver.github.io/shrinkray/assets/hello.mp4" type="video/mp4">
|
|
86
|
+
Your browser doesn't support video. <a href="gallery/enterprise-hello/hello.gif">View the GIF instead</a>.
|
|
87
|
+
</video>
|
|
88
|
+
|
|
89
|
+
(This is a toy example based on reducing a ridiculously bad version of hello world)
|
|
82
90
|
|
|
83
91
|
When it finishes you will be left with the reduced test case in `mytestcase.py`.
|
|
84
92
|
|
|
@@ -6,8 +6,8 @@ shrinkray/problem.py,sha256=_edENYk8OC5o_2ng0WZrhIfilhlY5IuOrqt0qWBZAuM,25979
|
|
|
6
6
|
shrinkray/process.py,sha256=-eP8h5X0ESbkcTic8FFEzkd4-vwaZ0YI5tLxUR25L8U,1599
|
|
7
7
|
shrinkray/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
8
|
shrinkray/reducer.py,sha256=8CF_SxkfVMBxiikQKwv-rlrQawTLhQxy6QnVwIWWiws,18601
|
|
9
|
-
shrinkray/state.py,sha256=
|
|
10
|
-
shrinkray/tui.py,sha256=
|
|
9
|
+
shrinkray/state.py,sha256=rrQQj30eHkFb1yTkAtwxJzZ70HvCYRzOtgM7pYAdFPY,29607
|
|
10
|
+
shrinkray/tui.py,sha256=cSq-tnr9hNFvM6xHxGcGZXf-wH_TsB6OZKTSRQRppQM,53250
|
|
11
11
|
shrinkray/ui.py,sha256=xuDUwU-MM3AetvwUB7bfzav0P_drUsBrKFPhON_Nr-k,2251
|
|
12
12
|
shrinkray/validation.py,sha256=piBCO-k9he_id6TWC4EHMK3GfuyPqRcNfkNJPVjxEaU,13366
|
|
13
13
|
shrinkray/work.py,sha256=GEZ14Kk3bvwUxAnACvY-wom2lVWaGrELMNxrDjv03dk,8110
|
|
@@ -23,11 +23,11 @@ shrinkray/passes/sat.py,sha256=OboY6jsKf6lph3pAFh535plvhNOVzEF8HJ66WEqsNm4,19483
|
|
|
23
23
|
shrinkray/passes/sequences.py,sha256=-5ajmMeHnS7onjjppbxLiP0F6mRSqiFI5DspBTj2x_M,2206
|
|
24
24
|
shrinkray/subprocess/__init__.py,sha256=qxZ19Nzizbm7H0MkKL38OqfP7U-VuOAvaqBVkmHFivY,375
|
|
25
25
|
shrinkray/subprocess/client.py,sha256=abBkrXaJcA6cd3l_avPuteO_kYnjU5IRG7VtlmiAJgE,9428
|
|
26
|
-
shrinkray/subprocess/protocol.py,sha256=
|
|
27
|
-
shrinkray/subprocess/worker.py,sha256=
|
|
28
|
-
shrinkray-25.12.
|
|
29
|
-
shrinkray-25.12.
|
|
30
|
-
shrinkray-25.12.
|
|
31
|
-
shrinkray-25.12.
|
|
32
|
-
shrinkray-25.12.
|
|
33
|
-
shrinkray-25.12.
|
|
26
|
+
shrinkray/subprocess/protocol.py,sha256=ow_BqMN82Z4QY19s2C8ifx4Tf4xpbypbYy-YaKgSOYk,6940
|
|
27
|
+
shrinkray/subprocess/worker.py,sha256=s-3D-2keFmOGElPEdvZTOSBWS6C7P2I_Ti9PGDxWjYM,23225
|
|
28
|
+
shrinkray-25.12.29.0.dist-info/licenses/LICENSE,sha256=iMKX79AuokJfIZUnGUARdUp30vVAoIPOJ7ek8TY63kk,1072
|
|
29
|
+
shrinkray-25.12.29.0.dist-info/METADATA,sha256=kXk3VCnESHkuX7Rv6Q2G3PtTqOCg1trCnhPgxnbZm4Y,8051
|
|
30
|
+
shrinkray-25.12.29.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
31
|
+
shrinkray-25.12.29.0.dist-info/entry_points.txt,sha256=wIZvnGyOdVeaLTiv2klnSyTe-EKkkwn4SwHh9bmJ7qk,104
|
|
32
|
+
shrinkray-25.12.29.0.dist-info/top_level.txt,sha256=fLif8-rFoFOnf5h8-vs3ECkKNWQopTQh3xvl1s7pchQ,10
|
|
33
|
+
shrinkray-25.12.29.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|