shrinkray 26.1.1.0__tar.gz → 26.2.4.0__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 (69) hide show
  1. {shrinkray-26.1.1.0/src/shrinkray.egg-info → shrinkray-26.2.4.0}/PKG-INFO +1 -1
  2. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/pyproject.toml +1 -1
  3. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/tui.py +66 -53
  4. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0/src/shrinkray.egg-info}/PKG-INFO +1 -1
  5. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_tui.py +119 -25
  6. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/LICENSE +0 -0
  7. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/README.md +0 -0
  8. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/setup.cfg +0 -0
  9. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/__init__.py +0 -0
  10. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/__main__.py +0 -0
  11. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/cli.py +0 -0
  12. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/formatting.py +0 -0
  13. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/history.py +0 -0
  14. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/passes/__init__.py +0 -0
  15. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/passes/bytes.py +0 -0
  16. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/passes/clangdelta.py +0 -0
  17. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/passes/definitions.py +0 -0
  18. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/passes/genericlanguages.py +0 -0
  19. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/passes/json.py +0 -0
  20. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/passes/patching.py +0 -0
  21. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/passes/python.py +0 -0
  22. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/passes/sat.py +0 -0
  23. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/passes/sequences.py +0 -0
  24. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/problem.py +0 -0
  25. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/process.py +0 -0
  26. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/py.typed +0 -0
  27. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/reducer.py +0 -0
  28. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/state.py +0 -0
  29. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/subprocess/__init__.py +0 -0
  30. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/subprocess/client.py +0 -0
  31. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/subprocess/protocol.py +0 -0
  32. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/subprocess/worker.py +0 -0
  33. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/ui.py +0 -0
  34. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/validation.py +0 -0
  35. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray/work.py +0 -0
  36. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray.egg-info/SOURCES.txt +0 -0
  37. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray.egg-info/dependency_links.txt +0 -0
  38. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray.egg-info/entry_points.txt +0 -0
  39. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray.egg-info/requires.txt +0 -0
  40. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/src/shrinkray.egg-info/top_level.txt +0 -0
  41. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_byte_reduction_passes.py +0 -0
  42. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_clang_delta.py +0 -0
  43. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_cli.py +0 -0
  44. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_definitions.py +0 -0
  45. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_dimacs_cnf.py +0 -0
  46. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_formatting.py +0 -0
  47. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_generic_language.py +0 -0
  48. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_generic_shrinking_properties.py +0 -0
  49. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_history.py +0 -0
  50. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_json_passes.py +0 -0
  51. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_main.py +0 -0
  52. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_misc_reduction_performance.py +0 -0
  53. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_natural_sort_orders.py +0 -0
  54. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_patching.py +0 -0
  55. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_problem.py +0 -0
  56. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_process.py +0 -0
  57. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_python_reducers.py +0 -0
  58. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_reducer.py +0 -0
  59. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_reduction_passes.py +0 -0
  60. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_sat.py +0 -0
  61. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_state.py +0 -0
  62. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_subprocess_client.py +0 -0
  63. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_subprocess_integration.py +0 -0
  64. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_subprocess_protocol.py +0 -0
  65. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_subprocess_worker.py +0 -0
  66. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_tui_snapshots.py +0 -0
  67. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_ui.py +0 -0
  68. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_validation.py +0 -0
  69. {shrinkray-26.1.1.0 → shrinkray-26.2.4.0}/tests/test_work.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shrinkray
3
- Version: 26.1.1.0
3
+ Version: 26.2.4.0
4
4
  Summary: Shrink Ray
5
5
  Author-email: "David R. MacIver" <david@drmaciver.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shrinkray"
3
- version = "26.1.1.0"
3
+ version = "26.2.4.0"
4
4
  description = "Shrink Ray"
