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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..f877bb8 --- /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 > 10 + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ + ^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/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), + )