Skip to content

Commit 9a17803

Browse files
ryan-williamsclaude
andcommitted
Add more tests and CI workflow
Tests: - Delete endpoint: validation, success, cache invalidation - `scan_id` parameter for time-travel feature - Depth filtering with/without depth column - Cache population behavior Fixtures: - Regenerate parquet fixtures for new depth-first sort order CI: - Run pytest on Python 3.11, 3.12, 3.13 - Type check with mypy (non-blocking) - Build and type check UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 6ae6185 commit 9a17803

File tree

6 files changed

+306
-6
lines changed

6 files changed

+306
-6
lines changed

.github/workflows/ci.yml

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
name: Test
12+
runs-on: ubuntu-latest
13+
strategy:
14+
matrix:
15+
python-version: ["3.11", "3.12", "3.13"]
16+
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- name: Install uv
21+
uses: astral-sh/setup-uv@v4
22+
23+
- name: Set up Python ${{ matrix.python-version }}
24+
run: uv python install ${{ matrix.python-version }}
25+
26+
- name: Create empty ui/dist for editable install
27+
run: mkdir -p ui/dist
28+
29+
- name: Install dependencies
30+
run: uv sync
31+
32+
- name: Run tests
33+
run: uv run pytest tests/ -v
34+
35+
typecheck:
36+
name: Type Check
37+
runs-on: ubuntu-latest
38+
steps:
39+
- uses: actions/checkout@v4
40+
41+
- name: Install uv
42+
uses: astral-sh/setup-uv@v4
43+
44+
- name: Set up Python
45+
run: uv python install 3.12
46+
47+
- name: Create empty ui/dist for editable install
48+
run: mkdir -p ui/dist
49+
50+
- name: Install dependencies
51+
run: uv sync
52+
53+
- name: Run mypy
54+
run: uv run mypy src/disk_tree --ignore-missing-imports
55+
continue-on-error: true # Don't fail build on type errors for now
56+
57+
ui-build:
58+
name: UI Build
59+
runs-on: ubuntu-latest
60+
steps:
61+
- uses: actions/checkout@v4
62+
63+
- uses: pnpm/action-setup@v4
64+
with:
65+
version: 9
66+
67+
- uses: actions/setup-node@v4
68+
with:
69+
node-version: 20
70+
cache: pnpm
71+
cache-dependency-path: ui/pnpm-lock.yaml
72+
73+
- name: Install dependencies
74+
run: cd ui && pnpm install --frozen-lockfile
75+
76+
- name: Type check
77+
run: cd ui && pnpm tsc --noEmit
78+
79+
- name: Build
80+
run: cd ui && pnpm build

tests/data/s3.parquet

-741 Bytes
Binary file not shown.

tests/data/s8g.parquet

-661 Bytes
Binary file not shown.

