tuieval 0.2.0.dev105__tar.gz → 0.2.0.dev108__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/CHANGELOG.md +2 -0
  2. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/_version.py +2 -2
  4. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/tui.py +63 -14
  5. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/tests/test_tuieval.py +57 -1
  6. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/.gitignore +0 -0
  7. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/LICENSE +0 -0
  8. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/README.md +0 -0
  9. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/RELEASING.md +0 -0
  10. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/images/brand/README.md +0 -0
  11. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/images/brand/favicon.ico +0 -0
  12. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/images/brand/readme-header.png +0 -0
  13. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/images/brand/social-preview.png +0 -0
  14. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/images/brand/tuieval-icon-1024.png +0 -0
  15. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/images/brand/tuieval-icon-128.png +0 -0
  16. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/images/brand/tuieval-icon-16.png +0 -0
  17. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/images/brand/tuieval-icon-16.svg +0 -0
  18. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/images/brand/tuieval-icon-256.png +0 -0
  19. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/images/brand/tuieval-icon-32.png +0 -0
  20. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/images/brand/tuieval-icon-48.png +0 -0
  21. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/images/brand/tuieval-icon-512.png +0 -0
  22. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/images/brand/tuieval-icon-64.png +0 -0
  23. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  24. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/images/brand/tuieval-icon.svg +0 -0
  25. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/images/tui-answer-reasoning.png +0 -0
  26. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/images/tui-run-reasoning.png +0 -0
  27. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/images/tui-setup.png +0 -0
  28. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/models.md +0 -0
  29. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/docs/writing-packs.md +0 -0
  30. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/pyproject.toml +0 -0
  31. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/__init__.py +0 -0
  32. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/__main__.py +0 -0
  33. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/builtin.toml +0 -0
  34. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/cli.py +0 -0
  35. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/client.py +0 -0
  36. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/compare.py +0 -0
  37. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/doctor.py +0 -0
  38. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/engine.py +0 -0
  39. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/export.py +0 -0
  40. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/graders/__init__.py +0 -0
  41. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/graders/answer.py +0 -0
  42. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/graders/code.py +0 -0
  43. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/graders/rag.py +0 -0
  44. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/graders/reply.py +0 -0
  45. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/graders/tool_call.py +0 -0
  46. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/insights.py +0 -0
  47. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/logs.py +0 -0
  48. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/machines.py +0 -0
  49. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/onboard.py +0 -0
  50. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/packs.py +0 -0
  51. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/profiles.py +0 -0
  52. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/pta.py +0 -0
  53. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/remove.py +0 -0
  54. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/run_evals.py +0 -0
  55. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/scaffold.py +0 -0
  56. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/selftest.py +0 -0
  57. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/templates/models.toml +0 -0
  58. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  59. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/templates/packs/answer/system.txt +0 -0
  60. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  61. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/templates/packs/code/pack.toml +0 -0
  62. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/templates/packs/code/system.txt +0 -0
  63. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  65. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/templates/packs/rag/system.txt +0 -0
  66. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  68. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/templates/packs/reply/system.txt +0 -0
  69. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  71. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  72. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  74. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/tune.py +0 -0
  75. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/verdict.py +0 -0
  76. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/watch_proxy.py +0 -0
  77. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/workspace.py +0 -0
  78. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/src/tuieval/yamlout.py +0 -0
  79. {tuieval-0.2.0.dev105 → tuieval-0.2.0.dev108}/tests/mock_server.py +0 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - The Models heading shows how many are selected, naming any the filter hides; Start asks first when some are hidden.
