rbx.cp 0.5.61__py3-none-any.whl → 0.5.62__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.
Files changed (39) hide show
  1. rbx/box/cd.py +14 -0
  2. rbx/box/cli.py +6 -0
  3. rbx/box/code.py +34 -5
  4. rbx/box/contest/main.py +6 -2
  5. rbx/box/git_utils.py +28 -0
  6. rbx/box/package.py +23 -0
  7. rbx/box/packaging/boca/packager.py +3 -18
  8. rbx/box/packaging/moj/packager.py +1 -1
  9. rbx/box/packaging/polygon/upload.py +7 -5
  10. rbx/box/presets/__init__.py +80 -6
  11. rbx/box/presets/fetch.py +18 -1
  12. rbx/box/retries.py +2 -0
  13. rbx/box/solutions.py +238 -113
  14. rbx/box/solutions_test.py +3 -1
  15. rbx/box/tasks.py +6 -1
  16. rbx/box/testcase_utils.py +3 -0
  17. rbx/box/ui/css/app.tcss +14 -2
  18. rbx/box/ui/main.py +3 -5
  19. rbx/box/ui/screens/error.py +19 -0
  20. rbx/box/ui/screens/run.py +4 -12
  21. rbx/box/ui/screens/run_explorer.py +77 -1
  22. rbx/box/ui/screens/run_test_explorer.py +155 -0
  23. rbx/box/ui/screens/selector.py +26 -0
  24. rbx/box/ui/screens/test_explorer.py +20 -5
  25. rbx/box/ui/utils/__init__.py +0 -0
  26. rbx/box/ui/utils/run_ui.py +95 -0
  27. rbx/box/ui/widgets/__init__.py +0 -0
  28. rbx/box/ui/widgets/file_log.py +3 -1
  29. rbx/box/ui/widgets/test_output_box.py +104 -0
  30. rbx/box/ui/widgets/two_sided_test_output_box.py +56 -0
  31. rbx/grading/steps.py +1 -0
  32. rbx/resources/packagers/boca/compile/java +55 -59
  33. rbx/resources/packagers/boca/interactive/java +2 -2
  34. rbx/resources/packagers/boca/run/java +2 -2
  35. {rbx_cp-0.5.61.dist-info → rbx_cp-0.5.62.dist-info}/METADATA +1 -1
  36. {rbx_cp-0.5.61.dist-info → rbx_cp-0.5.62.dist-info}/RECORD +39 -30
  37. {rbx_cp-0.5.61.dist-info → rbx_cp-0.5.62.dist-info}/LICENSE +0 -0
  38. {rbx_cp-0.5.61.dist-info → rbx_cp-0.5.62.dist-info}/WHEEL +0 -0
  39. {rbx_cp-0.5.61.dist-info → rbx_cp-0.5.62.dist-info}/entry_points.txt +0 -0
rbx/box/ui/css/app.tcss CHANGED
@@ -63,8 +63,11 @@ FileLog > Log {
63
63
  background: transparent;
64
64
  }
65
65
 
66
- TestExplorerScreen {
67
- #test-metadata {
66
+ TestExplorerScreen, RunTestExplorerScreen {
67
+ #test-box-switcher {
68
+ height: 1fr;
69
+ }
70
+ #test-metadata, #test-box-metadata {
68
71
  min-height: 3;
69
72
  height: auto;
70
73
  }
@@ -76,6 +79,15 @@ TestExplorerScreen {
76
79
  #test-list {
77
80
  width: 1fr;
78
81
  }