tests/test_server.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,3 +624,226 @@ def test_compare_detects_added_removed(self, test_client):
624624
rows_by_status = {r['path']: r['status'] for r in data['rows']}
625625
assert rows_by_status.get('child1') == 'removed'
626626
assert rows_by_status.get('child2') == 'added'
627+
628+
629+
class TestDeleteEndpoint:
630+
"""Tests for POST /api/delete endpoint."""
631+
632+
def test_delete_requires_path(self, test_client):
633+
"""Returns 400 when path is not provided."""
634+
client, _, _ = test_client
635+
response = client.post('/api/delete', json={})
636+
assert response.status_code == 400
637+
assert 'Path is required' in response.json['error']
638+
639+
def test_delete_requires_absolute_path(self, test_client):
640+
"""Returns 400 for relative paths."""
641+
client, _, _ = test_client
642+
response = client.post('/api/delete', json={'path': 'relative/path'})
643+
assert response.status_code == 400
644+
assert 'absolute' in response.json['error'].lower()
645+
646+
def test_delete_nonexistent_path(self, test_client):
647+
"""Returns 404 for paths that don't exist."""
648+
client, _, _ = test_client
649+
response = client.post('/api/delete', json={'path': '/nonexistent/path/12345'})
650+
assert response.status_code == 404
651+
652+
def test_delete_file_success(self, test_client):
653+
"""Successfully deletes a file and returns stats."""
654+
client, db_path, scans_dir = test_client
655+
656+
# Create a file to delete
657+
test_file = os.path.join(scans_dir, 'to_delete.txt')
658+
with open(test_file, 'w') as f:
659+
f.write('test content')
660+
661+
response = client.post('/api/delete', json={'path': test_file})
662+
assert response.status_code == 200
663+
data = response.json
664+
assert data['success'] is True
665+
assert data['path'] == test_file
666+
assert data['deleted_size'] > 0
667+
assert not os.path.exists(test_file)
668+
669+
def test_delete_clears_cache(self, test_client):
670+
"""Delete clears server caches so next request gets fresh data."""
671+
client, db_path, scans_dir = test_client
672+
673+
# Create scan with a file
674+
parquet_path = create_test_parquet(scans_dir, 'test', [
675+
{'path': '.', 'size': 1000, 'mtime': 100, 'kind': 'dir', 'parent': '', 'uri': '/test', 'n_desc': 2, 'n_children': 1},
676+
{'path': 'file.txt', 'size': 500, 'mtime': 80, 'kind': 'file', 'parent': '.', 'uri': '/test/file.txt', 'n_desc': 1, 'n_children': 0},
677+
])
678+
679+
conn = sqlite3.connect(db_path)
680+
conn.execute(
681+
'INSERT INTO scan (path, time, blob, size, n_children, n_desc) VALUES (?, ?, ?, ?, ?, ?)',
682+
('/test', '2025-01-01T12:00:00', parquet_path, 1000, 1, 2),
683+
)
684+
conn.commit()
685+
conn.close()
686+
687+
# First request to populate cache
688+
response1 = client.get('/api/scan?uri=/test')
689+
assert response1.status_code == 200
690+
691+
# Create file to delete (outside of scan data, just to trigger cache clear)
692+
test_file = os.path.join(scans_dir, 'deleteme.txt')
693+
with open(test_file, 'w') as f:
694+
f.write('x')
695+
696+
# Delete should clear caches
697+
from disk_tree.server import _cache, _parquet_cache
698+
assert len(_cache) > 0 or len(_parquet_cache) > 0 # Something should be cached
699+
700+
response = client.post('/api/delete', json={'path': test_file})
701+
assert response.status_code == 200
702+
703+
# Caches should be cleared
704+
assert len(_cache) == 0
705+
assert len(_parquet_cache) == 0
706+
707+
708+
class TestScanIdParameter:
709+
"""Tests for scan_id parameter (time-travel feature)."""
710+
711+
def test_scan_id_uses_specific_scan(self, test_client):
712+
"""scan_id parameter uses the specified scan instead of latest."""
713+
client, db_path, scans_dir = test_client
714+
715+
# Create two scans with different data
716+
parquet1 = create_test_parquet(scans_dir, 'old', [
717+
{'path': '.', 'size': 1000, 'mtime': 100, 'kind': 'dir', 'parent': '', 'uri': '/test', 'n_desc': 1, 'n_children': 0},
718+
])
719+
parquet2 = create_test_parquet(scans_dir, 'new', [
720+
{'path': '.', 'size': 2000, 'mtime': 200, 'kind': 'dir', 'parent': '', 'uri': '/test', 'n_desc': 1, 'n_children': 0},
721+
])
722+
723+
conn = sqlite3.connect(db_path)
724+
conn.execute(
725+
'INSERT INTO scan (path, time, blob, size, n_children, n_desc) VALUES (?, ?, ?, ?, ?, ?)',
726+
('/test', '2025-01-01T12:00:00', parquet1, 1000, 0, 1),
727+
)
728+
conn.execute(
729+
'INSERT INTO scan (path, time, blob, size, n_children, n_desc) VALUES (?, ?, ?, ?, ?, ?)',
730+
('/test', '2025-01-02T12:00:00', parquet2, 2000, 0, 1),
731+
)
732+
conn.commit()
733+
conn.close()
734+
735+
# Without scan_id, should get latest (size=2000)
736+
response = client.get('/api/scan?uri=/test')
737+
assert response.status_code == 200
738+
assert response.json['root']['size'] == 2000
739+
740+
# With scan_id=1, should get old scan (size=1000)
741+
response = client.get('/api/scan?uri=/test&scan_id=1')
742+
assert response.status_code == 200
743+
assert response.json['root']['size'] == 1000
744+
745+
def test_scan_id_invalid_returns_error(self, test_client):
746+
"""Returns 400 if scan_id doesn't cover the requested path."""
747+
client, db_path, scans_dir = test_client
748+
749+
parquet = create_test_parquet(scans_dir, 'test', [
750+
{'path': '.', 'size': 1000, 'mtime': 100, 'kind': 'dir', 'parent': '', 'uri': '/other', 'n_desc': 1, 'n_children': 0},
751+
])
752+
753+
conn = sqlite3.connect(db_path)
754+
conn.execute(
755+
'INSERT INTO scan (path, time, blob) VALUES (?, ?, ?)',
756+
('/other', '2025-01-01T12:00:00', parquet),
757+
)
758+
conn.commit()
759+
conn.close()
760+
761+
# Request /test with scan_id=1 which is for /other
762+
response = client.get('/api/scan?uri=/test&scan_id=1')
763+
assert response.status_code == 400
764+
assert 'does not cover' in response.json['error']
765+
766+
767+
class TestDepthFiltering:
768+
"""Tests for depth-based parquet filtering."""
769+
770+
def test_depth_column_in_parquet(self, test_client):
771+
"""Parquet files with depth column support efficient filtering."""
772+
client, db_path, scans_dir = test_client
773+
774+
# Create parquet with depth column
775+
parquet_path = create_test_parquet(scans_dir, 'deep', [
776+
{'path': '.', 'size': 1000, 'mtime': 100, 'kind': 'dir', 'parent': '', 'uri': '/test', 'n_desc': 4, 'n_children': 1, 'depth': 0},
777+
{'path': 'a', 'size': 800, 'mtime': 90, 'kind': 'dir', 'parent': '.', 'uri': '/test/a', 'n_desc': 3, 'n_children': 1, 'depth': 1},
778+
{'path': 'a/b', 'size': 600, 'mtime': 80, 'kind': 'dir', 'parent': 'a', 'uri': '/test/a/b', 'n_desc': 2, 'n_children': 1, 'depth': 2},
779+
{'path': 'a/b/c', 'size': 400, 'mtime': 70, 'kind': 'file', 'parent': 'a/b', 'uri': '/test/a/b/c', 'n_desc': 1, 'n_children': 0, 'depth': 3},
780+
])
781+
782+
conn = sqlite3.connect(db_path)
783+
conn.execute(
784+
'INSERT INTO scan (path, time, blob, size, n_children, n_desc) VALUES (?, ?, ?, ?, ?, ?)',
785+
('/test', '2025-01-01T12:00:00', parquet_path, 1000, 1, 4),
786+
)
787+
conn.commit()
788+
conn.close()
789+
790+
# Request should work - depth filtering is an implementation detail
791+
response = client.get('/api/scan?uri=/test')
792+
assert response.status_code == 200
793+
assert response.json['root']['size'] == 1000
794+
assert len(response.json['children']) == 1
795+
assert response.json['children'][0]['path'] == 'a'
796+
797+
def test_parquet_without_depth_still_works(self, test_client):
798+
"""Parquet files without depth column still work (fallback to full load)."""
799+
client, db_path, scans_dir = test_client
800+
801+
# Create parquet WITHOUT depth column
802+
parquet_path = create_test_parquet(scans_dir, 'nodepth', [
803+
{'path': '.', 'size': 1000, 'mtime': 100, 'kind': 'dir', 'parent': '', 'uri': '/test', 'n_desc': 2, 'n_children': 1},
804+
{'path': 'child', 'size': 500, 'mtime': 80, 'kind': 'dir', 'parent': '.', 'uri': '/test/child', 'n_desc': 1, 'n_children': 0},
805+
])
806+
807+
conn = sqlite3.connect(db_path)
808+
conn.execute(
809+
'INSERT INTO scan (path, time, blob, size, n_children, n_desc) VALUES (?, ?, ?, ?, ?, ?)',
810+
('/test', '2025-01-01T12:00:00', parquet_path, 1000, 1, 2),
811+
)
812+
conn.commit()
813+
conn.close()
814+
815+
response = client.get('/api/scan?uri=/test')
816+
assert response.status_code == 200
817+
assert response.json['root']['size'] == 1000
818+
819+
820+
class TestCacheInvalidation:
821+
"""Tests for cache behavior."""
822+
823+
def test_cache_populated_on_request(self, test_client):
824+
"""Requests populate the cache."""
825+
client, db_path, scans_dir = test_client
826+
827+
parquet_path = create_test_parquet(scans_dir, 'test', [
828+
{'path': '.', 'size': 1000, 'mtime': 100, 'kind': 'dir', 'parent': '', 'uri': '/test', 'n_desc': 1, 'n_children': 0},
829+
])
830+
831+
conn = sqlite3.connect(db_path)
832+
conn.execute(
833+
'INSERT INTO scan (path, time, blob, size, n_children, n_desc) VALUES (?, ?, ?, ?, ?, ?)',
834+
('/test', '2025-01-01T12:00:00', parquet_path, 1000, 0, 1),
835+
)
836+
conn.commit()
837+
conn.close()
838+
839+
from disk_tree.server import _parquet_cache, clear_cache
840+
clear_cache()
841+
_parquet_cache.clear()
842+
843+
assert len(_parquet_cache) == 0
844+
845+
response = client.get('/api/scan?uri=/test')
846+
assert response.status_code == 200
847+
848+
# Parquet cache should now have an entry
849+
assert len(_parquet_cache) >= 1