5
5
  authors = [
6
6
  {name = "David R. MacIver", email = "david@drmaciver.com"}
@@ -13,7 +13,6 @@ from difflib import unified_diff
13
13
  from typing import Literal, Protocol, cast
14
14
 
15
15
  import humanize
16
- from rich.markup import escape as escape_markup
17
16
  from rich.text import Text
18
17
  from textual import work
19
18
  from textual.app import App, ComposeResult
@@ -46,6 +45,7 @@ from shrinkray.subprocess.protocol import (
46
45
 
47
46
  ThemeMode = Literal["auto", "dark", "light"]
48
47
 
48
+
49
49
  # Custom themes with true white/black backgrounds
50
50
  SHRINKRAY_LIGHT_THEME = Theme(
51
51
  name="shrinkray-light",
@@ -505,20 +505,20 @@ class ContentPreview(Static):
505
505
  # Fallback based on common terminal height
506
506
  return 30
507
507
 
508
- def render(self) -> str:
508
+ def render(self) -> Text:
509
509
  if not self.preview_content:
510
- return "Loading..."
510
+ return Text("Loading...")
511
511
 
512
512
  available_lines = self._get_available_lines()
513
513
 
514
514
  if self.hex_mode:
515
- return f"[Hex mode]\n{self.preview_content}"
515
+ return Text(f"[Hex mode]\n{self.preview_content}")
516
516
 
517
517
  lines = self.preview_content.split("\n")
518
518
 
519
519
  # For small files that fit, show full content
520
520
  if len(lines) <= available_lines:
521
- return self.preview_content
521
+ return Text(self.preview_content)
522
522
 
523
523
  # For larger files, show diff if we have previous displayed content
524
524
  if (
@@ -530,10 +530,10 @@ class ContentPreview(Static):
530
530
  diff = list(unified_diff(prev_lines, curr_lines, lineterm=""))
531
531
  if diff:
532
532
  # Show as much diff as fits
533
- return "\n".join(diff[:available_lines])
533
+ return Text("\n".join(diff[:available_lines]))
534
534
 
535
535
  # No diff available, show truncated content
536
- return (
536
+ return Text(
537
537
  "\n".join(lines[:available_lines])
538
538
  + f"\n\n... ({len(lines) - available_lines} more lines)"
539
539
  )
@@ -591,39 +591,46 @@ class OutputPreview(Static):
591
591
  pass
592
592
  return 30
593
593
 
594
- def render(self) -> str:
594
+ def render(self) -> Text:
595
595
  # Header line - use return_code to determine if test is running
596
596
  # (return_code is None means still running, has value means completed)
597
597
  if self.active_test_id is not None and self.last_return_code is None:
598
- header = f"[green]Test #{self.active_test_id} running...[/green]"
598
+ header_text = f"Test #{self.active_test_id} running..."
599
+ header_style = "green"
599
600
  elif self.active_test_id is not None:
600
- header = f"[dim]Test #{self.active_test_id} exited with code {self.last_return_code}[/dim]"
601
+ header_text = (
602
+ f"Test #{self.active_test_id} exited with code {self.last_return_code}"
603
+ )
604
+ header_style = "dim"
601
605
  elif self._has_seen_output or self.output_content:
602
606
  # Have seen output before - show without header
603
- header = ""
607
+ header_text = ""
608
+ header_style = ""
604
609
  else:
605
- header = "[dim]No test output yet...[/dim]"
610
+ header_text = "No test output yet..."
611
+ header_style = "dim"
606
612
 
607
613
  if not self.output_content:
608
- return header
614
+ return Text(header_text, style=header_style)
609
615
 
610
616
  available_lines = self._get_available_lines()
611
- # Escape the output content to prevent Rich markup interpretation
612
- # (test output may contain characters like ^ that have special meaning)
613
- escaped_content = escape_markup(self.output_content)
614
- lines = escaped_content.split("\n")
615
-
616
- # Build prefix (header + newline, or empty if no header)
617
- prefix = f"{header}\n" if header else ""
617
+ lines = self.output_content.split("\n")
618
618
 
619
619
  # Show tail of output (most recent lines)
620
- if len(lines) <= available_lines:
621
- return f"{prefix}{escaped_content}"
620
+ if len(lines) > available_lines:
621
+ truncated_lines = lines[-(available_lines):]
622
+ skipped = len(lines) - available_lines
623
+ content = f"... ({skipped} earlier lines)\n" + "\n".join(truncated_lines)
624
+ else:
625
+ content = self.output_content
622
626
 
623
- # Truncate from the beginning
624
- truncated_lines = lines[-(available_lines):]
625
- skipped = len(lines) - available_lines
626
- return f"{prefix}... ({skipped} earlier lines)\n" + "\n".join(truncated_lines)
627
+ # Build result with styled header and unstyled content
628
+ result = Text()
629
+ if header_text:
630
+ result.append(header_text, style=header_style)
631
+ result.append("\n")
632
+ result.append(content)
633
+ return result
627
634
 
628
635
 
629
636
  class HelpScreen(ModalScreen[None]):
@@ -734,21 +741,20 @@ class ExpandedBoxModal(ModalScreen[None]):
734
741
  self._content_widget_id = content_widget_id
735
742
  self._file_path = file_path
736
743
 
737
- def _read_file(self, file_path: str) -> str:
744
+ def _read_file(self, file_path: str) -> Text:
738
745
  """Read file content, decoding as text if possible."""
739
746
  if not os.path.isfile(file_path):
740
- return "[dim]File not found[/dim]"
747
+ return Text("File not found", style="dim")
741
748
  try:
742
749
  with open(file_path, "rb") as f:
743
750
  raw_content = f.read()
744
751
  # Try to decode as text, fall back to hex display if binary
745
752
  encoding, text = try_decode(raw_content)
746
753
  if encoding is not None:
747
- # Escape Rich markup to prevent interpretation of [ ] etc
748
- return escape_markup(text)
749
- return "[Binary content - hex display]\n\n" + raw_content.hex()
754
+ return Text(text)
755
+ return Text("Binary content - hex display\n\n" + raw_content.hex())
750
756
  except OSError:
751
- return "[red]Error reading file[/red]"
757
+ return Text("Error reading file", style="red")
752
758
 
753
759
  def compose(self) -> ComposeResult:
754
760
  with Vertical():
@@ -781,16 +787,16 @@ class ExpandedBoxModal(ModalScreen[None]):
781
787
  return "Statistics not available"
782
788
  return stats_displays[0].render()
783
789
 
784
- def _get_file_content(self, app: "ShrinkRayApp") -> str:
790
+ def _get_file_content(self, app: "ShrinkRayApp") -> str | Text:
785
791
  """Get content from file or preview widget."""
786
792
  if self._file_path:
787
793
  return self._read_file(self._file_path)
788
794
  content_previews = list(app.query("#content-preview").results(ContentPreview))
789
795
  if not content_previews:
790
796
  return "Content preview not available"
791
- return content_previews[0].preview_content
797
+ return Text(content_previews[0].preview_content)
792
798
 
793
- def _get_output_content(self, app: "ShrinkRayApp") -> str:
799
+ def _get_output_content(self, app: "ShrinkRayApp") -> str | Text:
794
800
  """Get output content from the output preview widget."""
795
801
  output_previews = list(app.query("#output-preview").results(OutputPreview))
796
802
  if not output_previews:
@@ -811,21 +817,29 @@ class ExpandedBoxModal(ModalScreen[None]):
811
817
  )
812
818
  has_seen_output = output_preview._has_seen_output
813
819
 
814
- # Build header - return_code is None means test is still running
820
+ # Build header with styling
815
821
  if test_id is not None and return_code is None:
816
- header = f"[green]Test #{test_id} running...[/green]\n\n"
822
+ header_text = f"Test #{test_id} running..."
823
+ header_style = "green"
817
824
  elif test_id is not None:
818
- header = f"[dim]Test #{test_id} exited with code {return_code}[/dim]\n\n"
825
+ header_text = f"Test #{test_id} exited with code {return_code}"
826
+ header_style = "dim"
819
827
  else:
820
- header = ""
828
+ header_text = ""
829
+ header_style = ""
821
830
 
822
831
  if raw_content:
823
- return header + raw_content
832
+ result = Text()
833
+ if header_text:
834
+ result.append(header_text, style=header_style)
835
+ result.append("\n\n")
836
+ result.append(raw_content)
837
+ return result
824
838
  elif has_seen_output or test_id is not None:
825
839
  # We've seen output before - show header only (no "No test output" message)
826
- return header.rstrip("\n") if header else ""
840
+ return Text(header_text, style=header_style)
827
841
  else:
828
- return "[dim]No test output yet...[/dim]"
842
+ return Text("No test output yet...", style="dim")
829
843
 
830
844
  def on_mount(self) -> None:
831
845
  """Populate content from the source widget."""
@@ -1447,13 +1461,13 @@ class HistoryExplorerModal(ModalScreen[None]):
1447
1461
  if os.path.isfile(output_path):
1448
1462
  output_content = self._read_file(output_path)
1449
1463
  else:
1450
- output_content = "[dim]No output captured[/dim]"
1464
+ output_content = Text("No output captured", style="dim")
1451
1465
  self.query_one(f"#{output_preview_id}", Static).update(output_content)
1452
1466
 
1453
- def _read_file(self, file_path: str) -> str:
1467
+ def _read_file(self, file_path: str) -> Text:
1454
1468
  """Read file content, decoding as text if possible."""
1455
1469
  if not os.path.isfile(file_path):
1456
- return "[dim]File not found[/dim]"
1470
+ return Text("File not found", style="dim")
1457
1471
  try:
1458
1472
  with open(file_path, "rb") as f:
1459
1473
  raw_content = f.read()
@@ -1465,18 +1479,17 @@ class HistoryExplorerModal(ModalScreen[None]):
1465
1479
  # Try to decode as text
1466
1480
  encoding, text = try_decode(raw_content)
1467
1481
  if encoding is not None:
1468
- # Escape Rich markup to prevent interpretation of [ ] etc
1469
- text = escape_markup(text)
1482
+ result = Text(text)
1470
1483
  if truncated:
1471
- text += "\n\n[dim]... (truncated)[/dim]"
1472
- return text
1484
+ result.append("\n\n... (truncated)", style="dim")
1485
+ return result
1473
1486
  # Binary content - hex display
1474
- hex_display = "[Binary content - hex display]\n\n" + raw_content.hex()
1487
+ result = Text("Binary content - hex display\n\n" + raw_content.hex())
1475
1488
  if truncated:
1476
- hex_display += "\n\n[dim]... (truncated)[/dim]"
1477
- return hex_display
1489
+ result.append("\n\n... (truncated)", style="dim")
1490
+ return result
1478
1491
  except OSError:
1479
- return "[red]Error reading file[/red]"
1492
+ return Text("Error reading file", style="red")
1480
1493
 
1481
1494
  def action_restart_from_here(self) -> None:
1482
1495
  """Restart reduction from the currently selected history point."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shrinkray
3
- Version: 26.1.1.0
3
+ Version: 26.2.4.0
4
4
  Summary: Shrink Ray
5
5
  Author-email: "David R. MacIver" <david@drmaciver.com>
6
6
  License: MIT
@@ -9,6 +9,7 @@ from collections.abc import AsyncGenerator
9
9
  from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
10
10
 
11
11
  import pytest
12
+ from rich.text import Text
12
13
  from textual.app import App
13
14
  from textual.widgets import DataTable, Label, ListView, Static, TabbedContent
14
15
 
@@ -715,6 +716,40 @@ def test_output_preview_get_available_lines_app_zero_height():
715
716
  assert lines == 30
716
717
 
717
718
 
719
+ def test_output_preview_render_returns_text():
720
+ """Test that OutputPreview.render() returns a Text object, not a markup string.
721
+
722
+ Returning a Text object ensures Textual never parses user content as markup.
723
+ Previously, render() returned a markup string and used Rich's escape() which
724
+ failed on patterns like [key=; causing MarkupError.
725
+
726
+ This fixes the crash from: shrinkray --parallelism 60 interest2.sh bf_simple_yk.c
727
+ """
728
+ widget = OutputPreview()
729
+ # Content that would crash if parsed as markup
730
+ widget.output_content = "some output\n[key=;value"
731
+ widget.active_test_id = 1
732
+
733
+ rendered = widget.render()
734
+ assert isinstance(rendered, Text)
735
+ assert "[key=;value" in rendered
736
+
737
+
738
+ def test_content_preview_render_returns_text():
739
+ """Test that ContentPreview.render() returns a Text object, not a markup string.
740
+
741
+ Returning a Text object ensures Textual never parses user content as markup.
742
+ Previously, render() returned raw strings that would crash on patterns like
743
+ [Key=; when Textual parsed them as markup.
744
+ """
745
+ widget = ContentPreview()
746
+ widget.update_content("[Key=;value", False)
747
+
748
+ rendered = widget.render()
749
+ assert isinstance(rendered, Text)
750
+ assert "[Key=;value" in rendered
751
+
752
+
718
753
  # === ShrinkRayApp with fake client tests ===
719
754
 
720
755
 
@@ -1303,21 +1338,22 @@ def test_expanded_modal_read_file_success(tmp_path):
1303
1338
 
1304
1339
  modal = ExpandedBoxModal("Test", "content-container")
1305
1340
  content = modal._read_file(str(test_file))
1306
- assert content == "Hello World"
1341
+ assert isinstance(content, Text)
1342
+ assert content.plain == "Hello World"
1307
1343
 
1308
1344
 
1309
- def test_expanded_modal_read_file_escapes_markup(tmp_path):
1310
- """Test _read_file escapes Rich markup characters like [."""
1345
+ def test_expanded_modal_read_file_preserves_brackets(tmp_path):
1346
+ """Test _read_file preserves bracket characters in file content."""
1311
1347
  test_file = tmp_path / "test.txt"
1312
1348
  # Content with brackets that could be interpreted as Rich markup
1313
1349
  test_file.write_text("expected [bold] and [red]text[/red]")
1314
1350
 
1315
1351
  modal = ExpandedBoxModal("Test", "content-container")
1316
1352
  content = modal._read_file(str(test_file))
1317
- # Opening brackets should be escaped to prevent Rich interpretation
1318
- # Rich's escape() converts [ to \[ when followed by tag-like content
1319
- assert "[bold]" not in content or "\\[bold]" in content
1320
- assert "[red]" not in content or "\\[red]" in content
1353
+ # Returns a Text object so brackets are preserved literally
1354
+ assert isinstance(content, Text)
1355
+ assert "[bold]" in content.plain
1356
+ assert "[red]text[/red]" in content.plain
1321
1357
 
1322
1358
 
1323
1359
  def test_expanded_modal_read_file_binary(tmp_path):
@@ -1328,26 +1364,29 @@ def test_expanded_modal_read_file_binary(tmp_path):
1328
1364
 
1329
1365
  modal = ExpandedBoxModal("Test", "content-container")
1330
1366
  content = modal._read_file(str(test_file))
1367
+ assert isinstance(content, Text)
1331
1368
  assert "Binary content" in content
1332
1369
  assert "80818283" in content
1333
1370
 
1334
1371
 
1335
1372
  def test_expanded_modal_read_file_missing(tmp_path):
1336
- """Test _read_file returns error message for missing file."""
1373
+ """Test _read_file returns styled message for missing file."""
1337
1374
  modal = ExpandedBoxModal("Test", "content-container")
1338
1375
  result = modal._read_file(str(tmp_path / "nonexistent.txt"))
1339
- assert "[dim]File not found[/dim]" in result
1376
+ assert isinstance(result, Text)
1377
+ assert "File not found" in result
1340
1378
 
1341
1379
 
1342
1380
  def test_expanded_modal_read_file_oserror(tmp_path):
1343
- """Test _read_file returns error message on OSError."""
1381
+ """Test _read_file returns styled message on OSError."""
1344
1382
  modal = ExpandedBoxModal("Test", "content-container")
1345
1383
  # Create a file that exists but can't be read
1346
1384
  test_file = tmp_path / "unreadable.txt"
1347
1385
  test_file.write_text("content")
1348
1386
  with patch("builtins.open", side_effect=OSError("Permission denied")):
1349
1387
  result = modal._read_file(str(test_file))
1350
- assert "[red]Error reading file[/red]" in result
1388
+ assert isinstance(result, Text)
1389
+ assert "Error reading file" in result
1351
1390
 
1352
1391
 
1353
1392
  # === ExpandedBoxModal integration tests ===
@@ -5367,6 +5406,55 @@ def test_expanded_modal_output_with_empty_content_and_header():
5367
5406
  asyncio.run(run())
5368
5407
 
5369
5408
 
5409
+ def test_expanded_modal_output_content_without_test_id():
5410
+ """Test output modal with content but no test ID (no header)."""
5411
+
5412
+ async def run():
5413
+ fake_client = FakeReductionClient(updates=[], wait_indefinitely=True)
5414
+
5415
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
5416
+ f.write("test")
5417
+ temp_file = f.name
5418
+
5419
+ try:
5420
+ app = ShrinkRayApp(
5421
+ file_path=temp_file,
5422
+ test=["true"],
5423
+ exit_on_completion=False,
5424
+ client=fake_client,
5425
+ )
5426
+
5427
+ async with app.run_test() as pilot:
5428
+ await pilot.pause()
5429
+
5430
+ # Set output preview with content but no test ID
5431
+ output_preview = app.query_one("#output-preview", OutputPreview)
5432
+ output_preview.active_test_id = None
5433
+ output_preview._has_seen_output = True
5434
+ output_preview.output_content = "some raw output"
5435
+ output_preview._pending_content = "some raw output"
5436
+ output_preview._pending_test_id = None
5437
+
5438
+ # Create modal for output-container
5439
+ modal = ExpandedBoxModal("Output", "output-container", None)
5440
+ await app.push_screen(modal)
5441
+ await pilot.pause()
5442
+
5443
+ # Should show content without header
5444
+ content = modal.query_one("#expanded-content", Static)
5445
+ content_str = get_static_content(content)
5446
+ assert "some raw output" in content_str
5447
+ assert "Test #" not in content_str
5448
+
5449
+ await pilot.press("escape")
5450
+ await pilot.press("q")
5451
+ finally:
5452
+ if os.path.exists(temp_file):
5453
+ os.unlink(temp_file)
5454
+
5455
+ asyncio.run(run())
5456
+
5457
+
5370
5458
  # === Action method tests ===
5371
5459
 
5372
5460
 
@@ -6125,11 +6213,12 @@ def test_history_modal_read_file_success(tmp_path):
6125
6213
  modal = HistoryExplorerModal(str(tmp_path), "test.txt")
6126
6214
  content = modal._read_file(str(test_file))
6127
6215
 
6128
- assert content == "Hello World\nLine 2"
6216
+ assert isinstance(content, Text)
6217
+ assert content.plain == "Hello World\nLine 2"
6129
6218
 
6130
6219
 
6131
- def test_history_modal_read_file_escapes_markup(tmp_path):
6132
- """Test _read_file escapes Rich markup characters like [."""
6220
+ def test_history_modal_read_file_preserves_brackets(tmp_path):
6221
+ """Test _read_file preserves bracket characters in file content."""
6133
6222
  test_file = tmp_path / "test.txt"
6134
6223
  # Content with brackets that look like Rich markup tags
6135
6224
  test_file.write_text("error: [bold]text[/bold] failed")
@@ -6137,9 +6226,9 @@ def test_history_modal_read_file_escapes_markup(tmp_path):
6137
6226
  modal = HistoryExplorerModal(str(tmp_path), "test.txt")
6138
6227
  content = modal._read_file(str(test_file))
6139
6228
 
6140
- # Opening brackets should be escaped to prevent Rich interpretation
6141
- assert "[bold]" not in content or "\\[bold]" in content
6142
- # The original text should not be corrupted
6229
+ # Returns a Text object so brackets are preserved literally
6230
+ assert isinstance(content, Text)
6231
+ assert "[bold]" in content.plain
6143
6232
  assert "error:" in content
6144
6233
  assert "failed" in content
6145
6234
 
@@ -6153,15 +6242,17 @@ def test_history_modal_read_file_binary(tmp_path):
6153
6242
  content = modal._read_file(str(test_file))
6154
6243
 
6155
6244
  # Should fall back to hex display
6245
+ assert isinstance(content, Text)
6156
6246
  assert "Binary content" in content
6157
6247
 
6158
6248
 
6159
6249
  def test_history_modal_read_file_missing(tmp_path):
6160
- """Test _read_file returns error message for missing file."""
6250
+ """Test _read_file returns styled message for missing file."""
6161
6251
  modal = HistoryExplorerModal(str(tmp_path), "test.txt")
6162
6252
  content = modal._read_file(str(tmp_path / "nonexistent.txt"))
6163
6253
 
6164
- assert "[dim]File not found[/dim]" in content
6254
+ assert isinstance(content, Text)
6255
+ assert "File not found" in content
6165
6256
 
6166
6257
 
6167
6258
  def test_history_modal_read_file_truncated_text(tmp_path):
@@ -6174,9 +6265,10 @@ def test_history_modal_read_file_truncated_text(tmp_path):
6174
6265
  content = modal._read_file(str(test_file))
6175
6266
 
6176
6267
  # Should be truncated
6177
- assert "[dim]... (truncated)[/dim]" in content
6178
- # Content should be limited
6179
- assert len(content) < 60000
6268
+ assert isinstance(content, Text)
6269
+ assert "truncated" in content
6270
+ # Content should be limited (50000 chars + truncation message)
6271
+ assert len(content.plain) < 60000
6180
6272
 
6181
6273
 
6182
6274
  def test_history_modal_read_file_truncated_binary(tmp_path):
@@ -6189,8 +6281,9 @@ def test_history_modal_read_file_truncated_binary(tmp_path):
6189
6281
  content = modal._read_file(str(test_file))
6190
6282
 
6191
6283
  # Should be truncated and shown as binary
6192
- assert "[Binary content" in content
6193
- assert "[dim]... (truncated)[/dim]" in content
6284
+ assert isinstance(content, Text)
6285
+ assert "Binary content" in content
6286
+ assert "truncated" in content
6194
6287
 
6195
6288
 
6196
6289
  def test_history_modal_read_file_oserror(tmp_path):
@@ -6204,7 +6297,8 @@ def test_history_modal_read_file_oserror(tmp_path):
6204
6297
  with patch("builtins.open", side_effect=OSError("Permission denied")):
6205
6298
  content = modal._read_file(str(test_file))
6206
6299
 
6207
- assert "[red]Error reading file[/red]" in content
6300
+ assert isinstance(content, Text)
6301
+ assert "Error reading file" in content
6208
6302
 
6209
6303
 
6210
6304
  def test_history_modal_on_list_view_highlighted_no_entries(tmp_path):
File without changes
File without changes
File without changes