82
+ TestBoxWidget, TwoSidedTestBoxWidget {
83
+ height: 1fr;
84
+ #test-box-1 {
85
+ width: 1fr;
86
+ }
87
+ #test-box-2 {
88
+ width: 1fr;
89
+ }
90
+ }
79
91
  FileLog {
80
92
  border: solid $accent;
81
93
  padding: 0 1;
rbx/box/ui/main.py CHANGED
@@ -5,14 +5,12 @@ from textual.containers import Center
5
5
  from textual.screen import Screen
6
6
  from textual.widgets import Footer, Header, OptionList
7
7
 
8
- from rbx.box.ui.screens.build import BuildScreen
9
- from rbx.box.ui.screens.run import RunScreen
8
+ from rbx.box.ui.screens.run_explorer import RunExplorerScreen
10
9
  from rbx.box.ui.screens.test_explorer import TestExplorerScreen
11
10
 
12
11
  SCREEN_OPTIONS = [
13
- ('Run solutions against define testsets.', RunScreen),
14
- ('Build tests.', BuildScreen),
15
- ('Explore tests.', TestExplorerScreen),
12
+ ('Explore tests built by `rbx build`.', TestExplorerScreen),
13
+ ('Explore results of a past `rbx run`.', RunExplorerScreen),
16
14
  ]
17
15
 
18
16
 
@@ -0,0 +1,19 @@
1
+ from textual.app import ComposeResult
2
+ from textual.screen import Screen
3
+ from textual.widgets import Footer, Header, Label
4
+
5
+
6
+ class ErrorScreen(Screen):
7
+ BINDINGS = [('q', 'app.pop_screen', 'Quit')]
8
+
9
+ def __init__(self, message: str):
10
+ super().__init__()
11
+ self.message = message
12
+
13
+ def compose(self) -> ComposeResult:
14
+ yield Header()
15
+ yield Footer()
16
+ yield Label(self.message)
17
+
18
+ def on_mount(self):
19
+ self.query_one(Label).border_title = 'Error'
rbx/box/ui/screens/run.py CHANGED
@@ -81,20 +81,12 @@ class SolutionReportScreen(Screen):
81
81
  if self.log_display_state is not None:
82
82
  self.query_one(LogDisplay).load(self.log_display_state)
83
83
 
84
- def _find_solution_index_in_skeleton(self, sol: Solution) -> int:
85
- for i, solution in enumerate(self.skeleton.solutions):
86
- if solution.path == sol.path:
87
- return i
88
- raise
89
-
90
84
  async def process(self, item: EvaluationItem, eval: Evaluation):
91
- pkg = package.find_problem_package_or_die()
92
- sol_idx_in_skeleton = self._find_solution_index_in_skeleton(
93
- pkg.solutions[item.solution_index]
94
- )
95
- group = self.skeleton.find_group_skeleton(item.group_name)
85
+ sol_idx_in_skeleton = self.skeleton.find_solution_skeleton_index(item.solution)
86
+ assert sol_idx_in_skeleton is not None
87
+ group = self.skeleton.find_group_skeleton(item.testcase_entry.group)
96
88
  assert group is not None
97
- tc = group.testcases[item.testcase_index]
89
+ tc = group.testcases[item.testcase_entry.index]
98
90
 
99
91
  textual.log(len(list(self.query(DataTable))), sol_idx_in_skeleton)
100
92
  table = self.query(DataTable)[sol_idx_in_skeleton]
@@ -1,5 +1,81 @@
1
+ from typing import Optional
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.reactive import reactive
1
5
  from textual.screen import Screen
6
+ from textual.widgets import Footer, Header, Label, ListItem, ListView
7
+
8
+ from rbx.box.solutions import SolutionReportSkeleton
9
+ from rbx.box.ui.screens.error import ErrorScreen
10
+ from rbx.box.ui.screens.run_test_explorer import RunTestExplorerScreen
11
+ from rbx.box.ui.screens.selector import SelectorScreen
12
+ from rbx.box.ui.utils.run_ui import get_skeleton, get_solution_markup, has_run
2
13
 
3
14
 
4
15
  class RunExplorerScreen(Screen):
5
- pass
16
+ BINDINGS = [('s', 'compare_with', 'Compare with')]
17
+
18
+ skeleton: reactive[Optional[SolutionReportSkeleton]] = reactive(
19
+ None, recompose=True
20
+ )
21
+
22
+ def compose(self) -> ComposeResult:
23
+ yield Header()
24
+ yield Footer()
25
+
26
+ items = []
27
+ if self.skeleton:
28
+ items = [
29
+ Label(get_solution_markup(self.skeleton, sol), markup=True)
30
+ for i, sol in enumerate(self.skeleton.solutions)
31
+ ]
32
+ run_list = ListView(*[ListItem(item) for item in items], id='run-list')
33
+ run_list.border_title = 'Runs'
34
+ yield run_list
35
+
36
+ def on_mount(self):
37
+ if not has_run():
38
+ self.app.switch_screen(ErrorScreen('No runs found. Run `rbx run` first.'))
39
+ return
40
+
41
+ self.skeleton = get_skeleton()
42
+
43
+ def on_list_view_selected(self, event: ListView.Selected):
44
+ selected_index = event.list_view.index
45
+ if selected_index is None:
46
+ return
47
+ if self.skeleton is None:
48
+ return
49
+ self.app.push_screen(
50
+ RunTestExplorerScreen(
51
+ self.skeleton, self.skeleton.solutions[selected_index]
52
+ )
53
+ )
54
+
55
+ def action_compare_with(self):
56
+ if self.skeleton is None:
57
+ return
58
+ list_view = self.query_one('#run-list', ListView)
59
+ if list_view.index is None:
60
+ return
61
+ test_solution = self.skeleton.solutions[list_view.index]
62
+
63
+ options = [
64
+ ListItem(Label(f'{sol.path}', markup=False))
65
+ for sol in self.skeleton.solutions
66
+ ]
67
+
68
+ def on_selected(index: Optional[int]):
69
+ if index is None:
70
+ return
71
+ if self.skeleton is None:
72
+ return
73
+ base_solution = self.skeleton.solutions[index]
74
+ self.app.push_screen(
75
+ RunTestExplorerScreen(self.skeleton, test_solution, base_solution)
76
+ )
77
+
78
+ self.app.push_screen(
79
+ SelectorScreen(options, title='Select a solution to compare against'),
80
+ callback=on_selected,
81
+ )
@@ -0,0 +1,155 @@
1
+ from typing import List, Optional
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Horizontal, Vertical
5
+ from textual.reactive import reactive
6
+ from textual.screen import Screen
7
+ from textual.widgets import Footer, Header, Label, ListItem, ListView
8
+
9
+ from rbx.box import package
10
+ from rbx.box.solutions import SolutionReportSkeleton, SolutionSkeleton
11
+ from rbx.box.testcase_extractors import (
12
+ GenerationTestcaseEntry,
13
+ extract_generation_testcases,
14
+ )
15
+ from rbx.box.ui.utils.run_ui import (
16
+ get_run_testcase_markup,
17
+ get_run_testcase_metadata_markup,
18
+ )
19
+ from rbx.box.ui.widgets.file_log import FileLog
20
+ from rbx.box.ui.widgets.test_output_box import TestcaseRenderingData
21
+ from rbx.box.ui.widgets.two_sided_test_output_box import TwoSidedTestBoxWidget
22
+
23
+
24
+ class RunTestExplorerScreen(Screen):
25
+ BINDINGS = [
26
+ ('q', 'app.pop_screen', 'Quit'),
27
+ ('1', 'show_output', 'Show output'),
28
+ ('2', 'show_stderr', 'Show stderr'),
29
+ ('3', 'show_log', 'Show log'),
30
+ ('m', 'toggle_metadata', 'Toggle metadata'),
31
+ ('s', 'toggle_side_by_side', 'Toggle sxs'),
32
+ ]
33
+
34
+ side_by_side: reactive[bool] = reactive(False)
35
+ diff_with_data: reactive[Optional[TestcaseRenderingData]] = reactive(
36
+ default=None,
37
+ )
38
+
39
+ def __init__(
40
+ self,
41
+ skeleton: SolutionReportSkeleton,
42
+ solution: SolutionSkeleton,
43
+ diff_solution: Optional[SolutionSkeleton] = None,
44
+ ):
45
+ super().__init__()
46
+ self.skeleton = skeleton
47
+ self.solution = solution
48
+ self.diff_solution = diff_solution
49
+ self.set_reactive(RunTestExplorerScreen.side_by_side, diff_solution is not None)
50
+
51
+ self._entries: List[GenerationTestcaseEntry] = []
52
+
53
+ def compose(self) -> ComposeResult:
54
+ yield Header()
55
+ yield Footer()
56
+ with Horizontal(id='test-explorer'):
57
+ with Vertical(id='test-list-container'):
58
+ yield ListView(id='test-list')
59
+ with Vertical(id='test-details'):
60
+ yield FileLog(id='test-input')
61
+ yield TwoSidedTestBoxWidget(id='test-output')
62
+
63
+ async def on_mount(self):
64
+ self.query_one('#test-list').border_title = 'Tests'
65
+ self.query_one('#test-input').border_title = 'Input'
66
+
67
+ await self._update_tests()
68
+
69
+ def _get_rendering_data(
70
+ self, solution: SolutionSkeleton, entry: GenerationTestcaseEntry
71
+ ) -> TestcaseRenderingData:
72
+ rendering_data = TestcaseRenderingData.from_one_path(
73
+ solution.get_entry_prefix(entry.group_entry)
74
+ )
75
+ rendering_data.rich_content = get_run_testcase_metadata_markup(
76
+ self.skeleton, solution, entry.group_entry
77
+ )
78
+ return rendering_data
79
+
80
+ def _update_selected_test(self, index: Optional[int]):
81
+ input = self.query_one('#test-input', FileLog)
82
+ output = self.query_one('#test-output', TwoSidedTestBoxWidget)
83
+
84
+ if index is None:
85
+ input.path = None
86
+ output.reset()
87
+ return
88
+ entry = self._entries[index]
89
+ input.path = entry.metadata.copied_to.inputPath
90
+ output.data = self._get_rendering_data(self.solution, entry)
91
+
92
+ if self.diff_solution is not None:
93
+ self.diff_with_data = self._get_rendering_data(self.diff_solution, entry)
94
+ else:
95
+ self.diff_with_data = TestcaseRenderingData.from_one_path(
96
+ entry.group_entry.get_prefix_path()
97
+ )
98
+
99
+ async def _update_tests(self):
100
+ self.watch(
101
+ self.query_one('#test-list', ListView),
102
+ 'index',
103
+ self._update_selected_test,
104
+ )
105
+
106
+ self._entries = await extract_generation_testcases(self.skeleton.entries)
107
+
108
+ test_markups = [
109
+ get_run_testcase_markup(self.solution, entry.group_entry)
110
+ for entry in self._entries
111
+ ]
112
+
113
+ await self.query_one('#test-list', ListView).clear()
114
+ await self.query_one('#test-list', ListView).extend(
115
+ [ListItem(Label(name, markup=True)) for name in test_markups]
116
+ )
117
+
118
+ def has_diffable_solution(self) -> bool:
119
+ return self.diff_solution is not None or package.get_main_solution() is not None
120
+
121
+ def action_show_output(self):
122
+ self.query_one('#test-output', TwoSidedTestBoxWidget).show_output()
123
+
124
+ def action_show_stderr(self):
125
+ self.query_one('#test-output', TwoSidedTestBoxWidget).show_stderr()
126
+
127
+ def action_show_log(self):
128
+ self.query_one('#test-output', TwoSidedTestBoxWidget).show_log()
129
+
130
+ def action_toggle_metadata(self):
131
+ self.query_one('#test-output', TwoSidedTestBoxWidget).toggle_metadata()
132
+
133
+ def action_toggle_side_by_side(self):
134
+ self.side_by_side = not self.side_by_side
135
+
136
+ def watch_side_by_side(self, side_by_side: bool):
137
+ widget = self.query_one('#test-output', TwoSidedTestBoxWidget)
138
+
139
+ if side_by_side:
140
+ if not self.has_diffable_solution():
141
+ self.app.notify(
142
+ 'Found no solution to compare against', severity='error'
143
+ )
144
+ return
145
+ widget.diff_with_data = self.diff_with_data
146
+ else:
147
+ widget.diff_with_data = None
148
+
149
+ def watch_diff_with_data(self, diff_with_data: Optional[TestcaseRenderingData]):
150
+ if not self.has_diffable_solution():
151
+ return
152
+ if not self.side_by_side:
153
+ return
154
+ widget = self.query_one('#test-output', TwoSidedTestBoxWidget)
155
+ widget.diff_with_data = diff_with_data
@@ -0,0 +1,26 @@
1
+ from typing import List, Optional
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.screen import ModalScreen
5
+ from textual.widgets import ListItem, ListView
6
+
7
+
8
+ class SelectorScreen(ModalScreen[int]):
9
+ BINDINGS = [('q', 'cancel', 'Cancel')]
10
+
11
+ def __init__(self, options: List[ListItem], title: Optional[str] = None):
12
+ super().__init__()
13
+ self.options = options
14
+ self.title = title
15
+
16
+ def compose(self) -> ComposeResult:
17
+ list_view = ListView(*self.options)
18
+ if self.title:
19
+ list_view.border_title = self.title
20
+ yield list_view
21
+
22
+ def on_list_view_selected(self, event: ListView.Selected):
23
+ self.dismiss(event.list_view.index)
24
+
25
+ def action_cancel(self):
26
+ self.dismiss(None)
@@ -11,12 +11,16 @@ from rbx.box.testcase_extractors import (
11
11
  )
12
12
  from rbx.box.ui.widgets.file_log import FileLog
13
13
  from rbx.box.ui.widgets.rich_log_box import RichLogBox
14
+ from rbx.box.ui.widgets.test_output_box import TestBoxWidget, TestcaseRenderingData
14
15
 
15
16
 
16
17
  class TestExplorerScreen(Screen):
17
18
  BINDINGS = [
18
19
  ('q', 'app.pop_screen', 'Quit'),
19
20
  ('m', 'toggle_metadata', 'Toggle metadata'),
21
+ ('1', 'show_output', 'Show output'),
22
+ ('2', 'show_stderr', 'Show stderr'),
23
+ ('3', 'show_log', 'Show log'),
20
24
  ]
21
25
 
22
26
  def __init__(self):
@@ -31,13 +35,12 @@ class TestExplorerScreen(Screen):
31
35
  yield ListView(id='test-list')
32
36
  with Vertical(id='test-details'):
33
37
  yield FileLog(id='test-input')
34
- yield FileLog(id='test-output')
38
+ yield TestBoxWidget(id='test-output')
35
39
  yield RichLogBox(id='test-metadata')
36
40
 
37
41
  async def on_mount(self):
38
42
  self.query_one('#test-list').border_title = 'Tests'
39
43
  self.query_one('#test-input').border_title = 'Input'
40
- self.query_one('#test-output').border_title = 'Output'
41
44
 
42
45
  metadata = self.query_one('#test-metadata', RichLogBox)
43
46
  metadata.display = False
@@ -53,17 +56,20 @@ class TestExplorerScreen(Screen):
53
56
 
54
57
  def _update_selected_test(self, index: Optional[int]):
55
58
  input = self.query_one('#test-input', FileLog)
56
- output = self.query_one('#test-output', FileLog)
59
+ output = self.query_one('#test-output', TestBoxWidget)
57
60
  metadata = self.query_one('#test-metadata', RichLog)
58
61
 
59
62
  if index is None:
60
63
  input.path = None
61
- output.path = None
64
+ output.reset()
62
65
  metadata.clear().write('No test selected')
63
66
  return
64
67
  entry = self._entries[index]
65
68
  input.path = entry.metadata.copied_to.inputPath
66
- output.path = entry.metadata.copied_to.outputPath
69
+
70
+ output.data = TestcaseRenderingData.from_one_path(
71
+ entry.metadata.copied_to.outputPath
72
+ )
67
73
 
68
74
  metadata.clear()
69
75
  metadata.write(
@@ -98,3 +104,12 @@ class TestExplorerScreen(Screen):
98
104
  await self.query_one('#test-list', ListView).extend(
99
105
  [ListItem(Label(name)) for name in test_names]
100
106
  )
107
+
108
+ def action_show_output(self):
109
+ self.query_one('#test-output', TestBoxWidget).show_output()
110
+
111
+ def action_show_stderr(self):
112
+ self.query_one('#test-output', TestBoxWidget).show_stderr()
113
+
114
+ def action_show_log(self):
115
+ self.query_one('#test-output', TestBoxWidget).show_log()
File without changes
@@ -0,0 +1,95 @@
1
+ import typing
2
+ from typing import List, Optional
3
+
4
+ from rbx import utils
5
+ from rbx.box import package, solutions
6
+ from rbx.box.solutions import SolutionReportSkeleton, SolutionSkeleton
7
+ from rbx.box.testcase_utils import TestcaseEntry
8
+ from rbx.grading.steps import Evaluation
9
+
10
+
11
+ def has_run() -> bool:
12
+ return (package.get_problem_runs_dir() / 'skeleton.yml').is_file()
13
+
14
+
15
+ def get_skeleton() -> SolutionReportSkeleton:
16
+ skeleton_path = package.get_problem_runs_dir() / 'skeleton.yml'
17
+ return utils.model_from_yaml(
18
+ SolutionReportSkeleton,
19
+ skeleton_path.read_text(),
20
+ )
21
+
22
+
23
+ def get_solution_eval(
24
+ solution: SolutionSkeleton, entry: TestcaseEntry
25
+ ) -> Optional[Evaluation]:
26
+ path = solution.get_entry_prefix(entry).with_suffix('.eval')
27
+ if not path.is_file():
28
+ return None
29
+ return utils.model_from_yaml(Evaluation, path.read_text())
30
+
31
+
32
+ def get_solution_evals(
33
+ skeleton: SolutionReportSkeleton, solution: SolutionSkeleton
34
+ ) -> List[Optional[Evaluation]]:
35
+ return [get_solution_eval(solution, entry) for entry in skeleton.entries]
36
+
37
+
38
+ def get_solution_evals_or_null(
39
+ skeleton: SolutionReportSkeleton, solution: SolutionSkeleton
40
+ ) -> Optional[List[Evaluation]]:
41
+ evals = get_solution_evals(skeleton, solution)
42
+ if any(e is None for e in evals):
43
+ return None
44
+ return typing.cast(List[Evaluation], evals)
45
+
46
+
47
+ def get_solution_markup(
48
+ skeleton: SolutionReportSkeleton, solution: SolutionSkeleton
49
+ ) -> str:
50
+ header = f'[b $accent]{solution.path}[/b $accent] ({solution.path})'
51
+
52
+ evals = get_solution_evals_or_null(skeleton, solution)
53
+ report = solutions.get_solution_outcome_report(
54
+ solution, evals or [], skeleton.verification, subset=False
55
+ )
56
+ if evals is None:
57
+ return header + '\n' + report.get_verdict_markup(incomplete=True)
58
+ return header + '\n' + report.get_outcome_markup()
59
+
60
+
61
+ def get_run_testcase_markup(solution: SolutionSkeleton, entry: TestcaseEntry) -> str:
62
+ eval = get_solution_eval(solution, entry)
63
+ if eval is None:
64
+ return f'{entry}'
65
+ testcase_markup = solutions.get_testcase_markup_verdict(eval)
66
+ return f'{testcase_markup} {entry}'
67
+
68
+
69
+ def _get_checker_msg_last_line(eval: Evaluation) -> Optional[str]:
70
+ if eval.result.message is None:
71
+ return None
72
+ return eval.result.message.rstrip().split('\n')[-1]
73
+
74
+
75
+ def get_run_testcase_metadata_markup(
76
+ skeleton: SolutionReportSkeleton, solution: SolutionSkeleton, entry: TestcaseEntry
77
+ ) -> Optional[str]:
78
+ eval = get_solution_eval(solution, entry)
79
+ if eval is None:
80
+ return None
81
+ time_str = solutions.get_capped_evals_formatted_time(
82
+ solution, [eval], skeleton.verification
83
+ )
84
+ memory_str = solutions.get_evals_formatted_memory([eval])
85
+
86
+ checker_msg = _get_checker_msg_last_line(eval)
87
+
88
+ lines = []
89
+ lines.append(
90
+ f'[b]{solutions.get_full_outcome_markup_verdict(eval.result.outcome)}[/b]'
91
+ )
92
+ lines.append(f'[b]Time:[/b] {time_str} / [b]Memory:[/b] {memory_str}')
93
+ if checker_msg is not None:
94
+ lines.append(f'[b]Checker:[/b] {checker_msg}')
95
+ return '\n'.join(lines)
File without changes
@@ -54,10 +54,12 @@ class FileLog(Widget, can_focus=False):
54
54
  log.clear()
55
55
 
56
56
  if path is None:
57
+ self.border_subtitle = '(no file selected)'
57
58
  return
58
59
 
59
60
  if not path.is_file():
60
- self.query_one(Log).write(f'File {path} does not exist')
61
+ path_str = str(path.relative_to(pathlib.Path.cwd()))
62
+ self.border_subtitle = f'{path_str} (does not exist)'
61
63
  return
62
64
 
63
65
  self._load_file(path)
@@ -0,0 +1,104 @@
1
+ import dataclasses
2
+ import pathlib
3
+ from typing import Optional
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Vertical
7
+ from textual.reactive import reactive
8
+ from textual.widget import Widget
9
+ from textual.widgets import ContentSwitcher
10
+
11
+ from rbx.box.ui.widgets.file_log import FileLog
12
+ from rbx.box.ui.widgets.rich_log_box import RichLogBox
13
+
14
+
15
+ @dataclasses.dataclass
16
+ class TestcaseRenderingData:
17
+ input_path: Optional[pathlib.Path] = None
18
+ output_path: Optional[pathlib.Path] = None
19
+ stderr_path: Optional[pathlib.Path] = None
20
+ log_path: Optional[pathlib.Path] = None
21
+ rich_content: Optional[str] = None
22
+
23
+ @classmethod
24
+ def from_one_path(cls, path: pathlib.Path) -> 'TestcaseRenderingData':
25
+ return cls(
26
+ input_path=path.with_suffix('.in'),
27
+ output_path=path.with_suffix('.out'),
28
+ stderr_path=path.with_suffix('.err'),
29
+ log_path=path.with_suffix('.log'),
30
+ )
31
+
32
+
33
+ class TestBoxWidget(Widget, can_focus=False):
34
+ data: reactive[TestcaseRenderingData] = reactive(
35
+ default=lambda: TestcaseRenderingData(),
36
+ bindings=True,
37
+ )
38
+
39
+ @dataclasses.dataclass
40
+ class Logs:
41
+ output: FileLog
42
+ stderr: FileLog
43
+ log: FileLog
44
+
45
+ def logs(self) -> Logs:
46
+ return self.Logs(
47
+ output=self.query_one('#test-box-output', FileLog),
48
+ stderr=self.query_one('#test-box-stderr', FileLog),
49
+ log=self.query_one('#test-box-log', FileLog),
50
+ )
51
+
52
+ def compose(self) -> ComposeResult:
53
+ with Vertical():
54
+ with ContentSwitcher(initial='test-box-output', id='test-box-switcher'):
55
+ yield FileLog(id='test-box-output')
56
+ yield FileLog(id='test-box-stderr')
57
+ yield FileLog(id='test-box-log')
58
+ yield RichLogBox(id='test-box-metadata')
59
+
60
+ def on_mount(self):
61
+ logs = self.logs()
62
+ logs.output.border_title = 'Output'
63
+ logs.stderr.border_title = 'Stderr'
64
+ logs.log.border_title = 'Log'
65
+
66
+ metadata = self.query_one('#test-box-metadata', RichLogBox)
67
+ metadata.display = False
68
+ metadata.border_title = 'Metadata'
69
+ metadata.wrap = True
70
+ metadata.markup = True
71
+ metadata.clear()
72
+ metadata.write('No metadata')
73
+
74
+ self.watch_data(self.data)
75
+
76
+ def watch_data(self, data: TestcaseRenderingData):
77
+ logs = self.logs()
78
+ logs.output.path = data.output_path
79
+ logs.stderr.path = data.stderr_path
80
+ logs.log.path = data.log_path
81
+
82
+ metadata = self.query_one('#test-box-metadata', RichLogBox)
83
+ metadata.clear()
84
+ if data.rich_content is not None:
85
+ metadata.write(data.rich_content)
86
+ else:
87
+ metadata.write('No metadata')
88
+
89
+ def reset(self):
90
+ self.data = TestcaseRenderingData()
91
+
92
+ def show_output(self):
93
+ self.query_one(ContentSwitcher).current = 'test-box-output'
94
+
95
+ def show_stderr(self):
96
+ self.query_one(ContentSwitcher).current = 'test-box-stderr'
97
+
98
+ def show_log(self):
99
+ self.query_one(ContentSwitcher).current = 'test-box-log'
100
+
101
+ def toggle_metadata(self):
102
+ metadata = self.query_one('#test-box-metadata', RichLogBox)
103
+ metadata.display = not metadata.display
104
+ metadata.refresh()