casecraft 1.3.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.
- casecraft/__init__.py +0 -0
- casecraft/app.py +478 -0
- casecraft/cli.py +32 -0
- casecraft/models.py +95 -0
- casecraft/runner.py +194 -0
- casecraft/utils.py +65 -0
- casecraft/widgets/__init__.py +0 -0
- casecraft/widgets/add_modal.py +190 -0
- casecraft/widgets/diff_viewer.py +169 -0
- casecraft/widgets/file_browser.py +243 -0
- casecraft/widgets/help_modal.py +111 -0
- casecraft/widgets/history_modal.py +99 -0
- casecraft/widgets/import_modal.py +179 -0
- casecraft/widgets/search_modal.py +89 -0
- casecraft-1.3.0.dist-info/METADATA +132 -0
- casecraft-1.3.0.dist-info/RECORD +19 -0
- casecraft-1.3.0.dist-info/WHEEL +5 -0
- casecraft-1.3.0.dist-info/entry_points.txt +2 -0
- casecraft-1.3.0.dist-info/top_level.txt +1 -0
casecraft/__init__.py
ADDED
|
File without changes
|
casecraft/app.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""
|
|
2
|
+
app.py — CaseCraft 2.0 (3-pane layout with Taproom colors).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from textual.app import App, ComposeResult
|
|
9
|
+
from textual.containers import Horizontal, Vertical
|
|
10
|
+
from textual.widgets import DataTable, Static, Input, Label, Button
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.reactive import reactive
|
|
13
|
+
from textual.screen import ModalScreen
|
|
14
|
+
|
|
15
|
+
from casecraft.models import Session, TestCase, TestResult, Verdict, WorkspaceState
|
|
16
|
+
from casecraft.runner import run_all_test_cases_async
|
|
17
|
+
from casecraft.utils import (
|
|
18
|
+
is_initialized,
|
|
19
|
+
initialize_workspace,
|
|
20
|
+
load_sessions,
|
|
21
|
+
save_sessions,
|
|
22
|
+
get_or_create_session,
|
|
23
|
+
load_last_file,
|
|
24
|
+
save_last_file
|
|
25
|
+
)
|
|
26
|
+
from casecraft.widgets.add_modal import AddTestCaseModal
|
|
27
|
+
|
|
28
|
+
CSS = """
|
|
29
|
+
Screen {
|
|
30
|
+
background: #282c34;
|
|
31
|
+
color: #abb2bf;
|
|
32
|
+
padding: 1 2;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#top-bar {
|
|
36
|
+
height: 3;
|
|
37
|
+
layout: horizontal;
|
|
38
|
+
margin-bottom: 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#search-box {
|
|
42
|
+
width: 1fr;
|
|
43
|
+
border: round #e5c07b;
|
|
44
|
+
height: 3;
|
|
45
|
+
background: #282c34;
|
|
46
|
+
color: #abb2bf;
|
|
47
|
+
}
|
|
48
|
+
#search-box:focus {
|
|
49
|
+
border: round #98c379;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#viewing-box {
|
|
53
|
+
width: 30;
|
|
54
|
+
border: round #e5c07b;
|
|
55
|
+
height: 3;
|
|
56
|
+
content-align: center middle;
|
|
57
|
+
margin-left: 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#main-container {
|
|
61
|
+
height: 1fr;
|
|
62
|
+
layout: horizontal;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#left-column {
|
|
66
|
+
width: 35%;
|
|
67
|
+
height: 1fr;
|
|
68
|
+
border: round #e5c07b;
|
|
69
|
+
background: #282c34;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#prob-table {
|
|
73
|
+
height: 40%;
|
|
74
|
+
border-bottom: hkey #e5c07b;
|
|
75
|
+
background: #282c34;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#tc-table {
|
|
79
|
+
height: 60%;
|
|
80
|
+
background: #282c34;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#right-panel {
|
|
84
|
+
width: 65%;
|
|
85
|
+
height: 1fr;
|
|
86
|
+
border: round #e5c07b;
|
|
87
|
+
padding: 0 1;
|
|
88
|
+
margin-left: 1;
|
|
89
|
+
overflow-y: auto;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
DataTable > .datatable--header {
|
|
93
|
+
background: #282c34;
|
|
94
|
+
color: #e5c07b;
|
|
95
|
+
text-style: bold;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
DataTable > .datatable--cursor {
|
|
99
|
+
background: #3e4452;
|
|
100
|
+
color: #ffffff;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
DataTable:focus > .datatable--cursor {
|
|
104
|
+
background: #e5c07b;
|
|
105
|
+
color: #282c34;
|
|
106
|
+
text-style: bold;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#footer-block {
|
|
110
|
+
height: 4;
|
|
111
|
+
margin-top: 1;
|
|
112
|
+
color: #abb2bf;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* Modals */
|
|
116
|
+
PromptModal, InitModal {
|
|
117
|
+
align: center middle;
|
|
118
|
+
background: rgba(40,44,52,0.85);
|
|
119
|
+
}
|
|
120
|
+
#prompt-dialog {
|
|
121
|
+
width: 60;
|
|
122
|
+
height: auto;
|
|
123
|
+
background: #282c34;
|
|
124
|
+
border: round #e5c07b;
|
|
125
|
+
padding: 1 2;
|
|
126
|
+
}
|
|
127
|
+
#init-dialog {
|
|
128
|
+
width: 50;
|
|
129
|
+
height: auto;
|
|
130
|
+
background: #282c34;
|
|
131
|
+
border: round #e5c07b;
|
|
132
|
+
padding: 1 2;
|
|
133
|
+
align: center middle;
|
|
134
|
+
}
|
|
135
|
+
#init-dialog Button {
|
|
136
|
+
margin-top: 1;
|
|
137
|
+
width: 100%;
|
|
138
|
+
}
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
class InitModal(ModalScreen[bool]):
|
|
142
|
+
def compose(self) -> ComposeResult:
|
|
143
|
+
with Vertical(id="init-dialog"):
|
|
144
|
+
yield Label("[b #e5c07b]Not Initialized[/b]\n")
|
|
145
|
+
yield Label("CaseCraft is not initialized in this directory.")
|
|
146
|
+
yield Label("Would you like to initialize it by creating a `.casecraft` folder?")
|
|
147
|
+
yield Button("Initialize Workspace", id="btn-init", variant="success")
|
|
148
|
+
yield Button("Quit", id="btn-quit", variant="error")
|
|
149
|
+
|
|
150
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
151
|
+
if event.button.id == "btn-init":
|
|
152
|
+
self.dismiss(True)
|
|
153
|
+
else:
|
|
154
|
+
self.dismiss(False)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class PromptModal(ModalScreen[str | None]):
|
|
158
|
+
def __init__(self, title: str, placeholder: str = ""):
|
|
159
|
+
super().__init__()
|
|
160
|
+
self.title_text = title
|
|
161
|
+
self.placeholder = placeholder
|
|
162
|
+
|
|
163
|
+
def compose(self) -> ComposeResult:
|
|
164
|
+
with Vertical(id="prompt-dialog"):
|
|
165
|
+
yield Label(self.title_text, classes="prompt-title")
|
|
166
|
+
yield Input(placeholder=self.placeholder, id="prompt-input")
|
|
167
|
+
yield Label("[dim]Press Enter to confirm, Escape to cancel.[/dim]")
|
|
168
|
+
|
|
169
|
+
def on_mount(self):
|
|
170
|
+
self.query_one(Input).focus()
|
|
171
|
+
|
|
172
|
+
def on_input_submitted(self, event: Input.Submitted):
|
|
173
|
+
self.dismiss(event.value)
|
|
174
|
+
|
|
175
|
+
def on_key(self, event):
|
|
176
|
+
if event.key == "escape":
|
|
177
|
+
self.dismiss(None)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class CaseCraftApp(App):
|
|
181
|
+
CSS = CSS
|
|
182
|
+
TITLE = "CaseCraft ~ Taproom UI (3-Pane)"
|
|
183
|
+
|
|
184
|
+
BINDINGS = [
|
|
185
|
+
Binding("space", "run_all", "Run All", show=False),
|
|
186
|
+
Binding("p", "add_problem", "Add Prob", show=False),
|
|
187
|
+
Binding("a", "add_test_case", "Add TC", show=False),
|
|
188
|
+
Binding("e", "edit_test_case", "Edit TC", show=False),
|
|
189
|
+
Binding("d", "delete_selected", "Delete", show=False),
|
|
190
|
+
Binding("q", "quit", "Quit", show=False),
|
|
191
|
+
Binding("slash", "focus_search", "Search", show=False),
|
|
192
|
+
Binding("escape", "clear_search", "Clear Search", show=False),
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
active_problem = reactive(None)
|
|
196
|
+
active_test_case = reactive(None)
|
|
197
|
+
search_query = reactive("", init=False)
|
|
198
|
+
|
|
199
|
+
def __init__(self):
|
|
200
|
+
super().__init__()
|
|
201
|
+
# We load workspace empty initially, will populate after mount if initialized
|
|
202
|
+
self.workspace = WorkspaceState(sessions={})
|
|
203
|
+
self.results: dict[str, TestResult] = {}
|
|
204
|
+
|
|
205
|
+
def compose(self) -> ComposeResult:
|
|
206
|
+
with Horizontal(id="top-bar"):
|
|
207
|
+
yield Input(placeholder="/ Search test cases...", id="search-box")
|
|
208
|
+
yield Static("Viewing: All", id="viewing-box")
|
|
209
|
+
|
|
210
|
+
with Horizontal(id="main-container"):
|
|
211
|
+
with Vertical(id="left-column"):
|
|
212
|
+
yield DataTable(id="prob-table", cursor_type="row")
|
|
213
|
+
yield DataTable(id="tc-table", cursor_type="row")
|
|
214
|
+
yield Static("Select a test case to view details...", id="right-panel")
|
|
215
|
+
|
|
216
|
+
yield Static(
|
|
217
|
+
"General : [b #e5c07b]q/ctrl+c[/]: quit [b #e5c07b]space[/]: run tests [b #e5c07b]/[/]: search [b #e5c07b]Esc[/]: clear search\n"
|
|
218
|
+
"Management : [b #e5c07b]a[/]: add test case [b #e5c07b]e[/]: edit test case [b #e5c07b]d[/]: delete\n"
|
|
219
|
+
"Workspace : [b #e5c07b]p[/]: load problem file [b #e5c07b]tab[/]: switch focus",
|
|
220
|
+
id="footer-block"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def on_mount(self):
|
|
224
|
+
prob_table = self.query_one("#prob-table", DataTable)
|
|
225
|
+
prob_table.add_column("Problem File")
|
|
226
|
+
|
|
227
|
+
tc_table = self.query_one("#tc-table", DataTable)
|
|
228
|
+
tc_table.add_columns("Verdict", "Test Case", "Time")
|
|
229
|
+
|
|
230
|
+
if not is_initialized():
|
|
231
|
+
def check_init(result: bool):
|
|
232
|
+
if result:
|
|
233
|
+
initialize_workspace()
|
|
234
|
+
self._load_workspace_data()
|
|
235
|
+
else:
|
|
236
|
+
self.exit()
|
|
237
|
+
self.push_screen(InitModal(), check_init)
|
|
238
|
+
else:
|
|
239
|
+
self._load_workspace_data()
|
|
240
|
+
|
|
241
|
+
def _load_workspace_data(self):
|
|
242
|
+
self.workspace = WorkspaceState(
|
|
243
|
+
sessions=load_sessions(),
|
|
244
|
+
active_file=load_last_file()
|
|
245
|
+
)
|
|
246
|
+
self.refresh_problems()
|
|
247
|
+
prob_table = self.query_one("#prob-table", DataTable)
|
|
248
|
+
if self.workspace.active_file and self.workspace.active_file in self.workspace.sessions:
|
|
249
|
+
self._select_problem_by_path(self.workspace.active_file)
|
|
250
|
+
prob_table.focus()
|
|
251
|
+
else:
|
|
252
|
+
prob_table.focus()
|
|
253
|
+
|
|
254
|
+
def _select_problem_by_path(self, fp: str):
|
|
255
|
+
pt = self.query_one("#prob-table", DataTable)
|
|
256
|
+
for i, row_key in enumerate(pt.rows):
|
|
257
|
+
if row_key.value == fp:
|
|
258
|
+
pt.move_cursor(row=i)
|
|
259
|
+
self.active_problem = fp
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
def refresh_problems(self):
|
|
263
|
+
pt = self.query_one("#prob-table", DataTable)
|
|
264
|
+
pt.clear()
|
|
265
|
+
|
|
266
|
+
q = self.search_query.lower()
|
|
267
|
+
for fp in sorted(self.workspace.sessions.keys()):
|
|
268
|
+
name = Path(fp).name
|
|
269
|
+
if q and q not in name.lower():
|
|
270
|
+
tc_match = any(q in tc.label.lower() for tc in self.workspace.sessions[fp].test_cases)
|
|
271
|
+
if not tc_match:
|
|
272
|
+
continue
|
|
273
|
+
pt.add_row(name, key=fp)
|
|
274
|
+
|
|
275
|
+
def refresh_test_cases(self):
|
|
276
|
+
tc_table = self.query_one("#tc-table", DataTable)
|
|
277
|
+
tc_table.clear()
|
|
278
|
+
|
|
279
|
+
if not self.active_problem:
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
session = self.workspace.sessions.get(self.active_problem)
|
|
283
|
+
if not session:
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
q = self.search_query.lower()
|
|
287
|
+
for tc in session.test_cases:
|
|
288
|
+
if q and q not in tc.label.lower() and q not in Path(self.active_problem).name.lower():
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
res = self.results.get(tc.id)
|
|
292
|
+
verdict = res.verdict.value if res else Verdict.PENDING.value
|
|
293
|
+
|
|
294
|
+
if verdict == Verdict.ACCEPTED.value:
|
|
295
|
+
verdict_str = f"[#98c379]{verdict}[/]"
|
|
296
|
+
elif verdict in (Verdict.PENDING.value, Verdict.RUNNING.value):
|
|
297
|
+
verdict_str = f"[#61afef]{verdict}[/]"
|
|
298
|
+
else:
|
|
299
|
+
verdict_str = f"[#e06c75]{verdict}[/]"
|
|
300
|
+
|
|
301
|
+
time_str = f"{res.runtime_ms:.0f}ms" if res and res.runtime_ms else "—"
|
|
302
|
+
tc_table.add_row(verdict_str, tc.label, time_str, key=tc.id)
|
|
303
|
+
|
|
304
|
+
def watch_active_problem(self, old_val, new_val):
|
|
305
|
+
if new_val:
|
|
306
|
+
self.workspace.active_file = new_val
|
|
307
|
+
save_last_file(new_val)
|
|
308
|
+
self.query_one("#viewing-box", Static).update(f"Viewing: {Path(new_val).name}")
|
|
309
|
+
self.active_test_case = None
|
|
310
|
+
self.refresh_test_cases()
|
|
311
|
+
self.update_diff_view()
|
|
312
|
+
|
|
313
|
+
tc_table = self.query_one("#tc-table", DataTable)
|
|
314
|
+
if tc_table.row_count > 0:
|
|
315
|
+
tc_table.move_cursor(row=0)
|
|
316
|
+
self.active_test_case = tc_table.coordinate_to_cell_key(tc_table.cursor_coordinate).row_key.value
|
|
317
|
+
|
|
318
|
+
def watch_active_test_case(self, old_val, new_val):
|
|
319
|
+
self.update_diff_view()
|
|
320
|
+
|
|
321
|
+
def watch_search_query(self, old_val, new_val):
|
|
322
|
+
self.refresh_problems()
|
|
323
|
+
self.refresh_test_cases()
|
|
324
|
+
|
|
325
|
+
def on_input_changed(self, event: Input.Changed):
|
|
326
|
+
if event.input.id == "search-box":
|
|
327
|
+
self.search_query = event.value
|
|
328
|
+
|
|
329
|
+
def update_diff_view(self):
|
|
330
|
+
panel = self.query_one("#right-panel", Static)
|
|
331
|
+
|
|
332
|
+
if not self.active_problem or not self.active_test_case:
|
|
333
|
+
panel.update("Select a test case to view details...")
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
session = self.workspace.sessions.get(self.active_problem)
|
|
337
|
+
if not session:
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
tc = next((t for t in session.test_cases if t.id == self.active_test_case), None)
|
|
341
|
+
if tc:
|
|
342
|
+
res = self.results.get(tc.id)
|
|
343
|
+
prob_name = Path(self.active_problem).name
|
|
344
|
+
|
|
345
|
+
exp = tc.expected_output.strip() or "(empty)"
|
|
346
|
+
|
|
347
|
+
if res:
|
|
348
|
+
act = res.actual_output.strip() or "(empty)"
|
|
349
|
+
err = res.error.strip() if res.error else ""
|
|
350
|
+
|
|
351
|
+
color = "#98c379" if res.verdict == Verdict.ACCEPTED else "#e06c75"
|
|
352
|
+
if res.verdict in (Verdict.PENDING, Verdict.RUNNING):
|
|
353
|
+
color = "#61afef"
|
|
354
|
+
|
|
355
|
+
text = f"[b #e5c07b]▣ {tc.label}[/]\n"
|
|
356
|
+
text += f"Problem: {prob_name}\n"
|
|
357
|
+
text += f"Status: [{color}]{res.verdict.value}[/]\n"
|
|
358
|
+
text += f"Runtime: {res.runtime_ms:.0f}ms\n"
|
|
359
|
+
text += f"File: {self.active_problem}\n\n"
|
|
360
|
+
|
|
361
|
+
text += f"────────────────────────────────\n\n"
|
|
362
|
+
text += f"[b #e5c07b]Expected Output:[/]\n[#abb2bf]{exp}[/]\n\n"
|
|
363
|
+
text += f"[b #e5c07b]Actual Output:[/]\n[{color}]{act}[/]\n"
|
|
364
|
+
|
|
365
|
+
if err:
|
|
366
|
+
text += f"\n[b #e06c75]Stderr/Error:[/]\n[#d19a66]{err}[/]\n"
|
|
367
|
+
panel.update(text)
|
|
368
|
+
else:
|
|
369
|
+
text = f"[b #e5c07b]▣ {tc.label}[/]\n"
|
|
370
|
+
text += f"Problem: {prob_name}\n"
|
|
371
|
+
text += f"Status: [#61afef]—[/]\n"
|
|
372
|
+
text += f"File: {self.active_problem}\n\n"
|
|
373
|
+
text += f"────────────────────────────────\n\n"
|
|
374
|
+
text += f"[b #e5c07b]Expected Output:[/]\n[#abb2bf]{exp}[/]\n\n"
|
|
375
|
+
text += f"[dim]Press space to run test case and see actual output.[/]"
|
|
376
|
+
panel.update(text)
|
|
377
|
+
|
|
378
|
+
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted):
|
|
379
|
+
if event.data_table.id == "prob-table" and event.row_key:
|
|
380
|
+
self.active_problem = str(event.row_key.value)
|
|
381
|
+
elif event.data_table.id == "tc-table" and event.row_key:
|
|
382
|
+
self.active_test_case = str(event.row_key.value)
|
|
383
|
+
|
|
384
|
+
def action_focus_search(self):
|
|
385
|
+
self.query_one("#search-box", Input).focus()
|
|
386
|
+
|
|
387
|
+
def action_clear_search(self):
|
|
388
|
+
sb = self.query_one("#search-box", Input)
|
|
389
|
+
if sb.has_focus:
|
|
390
|
+
sb.value = ""
|
|
391
|
+
self.query_one("#tc-table", DataTable).focus()
|
|
392
|
+
else:
|
|
393
|
+
self.query_one("#tc-table", DataTable).focus()
|
|
394
|
+
|
|
395
|
+
async def action_add_problem(self):
|
|
396
|
+
def check_reply(fp: str | None):
|
|
397
|
+
if fp:
|
|
398
|
+
path = Path(fp)
|
|
399
|
+
if not path.is_absolute():
|
|
400
|
+
path = Path.cwd() / path
|
|
401
|
+
path_str = str(path.resolve())
|
|
402
|
+
if path_str not in self.workspace.sessions:
|
|
403
|
+
get_or_create_session(self.workspace.sessions, path_str)
|
|
404
|
+
save_sessions(self.workspace.sessions)
|
|
405
|
+
self.refresh_problems()
|
|
406
|
+
self._select_problem_by_path(path_str)
|
|
407
|
+
self.push_screen(PromptModal("Enter file path to load (e.g. main.py):"), check_reply)
|
|
408
|
+
|
|
409
|
+
async def action_add_test_case(self):
|
|
410
|
+
if not self.active_problem:
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
def check_reply(tc: TestCase | None):
|
|
414
|
+
if tc:
|
|
415
|
+
self.workspace.sessions[self.active_problem].test_cases.append(tc)
|
|
416
|
+
save_sessions(self.workspace.sessions)
|
|
417
|
+
self.refresh_test_cases()
|
|
418
|
+
self.push_screen(AddTestCaseModal(), check_reply)
|
|
419
|
+
|
|
420
|
+
async def action_edit_test_case(self):
|
|
421
|
+
if not self.active_problem or not self.active_test_case:
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
session = self.workspace.sessions.get(self.active_problem)
|
|
425
|
+
tc = next((t for t in session.test_cases if t.id == self.active_test_case), None)
|
|
426
|
+
if not tc:
|
|
427
|
+
return
|
|
428
|
+
|
|
429
|
+
def check_reply(updated_tc: TestCase | None):
|
|
430
|
+
if updated_tc:
|
|
431
|
+
idx = next((i for i, t in enumerate(session.test_cases) if t.id == updated_tc.id), None)
|
|
432
|
+
if idx is not None:
|
|
433
|
+
session.test_cases[idx] = updated_tc
|
|
434
|
+
save_sessions(self.workspace.sessions)
|
|
435
|
+
self.refresh_test_cases()
|
|
436
|
+
self.update_diff_view()
|
|
437
|
+
|
|
438
|
+
self.push_screen(AddTestCaseModal(existing=tc), check_reply)
|
|
439
|
+
|
|
440
|
+
async def action_delete_selected(self):
|
|
441
|
+
focused = self.focused
|
|
442
|
+
if focused and focused.id == "prob-table":
|
|
443
|
+
if self.active_problem in self.workspace.sessions:
|
|
444
|
+
del self.workspace.sessions[self.active_problem]
|
|
445
|
+
save_sessions(self.workspace.sessions)
|
|
446
|
+
self.active_problem = None
|
|
447
|
+
self.refresh_problems()
|
|
448
|
+
elif focused and focused.id == "tc-table":
|
|
449
|
+
if self.active_problem and self.active_test_case:
|
|
450
|
+
session = self.workspace.sessions.get(self.active_problem)
|
|
451
|
+
if session:
|
|
452
|
+
session.test_cases = [t for t in session.test_cases if t.id != self.active_test_case]
|
|
453
|
+
save_sessions(self.workspace.sessions)
|
|
454
|
+
self.active_test_case = None
|
|
455
|
+
self.refresh_test_cases()
|
|
456
|
+
|
|
457
|
+
async def action_run_all(self):
|
|
458
|
+
if not self.active_problem:
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
session = self.workspace.sessions.get(self.active_problem)
|
|
462
|
+
if not session or not session.test_cases:
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
for tc in session.test_cases:
|
|
466
|
+
self.results[tc.id] = TestResult(tc, Verdict.RUNNING, 0, "", "", False)
|
|
467
|
+
self.refresh_test_cases()
|
|
468
|
+
|
|
469
|
+
def progress(res: TestResult):
|
|
470
|
+
self.results[res.test_case.id] = res
|
|
471
|
+
self.refresh_test_cases()
|
|
472
|
+
self.update_diff_view()
|
|
473
|
+
|
|
474
|
+
results = await run_all_test_cases_async(self.active_problem, session.test_cases, progress_callback=progress)
|
|
475
|
+
|
|
476
|
+
if __name__ == "__main__":
|
|
477
|
+
app = CaseCraftApp()
|
|
478
|
+
app.run()
|
casecraft/cli.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli.py — Command-Line Interface for CaseCraft.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from casecraft.utils import is_initialized, initialize_workspace
|
|
9
|
+
from casecraft.app import CaseCraftApp
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
parser = argparse.ArgumentParser(description="CaseCraft - TUI for algorithmic test cases.")
|
|
13
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
14
|
+
|
|
15
|
+
init_parser = subparsers.add_parser("init", help="Initialize a CaseCraft workspace in the current directory.")
|
|
16
|
+
|
|
17
|
+
args = parser.parse_args()
|
|
18
|
+
|
|
19
|
+
if args.command == "init":
|
|
20
|
+
if is_initialized():
|
|
21
|
+
print("CaseCraft is already initialized in this directory (.casecraft exists).")
|
|
22
|
+
else:
|
|
23
|
+
initialize_workspace()
|
|
24
|
+
print("Successfully initialized CaseCraft in .casecraft/")
|
|
25
|
+
sys.exit(0)
|
|
26
|
+
|
|
27
|
+
# If no command provided, run the TUI
|
|
28
|
+
app = CaseCraftApp()
|
|
29
|
+
app.run()
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
main()
|
casecraft/models.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""
|
|
2
|
+
models.py — Data models for CaseCraft 2.0.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Verdict(str, Enum):
|
|
14
|
+
ACCEPTED = "AC"
|
|
15
|
+
WRONG_ANSWER = "WA"
|
|
16
|
+
RUNTIME_ERROR = "RE"
|
|
17
|
+
TIME_LIMIT_EXCEEDED = "TLE"
|
|
18
|
+
COMPILATION_ERROR = "CE"
|
|
19
|
+
PENDING = "—"
|
|
20
|
+
RUNNING = "↻"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
VERDICT_COLOR: dict[Verdict, str] = {
|
|
24
|
+
Verdict.ACCEPTED: "#9ece6a",
|
|
25
|
+
Verdict.WRONG_ANSWER: "#f7768e",
|
|
26
|
+
Verdict.RUNTIME_ERROR: "#bb9af7",
|
|
27
|
+
Verdict.TIME_LIMIT_EXCEEDED: "#e0af68",
|
|
28
|
+
Verdict.COMPILATION_ERROR: "#f7768e",
|
|
29
|
+
Verdict.PENDING: "#565f89",
|
|
30
|
+
Verdict.RUNNING: "#7aa2f7",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class TestCase:
|
|
36
|
+
label: str
|
|
37
|
+
input_data: str
|
|
38
|
+
expected_output: str
|
|
39
|
+
id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict:
|
|
42
|
+
return {
|
|
43
|
+
"id": self.id,
|
|
44
|
+
"label": self.label,
|
|
45
|
+
"input_data": self.input_data,
|
|
46
|
+
"expected_output": self.expected_output,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_dict(cls, data: dict) -> TestCase:
|
|
51
|
+
return cls(
|
|
52
|
+
id=data.get("id", uuid.uuid4().hex[:8]),
|
|
53
|
+
label=data.get("label", "Test Case"),
|
|
54
|
+
input_data=data.get("input_data", ""),
|
|
55
|
+
expected_output=data.get("expected_output", ""),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class TestResult:
|
|
61
|
+
test_case: TestCase
|
|
62
|
+
verdict: Verdict
|
|
63
|
+
runtime_ms: Optional[float]
|
|
64
|
+
actual_output: str
|
|
65
|
+
error: Optional[str]
|
|
66
|
+
timed_out: bool
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Session:
|
|
71
|
+
"""Represents a specific problem or file."""
|
|
72
|
+
file_path: str
|
|
73
|
+
test_cases: list[TestCase] = field(default_factory=list)
|
|
74
|
+
last_opened: str = ""
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict:
|
|
77
|
+
return {
|
|
78
|
+
"file_path": self.file_path,
|
|
79
|
+
"last_opened": self.last_opened,
|
|
80
|
+
"test_cases": [tc.to_dict() for tc in self.test_cases],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_dict(cls, data: dict) -> Session:
|
|
85
|
+
return cls(
|
|
86
|
+
file_path=data["file_path"],
|
|
87
|
+
last_opened=data.get("last_opened", ""),
|
|
88
|
+
test_cases=[TestCase.from_dict(tc) for tc in data.get("test_cases", [])],
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class WorkspaceState:
|
|
93
|
+
"""Root state for the application."""
|
|
94
|
+
sessions: dict[str, Session] = field(default_factory=dict)
|
|
95
|
+
active_file: str | None = None
|