tuieval 0.2.0.dev135__tar.gz → 0.2.0.dev137__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 (82) hide show
  1. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/CHANGELOG.md +1 -1
  2. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/_version.py +2 -2
  4. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/tui.py +24 -5
  5. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/tests/test_tuieval.py +49 -6
  6. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/.gitignore +0 -0
  7. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/LICENSE +0 -0
  8. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/README.md +0 -0
  9. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/RELEASING.md +0 -0
  10. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/agent-packs.md +0 -0
  11. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/images/brand/README.md +0 -0
  12. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/images/brand/favicon.ico +0 -0
  13. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/images/brand/readme-header.png +0 -0
  14. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/images/brand/social-preview.png +0 -0
  15. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-1024.png +0 -0
  16. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-128.png +0 -0
  17. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-16.png +0 -0
  18. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-16.svg +0 -0
  19. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-256.png +0 -0
  20. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-32.png +0 -0
  21. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-48.png +0 -0
  22. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-512.png +0 -0
  23. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-64.png +0 -0
  24. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  25. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon.svg +0 -0
  26. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/images/tui-answer-reasoning.png +0 -0
  27. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/images/tui-run-reasoning.png +0 -0
  28. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/images/tui-setup.png +0 -0
  29. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/models.md +0 -0
  30. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/docs/writing-packs.md +0 -0
  31. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/pyproject.toml +0 -0
  32. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/__init__.py +0 -0
  33. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/__main__.py +0 -0
  34. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/agents.py +0 -0
  35. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/builtin.toml +0 -0
  36. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/cli.py +0 -0
  37. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/client.py +0 -0
  38. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/compare.py +0 -0
  39. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/dates.py +0 -0
  40. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/doctor.py +0 -0
  41. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/engine.py +0 -0
  42. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/export.py +0 -0
  43. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/graders/__init__.py +0 -0
  44. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/graders/answer.py +0 -0
  45. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/graders/code.py +0 -0
  46. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/graders/rag.py +0 -0
  47. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/graders/reply.py +0 -0
  48. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/graders/tool_call.py +0 -0
  49. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/insights.py +0 -0
  50. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/logs.py +0 -0
  51. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/machines.py +0 -0
  52. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/onboard.py +0 -0
  53. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/packs.py +0 -0
  54. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/profiles.py +0 -0
  55. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/pta.py +0 -0
  56. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/remove.py +0 -0
  57. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/run_evals.py +0 -0
  58. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/scaffold.py +0 -0
  59. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/selftest.py +0 -0
  60. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/templates/models.toml +0 -0
  61. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  62. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/answer/system.txt +0 -0
  63. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/code/pack.toml +0 -0
  65. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/code/system.txt +0 -0
  66. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  68. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/rag/system.txt +0 -0
  69. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  71. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/reply/system.txt +0 -0
  72. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  74. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  75. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  76. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  77. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/tune.py +0 -0
  78. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/verdict.py +0 -0
  79. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/watch_proxy.py +0 -0
  80. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/workspace.py +0 -0
  81. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/src/tuieval/yamlout.py +0 -0
  82. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev137}/tests/mock_server.py +0 -0
@@ -3,7 +3,7 @@
3
3
  ## Unreleased
4
4
 
5
5
  - Answers stuck in a loop (one short unit repeated over 2000 chars, in the reasoning or the answer) are stopped as they stream and failed as `LOOP`, instead of running to max_tokens. `[defaults] loop_stop_chars` sets the length; 0 turns it off.
6
- - Repeats: the PTA index, `tuieval compare` and Setup show how many times each model answered each question (`×3`, `×1-3`), so single-run results stand out.
6
+ - Repeats: the PTA index, Per question, `tuieval compare` and Setup show how many times each model answered each question (`×3`, `×1-3`), so single-run results stand out.
7
7
  - Partial credit: the PTA index shows each model's score (e.g. 5 of 6 checks passed is 83%) beside accuracy, and can be ordered by it (By score, `tuieval pta --sort score`). Why models fail splits failures into near miss, partly right and wrong; Failures shows each one's kind and checks and filters by kind; Per question cells show `✗ 5/6`. Verdicts are unchanged.
8
8
  - Results: picking a pack (PTA index or Per question) compares the models that answered all of it; the Models button can change that, and All packs goes back to the earlier pick.
