From 56ddf0c3f8d47aba57c9796a1a1a3eb8b04ed174 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 24 Mar 2026 23:59:13 +0000 Subject: [PATCH 1/8] Add unit tests for browsing, filtering, searching, bookmarks, and columns - test_browsing: app startup, cursor movement, jump top/bottom, page up/down, resize - test_filtering: filter reduces rows, clear restores, search highlights, next/prev, escape closes - test_bookmarks_columns: toggle bookmark, bookmarks panel, column selector, column visibility, initial columns Co-Authored-By: Claude Opus 4.6 --- tests/test_bookmarks_columns.py | 104 +++++++++++++++++++++++++ tests/test_browsing.py | 104 +++++++++++++++++++++++++ tests/test_filtering.py | 133 ++++++++++++++++++++++++++++++++ 3 files changed, 341 insertions(+) create mode 100644 tests/test_bookmarks_columns.py create mode 100644 tests/test_browsing.py create mode 100644 tests/test_filtering.py diff --git a/tests/test_bookmarks_columns.py b/tests/test_bookmarks_columns.py new file mode 100644 index 0000000..ed42d42 --- /dev/null +++ b/tests/test_bookmarks_columns.py @@ -0,0 +1,104 @@ +import polars as pl + +from dt_browser.bookmarks import Bookmarks +from dt_browser.browser import DtBrowser, DtBrowserApp +from dt_browser.column_selector import ColumnSelector + + +def _make_app() -> DtBrowserApp: + df = pl.DataFrame({"name": ["alice", "bob", "charlie"], "value": [10, 20, 30]}) + 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 diff --git a/tests/test_browsing.py b/tests/test_browsing.py new file mode 100644 index 0000000..0e65117 --- /dev/null +++ b/tests/test_browsing.py @@ -0,0 +1,104 @@ +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_resize_changes_visible_columns(): + """Resizing to a narrow terminal reduces the number of visible columns.""" + app = _make_app() + async with app.run_test(size=(160, 30)) as pilot: + await pilot.pause() + table = app.query_one("#main_table", CustomTable) + wide_widths = set(table._widths.keys()) + + # Now resize to something narrow + await pilot.resize_terminal(40, 30) + await pilot.pause() + + # After resize, fewer columns should fit in the rendered output + _, render_df = table.render_header_and_table + rendered_cols = [c for c in render_df.columns if c in wide_widths] + assert len(rendered_cols) < len(wide_widths) + + +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_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" From 409de8baa1c85cdd6d85bedb15713d5de88bf30b Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 25 Mar 2026 00:15:28 +0000 Subject: [PATCH 2/8] Add tests for resize, bookmark navigation, and computed expressions - test_resize: incremental shrink/expand, shrink-then-restore, cursor validity - test_bookmarks_columns: bookmark navigation via panel selection, multi-bookmark navigation, panel close on escape - test_expressions: compute new column, use in filter, chain as input for further expressions, expression box close - Moved resize test out of test_browsing into dedicated test_resize file Co-Authored-By: Claude Opus 4.6 --- tests/test_bookmarks_columns.py | 127 +++++++++++++++++++++- tests/test_browsing.py | 18 ---- tests/test_expressions.py | 115 ++++++++++++++++++++ tests/test_resize.py | 179 ++++++++++++++++++++++++++++++++ 4 files changed, 417 insertions(+), 22 deletions(-) create mode 100644 tests/test_expressions.py create mode 100644 tests/test_resize.py diff --git a/tests/test_bookmarks_columns.py b/tests/test_bookmarks_columns.py index ed42d42..8e97a20 100644 --- a/tests/test_bookmarks_columns.py +++ b/tests/test_bookmarks_columns.py @@ -3,10 +3,19 @@ from dt_browser.bookmarks import Bookmarks from dt_browser.browser import DtBrowser, DtBrowserApp from dt_browser.column_selector import ColumnSelector - - -def _make_app() -> DtBrowserApp: - df = pl.DataFrame({"name": ["alice", "bob", "charlie"], "value": [10, 20, 30]}) +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) @@ -102,3 +111,113 @@ async def test_all_columns_present_initially(): 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() + browser = app.query_one(DtBrowser) + 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 index 0e65117..d6c9d72 100644 --- a/tests/test_browsing.py +++ b/tests/test_browsing.py @@ -68,24 +68,6 @@ async def test_cursor_jump_top_bottom(): assert table.cursor_coordinate.row == 0 -async def test_resize_changes_visible_columns(): - """Resizing to a narrow terminal reduces the number of visible columns.""" - app = _make_app() - async with app.run_test(size=(160, 30)) as pilot: - await pilot.pause() - table = app.query_one("#main_table", CustomTable) - wide_widths = set(table._widths.keys()) - - # Now resize to something narrow - await pilot.resize_terminal(40, 30) - await pilot.pause() - - # After resize, fewer columns should fit in the rendered output - _, render_df = table.render_header_and_table - rendered_cols = [c for c in render_df.columns if c in wide_widths] - assert len(rendered_cols) < len(wide_widths) - - async def test_page_down_up(): """Pagedown moves cursor significantly, pageup brings it back.""" app = _make_app(num_rows=100) diff --git a/tests/test_expressions.py b/tests/test_expressions.py new file mode 100644 index 0000000..6d0fb28 --- /dev/null +++ b/tests/test_expressions.py @@ -0,0 +1,115 @@ +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 +from dt_browser.filter_box import FilterBox + + +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] + + +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] + + +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_resize.py b/tests/test_resize.py new file mode 100644 index 0000000..6593635 --- /dev/null +++ b/tests/test_resize.py @@ -0,0 +1,179 @@ +import polars as pl +from textual.coordinate import Coordinate + +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) + all_cols = list(table._widths.keys()) + 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}" + ) From e7b23ce544a09eee30da63ef94b7eb0dd7813c16 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 25 Mar 2026 00:18:46 +0000 Subject: [PATCH 3/8] Assert computed columns are visible in table, fix lint errors - Add assertions that computed columns appear in table._dt.columns and table._widths after expression evaluation - Remove unused imports and variables flagged by ruff Co-Authored-By: Claude Opus 4.6 --- src/dt_browser/browser.py | 1 - tests/test_bookmarks_columns.py | 1 - tests/test_expressions.py | 13 ++++++++++++- tests/test_resize.py | 2 -- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/dt_browser/browser.py b/src/dt_browser/browser.py index 85fd17e..38a5bc3 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 diff --git a/tests/test_bookmarks_columns.py b/tests/test_bookmarks_columns.py index 8e97a20..709d715 100644 --- a/tests/test_bookmarks_columns.py +++ b/tests/test_bookmarks_columns.py @@ -156,7 +156,6 @@ async def test_multiple_bookmarks_navigation(): 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) # Bookmark row 0 diff --git a/tests/test_expressions.py b/tests/test_expressions.py index 6d0fb28..934d021 100644 --- a/tests/test_expressions.py +++ b/tests/test_expressions.py @@ -3,7 +3,6 @@ from dt_browser.browser import DtBrowser, DtBrowserApp from dt_browser.custom_table import CustomTable from dt_browser.expression_box import ExpressionBox -from dt_browser.filter_box import FilterBox def _make_app() -> DtBrowserApp: @@ -45,6 +44,11 @@ async def test_compute_new_column(): 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.""" @@ -99,6 +103,13 @@ async def test_computed_column_as_input_for_further_expression(): # 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.""" diff --git a/tests/test_resize.py b/tests/test_resize.py index 6593635..addd502 100644 --- a/tests/test_resize.py +++ b/tests/test_resize.py @@ -1,5 +1,4 @@ import polars as pl -from textual.coordinate import Coordinate from dt_browser.browser import DtBrowserApp from dt_browser.custom_table import CustomTable @@ -55,7 +54,6 @@ async def test_resize_changes_visible_columns(): async with app.run_test(size=(160, 30)) as pilot: await pilot.pause() table = app.query_one("#main_table", CustomTable) - all_cols = list(table._widths.keys()) wide_visible = _visible_data_columns(table) # Now resize to something narrow From 2a27da22c347172fb58221a1e57e456455e3653d Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 25 Mar 2026 00:26:06 +0000 Subject: [PATCH 4/8] Add snapshot tests and fix mypy errors in merged expression code Add 8 snapshot tests covering initial view, cursor movement, filtering, search results, column selector, column hiding, narrow terminal, and row detail. Fix mypy type errors in expression_box.py (None guard for event.item.name) and browser.py (use empty string instead of None for pending_action reactive). Add pytest-textual-snapshot dev dependency. Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 1 + src/dt_browser/browser.py | 207 ++++-------------- src/dt_browser/expression_box.py | 3 +- .../test_snap_column_hidden.svg | 186 ++++++++++++++++ .../test_snap_column_selector_open.svg | 185 ++++++++++++++++ .../test_snapshots/test_snap_cursor_moved.svg | 180 +++++++++++++++ .../test_snap_filter_applied.svg | 182 +++++++++++++++ .../test_snapshots/test_snap_initial_view.svg | 180 +++++++++++++++ .../test_snap_narrow_terminal.svg | 181 +++++++++++++++ .../test_snapshots/test_snap_row_detail.svg | 180 +++++++++++++++ .../test_snap_search_results.svg | 180 +++++++++++++++ tests/test_snapshots.py | 102 +++++++++ 12 files changed, 1606 insertions(+), 161 deletions(-) create mode 100644 tests/__snapshots__/test_snapshots/test_snap_column_hidden.svg create mode 100644 tests/__snapshots__/test_snapshots/test_snap_column_selector_open.svg create mode 100644 tests/__snapshots__/test_snapshots/test_snap_cursor_moved.svg create mode 100644 tests/__snapshots__/test_snapshots/test_snap_filter_applied.svg create mode 100644 tests/__snapshots__/test_snapshots/test_snap_initial_view.svg create mode 100644 tests/__snapshots__/test_snapshots/test_snap_narrow_terminal.svg create mode 100644 tests/__snapshots__/test_snapshots/test_snap_row_detail.svg create mode 100644 tests/__snapshots__/test_snapshots/test_snap_search_results.svg create mode 100644 tests/test_snapshots.py diff --git a/pyproject.toml b/pyproject.toml index b4f1538..5415d87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dev = [ "pylint", "pytest", "pytest-asyncio", + "pytest-textual-snapshot", "ruff", ] diff --git a/src/dt_browser/browser.py b/src/dt_browser/browser.py index 38a5bc3..4574d7d 100644 --- a/src/dt_browser/browser.py +++ b/src/dt_browser/browser.py @@ -77,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) @@ -115,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] @@ -132,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] @@ -233,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) @@ -253,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: @@ -296,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) @@ -341,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 = [ @@ -396,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, @@ -432,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()) @@ -474,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,) @@ -492,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() @@ -517,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 @@ -544,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 @@ -571,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 @@ -634,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) @@ -686,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 @@ -723,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) @@ -743,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 @@ -765,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 ] @@ -823,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", @@ -882,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 @@ -923,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) @@ -939,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) @@ -983,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, @@ -1010,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/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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DtBrowserApp + + + + + + + + + +  Row # name    value category                               ▔ Row Detail ▔▔▔▔▔▔▔▔▔▔▎▔ Show/Hide/Reorder Columns ▔▔▔▔▔▎ +    1 item_0      0 cat_0                                   Field    dtype      ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +    2 item_1      1 cat_1                                   Row #    UInt32     Type to filter columns  +    3 item_2      2 cat_2                                   name     String     ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +    4 item_3      3 cat_3                                   value    Int64      ▊▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎▎ +    5 item_4      4 cat_4                                   score    Float64    ▊▊X Row #▎▎ +    6 item_5      5 cat_0                                   category String     ▊▊X name▎▎ +    7 item_6      6 cat_1                                  ▊▊X value▎▎ +    8 item_7      7 cat_2                                  ▊▊X score▎▎ +    9 item_8      8 cat_3                                  ▊▊X category▎▎ +   10 item_9      9 cat_4                                  ▊▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎▎ +   11 item_10    10 cat_0                                  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ +   12 item_11    11 cat_1                                   +   13 item_12    12 cat_2                                   +   14 item_13    13 cat_3                                   +   15 item_14    14 cat_4                                   +   16 item_15    15 cat_0                                   +   17 item_16    16 cat_1                                   +   18 item_17    17 cat_2                                   +   19 item_18    18 cat_3                                   +   20 item_19    19 cat_4                                   + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ + esc Close and Apply  ctrl+a Apply  ctrl+s Select All  ctrl+x Deselect All 1 / 20^p palette + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DtBrowserApp + + + + + + + + + +  Row # name    value score category                         ▔ Row Detail ▔▔▔▔▔▔▔▔▔▔▎▔ Show/Hide/Reorder Columns ▔▔▔▔▔▎ +    1 item_0      0   0.0 cat_0                             Field    dtype      ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +    2 item_1      1   1.5 cat_1                             Row #    UInt32     Type to filter columns  +    3 item_2      2   3.0 cat_2                             name     String     ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +    4 item_3      3   4.5 cat_3                             value    Int64      ▊▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎▎ +    5 item_4      4   6.0 cat_4                             score    Float64    ▊▊X Row #▎▎ +    6 item_5      5   7.5 cat_0                             category String     ▊▊X name▎▎ +    7 item_6      6   9.0 cat_1                            ▊▊X value▎▎ +    8 item_7      7  10.5 cat_2                            ▊▊X score▎▎ +    9 item_8      8  12.0 cat_3                            ▊▊X category▎▎ +   10 item_9      9  13.5 cat_4                            ▊▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎▎ +   11 item_10    10  15.0 cat_0                            ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ +   12 item_11    11  16.5 cat_1                             +   13 item_12    12  18.0 cat_2                             +   14 item_13    13  19.5 cat_3                             +   15 item_14    14  21.0 cat_4                             +   16 item_15    15  22.5 cat_0                             +   17 item_16    16  24.0 cat_1                             +   18 item_17    17  25.5 cat_2                             +   19 item_18    18  27.0 cat_3                             +   20 item_19    19  28.5 cat_4                             + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ + esc Close and Apply  ctrl+a Apply  ctrl+s Select All  ctrl+x Deselect All 1 / 20^p palette + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DtBrowserApp + + + + + + + + + +  Row # name    value score category                                                 ▔ Row Detail ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎ +     1 item_0      0   0.0 cat_0                                                     Field    dtype   Value          +     2 item_1      1   1.5 cat_1                                                     Row #    UInt32  4              +     3 item_2      2   3.0 cat_2                                                     name     String  item_3         +     4 item_3      3   4.5 cat_3                                                     value    Int64   3              +     5 item_4      4   6.0 cat_4                                                     score    Float64 4.5            +     6 item_5      5   7.5 cat_0                                                     category String  cat_3          +     7 item_6      6   9.0 cat_1                                                     +     8 item_7      7  10.5 cat_2                                                     +     9 item_8      8  12.0 cat_3                                                     +    10 item_9      9  13.5 cat_4                                                     +    11 item_10    10  15.0 cat_0                                                     +    12 item_11    11  16.5 cat_1                                                     +    13 item_12    12  18.0 cat_2                                                     +    14 item_13    13  19.5 cat_3                                                     +    15 item_14    14  21.0 cat_4                                                     +    16 item_15    15  22.5 cat_0                                                     +    17 item_16    16  24.0 cat_1                                                     +    18 item_17    17  25.5 cat_2                                                     +    19 item_18    18  27.0 cat_3                                                     +    20 item_19    19  28.5 cat_4                                                     + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ + f Filter rows  / Search  b Add/Del Bookmark  x Compute Expressions...  c Columns...  r Toggle Row Detail  s^p palette + + + 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..12b2a06 --- /dev/null +++ b/tests/__snapshots__/test_snapshots/test_snap_filter_applied.svg @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DtBrowserApp + + + + + + + + + +  Row # name    value score category                                                 ▔ Row Detail ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎ +   12 item_11    11  16.5 cat_1                                                     Field    dtype   Value          +   13 item_12    12  18.0 cat_2                                                     Row #    UInt32  1              +   14 item_13    13  19.5 cat_3                                                     name     String  item_0         +   15 item_14    14  21.0 cat_4                                                     value    Int64   0              +   16 item_15    15  22.5 cat_0                                                     score    Float64 0.0            +   17 item_16    16  24.0 cat_1                                                     category String  cat_0          +   18 item_17    17  25.5 cat_2                                                     +   19 item_18    18  27.0 cat_3                                                     +   20 item_19    19  28.5 cat_4                                                     + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ +▔ Filter dataframe ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +value > 10 +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +value > 5 +value > 10 +doubled > 40 +value > 2 +value > 1 + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ + ^t Select/copy value from table  esc Close 1 / 9 (Filtered from 20)^p palette + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DtBrowserApp + + + + + + + + + +  Row # name    value score category                                                 ▔ Row Detail ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎ +    1 item_0      0   0.0 cat_0                                                     Field    dtype   Value          +    2 item_1      1   1.5 cat_1                                                     Row #    UInt32  1              +    3 item_2      2   3.0 cat_2                                                     name     String  item_0         +    4 item_3      3   4.5 cat_3                                                     value    Int64   0              +    5 item_4      4   6.0 cat_4                                                     score    Float64 0.0            +    6 item_5      5   7.5 cat_0                                                     category String  cat_0          +    7 item_6      6   9.0 cat_1                                                     +    8 item_7      7  10.5 cat_2                                                     +    9 item_8      8  12.0 cat_3                                                     +   10 item_9      9  13.5 cat_4                                                     +   11 item_10    10  15.0 cat_0                                                     +   12 item_11    11  16.5 cat_1                                                     +   13 item_12    12  18.0 cat_2                                                     +   14 item_13    13  19.5 cat_3                                                     +   15 item_14    14  21.0 cat_4                                                     +   16 item_15    15  22.5 cat_0                                                     +   17 item_16    16  24.0 cat_1                                                     +   18 item_17    17  25.5 cat_2                                                     +   19 item_18    18  27.0 cat_3                                                     +   20 item_19    19  28.5 cat_4                                                     + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ + f Filter rows  / Search  b Add/Del Bookmark  x Compute Expressions...  c Columns...  r Toggle Row Detail  s^p palette + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DtBrowserApp + + + + + + + + + +  Row # name    value score category▔ Row Detail ▔▔▔▔▔▔▔▔▔▔▎ +    1 item_0      0   0.0 cat_0     Field    dtype       +    2 item_1      1   1.5 cat_1     Row #    UInt32      +    3 item_2      2   3.0 cat_2     name     String      +    4 item_3      3   4.5 cat_3     value    Int64       +    5 item_4      4   6.0 cat_4     score    Float64     +    6 item_5      5   7.5 cat_0     category String      +    7 item_6      6   9.0 cat_1     +    8 item_7      7  10.5 cat_2     +    9 item_8      8  12.0 cat_3     +   10 item_9      9  13.5 cat_4     +   11 item_10    10  15.0 cat_0     +   12 item_11    11  16.5 cat_1     +   13 item_12    12  18.0 cat_2     +   14 item_13    13  19.5 cat_3     +   15 item_14    14  21.0 cat_4     +   16 item_15    15  22.5 cat_0     +   17 item_16    16  24.0 cat_1     +   18 item_17    17  25.5 cat_2     +   19 item_18    18  27.0 cat_3     +   20 item_19    19  28.5 cat_4     + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ + f Filter rows  / Search  b Add/Del Bookmark  x ^p palette + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DtBrowserApp + + + + + + + + + +  Row # name    value score category                                                 ▔ Row Detail ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎ +    1 item_0      0   0.0 cat_0                                                     Field    dtype   Value          +    2 item_1      1   1.5 cat_1                                                     Row #    UInt32  3              +    3 item_2      2   3.0 cat_2                                                     name     String  item_2         +    4 item_3      3   4.5 cat_3                                                     value    Int64   2              +    5 item_4      4   6.0 cat_4                                                     score    Float64 3.0            +    6 item_5      5   7.5 cat_0                                                     category String  cat_2          +    7 item_6      6   9.0 cat_1                                                     +    8 item_7      7  10.5 cat_2                                                     +    9 item_8      8  12.0 cat_3                                                     +   10 item_9      9  13.5 cat_4                                                     +   11 item_10    10  15.0 cat_0                                                     +   12 item_11    11  16.5 cat_1                                                     +   13 item_12    12  18.0 cat_2                                                     +   14 item_13    13  19.5 cat_3                                                     +   15 item_14    14  21.0 cat_4                                                     +   16 item_15    15  22.5 cat_0                                                     +   17 item_16    16  24.0 cat_1                                                     +   18 item_17    17  25.5 cat_2                                                     +   19 item_18    18  27.0 cat_3                                                     +   20 item_19    19  28.5 cat_4                                                     + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ + f Filter rows  / Search  b Add/Del Bookmark  x Compute Expressions...  c Columns...  r Toggle Row Detail  s^p palette + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DtBrowserApp + + + + + + + + + +  Row # name    value score category                                                 ▔ Row Detail ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎ +    1 item_0      0   0.0 cat_0                                                     Field    dtype   Value          +    2 item_1      1   1.5 cat_1                                                     Row #    UInt32  7              +    3 item_2      2   3.0 cat_2                                                     name     String  item_6         +    4 item_3      3   4.5 cat_3                                                     value    Int64   6              +    5 item_4      4   6.0 cat_4                                                     score    Float64 9.0            +    6 item_5      5   7.5 cat_0                                                     category String  cat_1          +    7 item_6      6   9.0 cat_1                                                     +     8 item_7      7  10.5 cat_2                                                     +     9 item_8      8  12.0 cat_3                                                     +    10 item_9      9  13.5 cat_4                                                     +    11 item_10    10  15.0 cat_0                                                     +    12 item_11    11  16.5 cat_1                                                     +    13 item_12    12  18.0 cat_2                                                     +    14 item_13    13  19.5 cat_3                                                     +    15 item_14    14  21.0 cat_4                                                     +    16 item_15    15  22.5 cat_0                                                     +    17 item_16    16  24.0 cat_1                                                     +    18 item_17    17  25.5 cat_2                                                     +    19 item_18    18  27.0 cat_3                                                     +    20 item_19    19  28.5 cat_4                                                     + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ + f Filter rows  / Search  n Next  b Add/Del Bookmark  x Compute Expressions...  c Columns...  r Toggle Row D^p palette + + + 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), + ) From ff88730f8b48f820fb100383d1698f55c6006c43 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 25 Mar 2026 00:57:34 +0000 Subject: [PATCH 5/8] Exclude snapshot tests from CI test runs Snapshot tests depend on environment-specific SVG rendering and fail in CI. Mark them with a `snapshot` pytest marker and exclude from `make test`. Add separate `make test-snapshot` target for local use. Co-Authored-By: Claude Opus 4.6 --- Makefile | 5 ++++- pyproject.toml | 1 + tests/test_snapshots.py | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index be4913c..0a3611c 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,10 @@ activate: .make.package_installed @bash --init-file <(echo "$(ACTIVATE)") test: .make.package_installed | $(.VENV) - @$(ACTIVATE) && pytest tests/ + @$(ACTIVATE) && pytest tests/ -m "not snapshot" + +test-snapshot: .make.package_installed | $(.VENV) + @$(ACTIVATE) && pytest tests/ -m snapshot check: .make.formatted .make.linted .make.typed diff --git a/pyproject.toml b/pyproject.toml index 5415d87..3d7b78b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ build-backend = "setuptools.build_meta" [tool.pytest.ini_options] asyncio_mode = "auto" +markers = ["snapshot: visual snapshot regression tests (deselect with '-m not snapshot')"] [tool.mypy] ignore_missing_imports = true diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py index e85ee35..4155efe 100644 --- a/tests/test_snapshots.py +++ b/tests/test_snapshots.py @@ -1,9 +1,12 @@ import polars as pl +import pytest from textual.pilot import Pilot from dt_browser.browser import DtBrowserApp from dt_browser.custom_table import CustomTable +pytestmark = pytest.mark.snapshot + def _make_app(num_rows: int = 20) -> DtBrowserApp: df = pl.DataFrame( From d94a3f81ab2a502d64b9e00d969b0e4536b531fe Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 25 Mar 2026 01:02:31 +0000 Subject: [PATCH 6/8] Run snapshot tests in CI and upload diff report as artifact When snapshot tests fail in CI, the snapshot_report.html (visual diff) is uploaded as a downloadable artifact and a note is added to the workflow summary. Uses continue-on-error to ensure artifacts are captured before failing the job. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 25 ++++++++++++++++++++++++- Makefile | 5 +---- pyproject.toml | 1 - tests/test_snapshots.py | 3 --- 4 files changed, 25 insertions(+), 9 deletions(-) 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/Makefile b/Makefile index 0a3611c..be4913c 100644 --- a/Makefile +++ b/Makefile @@ -27,10 +27,7 @@ activate: .make.package_installed @bash --init-file <(echo "$(ACTIVATE)") test: .make.package_installed | $(.VENV) - @$(ACTIVATE) && pytest tests/ -m "not snapshot" - -test-snapshot: .make.package_installed | $(.VENV) - @$(ACTIVATE) && pytest tests/ -m snapshot + @$(ACTIVATE) && pytest tests/ check: .make.formatted .make.linted .make.typed diff --git a/pyproject.toml b/pyproject.toml index 3d7b78b..5415d87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,6 @@ build-backend = "setuptools.build_meta" [tool.pytest.ini_options] asyncio_mode = "auto" -markers = ["snapshot: visual snapshot regression tests (deselect with '-m not snapshot')"] [tool.mypy] ignore_missing_imports = true diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py index 4155efe..e85ee35 100644 --- a/tests/test_snapshots.py +++ b/tests/test_snapshots.py @@ -1,12 +1,9 @@ import polars as pl -import pytest from textual.pilot import Pilot from dt_browser.browser import DtBrowserApp from dt_browser.custom_table import CustomTable -pytestmark = pytest.mark.snapshot - def _make_app(num_rows: int = 20) -> DtBrowserApp: df = pl.DataFrame( From 818166d5bf2be777fe555e16e7895c37d8370e5a Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 25 Mar 2026 01:12:24 +0000 Subject: [PATCH 7/8] Pin syrupy < 5 to prevent snapshot format incompatibility in CI The snapshot baselines are generated locally and compared in CI. Pin syrupy to 4.x to ensure both environments use the same snapshot storage format and path conventions. Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5415d87..4f4c546 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,8 @@ dev = [ "pylint", "pytest", "pytest-asyncio", - "pytest-textual-snapshot", + "pytest-textual-snapshot >= 1.1.0", + "syrupy >= 4.8.0, < 5", "ruff", ] From 7a42677333d8374d802dcd1a41d9e45ecb34492b Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 25 Mar 2026 01:19:44 +0000 Subject: [PATCH 8/8] Isolate filter history per test via DT_BROWSER_HISTORY_FILE env var FilterBox now reads DT_BROWSER_HISTORY_FILE to override the default history path. A new autouse conftest fixture sets this to a temp file per test, preventing history from leaking across tests when execution order varies. Re-recorded snapshot for test_snap_filter_applied. Co-Authored-By: Claude Opus 4.6 --- src/dt_browser/filter_box.py | 9 ++++++++- .../test_snapshots/test_snap_filter_applied.svg | 12 ++++++------ tests/conftest.py | 13 +++++++++++++ 3 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 tests/conftest.py 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_filter_applied.svg b/tests/__snapshots__/test_snapshots/test_snap_filter_applied.svg index 12b2a06..f877bb8 100644 --- a/tests/__snapshots__/test_snapshots/test_snap_filter_applied.svg +++ b/tests/__snapshots__/test_snapshots/test_snap_filter_applied.svg @@ -145,7 +145,7 @@ - +  Row # name    value score category                                                 ▔ Row Detail ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎    12 item_11    11  16.5 cat_1                                                     Field    dtype   Value          @@ -168,11 +168,11 @@ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -value > 5 -value > 10 -doubled > 40 -value > 2 -value > 1 +value > 10 + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ 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"))