diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6dce857..de01327 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,4 +25,27 @@ jobs:
with:
python-version: "3.12"
- run: make activate
- - run: make test
+ - name: Run tests
+ id: tests
+ continue-on-error: true
+ run: |
+ source .venv/bin/activate
+ pytest tests/
+ - name: Snapshot report to summary
+ if: always()
+ run: |
+ if [ -f snapshot_report.html ]; then
+ echo "## Snapshot Test Report" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "Snapshot differences were detected. Download the **snapshot-report** artifact for a detailed visual comparison." >> $GITHUB_STEP_SUMMARY
+ fi
+ - name: Upload snapshot report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: snapshot-report
+ path: snapshot_report.html
+ if-no-files-found: ignore
+ - name: Fail if tests failed
+ if: steps.tests.outcome == 'failure'
+ run: exit 1
diff --git a/pyproject.toml b/pyproject.toml
index b4f1538..4f4c546 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -27,6 +27,8 @@ dev = [
"pylint",
"pytest",
"pytest-asyncio",
+ "pytest-textual-snapshot >= 1.1.0",
+ "syrupy >= 4.8.0, < 5",
"ruff",
]
diff --git a/src/dt_browser/browser.py b/src/dt_browser/browser.py
index 85fd17e..4574d7d 100644
--- a/src/dt_browser/browser.py
+++ b/src/dt_browser/browser.py
@@ -1,4 +1,3 @@
-import asyncio
import datetime
import gc
import pathlib
@@ -78,20 +77,13 @@ def __init__(self, *args, bookmarks: Bookmarks, **kwargs):
self._search_highlight: Style = Style.null()
def on_mount(self):
- self._bookmark_highlight = self.get_component_rich_style(
- "datatable--row-bookmark"
- )
- self._search_highlight = self.get_component_rich_style(
- "datatable--row-search-result"
- )
+ self._bookmark_highlight = self.get_component_rich_style("datatable--row-bookmark")
+ self._search_highlight = self.get_component_rich_style("datatable--row-search-result")
def _get_sel_col_bg_color(self, struct: dict[str, Any]) -> str:
if self.active_search_queue and struct[INDEX_COL] in self.active_search_queue:
return _color_name(self._search_highlight.bgcolor)
- if (
- self._bookmarks.has_bookmarks
- and struct[INDEX_COL] in self._bookmarks.meta_dt[INDEX_COL]
- ):
+ if self._bookmarks.has_bookmarks and struct[INDEX_COL] in self._bookmarks.meta_dt[INDEX_COL]:
return _color_name(self._bookmark_highlight.bgcolor)
return super()._get_sel_col_bg_color(struct)
@@ -116,9 +108,7 @@ def _get_row_bg_color_expr(self, cursor_row_idx: int) -> pl.Expr:
def _guess_timestamp_cols(df: pl.DataFrame):
- date_range = pl.Series(
- values=[datetime.date(2001, 1, 1), datetime.date(2042, 1, 1)]
- )
+ date_range = pl.Series(values=[datetime.date(2001, 1, 1), datetime.date(2042, 1, 1)])
epoch_units: tuple[Literal["s", "ms", "us", "ns"], ...] = ("s", "ms", "us", "ns")
converts = [(x,) + tuple(date_range.dt.epoch(x)) for x in epoch_units]
@@ -133,12 +123,7 @@ def _guess_timestamp_cols(df: pl.DataFrame):
)
.select(
count=pl.col("is_inside").any()
- & (
- pl.any_horizontal(
- pl.col("is_zero"), pl.col("is_inside")
- ).sum()
- == pl.len()
- )
+ & (pl.any_horizontal(pl.col("is_zero"), pl.col("is_inside")).sum() == pl.len())
)
.collect()
.get_column("count")[0]
@@ -234,9 +219,7 @@ def compose(self):
widths.append("auto")
with Horizontal(classes="tablefooter--search"):
yield Label("Search: ")
- yield ReactiveLabel().data_bind(
- value=TableFooter.active_search_idx_display
- )
+ yield ReactiveLabel().data_bind(value=TableFooter.active_search_idx_display)
yield Label(" / ")
yield ReactiveLabel().data_bind(value=TableFooter.active_search_len)
@@ -254,11 +237,7 @@ def compose(self):
self.styles.grid_size_columns = len(widths)
def compute_total_rows_display(self):
- return (
- f" (Filtered from {self.total_rows:,})"
- if self.cur_total_rows != self.total_rows
- else ""
- )
+ return f" (Filtered from {self.total_rows:,})" if self.cur_total_rows != self.total_rows else ""
def compute_active_search_idx_display(self):
if self.active_search_idx is None:
@@ -297,28 +276,18 @@ def watch_row_df(self):
if self.row_df.is_empty():
return
display_df = self.row_df.with_columns(
- (
- polars_list_to_string(pl.col(x))
- if isinstance(dtype, pl.List)
- else pl.col(x).cast(pl.Utf8)
- )
+ (polars_list_to_string(pl.col(x)) if isinstance(dtype, pl.List) else pl.col(x).cast(pl.Utf8))
for x, dtype in self.row_df.schema.items()
).transpose(include_header=True, header_name="Field", column_names=["Value"])
if self._base_schema is None or self._base_schema != self.row_df.schema:
self._base_schema = self.row_df.schema
- self._schema = pl.from_dict(
- {k: str(v) for k, v in self.row_df.schema.items()}, strict=False
- ).transpose(
+ self._schema = pl.from_dict({k: str(v) for k, v in self.row_df.schema.items()}, strict=False).transpose(
include_header=True, header_name="Field", column_names=["dtype"]
)
assert self._schema is not None
- display_df = display_df.join(self._schema, on=["Field"]).select(
- ["Field", "dtype", "Value"]
- )
- self._dt.set_dt(
- display_df, display_df.with_row_index(name=INDEX_COL).select([INDEX_COL])
- )
+ display_df = display_df.join(self._schema, on=["Field"]).select(["Field", "dtype", "Value"])
+ self._dt.set_dt(display_df, display_df.with_row_index(name=INDEX_COL).select([INDEX_COL]))
self.styles.width = self._dt.virtual_size.width + self.gutter.width + 1
self._dt.refresh()
# self._dt.go_to_cell(coord)
@@ -342,9 +311,7 @@ def from_file_path(path: pathlib.Path, has_header: bool = True) -> pl.DataFrame:
raise TypeError(f"Dont know how to load file type {path.suffix} for {path}")
-class DtBrowser(
- Widget
-): # pylint: disable=too-many-public-methods,too-many-instance-attributes
+class DtBrowser(Widget): # pylint: disable=too-many-public-methods,too-many-instance-attributes
"""A Textual app to manage stopwatches."""
BINDINGS = [
@@ -397,35 +364,23 @@ def __init__(
else source_file_or_table
)
old_cols = bt.columns
- bt = bt.with_row_index("Row #", offset=1).with_columns(
- pl.col("Row #"), *old_cols
- )
- self.removed_cols = {
- x: v for x, v in bt.schema.items() if not CustomTable.can_draw(bt[x])
- }
+ bt = bt.with_row_index("Row #", offset=1).with_columns(pl.col("Row #"), *old_cols)
+ self.removed_cols = {x: v for x, v in bt.schema.items() if not CustomTable.can_draw(bt[x])}
# bt = bt.with_columns(TestTs=datetime.datetime.now())
self._display_dt = self._filtered_dt = self._original_dt = bt.select(
[x for x in bt.columns if x not in self.removed_cols]
)
- self._meta_dt = self._original_meta = self._original_dt.with_row_index(
- name=INDEX_COL
- ).select([INDEX_COL])
+ self._meta_dt = self._original_meta = self._original_dt.with_row_index(name=INDEX_COL).select([INDEX_COL])
self._table_name = table_name
self._bookmarks = Bookmarks()
self._suggestor = ColumnNameSuggestor()
self.visible_columns = tuple(self._original_dt.columns)
self.all_columns = self.visible_columns
self.read_only_columns = self.all_columns
- self._filter_box = FilterBox(
- suggestor=self._suggestor, id="filter", classes="toolbox"
- )
- self._expression_box = ExpressionBox(
- suggestor=self._suggestor, id="expr", classes="toolbox"
- )
+ self._filter_box = FilterBox(suggestor=self._suggestor, id="filter", classes="toolbox")
+ self._expression_box = ExpressionBox(suggestor=self._suggestor, id="expr", classes="toolbox")
self._select_interest: str | None = None
- self._column_selector = ColumnSelector(
- id=_SHOW_COLUMNS_ID, title="Show/Hide/Reorder Columns"
- )
+ self._column_selector = ColumnSelector(id=_SHOW_COLUMNS_ID, title="Show/Hide/Reorder Columns")
self._color_selector = ColumnSelector(
allow_reorder=False,
id=_COLOR_COLUMNS_ID,
@@ -433,9 +388,7 @@ def __init__(
)
self._ts_cols = dict(_guess_timestamp_cols(self._original_dt))
- self._ts_col_selector = ColumnSelector(
- id=_TS_COLUMNS_ID, title="Select epoch timestamp columns"
- )
+ self._ts_col_selector = ColumnSelector(id=_TS_COLUMNS_ID, title="Select epoch timestamp columns")
self._ts_col_names: dict[str, str] = {}
self.available_timestamp_columns = tuple(self._ts_cols.keys())
@@ -475,9 +428,7 @@ async def update_expressions(self, event: ExpressionBox.ExpressionSubmitted):
try:
query = f"select {event.value} AS calc from dt"
self._original_dt = self._original_dt.with_columns(
- pl.Series((await ctx.execute(query).collect_async())["calc"]).alias(
- event.column_name
- )
+ pl.Series((await ctx.execute(query).collect_async())["calc"]).alias(event.column_name)
)
if event.column_name not in self.all_columns:
self.visible_columns = self.visible_columns + (event.column_name,)
@@ -493,16 +444,14 @@ async def update_expressions(self, event: ExpressionBox.ExpressionSubmitted):
timeout=10,
)
finally:
- foot.pending_action = None
+ foot.pending_action = ""
@on(ExpressionBox.ExpressionDeleted)
async def remove_expression(self, event: ExpressionBox.ExpressionDeleted):
if event.column_name not in self._original_dt.columns:
return
self._original_dt.drop_in_place(event.column_name)
- self.visible_columns = tuple(
- x for x in self.visible_columns if x != event.column_name
- )
+ self.visible_columns = tuple(x for x in self.visible_columns if x != event.column_name)
self.all_columns = tuple(x for x in self.all_columns if x != event.column_name)
self.apply_filter()
self.watch_cur_row()
@@ -518,20 +467,10 @@ async def _apply_filter(self):
)
else:
(foot := self.query_one(TableFooter)).filter_pending = True
- ctx = pl.SQLContext(
- frames={
- "dt": pl.concat(
- [self._original_dt, self._original_meta], how="horizontal"
- )
- }
- )
+ ctx = pl.SQLContext(frames={"dt": pl.concat([self._original_dt, self._original_meta], how="horizontal")})
try:
- query = self.current_filter.replace(" && ", " and ").replace(
- " || ", " or "
- )
- dt = await ctx.execute(
- f"select * from dt where {query}"
- ).collect_async()
+ query = self.current_filter.replace(" && ", " and ").replace(" || ", " or ")
+ dt = await ctx.execute(f"select * from dt where {query}").collect_async()
meta = dt.select([x for x in dt.columns if x.startswith("__")])
dt = dt.select([x for x in dt.columns if not x.startswith("__")])
self.is_filtered = True
@@ -545,9 +484,7 @@ async def _apply_filter(self):
self._set_filtered_dt(dt, meta, new_row=0)
except Exception as e:
self.query_one(FilterBox).query_failed(query)
- self.notify(
- f"Failed to apply filter due to: {e}", severity="error", timeout=10
- )
+ self.notify(f"Failed to apply filter due to: {e}", severity="error", timeout=10)
self.current_filter = None
foot.filter_pending = False
@@ -572,37 +509,23 @@ async def watch_active_search(self, goto: bool = True):
(foot := self.query_one(TableFooter)).search_pending = True
try:
idx_name = "__search_idx"
- ctx = pl.SQLContext(
- frames={"dt": self._display_dt.with_row_index(idx_name)}
- )
+ ctx = pl.SQLContext(frames={"dt": self._display_dt.with_row_index(idx_name)})
query = self.active_search.replace(" && ", " and ").replace(" || ", " or ")
search_queue = list(
- (
- await ctx.execute(
- f"select {idx_name} from dt where {query}"
- ).collect_async()
- )[idx_name]
+ (await ctx.execute(f"select {idx_name} from dt where {query}").collect_async())[idx_name]
)
foot.search_pending = False
if not search_queue:
- self.notify(
- "No results found for search", severity="warning", timeout=5
- )
+ self.notify("No results found for search", severity="warning", timeout=5)
else:
self.active_search_queue = search_queue
self.active_search_idx = -1
if goto:
# Find the nearest index to the current cursor
- coord = self.query_one(
- "#main_table", CustomTable
- ).cursor_coordinate.row
+ coord = self.query_one("#main_table", CustomTable).cursor_coordinate.row
next_row = next(
- (
- i
- for i, x in enumerate(self.active_search_queue)
- if x > coord
- ),
+ (i for i, x in enumerate(self.active_search_queue) if x > coord),
0,
)
self.active_search_idx = next_row - 1
@@ -635,9 +558,7 @@ def action_iter_search(self, forward: bool):
def action_toggle_bookmark(self):
row_idx = self.query_one("#main_table", CustomTable).cursor_coordinate.row
- did_add = self._bookmarks.toggle_bookmark(
- self._display_dt[row_idx], self._meta_dt[row_idx]
- )
+ did_add = self._bookmarks.toggle_bookmark(self._display_dt[row_idx], self._meta_dt[row_idx])
self.refresh_bindings()
self.query_one("#main_table", CustomTable).refresh(repaint=True, layout=True)
@@ -687,14 +608,10 @@ async def action_show_save(self):
elif target.endswith(".csv"):
self._display_dt.write_csv(target)
else:
- self.notify(
- f"Dont know how to write file {target}", severity="error", timeout=5
- )
+ self.notify(f"Dont know how to write file {target}", severity="error", timeout=5)
return
except Exception as e:
- self.notify(
- f"Failed to save to {target} due to: {e}", severity="error", timeout=10
- )
+ self.notify(f"Failed to save to {target} due to: {e}", severity="error", timeout=10)
return
size = pathlib.Path(target).stat().st_size
@@ -724,9 +641,7 @@ async def action_show_colors(self):
async def action_timestamp_selector(self):
await self.query_one("#main_hori", Horizontal).mount(self._ts_col_selector)
- def _set_filtered_dt(
- self, filtered_dt: pl.DataFrame, filtered_meta: pl.DataFrame, **kwargs
- ):
+ def _set_filtered_dt(self, filtered_dt: pl.DataFrame, filtered_meta: pl.DataFrame, **kwargs):
self._filtered_dt = filtered_dt
self._meta_dt = filtered_meta
self._set_active_dt(self._filtered_dt, **kwargs)
@@ -744,9 +659,7 @@ def _set_active_dt(self, active_dt: pl.DataFrame, new_row: int | None = None):
if self._display_dt.is_empty():
self.show_row_detail = False
self.watch_active_search(goto=False)
- (table := self.query_one("#main_table", CustomTable)).set_dt(
- self._display_dt, self._meta_dt
- )
+ (table := self.query_one("#main_table", CustomTable)).set_dt(self._display_dt, self._meta_dt)
if new_row is not None:
table.move_cursor(row=new_row, column=None)
self.cur_row = new_row
@@ -766,26 +679,19 @@ async def set_timestamp_cols(self, event: ColumnSelector.ColumnSelectionChanged)
@work(exclusive=True)
async def watch_timestamp_columns(self):
- old_cols = [
- v for k, v in self._ts_col_names.items() if k not in self.timestamp_columns
- ]
+ old_cols = [v for k, v in self._ts_col_names.items() if k not in self.timestamp_columns]
self._original_dt = self._original_dt.drop(old_cols)
self._ts_col_names = {
- x: f"{x} (ns)" if self._ts_cols[x] == _ALREADY_DT else f"{x} (Local)"
- for x in self.timestamp_columns
+ x: f"{x} (ns)" if self._ts_cols[x] == _ALREADY_DT else f"{x} (Local)" for x in self.timestamp_columns
}
if self._ts_col_names:
- (foot := self.query_one(TableFooter)).pending_action = (
- "Computing Ts columns"
- )
+ (foot := self.query_one(TableFooter)).pending_action = "Computing Ts columns"
try:
calc_expr = [
(
pl.col(x).dt.epoch("ns")
if self._ts_cols[x] == _ALREADY_DT
- else pl.from_epoch(
- pl.col(x), time_unit=self._ts_cols[x]
- ).dt.convert_time_zone(_TIMEZONE)
+ else pl.from_epoch(pl.col(x), time_unit=self._ts_cols[x]).dt.convert_time_zone(_TIMEZONE)
).alias(self._ts_col_names[x])
for x in self.timestamp_columns
]
@@ -824,9 +730,7 @@ def handle_bookmark_select(self, event: Bookmarks.BookmarkSelected):
coord = dt.cursor_coordinate
sel_idx = event.selected_index
if self.is_filtered:
- filt = self._meta_dt.with_row_index("__displayIndex").filter(
- pl.col(INDEX_COL) == sel_idx
- )
+ filt = self._meta_dt.with_row_index("__displayIndex").filter(pl.col(INDEX_COL) == sel_idx)
if filt.is_empty():
self.notify(
"Bookmark not present in filtered view. Remove filters to select this bookmark",
@@ -883,19 +787,14 @@ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | No
if not (edtq := self.query_one("#main_table", CustomTable)):
return False
- if not edtq.has_focus and action in (
- x.action if isinstance(x, Binding) else x[1] for x in DtBrowser.BINDINGS
- ):
+ if not edtq.has_focus and action in (x.action if isinstance(x, Binding) else x[1] for x in DtBrowser.BINDINGS):
return False
match action:
case "iter_search":
if not self.active_search_queue:
return False
- if (
- bool(parameters[0])
- and self.active_search_idx == len(self.active_search_queue) - 1
- ):
+ if bool(parameters[0]) and self.active_search_idx == len(self.active_search_queue) - 1:
return False
if not bool(parameters[0]) and self.active_search_idx == 0:
return False
@@ -924,14 +823,7 @@ async def watch_color_by(self):
await self._original_dt.lazy()
.with_columns(
__color=(
- (
- pl.any_horizontal(
- *(
- pl.col(x) != pl.col(x).shift(1)
- for x in cols
- )
- )
- )
+ (pl.any_horizontal(*(pl.col(x) != pl.col(x).shift(1) for x in cols)))
.cum_sum()
.fill_null(0)
% len(COLORS.categories)
@@ -940,9 +832,7 @@ async def watch_color_by(self):
.collect_async()
)[COLOR_COL],
)
- self._original_meta = self._original_meta.with_columns(
- __color=self._color_by_cache.get(cols)
- )
+ self._original_meta = self._original_meta.with_columns(__color=self._color_by_cache.get(cols))
self._meta_dt = (
await self._meta_dt.lazy()
.drop(COLOR_COL, strict=False)
@@ -984,9 +874,7 @@ def compose(self) -> ComposeResult:
selected_columns=DtBrowser.timestamp_columns,
available_columns=DtBrowser.available_timestamp_columns,
)
- self._color_selector.data_bind(
- selected_columns=DtBrowser.color_by, available_columns=DtBrowser.all_columns
- )
+ self._color_selector.data_bind(selected_columns=DtBrowser.color_by, available_columns=DtBrowser.all_columns)
self._column_selector.data_bind(
selected_columns=DtBrowser.visible_columns,
available_columns=DtBrowser.all_columns,
@@ -1011,9 +899,7 @@ def compose(self) -> ComposeResult:
)
-class DtBrowserApp(
- App
-): # pylint: disable=too-many-public-methods,too-many-instance-attributes
+class DtBrowserApp(App): # pylint: disable=too-many-public-methods,too-many-instance-attributes
def __init__(
self,
table_name: str | pathlib.Path,
diff --git a/src/dt_browser/expression_box.py b/src/dt_browser/expression_box.py
index 89e98de..ffa1c9d 100644
--- a/src/dt_browser/expression_box.py
+++ b/src/dt_browser/expression_box.py
@@ -128,7 +128,8 @@ async def action_delete_expression(self):
@on(ListView.Selected)
def input_historical(self, event: ListView.Selected):
box = self.query_one(Input)
- box.value = f"{event.item.name} = {self.current_expressions[event.item.name]}"
+ name = event.item.name or ""
+ box.value = f"{name} = {self.current_expressions[name]}"
box.focus()
def key_down(self):
diff --git a/src/dt_browser/filter_box.py b/src/dt_browser/filter_box.py
index a29c0c9..5ae1ba7 100644
--- a/src/dt_browser/filter_box.py
+++ b/src/dt_browser/filter_box.py
@@ -1,3 +1,4 @@
+import os
import pathlib
from dataclasses import dataclass
@@ -12,6 +13,12 @@
from dt_browser import HasState, ReceivesTableSelect
+def _default_history_file() -> pathlib.Path:
+ if env := os.environ.get("DT_BROWSER_HISTORY_FILE"):
+ return pathlib.Path(env)
+ return pathlib.Path("~/.cache/dtbrowser/filters.txt").expanduser()
+
+
class FilterBox(ReceivesTableSelect, HasState):
DEFAULT_CSS = """
FilterBox {
@@ -54,7 +61,7 @@ class GoToSubmitted(Message):
def __init__(self, *args, suggestor: Suggester | None = None, **kwargs):
super().__init__(*args, **kwargs)
self._history: list[str] = []
- self._history_file = pathlib.Path("~/.cache/dtbrowser/filters.txt").expanduser()
+ self._history_file = _default_history_file()
self._history_file.parent.mkdir(exist_ok=True, parents=True)
if self._history_file.exists():
with self._history_file.open("r", encoding="utf-8") as f:
diff --git a/tests/__snapshots__/test_snapshots/test_snap_column_hidden.svg b/tests/__snapshots__/test_snapshots/test_snap_column_hidden.svg
new file mode 100644
index 0000000..0e96097
--- /dev/null
+++ b/tests/__snapshots__/test_snapshots/test_snap_column_hidden.svg
@@ -0,0 +1,186 @@
+
diff --git a/tests/__snapshots__/test_snapshots/test_snap_column_selector_open.svg b/tests/__snapshots__/test_snapshots/test_snap_column_selector_open.svg
new file mode 100644
index 0000000..cfcf083
--- /dev/null
+++ b/tests/__snapshots__/test_snapshots/test_snap_column_selector_open.svg
@@ -0,0 +1,185 @@
+
diff --git a/tests/__snapshots__/test_snapshots/test_snap_cursor_moved.svg b/tests/__snapshots__/test_snapshots/test_snap_cursor_moved.svg
new file mode 100644
index 0000000..bc4fb13
--- /dev/null
+++ b/tests/__snapshots__/test_snapshots/test_snap_cursor_moved.svg
@@ -0,0 +1,180 @@
+
diff --git a/tests/__snapshots__/test_snapshots/test_snap_filter_applied.svg b/tests/__snapshots__/test_snapshots/test_snap_filter_applied.svg
new file mode 100644
index 0000000..f877bb8
--- /dev/null
+++ b/tests/__snapshots__/test_snapshots/test_snap_filter_applied.svg
@@ -0,0 +1,182 @@
+
diff --git a/tests/__snapshots__/test_snapshots/test_snap_initial_view.svg b/tests/__snapshots__/test_snapshots/test_snap_initial_view.svg
new file mode 100644
index 0000000..bb68455
--- /dev/null
+++ b/tests/__snapshots__/test_snapshots/test_snap_initial_view.svg
@@ -0,0 +1,180 @@
+
diff --git a/tests/__snapshots__/test_snapshots/test_snap_narrow_terminal.svg b/tests/__snapshots__/test_snapshots/test_snap_narrow_terminal.svg
new file mode 100644
index 0000000..c6b2a22
--- /dev/null
+++ b/tests/__snapshots__/test_snapshots/test_snap_narrow_terminal.svg
@@ -0,0 +1,181 @@
+
diff --git a/tests/__snapshots__/test_snapshots/test_snap_row_detail.svg b/tests/__snapshots__/test_snapshots/test_snap_row_detail.svg
new file mode 100644
index 0000000..65e86e7
--- /dev/null
+++ b/tests/__snapshots__/test_snapshots/test_snap_row_detail.svg
@@ -0,0 +1,180 @@
+
diff --git a/tests/__snapshots__/test_snapshots/test_snap_search_results.svg b/tests/__snapshots__/test_snapshots/test_snap_search_results.svg
new file mode 100644
index 0000000..46098c3
--- /dev/null
+++ b/tests/__snapshots__/test_snapshots/test_snap_search_results.svg
@@ -0,0 +1,180 @@
+
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..e115572
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,13 @@
+import pathlib
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def _isolated_history(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch):
+ """Give every test its own empty filter-history file.
+
+ Prevents filter/search history written by one test from leaking
+ into another when test execution order changes.
+ """
+ monkeypatch.setenv("DT_BROWSER_HISTORY_FILE", str(tmp_path / "filters.txt"))
diff --git a/tests/test_bookmarks_columns.py b/tests/test_bookmarks_columns.py
new file mode 100644
index 0000000..709d715
--- /dev/null
+++ b/tests/test_bookmarks_columns.py
@@ -0,0 +1,222 @@
+import polars as pl
+
+from dt_browser.bookmarks import Bookmarks
+from dt_browser.browser import DtBrowser, DtBrowserApp
+from dt_browser.column_selector import ColumnSelector
+from dt_browser.custom_table import CustomTable
+
+
+def _make_app(num_rows: int = 3) -> DtBrowserApp:
+ if num_rows <= 3:
+ df = pl.DataFrame({"name": ["alice", "bob", "charlie"], "value": [10, 20, 30]})
+ else:
+ df = pl.DataFrame(
+ {
+ "name": [f"row_{i}" for i in range(num_rows)],
+ "value": list(range(num_rows)),
+ }
+ )
+ return DtBrowserApp("test", df)
+
+
+async def test_toggle_bookmark():
+ app = _make_app()
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+ browser = app.query_one(DtBrowser)
+ bookmarks = browser._bookmarks
+
+ # Bookmark the current row
+ await pilot.press("b")
+ await pilot.pause()
+ assert bookmarks.has_bookmarks
+
+ # Unbookmark the same row
+ await pilot.press("b")
+ await pilot.pause()
+ assert not bookmarks.has_bookmarks
+
+
+async def test_show_bookmarks_panel():
+ app = _make_app()
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+
+ # Add a bookmark first
+ await pilot.press("b")
+ await pilot.pause()
+
+ # Show bookmarks panel
+ await pilot.press("B")
+ await pilot.pause()
+
+ # Bookmarks widget should be mounted in the app
+ assert len(app.query(Bookmarks)) == 1
+
+
+async def test_column_selector_opens():
+ app = _make_app()
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+
+ await pilot.press("c")
+ await pilot.pause()
+
+ assert len(app.query(ColumnSelector)) > 0
+
+
+async def test_column_visibility():
+ app = _make_app()
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+ browser = app.query_one(DtBrowser)
+ initial_cols = browser.visible_columns
+
+ # Open column selector
+ await pilot.press("c")
+ await pilot.pause()
+
+ # The column selector's SelectionList should be focused.
+ # The first item (Row #) should be highlighted. Toggle it off by pressing space.
+ from textual.widgets import SelectionList
+
+ sel_list = app.query_one("#showColumns SelectionList", SelectionList)
+ col_selector = app.query_one("#showColumns", ColumnSelector)
+ assert set(col_selector.selected_columns) == set(initial_cols)
+
+ # Deselect the first column programmatically via the SelectionList
+ # (space key is consumed by the ColumnSelector's Input filter)
+ sel_list.deselect(initial_cols[0])
+ await pilot.pause()
+
+ # Verify the ColumnSelector's selected_columns changed
+ assert len(col_selector.selected_columns) == len(initial_cols) - 1
+
+ # Apply changes
+ await pilot.press("ctrl+a")
+ await pilot.pause()
+
+ new_cols = browser.visible_columns
+ assert len(new_cols) == len(initial_cols) - 1
+ assert initial_cols[0] not in new_cols
+
+
+async def test_all_columns_present_initially():
+ app = _make_app()
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+ browser = app.query_one(DtBrowser)
+
+ # The app adds "Row #" as the first column to the original dataframe columns
+ expected = ("Row #", "name", "value")
+ assert browser.visible_columns == expected
+ assert browser.all_columns == expected
+
+
+async def test_bookmark_navigation():
+ """Bookmark a row, navigate away, then use the bookmarks panel to jump back."""
+ app = _make_app(num_rows=50)
+ async with app.run_test(size=(120, 40)) as pilot:
+ await pilot.pause()
+ browser = app.query_one(DtBrowser)
+ main_table = app.query_one("#main_table", CustomTable)
+
+ # Navigate to row 25 using "G" (go to last row) then move up
+ # Instead, navigate down 25 times from row 0
+ for _ in range(25):
+ await pilot.press("down")
+ await pilot.pause()
+ assert main_table.cursor_coordinate.row == 25
+
+ # Bookmark row 25
+ await pilot.press("b")
+ await pilot.pause()
+ assert browser._bookmarks.has_bookmarks
+
+ # Go back to row 0
+ await pilot.press("g")
+ await pilot.pause()
+ assert main_table.cursor_coordinate.row == 0
+
+ # Open bookmarks panel
+ await pilot.press("B")
+ await pilot.pause()
+ assert len(app.query(Bookmarks)) == 1
+
+ # The bookmark table should have focus; press Enter to select
+ await pilot.press("enter")
+ await pilot.pause()
+
+ # Main table cursor should have moved to row 25
+ assert main_table.cursor_coordinate.row == 25
+
+
+async def test_multiple_bookmarks_navigation():
+ """Bookmark multiple rows, then navigate to a specific one via the bookmarks panel."""
+ app = _make_app(num_rows=50)
+ async with app.run_test(size=(120, 40)) as pilot:
+ await pilot.pause()
+ main_table = app.query_one("#main_table", CustomTable)
+
+ # Bookmark row 0
+ await pilot.press("b")
+ await pilot.pause()
+
+ # Navigate to row 10 and bookmark
+ for _ in range(10):
+ await pilot.press("down")
+ await pilot.pause()
+ assert main_table.cursor_coordinate.row == 10
+ await pilot.press("b")
+ await pilot.pause()
+
+ # Navigate to row 20 and bookmark
+ for _ in range(10):
+ await pilot.press("down")
+ await pilot.pause()
+ assert main_table.cursor_coordinate.row == 20
+ await pilot.press("b")
+ await pilot.pause()
+
+ # Go back to row 0
+ await pilot.press("g")
+ await pilot.pause()
+ assert main_table.cursor_coordinate.row == 0
+
+ # Open bookmarks panel
+ await pilot.press("B")
+ await pilot.pause()
+ assert len(app.query(Bookmarks)) == 1
+
+ # The bookmark table cursor starts at row 0 (first bookmark = row 0).
+ # Press down to move to the second bookmark (row 10), then Enter.
+ await pilot.press("down")
+ await pilot.pause()
+ await pilot.press("enter")
+ await pilot.pause()
+
+ # Main table should navigate to row 10
+ assert main_table.cursor_coordinate.row == 10
+
+
+async def test_bookmark_panel_closes_on_escape():
+ """Opening the bookmarks panel with B and closing it with Escape."""
+ app = _make_app()
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+
+ # Bookmark a row so we can open the panel
+ await pilot.press("b")
+ await pilot.pause()
+
+ # Open bookmarks panel
+ await pilot.press("B")
+ await pilot.pause()
+ assert len(app.query(Bookmarks)) == 1
+
+ # Press escape to close
+ await pilot.press("escape")
+ await pilot.pause()
+
+ # Bookmarks widget should be removed
+ assert len(app.query(Bookmarks)) == 0
diff --git a/tests/test_browsing.py b/tests/test_browsing.py
new file mode 100644
index 0000000..d6c9d72
--- /dev/null
+++ b/tests/test_browsing.py
@@ -0,0 +1,86 @@
+import polars as pl
+from textual.coordinate import Coordinate
+
+from dt_browser.browser import DtBrowserApp
+from dt_browser.custom_table import CustomTable
+
+
+def _make_app(num_rows: int = 50) -> DtBrowserApp:
+ df = pl.DataFrame(
+ {
+ "name": [f"item_{i}" for i in range(num_rows)],
+ "value": list(range(num_rows)),
+ "score": [float(i) * 1.5 for i in range(num_rows)],
+ "category": [f"cat_{i % 5}" for i in range(num_rows)],
+ "extra": [f"extra_data_{i}" for i in range(num_rows)],
+ }
+ )
+ return DtBrowserApp("test", df)
+
+
+async def test_app_starts_with_data():
+ """App mounts and table has the correct number of rows."""
+ app = _make_app(num_rows=20)
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+ table = app.query_one("#main_table", CustomTable)
+ assert len(table._dt) == 20
+ assert table.cursor_coordinate == Coordinate(0, 0)
+
+
+async def test_cursor_movement_arrows():
+ """Arrow keys move the cursor down and right."""
+ app = _make_app()
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+ table = app.query_one("#main_table", CustomTable)
+
+ await pilot.press("down")
+ await pilot.pause()
+ assert table.cursor_coordinate.row == 1
+
+ await pilot.press("down")
+ await pilot.pause()
+ assert table.cursor_coordinate.row == 2
+
+ await pilot.press("right")
+ await pilot.pause()
+ assert table.cursor_coordinate.column == 1
+
+ await pilot.press("up")
+ await pilot.pause()
+ assert table.cursor_coordinate.row == 1
+
+
+async def test_cursor_jump_top_bottom():
+ """G goes to last row, g goes to first row."""
+ app = _make_app(num_rows=50)
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+ table = app.query_one("#main_table", CustomTable)
+
+ await pilot.press("G")
+ await pilot.pause()
+ assert table.cursor_coordinate.row == 49
+
+ await pilot.press("g")
+ await pilot.pause()
+ assert table.cursor_coordinate.row == 0
+
+
+async def test_page_down_up():
+ """Pagedown moves cursor significantly, pageup brings it back."""
+ app = _make_app(num_rows=100)
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+ table = app.query_one("#main_table", CustomTable)
+ assert table.cursor_coordinate.row == 0
+
+ await pilot.press("pagedown")
+ await pilot.pause()
+ row_after_pgdn = table.cursor_coordinate.row
+ assert row_after_pgdn > 5 # moved down significantly
+
+ await pilot.press("pageup")
+ await pilot.pause()
+ assert table.cursor_coordinate.row < row_after_pgdn
diff --git a/tests/test_expressions.py b/tests/test_expressions.py
new file mode 100644
index 0000000..934d021
--- /dev/null
+++ b/tests/test_expressions.py
@@ -0,0 +1,126 @@
+import polars as pl
+
+from dt_browser.browser import DtBrowser, DtBrowserApp
+from dt_browser.custom_table import CustomTable
+from dt_browser.expression_box import ExpressionBox
+
+
+def _make_app() -> DtBrowserApp:
+ df = pl.DataFrame({"name": ["alice", "bob", "charlie", "dave"], "value": [10, 20, 30, 40]})
+ return DtBrowserApp("test", df)
+
+
+async def _submit_expression(pilot, app, expr: str):
+ """Type an expression in the already-open expression box and submit."""
+ from textual.widgets import Input
+
+ inp = app.query_one("ExpressionBox Input", Input)
+ inp.value = ""
+ await pilot.pause()
+ await pilot.press(*list(expr))
+ await pilot.press("enter")
+ await pilot.pause()
+ # Allow the @work to complete
+ await pilot.pause()
+
+
+async def test_compute_new_column():
+ """Computing an expression adds a new column to the table."""
+ app = _make_app()
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+ browser = app.query_one(DtBrowser)
+ initial_cols = browser.visible_columns
+
+ await pilot.press("x")
+ await pilot.pause()
+ await _submit_expression(pilot, app, "doubled = value * 2")
+
+ assert "doubled" in browser.visible_columns
+ assert "doubled" in browser.all_columns
+ assert len(browser.visible_columns) == len(initial_cols) + 1
+
+ # Verify the computed values are correct
+ doubled_col = browser._original_dt["doubled"]
+ assert doubled_col.to_list() == [20, 40, 60, 80]
+
+ # Verify the column is visible in the rendered table
+ table = app.query_one("#main_table", CustomTable)
+ assert "doubled" in table._dt.columns
+ assert "doubled" in table._widths
+
+
+async def test_computed_column_usable_in_filter():
+ """A computed column can be used in a subsequent filter."""
+ app = _make_app()
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+ browser = app.query_one(DtBrowser)
+
+ # Add computed column
+ await pilot.press("x")
+ await pilot.pause()
+ await _submit_expression(pilot, app, "doubled = value * 2")
+ assert "doubled" in browser.all_columns
+
+ # Close expression box, then filter using the computed column
+ await pilot.press("escape")
+ await pilot.pause()
+
+ # Focus should be back on main table for "f" to work
+ app.query_one("#main_table", CustomTable).focus()
+ await pilot.pause()
+
+ await pilot.press("f")
+ await pilot.pause()
+ await pilot.press(*list("doubled > 40"))
+ await pilot.press("enter")
+ await pilot.pause()
+ await pilot.pause()
+
+ assert browser.is_filtered
+ # doubled > 40 means value > 20, so charlie(60) and dave(80)
+ assert browser.cur_total_rows == 2
+
+
+async def test_computed_column_as_input_for_further_expression():
+ """A computed column can be referenced in a subsequent expression."""
+ app = _make_app()
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+ browser = app.query_one(DtBrowser)
+
+ # First computed column
+ await pilot.press("x")
+ await pilot.pause()
+ await _submit_expression(pilot, app, "doubled = value * 2")
+ assert "doubled" in browser.all_columns
+
+ # Second computed column referencing the first (expression box still open)
+ await _submit_expression(pilot, app, "quadrupled = doubled * 2")
+ assert "quadrupled" in browser.all_columns
+
+ # Verify values
+ assert browser._original_dt["quadrupled"].to_list() == [40, 80, 120, 160]
+
+ # Both computed columns should be visible in the rendered table
+ table = app.query_one("#main_table", CustomTable)
+ assert "doubled" in table._dt.columns
+ assert "quadrupled" in table._dt.columns
+ assert "doubled" in table._widths
+ assert "quadrupled" in table._widths
+
+
+async def test_expression_box_closes_on_escape():
+ """Expression box closes when escape is pressed."""
+ app = _make_app()
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+
+ await pilot.press("x")
+ await pilot.pause()
+ assert len(app.query(ExpressionBox)) == 1
+
+ await pilot.press("escape")
+ await pilot.pause()
+ assert len(app.query(ExpressionBox)) == 0
diff --git a/tests/test_filtering.py b/tests/test_filtering.py
new file mode 100644
index 0000000..69eae45
--- /dev/null
+++ b/tests/test_filtering.py
@@ -0,0 +1,133 @@
+import polars as pl
+
+from dt_browser.browser import DtBrowser, DtBrowserApp
+from dt_browser.filter_box import FilterBox
+
+
+def _make_app() -> DtBrowserApp:
+ df = pl.DataFrame({"name": ["alice", "bob", "charlie", "dave"], "value": [1, 2, 3, 4]})
+ return DtBrowserApp("test", df)
+
+
+async def test_filter_reduces_rows():
+ app = _make_app()
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+ browser = app.query_one(DtBrowser)
+ original_rows = browser.cur_total_rows
+
+ # Open filter with "f", type a WHERE clause, press enter
+ await pilot.press("f")
+ await pilot.pause()
+ await pilot.press(*list("value > 2"))
+ await pilot.press("enter")
+ await pilot.pause()
+ # Worker needs time to complete
+ await pilot.pause()
+ await pilot.pause()
+
+ assert browser.is_filtered
+ assert browser.cur_total_rows < original_rows
+ assert browser.cur_total_rows == 2 # rows with value 3 and 4
+
+
+async def test_filter_clear_restores_rows():
+ app = _make_app()
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+ browser = app.query_one(DtBrowser)
+ original_rows = browser.cur_total_rows
+
+ # Apply a filter first
+ await pilot.press("f")
+ await pilot.pause()
+ await pilot.press(*list("value > 2"))
+ await pilot.press("enter")
+ await pilot.pause()
+ await pilot.pause()
+ await pilot.pause()
+
+ assert browser.is_filtered
+ assert browser.cur_total_rows < original_rows
+
+ # Clear the filter: open filter box, clear the input, submit empty
+ await pilot.press("f") # re-open filter box
+ await pilot.pause()
+ # Select all text in the input and delete it
+ filter_box = app.query_one(FilterBox)
+ inp = filter_box.query_one("Input")
+ # Clear the input by selecting all and deleting
+ inp.value = ""
+ await pilot.press("enter")
+ await pilot.pause()
+ await pilot.pause()
+ await pilot.pause()
+
+ assert not browser.is_filtered
+ assert browser.cur_total_rows == original_rows
+
+
+async def test_search_highlights_results():
+ app = _make_app()
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+ browser = app.query_one(DtBrowser)
+
+ # Open search with "/"
+ await pilot.press("/")
+ await pilot.pause()
+ await pilot.press(*list("value > 1"))
+ await pilot.press("enter")
+ await pilot.pause()
+ await pilot.pause()
+ await pilot.pause()
+
+ assert browser.active_search_queue is not None
+ assert len(browser.active_search_queue) == 3 # rows with value 2, 3, 4
+
+
+async def test_search_next_prev():
+ app = _make_app()
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+ browser = app.query_one(DtBrowser)
+
+ # Open search, submit query
+ await pilot.press("/")
+ await pilot.pause()
+ await pilot.press(*list("value > 1"))
+ await pilot.press("enter")
+ await pilot.pause()
+ await pilot.pause()
+ await pilot.pause()
+
+ assert browser.active_search_queue is not None
+
+ # After search, active_search_idx should be set (0 from the initial goto)
+ initial_idx = browser.active_search_idx
+
+ # Press "n" to go to next result
+ await pilot.press("n")
+ await pilot.pause()
+ assert browser.active_search_idx == initial_idx + 1
+
+ # Press "N" (shift+n) to go to previous result
+ await pilot.press("N")
+ await pilot.pause()
+ assert browser.active_search_idx == initial_idx
+
+
+async def test_filter_box_closes_on_escape():
+ app = _make_app()
+ async with app.run_test(size=(120, 30)) as pilot:
+ await pilot.pause()
+
+ # Open filter with "f"
+ await pilot.press("f")
+ await pilot.pause()
+ assert app.query(FilterBox), "FilterBox should be mounted"
+
+ # Press escape to close
+ await pilot.press("escape")
+ await pilot.pause()
+ assert not app.query(FilterBox), "FilterBox should be removed after escape"
diff --git a/tests/test_resize.py b/tests/test_resize.py
new file mode 100644
index 0000000..addd502
--- /dev/null
+++ b/tests/test_resize.py
@@ -0,0 +1,177 @@
+import polars as pl
+
+from dt_browser.browser import DtBrowserApp
+from dt_browser.custom_table import CustomTable
+
+
+def _make_wide_app(num_rows: int = 30) -> DtBrowserApp:
+ """Create an app with 10 columns of varied widths for resize testing."""
+ df = pl.DataFrame(
+ {
+ "id": list(range(num_rows)),
+ "name": [f"item_{i}" for i in range(num_rows)],
+ "description": [f"a longer description for row {i}" for i in range(num_rows)],
+ "category": [f"cat_{i % 5}" for i in range(num_rows)],
+ "price": [round(i * 3.14, 2) for i in range(num_rows)],
+ "quantity": [i * 10 for i in range(num_rows)],
+ "warehouse": [f"warehouse_{i % 3}_location" for i in range(num_rows)],
+ "supplier": [f"supplier_{i % 7}_name" for i in range(num_rows)],
+ "rating": [round(1.0 + (i % 50) / 10.0, 1) for i in range(num_rows)],
+ "notes": [f"some notes about item number {i}" for i in range(num_rows)],
+ }
+ )
+ return DtBrowserApp("test", df)
+
+
+def _visible_data_columns(table: CustomTable) -> list[str]:
+ """Return the data column names currently visible in the viewport.
+
+ Replicates the column-fitting logic from render_header_and_table to determine
+ which of the table's data columns fit in the current effective width.
+ """
+ scroll_x = table.scroll_offset.x
+ effective_width = table.scrollable_content_region.width
+ if effective_width <= 2:
+ return []
+ cols = []
+ for col_name in table._dt.columns:
+ min_offset = table._cum_widths[col_name] - scroll_x
+ max_offset = min_offset + table._widths[col_name]
+ if min_offset < 0:
+ continue
+ max_available = effective_width - min_offset - 2 # 2 * COL_PADDING where COL_PADDING=1
+ if max_offset >= effective_width and max_available < 4:
+ break
+ cols.append(col_name)
+ if max_offset >= effective_width:
+ break
+ return cols
+
+
+async def test_resize_changes_visible_columns():
+ """Resizing to a narrow terminal reduces the number of visible columns."""
+ app = _make_wide_app()
+ async with app.run_test(size=(160, 30)) as pilot:
+ await pilot.pause()
+ table = app.query_one("#main_table", CustomTable)
+ wide_visible = _visible_data_columns(table)
+
+ # Now resize to something narrow
+ await pilot.resize_terminal(40, 30)
+ await pilot.pause()
+
+ narrow_visible = _visible_data_columns(table)
+ assert len(narrow_visible) < len(wide_visible), (
+ f"Expected fewer columns at width 40 ({len(narrow_visible)}) "
+ f"than at width 160 ({len(wide_visible)})"
+ )
+
+
+async def test_incremental_shrink():
+ """Shrinking the terminal in steps monotonically reduces visible columns."""
+ app = _make_wide_app()
+ async with app.run_test(size=(200, 30)) as pilot:
+ await pilot.pause()
+ table = app.query_one("#main_table", CustomTable)
+
+ counts = []
+ for width in [200, 160, 120, 80, 60]:
+ await pilot.resize_terminal(width, 30)
+ await pilot.pause()
+ counts.append(len(_visible_data_columns(table)))
+
+ # Monotonically non-increasing
+ for i in range(len(counts) - 1):
+ assert counts[i] >= counts[i + 1], (
+ f"Column count increased from {counts[i]} to {counts[i + 1]} "
+ f"when shrinking terminal"
+ )
+
+ # Widest has strictly more columns than narrowest
+ assert counts[0] > counts[-1], (
+ f"Expected more columns at width 200 ({counts[0]}) than at 60 ({counts[-1]})"
+ )
+
+
+async def test_incremental_expand():
+ """Expanding the terminal in steps monotonically increases visible columns."""
+ app = _make_wide_app()
+ async with app.run_test(size=(60, 30)) as pilot:
+ await pilot.pause()
+ table = app.query_one("#main_table", CustomTable)
+
+ counts = []
+ for width in [60, 80, 120, 160, 200]:
+ await pilot.resize_terminal(width, 30)
+ await pilot.pause()
+ counts.append(len(_visible_data_columns(table)))
+
+ # Monotonically non-decreasing
+ for i in range(len(counts) - 1):
+ assert counts[i] <= counts[i + 1], (
+ f"Column count decreased from {counts[i]} to {counts[i + 1]} "
+ f"when expanding terminal"
+ )
+
+ # Narrowest has strictly fewer columns than widest
+ assert counts[0] < counts[-1], (
+ f"Expected fewer columns at width 60 ({counts[0]}) than at 200 ({counts[-1]})"
+ )
+
+
+async def test_shrink_then_expand_restores():
+ """Shrinking then expanding back to original width restores the same columns."""
+ app = _make_wide_app()
+ async with app.run_test(size=(160, 30)) as pilot:
+ await pilot.pause()
+ table = app.query_one("#main_table", CustomTable)
+
+ cols_before = _visible_data_columns(table)
+
+ # Shrink
+ await pilot.resize_terminal(60, 30)
+ await pilot.pause()
+
+ # Expand back
+ await pilot.resize_terminal(160, 30)
+ await pilot.pause()
+
+ cols_after = _visible_data_columns(table)
+
+ assert cols_before == cols_after, (
+ f"Columns differ after shrink/expand cycle: "
+ f"before={cols_before}, after={cols_after}"
+ )
+
+
+async def test_cursor_stays_valid_after_shrink():
+ """Cursor remains within valid bounds after shrinking hides columns."""
+ app = _make_wide_app()
+ async with app.run_test(size=(200, 30)) as pilot:
+ await pilot.pause()
+ table = app.query_one("#main_table", CustomTable)
+
+ # Move cursor to a rightward column
+ total_cols = len(table._dt.columns)
+ target_col = total_cols - 1
+ for _ in range(target_col):
+ await pilot.press("right")
+ await pilot.pause()
+ assert table.cursor_coordinate.column == target_col
+
+ # Shrink so some columns are hidden
+ await pilot.resize_terminal(60, 30)
+ await pilot.pause()
+
+ # Cursor column must be within valid range
+ cursor = table.cursor_coordinate
+ assert 0 <= cursor.row < len(table._dt)
+ assert 0 <= cursor.column < total_cols
+
+ # The cursor column must be visible in the viewport
+ visible_cols = _visible_data_columns(table)
+ cursor_col_name = table._dt.columns[cursor.column]
+ assert cursor_col_name in visible_cols, (
+ f"Cursor on column '{cursor_col_name}' which is not in "
+ f"visible columns {visible_cols}"
+ )
diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py
new file mode 100644
index 0000000..e85ee35
--- /dev/null
+++ b/tests/test_snapshots.py
@@ -0,0 +1,102 @@
+import polars as pl
+from textual.pilot import Pilot
+
+from dt_browser.browser import DtBrowserApp
+from dt_browser.custom_table import CustomTable
+
+
+def _make_app(num_rows: int = 20) -> DtBrowserApp:
+ df = pl.DataFrame(
+ {
+ "name": [f"item_{i}" for i in range(num_rows)],
+ "value": list(range(num_rows)),
+ "score": [round(i * 1.5, 1) for i in range(num_rows)],
+ "category": [f"cat_{i % 5}" for i in range(num_rows)],
+ }
+ )
+ return DtBrowserApp("test", df)
+
+
+def test_snap_initial_view(snap_compare):
+ """Snapshot of the app on initial load."""
+ assert snap_compare(_make_app(), terminal_size=(120, 30))
+
+
+def test_snap_cursor_moved(snap_compare):
+ """Snapshot after moving cursor down and right."""
+ assert snap_compare(
+ _make_app(),
+ press=["down", "down", "down", "right", "right"],
+ terminal_size=(120, 30),
+ )
+
+
+def test_snap_filter_applied(snap_compare):
+ """Snapshot after applying a filter."""
+
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.press("f")
+ await pilot.pause()
+ await pilot.press(*list("value > 10"))
+ await pilot.press("enter")
+ # Wait for the @work filter to complete
+ await pilot.app.workers.wait_for_complete()
+ await pilot.pause()
+
+ assert snap_compare(_make_app(), run_before=run_before, terminal_size=(120, 30))
+
+
+def test_snap_search_results(snap_compare):
+ """Snapshot showing search result highlighting."""
+
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.press("/")
+ await pilot.pause()
+ await pilot.press(*list("value > 5"))
+ await pilot.press("enter")
+ await pilot.pause()
+ await pilot.pause()
+
+ assert snap_compare(_make_app(), run_before=run_before, terminal_size=(120, 30))
+
+
+def test_snap_column_selector_open(snap_compare):
+ """Snapshot with column selector panel open."""
+ assert snap_compare(
+ _make_app(),
+ press=["c"],
+ terminal_size=(120, 30),
+ )
+
+
+def test_snap_column_hidden(snap_compare):
+ """Snapshot after hiding a column via the column selector."""
+
+ async def run_before(pilot: Pilot) -> None:
+ from textual.widgets import SelectionList
+
+ await pilot.press("c")
+ await pilot.pause()
+ # Deselect the "score" column
+ sel_list = pilot.app.query_one("#showColumns SelectionList", SelectionList)
+ sel_list.deselect("score")
+ await pilot.pause()
+ # Apply changes
+ await pilot.press("ctrl+a")
+ await pilot.pause()
+
+ assert snap_compare(_make_app(), run_before=run_before, terminal_size=(120, 30))
+
+
+def test_snap_narrow_terminal(snap_compare):
+ """Snapshot at a narrow terminal width showing fewer columns."""
+ assert snap_compare(_make_app(), terminal_size=(60, 30))
+
+
+def test_snap_row_detail(snap_compare):
+ """Snapshot with row detail panel visible (default)."""
+ assert snap_compare(
+ _make_app(),
+ press=["down", "down"],
+ terminal_size=(120, 30),
+ )