9
9
  - Fix: a request that runs out of time says `time limit: timed out after N s`, not `connection error`, and `tuieval compare` counts it against the model like the run does (it was left out as an error).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev135
3
+ Version: 0.2.0.dev137
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.dev135'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev135')
21
+ __version__ = version = '0.2.0.dev137'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev137')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -431,6 +431,15 @@ def trim_names(names, width):
431
431
  return trimmed if len(set(trimmed)) == len(set(names)) else list(names)
432
432
 
433
433
 
434
+ def repeats_tag(counts):
435
+ """A model's answers per question as markup, as the PTA index shows them: "×3", or "×1-3" when it varies;
436
+ yellow when it's ×1 (one answer decides each result, the roughest), else dim. "" for no answers."""
437
+ if not counts:
438
+ return ""
439
+ few, most = min(counts), max(counts)
440
+ return f"[{'yellow' if most == 1 else 'dim'}]×{few}{'' if few == most else f'-{most}'}[/]"
441
+
442
+
434
443
  def fit_first_column(header, rows, room, floor=24):
435
444
  """rows with the first cell trimmed in the middle (mid_trim) so a table of these columns fits room
436
445
  cells across, but never below floor cells. Cells are Rich markup; each column adds 2 cells of padding."""
@@ -3136,7 +3145,10 @@ class ResultsScreen(Screen):
3136
3145
  base = column_name(col.label)
3137
3146
  label = base + SORT_ARROWS[desc] if base == name else base
3138
3147
  if col.label.plain != label:
3139
- col.label = Text(label)
3148
+ text = col.label.copy() # keeps its styling, e.g. a model's ×n
3149
+ if len(text.plain) > len(base): # right_crop(0) would empty it
3150
+ text.right_crop(len(text.plain) - len(base))
3151
+ col.label = text + SORT_ARROWS[desc] if base == name else text
3140
3152
  w = cell_len(label) # room for the arrow (a column keeps the width it was added with)
3141
3153
  col.content_width = max(col.content_width, w)
3142
3154
  if not col.auto_width:
@@ -3404,7 +3416,9 @@ class ResultsScreen(Screen):
3404
3416
  here = self.app.engine.machine().id
3405
3417
  table = self.query_one("#per-question", DataTable)
3406
3418
  table.clear(columns=True)
3407
- table.add_columns(*self.PQ_FIXED, *models)
3419
+ table.add_columns(*self.PQ_FIXED, *(Text.from_markup(
3420
+ f"{rich_escape(m)} {repeats_tag([t['cells'][m]['n'] for t in tests if m in t['cells']])}".rstrip())
3421
+ for m in models))
3408
3422
  self.pq_tests, self.pq_models = {}, models
3409
3423
  for t in tests:
3410
3424
  cells = []
@@ -3427,7 +3441,8 @@ class ResultsScreen(Screen):
3427
3441
  "[green]bold[/green] = fewest tokens among passing answers · [red]cut[/red] = hit max_tokens · "
3428
3442
  "[yellow]over[/yellow] = ran past max_tokens (the provider didn't stop it) · "
3429
3443
  "◐ = pack unfinished (not in scores) · dim seconds = hosted, or measured on another machine; tokens compare "
3430
- "across machines, seconds don't. ×tokens = most / fewest tokens across models. Enter on a cell opens the "
3444
+ "across machines, seconds don't. ×tokens = most / fewest tokens across models. ×n beside a model: answers "
3445
+ "per question (×1: one answer decides each result, the roughest). Enter on a cell opens the "
3431
3446
  "answers. Click a column name to sort by it (a model's column: by its tokens).[/dim]" + ("\n[yellow]Left out, being rerun with other questions or settings: " + ", ".join(
3432
3447
  f"{os.path.basename(os.path.dirname(p))} · {os.path.basename(p)[:-5]}" for p in sorted(self.superseded))
3433
3448
  + ". Only the new run's answers (◐) are shown for these.[/yellow]" if self.superseded else ""))
@@ -3469,6 +3484,7 @@ class ResultsScreen(Screen):
3469
3484
  def hi(v, text, key, good):
3470
3485
  ref = best.get(key) if good == "max" else low.get(key)
3471
3486
  return f"[bold green]{text}[/bold green]" if v is not None and v == ref and len(rows) > 1 else text
3487
+ tags = {m: repeats_tag([t["cells"][m]["n"] for t in shared]) for m in models}
3472
3488
  cells = []