ui/src/components/CompareView.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@ function CompareRowComponent({
439439
routeType,
440440
onScan,
441441
isScanning,
442-
getProgress,
442+
getProgress: _getProgress,
443443
scan1,
444444
scan2,
445445
}: {
@@ -473,7 +473,6 @@ function CompareRowComponent({
473473
const compareUrl = params.toString() ? `${basePath}?${params}` : basePath
474474

475475
const td: React.CSSProperties = { padding: '8px 6px', textAlign: 'right', fontFamily: 'monospace', fontSize: '0.85em', whiteSpace: 'nowrap' }
476-
const dim: React.CSSProperties = { color: '#8b949e' }
477476

478477
// Size values
479478
const sizeBefore = row.status === 'added' ? '-' : formatSize(row.size_old ?? row.size)

ui/src/components/ScanDetails.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { FaChevronDown, FaChevronRight, FaExclamationTriangle, FaExchangeAlt, Fa
55
import { LazyPlot as Plot } from './LazyPlot'
66
import { useQuery } from '@tanstack/react-query'
77
import { fetchScanDetails, fetchScanHistory, startScan, fetchScanStatus, deletePath } from '../api'
8-
import type { Row, ScanJob, ScanProgress, ScanHistoryItem } from '../api'
8+
import type { Row, ScanJob, ScanProgress } from '../api'
99
import { useScanProgress } from '../hooks/useScanProgress'
1010
import { useRecentPaths } from '../hooks/useRecentPaths'
1111

@@ -435,12 +435,10 @@ function Treemap({ root, rows }: { root: Row; rows: Row[] }) {
435435

436436
// Find top-level ancestor
437437
let topLevel = row.parent
438-
let current = row
439438
while (topLevel && topLevel !== '.') {
440439
const parentRow = rows.find(r => r.path === topLevel)
441440
if (!parentRow || parentRow.parent === '.') break
442441
topLevel = parentRow.parent
443-
current = parentRow
444442
}
445443

446444
// topLevel is now the direct child of root, or we use the immediate parent
@@ -465,7 +463,7 @@ function Treemap({ root, rows }: { root: Row; rows: Row[] }) {
465463

466464
// Ensure parent chain is included
467465
for (const row of [...topRows]) {
468-
let parent = row.parent
466+
let parent: string | null | undefined = row.parent
469467
while (parent && parent !== '.' && !included.has(parent)) {
470468
const parentRow = subtreeRows.find(r => r.path === parent)
471469
if (parentRow) {

0 commit comments

Comments
 (0)