6
+ - Results → Failures: a Models button filters the list (the same pick as the other tabs), with the number of failures shown.
5
7
  - Fix: an answer followed by a stray `</think>` (or a stray code fence after it) was graded as empty; the answer before the tag is graded now. The code grader also reads a doubled ```` ```python ```` fence as one.
6
8
  - Run screen: `x` removes the highlighted pack from the run going if it hasn't started (the reverse of adding it); a model left with nothing isn't started.
7
9
  - `k` (skip model) asks first, naming the model. Yes/no dialogs start on No; `y` answers yes, `n` or Esc no.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev105
3
+ Version: 0.2.0.dev108
4
4
  Summary: Evaluate local and frontier LLMs on your own questions: accuracy, speed, tokens and PASS/FAIL verdicts, in the terminal.
5
5
  Project-URL: Homepage, https://github.com/ashe-wb/tuieval
6
6
  Project-URL: Issues, https://github.com/ashe-wb/tuieval/issues
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.0.dev105'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev105')
21
+ __version__ = version = '0.2.0.dev108'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev108')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -454,7 +454,8 @@ with the model's reasoning and answer as they stream, then recent results with t
454
454
  Results open here.
455
455
  Production readiness one verdict per model and use case; the table says why and what to run next
456
456
  Per question every question, model by model (filters: pack, level, Models disagree)
457
- Failures every wrong answer with the grader's reason; enter opens it in full
457
+ Failures every wrong answer with the grader's reason (filter: Models); enter opens it
458
+ in full
458
459
 
459
460
  Click a column name to sort a table by it; click it again to reverse. Empty cells stay last.
460
461
 
@@ -775,10 +776,7 @@ class SetupScreen(Screen):
775
776
  self.verdicts = {}
776
777
  self.app.log(f"verdicts failed: {err!r}")
777
778
  codes = group_codes(e)
778
- self.query_one("#models-legend", Label).update(
779
- "[b]Models[/b] [dim][green]✓[/green] pass [red]✗[/red] fail [yellow]?[/yellow] inconclusive "
780
- "grey = earlier · x hides[/dim]\n[dim]"
781
- + " ".join(f"{c} {g}" for g, c in codes.items()) + "[/dim]")
779
+ self.legend_codes = codes
782
780
  n_hidden = sum(m["label"] in hidden for m in e.cfg["models"])
783
781
  self.query_one("#show-hidden", Checkbox).label = f"Show hidden ({n_hidden})"
784
782
  sections = {}
@@ -818,8 +816,27 @@ class SetupScreen(Screen):
818
816
  at = values.index(keep) if keep in values else min(keep_at or 0, models.option_count - 1)
819
817
  models.highlighted = min((i for i in enabled if i >= at), default=enabled[-1])
820
818
  self.show_detail()
819
+ self.update_legend()
821
820
  self.update_estimate()
822
821
 
822
+ def unseen_models(self):
823
+ """Ticked models the list doesn't show right now (the filter leaves them out)."""
824
+ models = self.query_one("#models", SelectionList)
825
+ visible = {models.get_option_at_index(i).value for i in range(models.option_count)}
826
+ return [m["label"] for m in self.app.engine.cfg["models"]
827
+ if m["label"] in self.selected_models and m["label"] not in visible]
828
+
829
+ def update_legend(self):
830
+ """The Models heading: how many are ticked (and which ones the filter hides), then the key."""
831
+ n, unseen = len(self.selected_models), self.unseen_models()
832
+ count = f"[b]{n}[/b] selected" if n else "[dim]none selected[/dim]"
833
+ if unseen:
834
+ count += f" [yellow]({len(unseen)} not shown: {rich_escape(', '.join(unseen))})[/yellow]"
835
+ self.query_one("#models-legend", Label).update(
836
+ f"[b]Models[/b] {count} [dim][green]✓[/green] pass [red]✗[/red] fail [yellow]?[/yellow] "
837
+ "inconclusive grey = earlier · x hides[/dim]\n[dim]"
838
+ + " ".join(f"{c} {g}" for g, c in getattr(self, "legend_codes", {}).items()) + "[/dim]")
839
+
823
840
  @on(SelectionList.SelectionHighlighted, "#models")
824
841
  def show_detail(self, *_):
825
842
  """Everything about the highlighted model: verdicts in full, serving, per-pack results."""
@@ -893,6 +910,7 @@ class SetupScreen(Screen):
893
910
  return
894
911
  visible = {models.get_option_at_index(i).value for i in range(models.option_count)}
895
912
  self.selected_models = (self.selected_models - visible) | set(models.selected)
913
+ self.update_legend()
896
914
  self.update_estimate()
897
915
 
898
916
  def tier(self):
@@ -1109,10 +1127,20 @@ class SetupScreen(Screen):
1109
1127
  self.notify(f"Nothing to run: {item.problem}.", severity="warning")
1110
1128
  return
1111
1129
  self.app.start_or_queue(item)
1112
- if rows:
1113
- self.app.push_screen(RetestScreen(rows, force, rerun), go)
1130
+ def asked(yes=True):
1131
+ if not yes:
1132
+ return
1133
+ if rows:
1134
+ self.app.push_screen(RetestScreen(rows, force, rerun), go)
1135
+ else:
1136
+ go({})
1137
+ unseen = self.unseen_models()
1138
+ if unseen: # ticks the filter hides are easy to forget: name them before anything starts
1139
+ self.app.push_screen(ConfirmScreen(
1140
+ f"Start {len(labels)} model(s)? {len(unseen)} of them the filter hides:\n "
1141
+ + rich_escape("\n ".join(unseen) + f"\n\nAll selected: {', '.join(labels)}")), asked)
1114
1142
  else:
1115
- go({})
1143
+ asked()
1116
1144
 
1117
1145
  def action_tune(self):
1118
1146
  e = self.app.engine
@@ -2825,6 +2853,9 @@ class ResultsScreen(Screen):
2825
2853
  yield Static(id="pq-legend")
2826
2854
  yield DataTable(id="per-question", zebra_stripes=True, cursor_type="cell")
2827
2855
  with TabPane("Failures", id="tab-failures"):
2856
+ with Horizontal(id="fail-controls"):
2857
+ yield Button("Models: all", id="fail-models")
2858
+ yield Static(id="fail-count")
2828
2859
  yield Static("[dim]Enter on a row shows the whole answer, its reasoning and what was checked.[/dim]")
2829
2860
  yield DataTable(id="failures", zebra_stripes=True, cursor_type="row")
2830
2861
  yield Footer()
@@ -2902,12 +2933,7 @@ class ResultsScreen(Screen):
2902
2933
  info.update(f"{len(paths)} {'Smoke ' if smoke else ''}result files in {os.path.relpath(results_dir)}/" + banner
2903
2934
  + "".join(f"\n[yellow]⚠ {n}[/yellow]" for n in notes))
2904
2935
  self.rows, self.infos = rows, infos
2905
- f = self.query_one("#failures", DataTable)
2906
- f.add_columns("Model", "Level", "Test", "Reason")
2907
- # compare.failures lists the failed rows in order, so they line up with fail_rows
2908
- self.fail_rows = [r for r in rows if not r["ok"]]
2909
- for i, (model, test, reason, level) in enumerate(compare.failures(rows)):
2910
- f.add_row(model, diff_badge(level), rich_escape(test), rich_escape(reason[:130]), key=str(i))
2936
+ self.fill_failures()
2911
2937
  self.fill_per_question()
2912
2938
  self.fill_pta()
2913
2939
  self.fill_readiness(results_dir)
@@ -2916,6 +2942,24 @@ class ResultsScreen(Screen):
2916
2942
  self.fill_insights()
2917
2943
  self.sort_tables()
2918
2944
 
2945
+ def fill_failures(self):
2946
+ """Every wrong answer, of the models picked (Models: shared with the other tabs)."""
2947
+ f = self.query_one("#failures", DataTable)
2948
+ f.clear(columns=True)
2949
+ f.add_columns("Model", "Level", "Test", "Reason")
2950
+ every = {r["model"] for r in self.rows}
2951
+ chosen = {m for m in self.app.pq_models if m in every}
2952
+ rows = [r for r in self.rows if not chosen or r["model"] in chosen]
2953
+ # compare.failures lists the failed rows in order, so they line up with fail_rows
2954
+ self.fail_rows = [r for r in rows if not r["ok"]]
2955
+ for i, (model, test, reason, level) in enumerate(compare.failures(rows)):
2956
+ f.add_row(model, diff_badge(level), rich_escape(test), rich_escape(reason[:130]), key=str(i))
2957
+ self.query_one("#fail-models", Button).label = f"Models: {len(chosen)} picked" if chosen else "Models: all"
2958
+ n = len(self.fail_rows)
2959
+ self.query_one("#fail-count", Static).update(
2960
+ f"{n} failure{'s' * (n != 1)}" + (f" of {len(chosen)} model{'s' * (len(chosen) != 1)}" if chosen else ""))
2961
+ self.sort_table(f)
2962
+
2919
2963
  @on(DataTable.RowSelected, "#failures")
2920
2964
  def failure_selected(self, event):
2921
2965
  # in the order shown, so [ and ] step through them as sorted
@@ -2979,6 +3023,7 @@ class ResultsScreen(Screen):
2979
3023
  @on(Button.Pressed, "#pq-models")
2980
3024
  @on(Button.Pressed, "#pta-models")
2981
3025
  @on(Button.Pressed, "#ins-models")
3026
+ @on(Button.Pressed, "#fail-models")
2982
3027
  def pick_models(self):
2983
3028
  counts = {}
2984
3029
  for t in getattr(self, "pq_all_tests", []):
@@ -2997,6 +3042,7 @@ class ResultsScreen(Screen):
2997
3042
  # after the picker has closed: a table rebuilt under a modal keeps its header-only widths
2998
3043
  self.call_after_refresh(self.fill_per_question)
2999
3044
  self.call_after_refresh(self.fill_pta)
3045
+ self.call_after_refresh(self.fill_failures)
3000
3046
  self.insights_stale = True
3001
3047
  if self.query_one(TabbedContent).active == "tab-insights":
3002
3048
  self.call_after_refresh(self.fill_insights)
@@ -3397,6 +3443,9 @@ class EvalsApp(App):
3397
3443
  #pq-controls Checkbox { width: auto; }
3398
3444
  #pq-controls Select { width: 22; margin-right: 1; }
3399
3445
  #pq-models { margin-right: 1; min-width: 20; }
3446
+ #fail-controls { height: 3; }
3447
+ #fail-models { margin-right: 1; min-width: 20; }
3448
+ #fail-count { width: auto; padding: 1 1; }
3400
3449
  #pq-compare-title { padding: 0 1; height: auto; }
3401
3450
  #pq-summary { height: auto; } /* every picked model, no scrolling: the tab scrolls instead */
3402
3451
  #pq-legend { padding: 0 1; height: auto; }
@@ -1130,6 +1130,47 @@ class AddToRun(unittest.TestCase):
1130
1130
  self.assertEqual(fewer, 4) # its 4 questions are no longer counted
1131
1131
  self.assertEqual(out["running"], [False, True])
1132
1132
 
1133
+ def test_tui_counts_ticked_models_and_names_filtered_ones_before_start(self):
1134
+ code = textwrap.dedent("""
1135
+ import asyncio, json
1136
+ from tuieval.tui import EvalsApp, ConfirmScreen
1137
+ from textual.widgets import SelectionList, Input, Label
1138
+ async def go():
1139
+ app = EvalsApp({})
1140
+ async with app.run_test(size=(160, 50)) as pilot:
1141
+ await pilot.pause()
1142
+ s, out = app.screen, {}
1143
+ legend = lambda: str(s.query_one("#models-legend", Label).render())
1144
+ out["none"] = legend()
1145
+ s.query_one("#suites", SelectionList).select("apps")
1146
+ models = s.query_one("#models", SelectionList)
1147
+ models.select("a")
1148
+ models.select("b")
1149
+ await pilot.pause()
1150
+ out["two"] = legend()
1151
+ s.query_one("#filter", Input).value = "zzz-nothing"
1152
+ await pilot.pause()
1153
+ out["filtered"] = legend()
1154
+ s.action_start()
1155
+ await pilot.pause()
1156
+ out["asked"] = isinstance(app.screen, ConfirmScreen) and str(app.screen.question)
1157
+ await pilot.press("n")
1158
+ await pilot.pause()
1159
+ out["cancelled"] = app.screen is s and not app.is_busy()
1160
+ print(json.dumps(out))
1161
+ asyncio.run(go())
1162
+ """)
1163
+ p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=600,
1164
+ env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""))
1165
+ out = json.loads(p.stdout.strip().splitlines()[-1]) if p.stdout.strip() else p.stderr
1166
+ self.assertIsInstance(out, dict, out)
1167
+ self.assertIn("none selected", out["none"])
1168
+ self.assertIn("2 selected", out["two"])
1169
+ self.assertNotIn("not shown", out["two"])
1170
+ self.assertIn("2 not shown: a, b", out["filtered"])
1171
+ self.assertIn("All selected: a, b", out["asked"])
1172
+ self.assertTrue(out["cancelled"])
1173
+
1133
1174
  def test_tui_asks_before_skipping_a_model(self):
1134
1175
  path = os.path.join(self.ws, "models.toml")
1135
1176
  write(path, read(path).replace('"--delay", "0.2"', '"--delay", "3"')) # still running while it's asked
@@ -1571,6 +1612,17 @@ class PTAIndex(unittest.TestCase):
1571
1612
  app.screen.query_one(TabbedContent).active = "tab-failures"
1572
1613
  await pilot.pause(0.3)
1573
1614
  f = app.screen.query_one("#failures", DataTable)
1615
+ count = lambda: str(app.screen.query_one("#fail-count", Static).render())
1616
+ fails = {"all": [f.row_count, count()]}
1617
+ for m in ("good", "hosted"): # Models: only that model's failures
1618
+ app.pq_models = {m}
1619
+ app.screen.fill_failures()
1620
+ await pilot.pause(0.2)
1621
+ fails[m] = [f.row_count, count(), str(app.screen.query_one("#fail-models").label),
1622
+ sorted({str(f.get_row_at(r)[0]) for r in range(f.row_count)})]
1623
+ app.pq_models = set()
1624
+ app.screen.fill_failures()
1625
+ await pilot.pause(0.2)
1574
1626
  await click_header(f, "Test")
1575
1627
  sorts["tests"], _ = await click_header(f, "Test")
1576
1628
  sorts["tests"] = [str(f.get_row_at(r)[2]) for r in range(f.row_count)]
@@ -1581,7 +1633,7 @@ class PTAIndex(unittest.TestCase):
1581
1633
  s = app.screen
1582
1634
  sorts["opened"] = s.entries[s.index]["record"]["test"]
1583
1635
  print(json.dumps({"rows": rows, "bars": "P parsimony" in tri, "disagree": [both, alone],
1584
- "tab": tab, "sorts": sorts}))
1636
+ "tab": tab, "sorts": sorts, "fails": fails}))
1585
1637
  asyncio.run(go())
1586
1638
  """)
1587
1639
  p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=300,
@@ -1596,6 +1648,10 @@ class PTAIndex(unittest.TestCase):
1596
1648
  self.assertEqual((s["up"], s["down"], s["kept"]), (["hosted", "good"], ["good", "hosted"], ["good", "hosted"]))
1597
1649
  self.assertIn("answers right ▼", s["labels"])
1598
1650
  self.assertEqual((s["tests"][0], s["opened"]), ("apps: d", "d"))
1651
+ fails = got["fails"]
1652
+ self.assertEqual(fails["all"], [4, "4 failures"])
1653
+ self.assertEqual(fails["good"], [0, "0 failures of 1 model", "Models: 1 picked", []])
1654
+ self.assertEqual(fails["hosted"], [4, "4 failures of 1 model", "Models: 1 picked", ["hosted"]])
1599
1655
 
1600
1656
 
1601
1657
  class Unavailable(unittest.TestCase):
File without changes
File without changes