3473
3489
  for r in rows:
3474
3490
  tt = r["total_secs"]
@@ -3489,7 +3505,10 @@ class ResultsScreen(Screen):
3489
3505
  (f"[red]{r['cut']}[/red]" if r["cut"] else "0")
3490
3506
  + (f" [yellow]+{r['over']} over[/yellow]" if r.get("over") else "")])
3491
3507
  table.add_columns(*header)
3492
- table.add_rows(fit_first_column(header, cells, (self.size.width or self.app.size.width) - 3))
3508
+ tag_w = max(Text.from_markup(tag).cell_len + 1 for tag in tags.values())
3509
+ cells = fit_first_column(header, cells, (self.size.width or self.app.size.width) - 3 - tag_w,
3510
+ floor=24 - tag_w) # the column's floor counts the ×n too
3511
+ table.add_rows([[f"{rich_escape(c[0])} {tags[r['model']]}"] + c[1:] for c, r in zip(cells, rows)])
3493
3512
  line = f"[b]Comparison[/b] over the {len(shared)} questions all {len(models)} answered"
3494
3513
  if len(models) == 2:
3495
3514
  h = compare.head_to_head(shared, *models)
@@ -3506,7 +3525,7 @@ class ResultsScreen(Screen):
3506
3525
  title.update(line + " [dim]Time / Tokens: each question's median, added up. Med tok / Med s: median "
3507
3526
  "per answer. Tok/✓: tokens per answer right. Prompt t/s / Gen t/s: all tokens over all the time "
3508
3527
  "they took (cached prompts left out). Passed: questions right on every repeat, then the score (partial credit, e.g. checks passed) when some failed. Fewest / Fastest: questions won; ties count for both. "
3509
- "Cut off: answers that hit max_tokens.[/dim]")
3528
+ "Cut off: answers that hit max_tokens. ×n beside a name: answers per question.[/dim]")
3510
3529
 
3511
3530
  @staticmethod
3512
3531
  def pq_cell(c, best, here):
@@ -1739,7 +1739,7 @@ class PTAIndex(unittest.TestCase):
1739
1739
  print(t.row_count, str(s.query_one("#fail-count", Static).render()))
1740
1740
  s.fill_per_question()
1741
1741
  pq = s.query_one("#per-question", DataTable)
1742
- cols = [str(c.label) for c in pq.ordered_columns]
1742
+ cols = [str(c.label).split(" ×")[0] for c in pq.ordered_columns]
1743
1743
  print(sorted(str(pq.get_row_at(i)[cols.index("close")]).split()[1] for i in range(pq.row_count)))
1744
1744
  asyncio.run(go())
1745
1745
  """)
@@ -1801,7 +1801,7 @@ class PTAIndex(unittest.TestCase):
1801
1801
  s = app.screen
1802
1802
  def state():
1803
1803
  pq = s.query_one("#per-question", DataTable)
1804
- cols = [str(c.label) for c in pq.ordered_columns][len(s.PQ_FIXED):]
1804
+ cols = [str(c.label).split(" ×")[0] for c in pq.ordered_columns][len(s.PQ_FIXED):]
1805
1805
  return (sorted(app.pq_models), s.query_one("#pta-pack", Select).value,
1806
1806
  str(s.query_one("#pq-models", Button).label), cols, s.query_one("#pq-shared").value)
1807
1807
  s.query_one("#pq-pack", Select).value = "apps"
@@ -1955,7 +1955,7 @@ class PTAIndex(unittest.TestCase):
1955
1955
  pq.move_cursor(row=0, column=col)
1956
1956
  await pilot.press("enter")
1957
1957
  await pilot.pause(0.3)
1958
- opened[str(pq.ordered_columns[col].label.plain)] = app.screen.entries[0]["model"]
1958
+ opened[pq.ordered_columns[col].label.plain.split(" ×")[0]] = app.screen.entries[0]["model"]
1959
1959
  app.screen.dismiss()
1960
1960
  await pilot.pause(0.2)
1961
1961
  print(json.dumps({"rows": rows, "bars": "P parsimony" in tri, "disagree": [both, alone], "opened": opened,
@@ -2271,6 +2271,7 @@ class FirstRun(unittest.TestCase):
2271
2271
  tuieval(self.ws, "add", "my-model", "--server", "local")
2272
2272
  code = textwrap.dedent("""
