Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ dev = [
"pylint",
"pytest",
"pytest-asyncio",
"pytest-textual-snapshot >= 1.1.0",
"syrupy >= 4.8.0, < 5",
"ruff",
]

Expand Down
208 changes: 47 additions & 161 deletions src/dt_browser/browser.py

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/dt_browser/expression_box.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
9 changes: 8 additions & 1 deletion src/dt_browser/filter_box.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import pathlib
from dataclasses import dataclass

Expand All @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down
186 changes: 186 additions & 0 deletions tests/__snapshots__/test_snapshots/test_snap_column_hidden.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
185 changes: 185 additions & 0 deletions tests/__snapshots__/test_snapshots/test_snap_column_selector_open.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
180 changes: 180 additions & 0 deletions tests/__snapshots__/test_snapshots/test_snap_cursor_moved.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
182 changes: 182 additions & 0 deletions tests/__snapshots__/test_snapshots/test_snap_filter_applied.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
180 changes: 180 additions & 0 deletions tests/__snapshots__/test_snapshots/test_snap_initial_view.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
181 changes: 181 additions & 0 deletions tests/__snapshots__/test_snapshots/test_snap_narrow_terminal.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
180 changes: 180 additions & 0 deletions tests/__snapshots__/test_snapshots/test_snap_row_detail.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
180 changes: 180 additions & 0 deletions tests/__snapshots__/test_snapshots/test_snap_search_results.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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"))
222 changes: 222 additions & 0 deletions tests/test_bookmarks_columns.py
Original file line number Diff line number Diff line change
@@ -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
86 changes: 86 additions & 0 deletions tests/test_browsing.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading