Skip to content

Commit 6c1552d

Browse files
author
Adrien Reibel
committed
feat: add backend test suite (157 tests) and GitHub Actions CI
Test suite covering: - Profiler (format detection, column profiling) - Formatting (serialize_figure, summarize_args, extract_insights) - SSE serialization (all 9 event types) - State models (dataclasses, CardSchema discriminator, make_loop_result) - SourceRegistry and TurnState - API Pydantic models and converter functions - LangChain tools (execute_python, todo, list_sources, ask_question) - API dependencies (require_project, require_chat) - ProjectManager._reconstruct_profile CI pipeline (GitHub Actions): - backend-lint: ruff check + ruff format --check - backend-test: pytest (depends on lint) - frontend-lint: eslint + tsc --noEmit - docker-build: backend, sandbox, frontend images (depends on tests)
1 parent ef5dbe5 commit 6c1552d

17 files changed

Lines changed: 2077 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
concurrency:
10+
group: ci-${{ github.ref }}
11+
cancel-in-progress: true
12+
13+
jobs:
14+
# ── Backend: lint + format ─────────────────────────────────────────────
15+
backend-lint:
16+
name: "Backend · Lint & Format"
17+
runs-on: ubuntu-latest
18+
steps:
19+
- uses: actions/checkout@v4
20+
- uses: astral-sh/setup-uv@v6
21+
with:
22+
enable-cache: true
23+
- run: uv sync --frozen --dev
24+
- run: uv run ruff check core/ api/ db/ tests/
25+
- run: uv run ruff format --check core/ api/ db/ tests/
26+
27+
# ── Backend: tests ─────────────────────────────────────────────────────
28+
backend-test:
29+
name: "Backend · Tests"
30+
runs-on: ubuntu-latest
31+
needs: backend-lint
32+
steps:
33+
- uses: actions/checkout@v4
34+
- uses: astral-sh/setup-uv@v6
35+
with:
36+
enable-cache: true
37+
- run: uv sync --frozen --dev
38+
- run: uv run pytest tests/ -v --tb=short
39+
40+
# ── Frontend: lint + typecheck ─────────────────────────────────────────
41+
frontend-lint:
42+
name: "Frontend · Lint & Typecheck"
43+
runs-on: ubuntu-latest
44+
defaults:
45+
run:
46+
working-directory: frontend
47+
steps:
48+
- uses: actions/checkout@v4
49+
- uses: actions/setup-node@v4
50+
with:
51+
node-version: 22
52+
cache: npm
53+
cache-dependency-path: frontend/package-lock.json
54+
- run: npm ci
55+
- run: npm run lint
56+
- run: npm run typecheck
57+
58+
# ── Docker builds (parallel) ───────────────────────────────────────────
59+
docker-backend:
60+
name: "Docker · Backend"
61+
runs-on: ubuntu-latest
62+
needs: backend-test
63+
steps:
64+
- uses: actions/checkout@v4
65+
- run: docker build -t data-agent-backend:ci .
66+
67+
docker-sandbox:
68+
name: "Docker · Sandbox"
69+
runs-on: ubuntu-latest
70+
needs: backend-test
71+
steps:
72+
- uses: actions/checkout@v4
73+
- run: docker build -t data-agent-sandbox:ci sandbox/
74+
75+
docker-frontend:
76+
name: "Docker · Frontend"
77+
runs-on: ubuntu-latest
78+
needs: frontend-lint
79+
steps:
80+
- uses: actions/checkout@v4
81+
- run: >
82+
docker build
83+
--build-arg REWRITE_API_ORIGIN=http://backend:8000
84+
-t data-agent-frontend:ci
85+
frontend/

tests/BUGLOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# BUGLOG — Anomalies trouvées lors de l'écriture des tests
2+
3+
## [FIXED]`_detect_format` ne fonctionnait pas avec pandas 3.x (dtype "str")
4+
- **Fichier** : core/profiler.py
5+
- **Ligne(s)** : ~L60-L61
6+
- **Constaté** : La fonction commençait par `if series.dtype != "object": return None`. En pandas 3.x, le dtype par défaut des colonnes texte est `"str"` et non `"object"`. Aucun format n'était détecté.
7+
- **Fix** : `series.dtype not in ("object", "str", "string")` — accepte les 3 variantes de dtype texte.
8+
- **Test** : `test_default_string_dtype_is_detected` vérifie le fix.
9+
10+
---
11+
12+
## [ODDITY]`_format_output` : priorité implicite dans la chaîne elif
13+
- **Fichier** : core/tools/execute_python.py
14+
- **Ligne(s)** : ~L113-L142
15+
- **Constaté** : Quand `result` est `None` mais `card_updates` ET `cards` sont tous les deux non-vides, seul le message `card_updates` est affiché. Le message `cards` est dans un `elif` et ne sera jamais atteint dans ce cas.
16+
- **Attendu** : Comportement probablement voulu (card_updates a la priorité), mais pas documenté.
17+
- **Impact sur les tests** : Aucun — on teste chaque branche isolément.
18+
- **Sévérité** : **cosmétique**
19+
20+
---
21+
22+
## [ODDITY]`todo` tool : fallback `task` quand `content` est vide
23+
- **Fichier** : core/tools/todo.py
24+
- **Ligne(s)** : ~L39
25+
- **Constaté** : `content = item.get("content") or item.get("task", "")` — un item avec `{"content": "", "task": "real task"}` utilisera `"real task"` car la chaîne vide est falsy en Python.
26+
- **Attendu** : Incertain — est-ce que `content: ""` devrait être traité comme "pas de contenu" ou comme "contenu explicitement vide" ?
27+
- **Impact sur les tests** : Test `test_write_uses_task_fallback` vérifie ce comportement. Documenté ici au cas où ce serait non-intentionnel.
28+
- **Sévérité** : **mineur**
29+
30+
---
31+
32+
## [ODDITY]`_detect_format` : seuil strict pour petits datasets
33+
- **Fichier** : core/profiler.py
34+
- **Ligne(s)** : ~L66-L89
35+
- **Constaté** : Le seuil de détection currency est `> 0.5` (strict). Pour un dataset de 2 lignes avec 1 valeur currency, le ratio est 0.5 exactement, donc non détecté. C'est mathématiquement correct mais potentiellement surprenant pour de petits fichiers.
36+
- **Attendu** : Comportement correct, mais pourrait être documenté.
37+
- **Impact sur les tests** : Test `test_currency_below_threshold` vérifie explicitement ce cas limite.
38+
- **Sévérité** : **cosmétique**

tests/__init__.py

Whitespace-only changes.

tests/conftest.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
"""Shared fixtures for the backend test suite."""
2+
3+
from datetime import UTC, datetime
4+
from types import SimpleNamespace
5+
from unittest.mock import AsyncMock
6+
from uuid import uuid4
7+
8+
import pandas as pd
9+
import pytest
10+
11+
from core.models.sources import DataSource, SourceRegistry
12+
from core.models.turn import TurnState
13+
from core.profiler import build_profile
14+
from core.state import ColumnProfile, DataProfile, LoopResult
15+
from core.tools.context import ToolContext
16+
17+
# ---------------------------------------------------------------------------
18+
# Sample DataFrame — exercises every _detect_format branch
19+
# ---------------------------------------------------------------------------
20+
21+
22+
@pytest.fixture
23+
def sample_df() -> pd.DataFrame:
24+
"""DataFrame with mixed column types for profiler tests."""
25+
return pd.DataFrame(
26+
{
27+
"id": list(range(1, 11)),
28+
"price": [
29+
"$1,234",
30+
"$5,678",
31+
"$900",
32+
"$12",
33+
"$3,456",
34+
"$789",
35+
"$100",
36+
"$2,345",
37+
"$67",
38+
"$8,901",
39+
],
40+
"discount": ["10%", "20%", "30%", "15%", "25%", "5%", "40%", "35%", "50%", "45%"],
41+
"tags": ["A|B", "C|D", "E|F", "A|C", "B|D", "A|E", "C|F", "B|E", "D|F", "A|D"],
42+
"date": [
43+
"2024-01-15",
44+
"2024-02-20",
45+
"2024-03-10",
46+
"2024-04-05",
47+
"2024-05-18",
48+
"2024-06-22",
49+
"2024-07-30",
50+
"2024-08-14",
51+
"2024-09-01",
52+
"2024-10-25",
53+
],
54+
"score_str": ["42", "87", "15", "93", "66", "71", "38", "54", "29", "100"],
55+
"name": [
56+
"Alice",
57+
"Bob",
58+
"Charlie",
59+
"Diana",
60+
"Eve",
61+
"Frank",
62+
"Grace",
63+
"Hank",
64+
"Ivy",
65+
"Jack",
66+
],
67+
"with_nulls": [1.0, None, 3.0, None, 5.0, None, 7.0, None, 9.0, None],
68+
}
69+
)
70+
71+
72+
@pytest.fixture
73+
def sample_profile(sample_df) -> DataProfile:
74+
return build_profile(sample_df)
75+
76+
77+
@pytest.fixture
78+
def sample_column_profile() -> ColumnProfile:
79+
return ColumnProfile(
80+
name="test_col",
81+
dtype="int64",
82+
format=None,
83+
nulls_pct=0.0,
84+
cardinality=10,
85+
sample_values=["1", "2", "3"],
86+
)
87+
88+
89+
@pytest.fixture
90+
def sample_data_source(sample_profile) -> DataSource:
91+
return DataSource(
92+
name="sales",
93+
profile=sample_profile,
94+
origin="csv",
95+
row_count=10,
96+
columns=["id", "price", "discount", "tags", "date", "score_str", "name", "with_nulls"],
97+
sample_text=" id price discount tags ...\n0 1 $1,234 10% A|B ...",
98+
)
99+
100+
101+
@pytest.fixture
102+
def source_registry(sample_data_source) -> SourceRegistry:
103+
reg = SourceRegistry()
104+
reg.add("sales", sample_data_source)
105+
return reg
106+
107+
108+
@pytest.fixture
109+
def turn_state() -> TurnState:
110+
return TurnState()
111+
112+
113+
@pytest.fixture
114+
def tool_context(source_registry, turn_state) -> ToolContext:
115+
# Structure matches core/tools/context.py — ToolContext dataclass
116+
return ToolContext(
117+
project_id="test-project-id",
118+
sandbox=AsyncMock(),
119+
sources=source_registry,
120+
turn=turn_state,
121+
todos=[],
122+
)
123+
124+
125+
@pytest.fixture
126+
def runnable_config(tool_context):
127+
# Structure matches get_tool_context in core/tools/context.py:21
128+
# config["configurable"]["tool_context"]
129+
return {"configurable": {"tool_context": tool_context}}
130+
131+
132+
@pytest.fixture
133+
def sample_loop_result() -> LoopResult:
134+
return LoopResult(
135+
content="Analysis complete.",
136+
figs=[{"data": [], "layout": {}}],
137+
code="import pandas as pd\ndf.describe()",
138+
)
139+
140+
141+
# ---------------------------------------------------------------------------
142+
# Row factories — mimic SQLAlchemy model attributes from db/models.py
143+
# ---------------------------------------------------------------------------
144+
145+
146+
def make_project_row(**overrides):
147+
"""Factory for a mock ProjectRow (db/models.py)."""
148+
now = datetime.now(UTC)
149+
defaults = {
150+
"id": uuid4(),
151+
"name": "Test Project",
152+
"description": "A test project",
153+
"status": "ready",
154+
"model": "gemini-3-flash-preview",
155+
"suggested_questions": ["What trends?", "Show distribution"],
156+
"dashboard_content": None,
157+
"sources": [],
158+
"chats": [],
159+
"dashboard_cards": [],
160+
"created_at": now,
161+
"updated_at": now,
162+
}
163+
defaults.update(overrides)
164+
return SimpleNamespace(**defaults)
165+
166+
167+
def make_source_row(**overrides):
168+
"""Factory for a mock SourceRow (db/models.py)."""
169+
now = datetime.now(UTC)
170+
defaults = {
171+
"id": uuid4(),
172+
"project_id": uuid4(),
173+
"name": "sales",
174+
"origin": "csv",
175+
"row_count": 100,
176+
"columns": ["id", "price", "name"],
177+
"profile": {"row_count": 100, "columns": []},
178+
"created_at": now,
179+
}
180+
defaults.update(overrides)
181+
return SimpleNamespace(**defaults)
182+
183+
184+
def make_chat_row(**overrides):
185+
"""Factory for a mock ChatRow (db/models.py)."""
186+
now = datetime.now(UTC)
187+
defaults = {
188+
"id": uuid4(),
189+
"project_id": uuid4(),
190+
"title": "Test Chat",
191+
"pending_questions": None,
192+
"created_at": now,
193+
"updated_at": now,
194+
}
195+
defaults.update(overrides)
196+
return SimpleNamespace(**defaults)
197+
198+
199+
def make_card_row(**overrides):
200+
"""Factory for a mock DashboardCardRow (db/models.py)."""
201+
defaults = {
202+
"id": uuid4(),
203+
"project_id": uuid4(),
204+
"type": "chart",
205+
"title": "Revenue Chart",
206+
"code": "fig = px.bar(df, x='month', y='revenue')",
207+
"value": None,
208+
"fig": {"data": [], "layout": {}},
209+
"content": None,
210+
"layout": None,
211+
"position": 0,
212+
}
213+
defaults.update(overrides)
214+
return SimpleNamespace(**defaults)

0 commit comments

Comments
 (0)