2273
2273
  import asyncio
2274
+ from rich.text import Text
2274
2275
  from tuieval import compare
2275
2276
  from tuieval.tui import EvalsApp, ResultsScreen
2276
2277
  from textual.widgets import DataTable, TabbedContent
@@ -2288,14 +2289,56 @@ class FirstRun(unittest.TestCase):
2288
2289
  app.screen.fill_pq_summary(compare.per_question(rows), models)
2289
2290
  await pilot.pause(0.3)
2290
2291
  t = app.screen.query_one("#pq-summary", DataTable)
2291
- names = [str(t.get_row_at(i)[0]) for i in range(t.row_count)]
2292
+ names = [Text.from_markup(t.get_row_at(i)[0]).plain for i in range(t.row_count)]
2292
2293
  print(t.row_count, t.region.height, t.virtual_size.width <= t.region.width,
2293
- all(n.endswith("-q4_k_m-vllm") and "…" in n for n in names), len(set(names)))
2294
+ all(n.endswith("-q4_k_m-vllm ×1") and "…" in n for n in names), len(set(names)))
2294
2295
  asyncio.run(go())
2295
2296
  """)
2296
2297
  out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
2297
2298
  env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""), timeout=60)
2298
- self.assertEqual(out.stdout.strip(), "12 13 True True 12", out.stderr[-2000:]) # every row, no scroll
2299
+ # every row, no scroll; a trimmed name keeps its ×n
2300
+ self.assertEqual(out.stdout.strip(), "12 13 True True 12", out.stderr[-2000:])
2301
+
2302
+ def test_per_question_shows_repeats(self):
2303
+ tuieval(self.ws, "add", "my-model", "--server", "local")
2304
+ code = textwrap.dedent("""
2305
+ import asyncio
2306
+ from tuieval.tui import EvalsApp, ResultsScreen
2307
+ from textual.widgets import DataTable, TabbedContent
2308
+ rows = [{"model": m, "suite": "p", "test": f"p: q{q}", "ok": True, "latency": 1000, "tokens": 10,
2309
+ "truncated": False, "gen_tps": 20.0, "prompt_tps": None, "repeat": r, "score": 1.0}
2310
+ for m, reps in (("once", 1), ("thrice", 3)) for q in range(2) for r in range(reps)]
2311
+ rows += [dict(rows[-1], test="p: q2", repeat=0)] # one more question, answered once
2312
+ async def go():
2313
+ app = EvalsApp({})
2314
+ async with app.run_test(size=(160, 40)) as pilot:
2315
+ await pilot.pause()
2316
+ app.push_screen(ResultsScreen())
2317
+ await pilot.pause(0.5)
2318
+ s = app.screen
2319
+ s.rows, s.partial_rows, s.superseded = rows, [], set()
2320
+ s.query_one(TabbedContent).active = "tab-per-question"
2321
+ await pilot.pause(0.3)
2322
+ s.fill_per_question()
2323
+ await pilot.pause(0.3)
2324
+ pq = s.query_one("#per-question", DataTable)
2325
+ heads = [c.label for c in pq.ordered_columns][len(s.PQ_FIXED):]
2326
+ print([h.plain for h in heads], [str(h.spans[0].style) for h in heads])
2327
+ app.result_sorts["per-question"] = ("thrice ×1-3", True) # as a click on its name does
2328
+ s.sort_table(pq)
2329
+ head = pq.ordered_columns[-1].label
2330
+ print(head.plain, str(head.spans[0].style))
2331
+ t = s.query_one("#pq-summary", DataTable)
2332
+ print([t.get_row_at(i)[0] for i in range(t.row_count)])
2333
+ asyncio.run(go())
2334
+ """)
2335
+ out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
2336
+ env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""), timeout=60)
2337
+ self.assertEqual(out.stdout.strip().splitlines(), [
2338
+ "['once ×1', 'thrice ×1-3'] ['yellow', 'dim']", # over the questions shown
2339
+ "thrice ×1-3 ▼ dim", # sorting keeps the ×n's colour
2340
+ "['once [yellow]×1[/]', 'thrice [dim]×3[/]']", # Comparison: only the questions both answered
2341
+ ], out.stderr[-2000:])
2299
2342
 
2300
2343
  def test_results_open_on_pta_index(self):
2301
2344
  tuieval(self.ws, "add", "my-model", "--server", "local")
File without changes
File without changes