feat(037): Phase 2 US1 — Inspect Dashboard Query Model (T006-T010)

- T006: 4 deterministic inspection tests (basic, deterministic, inaccessible, missing)
- T007: 6 filter normalization tests (scope, hash, order, locale)
- T008: query_model.py — inspect_dashboard_query_model using SupersetClient methods
- T009: filters.py — normalize_filters with canonical ordering + deterministic hash
- T010: fingerprints.py — SHA-256 helpers for query model and filter hashing

10/10 tests pass. Uses get_dashboard, get_dashboard_charts,
get_dashboard_datasets, get_chart — no raw HTTP calls.
This commit is contained in:
2026-07-28 19:27:47 +03:00
parent 9d1e303ad9
commit 012f903a57
5 changed files with 847 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
#region BaselineEngine.Filters.Normalize [C:4] [TYPE Function] [SEMANTICS baseline,filter,canonical,scope]
# @defgroup BaselineEngine Filter normalization — canonical typed filter identity with deterministic hash.
# @LAYER Service
# @RELATION DEPENDS_ON -> [DashboardTesting.Schemas]
# @RELATION DEPENDS_ON -> [BaselineEngine.Fingerprints]
from __future__ import annotations
import hashlib
import json
from typing import Any
from src.schemas.dashboard_testing import (
DashboardQueryModel,
NormalizedFilter,
NormalizedFilterContext,
FilterValue,
)
def _canonical_filter_value(value: FilterValue) -> dict[str, Any]:
"""Serialize filter value to a canonical JSON-safe dict for hashing."""
d: dict[str, Any] = {}
if value.from_ is not None:
d["from"] = value.from_
if value.to is not None:
d["to"] = value.to
if value.value is not None:
d["value"] = value.value
if value.values is not None:
d["values"] = sorted(value.values) # sort for deterministic hash
if not value.inclusive:
d["inclusive"] = False
return d
def _compute_filters_hash(filters: list[NormalizedFilter]) -> str:
"""Compute deterministic SHA-256 of the canonical filter JSON."""
# Build canonical structure: sorted by (dataset_id, column, operator, filter_id)
records = []
for f in sorted(filters, key=lambda x: (x.dataset_id, x.column, x.operator, x.filter_id)):
records.append({
"filter_id": f.filter_id,
"dataset_id": f.dataset_id,
"column": f.column,
"operator": f.operator,
"value": _canonical_filter_value(f.value),
"target_chart_ids": sorted(f.target_chart_ids),
})
canonical = json.dumps(records, sort_keys=True, default=str)
return "sha256:" + hashlib.sha256(canonical.encode()).hexdigest()
def normalize_filters(
filter_inputs: list[NormalizedFilter],
query_model: DashboardQueryModel,
) -> NormalizedFilterContext:
"""
Validate dashboard filter values against metadata/scope and produce
canonical typed filter identity.
@PRE Query model is authoritative and fingerprint-valid.
@POST Filters are typed, sorted, scoped, and hashed.
@SIDE_EFFECT None.
@DATA_CONTRACT FilterInput[] + DashboardQueryModel -> NormalizedFilterContext
"""
valid_chart_ids = {ch.chart_id for ch in query_model.charts}
valid_filter_ids = {nf.filter_id for nf in query_model.native_filters}
validated: list[NormalizedFilter] = []
for fi in filter_inputs:
# Validate filter exists in query model
if fi.filter_id not in valid_filter_ids:
raise ValueError(
f"Filter '{fi.filter_id}' not found in query model native filters"
)
# Validate target charts exist
for tcid in fi.target_chart_ids:
if tcid not in valid_chart_ids:
raise ValueError(
f"Target chart {tcid} for filter '{fi.filter_id}' not in query model charts"
)
# Validate dataset_id exists in model
dataset_ids = {ds.dataset_id for ds in query_model.datasets}
if fi.dataset_id not in dataset_ids:
raise ValueError(
f"Dataset {fi.dataset_id} for filter '{fi.filter_id}' not in query model datasets"
)
validated.append(fi)
# Sort in canonical order: dataset_id, column, operator, filter_id
validated.sort(key=lambda x: (x.dataset_id, x.column, x.operator, x.filter_id))
filters_hash = _compute_filters_hash(validated)
return NormalizedFilterContext(
schema_version=1,
filters=validated,
filters_hash=filters_hash,
)
# #endregion BaselineEngine.Filters.Normalize

View File

@@ -0,0 +1,43 @@
#region BaselineEngine.Fingerprints [C:3] [TYPE Module] [SEMANTICS baseline,fingerprint,hash,deterministic]
# @defgroup BaselineEngine Fingerprint utilities — deterministic hashing for query models, filters, and metadata.
# @LAYER Service
from __future__ import annotations
import hashlib
import json
from typing import Any
def compute_sha256(data: str | bytes | dict | list) -> str:
"""
Compute a deterministic SHA-256 hash of the input.
For dicts/lists, JSON is canonicalized via sort_keys before hashing.
Returns a hex digest string.
@PRE Input is JSON-serializable.
@POST Returns lowercase hex string.
@SIDE_EFFECT None.
"""
if isinstance(data, (dict, list)):
raw = json.dumps(data, sort_keys=True, default=str).encode("utf-8")
elif isinstance(data, str):
raw = data.encode("utf-8")
else:
raw = bytes(data)
return hashlib.sha256(raw).hexdigest()
def compute_query_model_fingerprint(model_dict: dict[str, Any]) -> str:
"""
Compute a stable fingerprint for a query model dict.
Excludes the fingerprint field itself to avoid recursion.
@PRE model_dict is a JSON-safe dict from DashboardQueryModel.model_dump().
@POST Returns 'sha256:<hex>' fingerprint.
"""
stripped = {k: v for k, v in model_dict.items() if k != "query_model_fingerprint"}
return "sha256:" + compute_sha256(stripped)
# #endregion BaselineEngine.Fingerprints

View File

@@ -0,0 +1,267 @@
#region BaselineEngine.QueryModel.Inspect [C:5] [TYPE Function] [SEMANTICS baseline,inspection,dashboard,metadata]
# @defgroup BaselineEngine Dashboard query model inspection — extracts structured metadata from Superset.
# @LAYER Service
# @RELATION DEPENDS_ON -> [SupersetClient]
# @RELATION DEPENDS_ON -> [DashboardTesting.Schemas]
from __future__ import annotations
import hashlib
import json
from typing import Any
from src.core.superset_client import SupersetClient
from src.core.utils.network import SupersetAPIError
from src.schemas.dashboard_testing import (
DashboardQueryModel, ChartQueryModel, DatasetQueryModel, ColumnInfo,
NativeFilterModel, FilterTarget, MetricDescriptor, ColumnRef,
DashboardCapabilities, Warning, VizType,
)
def _safe_json_load(raw: Any) -> dict:
"""Parse JSON from string or return empty dict on failure."""
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
return json.loads(raw)
except (json.JSONDecodeError, TypeError):
return {}
return {}
def _parse_viz_type(raw: str | None) -> VizType:
"""Map Superset viz_type string to our enum."""
if not raw:
return VizType.OTHER
mapping: dict[str, VizType] = {
"table": VizType.TABLE, "bar": VizType.BAR, "line": VizType.LINE,
"pie": VizType.PIE, "big_number": VizType.BIG_NUMBER,
"big_number_total": VizType.BIG_NUMBER_TOTAL,
"filter_box": VizType.FILTER_BOX,
}
return mapping.get(raw, VizType.OTHER)
def _compute_fingerprint(model_dict: dict) -> str:
"""Compute deterministic SHA-256 fingerprint of the query model."""
canonical = json.dumps(model_dict, sort_keys=True, default=str)
return "sha256:" + hashlib.sha256(canonical.encode()).hexdigest()
async def inspect_dashboard_query_model(
client: SupersetClient,
environment_id: str,
dashboard_id: int,
) -> DashboardQueryModel:
"""
Build a deterministic query model from authoritative Superset metadata.
@PRE Environment and dashboard are readable by actor.
@POST Returns stable sorted model, per-resource warnings, capabilities, and fingerprint.
@SIDE_EFFECT Async GET calls through SupersetClient.
@DATA_CONTRACT InspectRequest -> DashboardQueryModel
"""
warnings: list[Warning] = []
# 1. Fetch dashboard metadata
try:
dash_response = await client.get_dashboard(dashboard_id)
dash_data = dash_response.get("result", dash_response)
except SupersetAPIError as e:
return DashboardQueryModel(
environment_id=environment_id, dashboard_id=dashboard_id,
title="[Fetch Failed]",
warnings=[Warning(source="inspection", resource=str(dashboard_id),
code="DASHBOARD_FETCH_FAILED", detail=str(e))],
query_model_fingerprint="sha256:error",
)
title = dash_data.get("dashboard_title", f"Dashboard {dashboard_id}")
slug = dash_data.get("slug")
json_metadata = _safe_json_load(dash_data.get("json_metadata", "{}"))
position_json = _safe_json_load(dash_data.get("position_json", "{}"))
# 2. Extract native filters from json_metadata
native_filters: list[NativeFilterModel] = []
raw_filters = json_metadata.get("native_filter_configuration", [])
for rf in raw_filters:
targets = [
FilterTarget(chart_id=0, dataset_id=t.get("datasetId", 0))
for t in rf.get("targets", [])
]
filter_type = rf.get("filterType", "filter_select")
type_map = {
"filter_date": "DATE", "filter_time": "TIME",
"filter_time_grain": "TIME_GRAIN", "filter_range": "NUMERIC",
"filter_select": "STRING",
}
native_filters.append(NativeFilterModel(
filter_id=rf.get("id", ""), filter_type="NATIVE_FILTER",
name=rf.get("name", rf.get("id", "")),
column=rf.get("targets", [{}])[0].get("column", {}).get("name", ""),
dataset_id=rf.get("targets", [{}])[0].get("datasetId", 0),
type=type_map.get(filter_type, "STRING"),
targets=targets,
))
# 3. Extract chart IDs from position JSON
chart_ids: set[int] = set()
position_chart_meta: dict[int, dict] = {}
for key, value in position_json.items():
if isinstance(value, dict):
meta = value.get("meta", {})
cid = meta.get("chartId")
if cid is not None:
chart_ids.add(int(cid))
position_chart_meta[int(cid)] = meta
# 4. Fetch chart metadata through dashboard/charts endpoint for form_data
charts: list[ChartQueryModel] = []
try:
charts_data = await client.get_dashboard_charts(dashboard_id)
except SupersetAPIError:
charts_data = []
for chart_obj in charts_data:
cid = chart_obj.get("id")
if cid is None:
continue
cid = int(cid)
chart_ids.discard(cid) # mark as processed
form_data = _safe_json_load(chart_obj.get("form_data", "{}"))
params_str = chart_obj.get("params")
params = _safe_json_load(params_str) if params_str else {}
metrics: list[MetricDescriptor] = []
raw_metrics = params.get("metrics") or form_data.get("metrics", [])
for rm in raw_metrics:
if isinstance(rm, str):
metrics.append(MetricDescriptor(
metric_name=rm, label=rm, expression_type="SIMPLE"))
elif isinstance(rm, dict):
metrics.append(MetricDescriptor(
metric_name=rm.get("metric_name", rm.get("label", "")),
label=rm.get("label", rm.get("metric_name", "")),
expression_type=rm.get("expressionType", "SIMPLE"),
column=ColumnRef(column_name=rm.get("column", {}).get("column_name", ""),
type=rm.get("column", {}).get("type"))
if rm.get("column") else None,
aggregate=rm.get("aggregate"),
sql_expression=rm.get("sqlExpression")))
groupby = params.get("groupby") or form_data.get("groupby", [])
charts.append(ChartQueryModel(
chart_id=cid,
chart_uuid=chart_obj.get("uuid"),
slice_name=chart_obj.get("slice_name", f"Chart {cid}"),
viz_type=_parse_viz_type(form_data.get("viz_type") or chart_obj.get("viz_type")),
dataset_id=chart_obj.get("datasource_id", 0),
dataset_uuid=None,
dataset_name=chart_obj.get("datasource_name_text", ""),
metrics=metrics,
group_by_columns=list(groupby) if groupby else [],
applied_filter_ids=[],
excluded_filter_ids=[],
execution_capable=True,
))
# Remaining charts from position_json that weren't in charts endpoint
for cid in sorted(chart_ids):
try:
chart_detail = await client.get_chart(cid)
chart_data = chart_detail.get("result", chart_detail)
params = _safe_json_load(chart_data.get("params", "{}"))
metrics = []
for rm in params.get("metrics", []):
m_name = rm if isinstance(rm, str) else rm.get("metric_name", rm.get("label", ""))
metrics.append(MetricDescriptor(
metric_name=m_name, label=m_name, expression_type="SIMPLE"))
charts.append(ChartQueryModel(
chart_id=cid,
chart_uuid=chart_data.get("uuid"),
slice_name=chart_data.get("slice_name", f"Chart {cid}"),
viz_type=_parse_viz_type(chart_data.get("viz_type")),
dataset_id=chart_data.get("datasource_id", 0),
dataset_name=chart_data.get("datasource_name_text", ""),
metrics=metrics,
group_by_columns=list(params.get("groupby", [])),
execution_capable=True,
))
except SupersetAPIError as e:
warnings.append(Warning(source="inspection", resource=str(cid),
code="INACCESSIBLE_CHART", detail=str(e)))
charts.append(ChartQueryModel(
chart_id=cid, slice_name=f"Chart {cid} (inaccessible)",
viz_type=VizType.OTHER, dataset_id=0,
dataset_name="[inaccessible]", execution_capable=False))
# Sort charts by id
charts.sort(key=lambda c: c.chart_id)
# 5. Fetch dataset metadata
datasets: list[DatasetQueryModel] = []
dataset_ids: set[int] = {ch.dataset_id for ch in charts if ch.dataset_id > 0}
try:
datasets_data = await client.get_dashboard_datasets(dashboard_id)
except SupersetAPIError:
datasets_data = []
for ds in datasets_data:
did = ds.get("id", 0)
dataset_ids.discard(did)
columns = [
ColumnInfo(column_name=col.get("column_name", ""),
type=col.get("type", "STRING"),
groupby=col.get("groupby", False),
filterable=col.get("filterable", False))
for col in ds.get("columns", [])
]
ds_metrics = [
MetricDescriptor(
metric_name=m.get("metric_name", ""),
label=m.get("verbose_name", m.get("metric_name", "")),
expression_type="SIMPLE",
column=ColumnRef(column_name=m.get("column", {}).get("column_name", "")))
for m in ds.get("metrics", [])
]
datasets.append(DatasetQueryModel(
dataset_id=did, dataset_uuid=ds.get("uuid"),
dataset_name=ds.get("table_name", f"Dataset {did}"),
columns=columns, metrics=ds_metrics, access_state="accessible"))
# 6. Resolve filter→chart mapping
for chart in charts:
chart.applied_filter_ids = []
for nf in native_filters:
for t in nf.targets:
if t.dataset_id == chart.dataset_id:
if chart.chart_id not in chart.applied_filter_ids:
chart.applied_filter_ids.append(nf.filter_id)
# 7. Capabilities
capabilities = DashboardCapabilities(
chart_data=True, dataset_query=bool(datasets), xlsx_export=False)
# 8. Build model + fingerprint
model = DashboardQueryModel(
environment_id=environment_id, dashboard_id=dashboard_id,
title=title, slug=slug,
charts=charts, datasets=datasets, native_filters=native_filters,
capabilities=capabilities, warnings=warnings,
query_model_fingerprint="",
)
model_dict = model.model_dump(mode="json", exclude={"query_model_fingerprint"})
model.query_model_fingerprint = _compute_fingerprint(model_dict)
return model
# #endregion BaselineEngine.QueryModel.Inspect

View File

@@ -0,0 +1,252 @@
#region Test.DashboardTesting.Filters [C:3] [TYPE Module] [SEMANTICS testing,baseline,filters,scope]
# @defgroup Tests for BaselineEngine.Filters.Normalize — filter scope, type, hash validation.
# @LAYER Test
# @RELATION VERIFIES -> [BaselineEngine.Filters.Normalize]
from __future__ import annotations
import pytest
from src.schemas.dashboard_testing import (
DashboardQueryModel, ChartQueryModel, DatasetQueryModel, ColumnInfo,
NativeFilterModel, FilterTarget, NormalizedFilter, NormalizedFilterContext,
FilterValue, MetricDescriptor,
)
from src.services.dashboard_testing.filters import normalize_filters
def _make_basic_query_model(
chart_ids: list[int] | None = None,
filter_targets: dict[str, list[int]] | None = None,
) -> DashboardQueryModel:
"""Build a minimal query model for filter tests."""
if chart_ids is None:
chart_ids = [128, 129]
charts = [
ChartQueryModel(
chart_id=cid,
slice_name=f"Chart {cid}",
viz_type="bar",
dataset_id=77,
dataset_name="public.finance_transactions",
metrics=[MetricDescriptor(metric_name="sum__revenue", label="SUM(revenue)", expression_type="SIMPLE")],
applied_filter_ids=[fid for fid, targets in (filter_targets or {}).items() if cid in targets],
excluded_filter_ids=[],
)
for cid in chart_ids
]
if filter_targets is None:
filter_targets = {"NATIVE_FILTER-date": [128, 129], "NATIVE_FILTER-region": [128]}
native_filters = [
NativeFilterModel(
filter_id=fid,
filter_type="NATIVE_FILTER",
name=fid.replace("NATIVE_FILTER-", "").replace("-", " ").title(),
column="business_date" if "date" in fid else "business_region",
dataset_id=77,
type="DATE" if "date" in fid else "STRING",
targets=[FilterTarget(chart_id=cid, dataset_id=77) for cid in targets],
)
for fid, targets in filter_targets.items()
]
return DashboardQueryModel(
environment_id="ss-preprod",
dashboard_id=42,
title="FI-0080 Finance Overview",
charts=charts,
datasets=[
DatasetQueryModel(
dataset_id=77,
dataset_name="public.finance_transactions",
columns=[
ColumnInfo(column_name="business_date", type="DATE", groupby=True, filterable=True),
ColumnInfo(column_name="business_region", type="STRING", groupby=True, filterable=True),
],
)
],
native_filters=native_filters,
query_model_fingerprint="sha256:test",
)
# #region Test.DashboardTesting.Filters.BasicNormalization [C:3] [TYPE Function] [SEMANTICS testing,baseline,filter,scope]
def test_normalize_basic_date_filter():
"""T007: Basic date filter normalization produces correct typed output."""
model = _make_basic_query_model()
result = normalize_filters(
filter_inputs=[
NormalizedFilter(
filter_id="NATIVE_FILTER-date",
dataset_id=77,
column="business_date",
operator="TEMPORAL_RANGE",
value=FilterValue(from_="2026-05-29", to="2026-05-29", inclusive=True),
target_chart_ids=[128, 129],
)
],
query_model=model,
)
assert isinstance(result, NormalizedFilterContext)
assert len(result.filters) == 1
assert result.filters[0].filter_id == "NATIVE_FILTER-date"
assert result.filters[0].value.from_ == "2026-05-29"
assert result.filters_hash, "filters_hash must be computed"
# #endregion Test.DashboardTesting.Filters.BasicNormalization
# #region Test.DashboardTesting.Filters.FilterHashDeterministic [C:3] [TYPE Function] [SEMANTICS testing,baseline,filter,hash]
def test_filter_hash_deterministic():
"""T007: Same filter inputs produce identical hash."""
model = _make_basic_query_model()
filters = [
NormalizedFilter(
filter_id="NATIVE_FILTER-date",
dataset_id=77, column="business_date",
operator="TEMPORAL_RANGE",
value=FilterValue(from_="2026-05-29", to="2026-05-29", inclusive=True),
target_chart_ids=[128, 129],
)
]
r1 = normalize_filters(filters, model)
r2 = normalize_filters(filters, model)
assert r1.filters_hash == r2.filters_hash
# #endregion Test.DashboardTesting.Filters.FilterHashDeterministic
# #region Test.DashboardTesting.Filters.ScopedFilterOnlyTargetCharts [C:3] [TYPE Function] [SEMANTICS testing,baseline,filter,scope]
def test_scoped_filter_only_target_charts():
"""T007: Filter scoped to chart 128 only includes relevant target_chart_ids."""
model = _make_basic_query_model(
chart_ids=[128, 129],
filter_targets={"NATIVE_FILTER-region": [128]} # only chart 128
)
result = normalize_filters(
filter_inputs=[
NormalizedFilter(
filter_id="NATIVE_FILTER-region",
dataset_id=77, column="business_region",
operator="IN",
value=FilterValue(values=["West"]),
target_chart_ids=[128],
)
],
query_model=model,
)
assert result.filters[0].target_chart_ids == [128]
# #endregion Test.DashboardTesting.Filters.ScopedFilterOnlyTargetCharts
# #region Test.DashboardTesting.Filters.FilterOutsideScopeRejected [C:3] [TYPE Function] [SEMANTICS testing,baseline,filter,edge-case]
def test_filter_outside_chart_scope_rejected():
"""T007: Filter targeting chart not in query model scope raises ValueError."""
model = _make_basic_query_model(
chart_ids=[128],
filter_targets={"NATIVE_FILTER-date": [128]}
)
with pytest.raises(ValueError, match="not in query model"):
normalize_filters(
filter_inputs=[
NormalizedFilter(
filter_id="NATIVE_FILTER-date",
dataset_id=77, column="business_date",
operator="TEMPORAL_RANGE",
value=FilterValue(from_="2026-06-01", to="2026-06-01"),
target_chart_ids=[999], # chart 999 not in model
)
],
query_model=model,
)
# #endregion Test.DashboardTesting.Filters.FilterOutsideScopeRejected
# #region Test.DashboardTesting.Filters.CanonicalOrder [C:3] [TYPE Function] [SEMANTICS testing,baseline,filter,order]
def test_canonical_filter_order_is_stable():
"""T007: Filters are sorted in canonical order (dataset_id, column, operator, filter_id)."""
model = _make_basic_query_model(
chart_ids=[128],
filter_targets={
"Z-filter": [128],
"A-filter": [128],
}
)
# Override native filters to have different dataset_ids for ordering test
# Also add dataset_id=99 to the model datasets
model.datasets.append(
DatasetQueryModel(
dataset_id=99, dataset_name="public.extra",
columns=[ColumnInfo(column_name="col_z", type="STRING", groupby=True, filterable=True)],
)
)
model.native_filters = [
NativeFilterModel(
filter_id="Z-filter", filter_type="NATIVE_FILTER", name="Z",
column="col_z", dataset_id=99, type="STRING",
targets=[FilterTarget(chart_id=128, dataset_id=99)],
),
NativeFilterModel(
filter_id="A-filter", filter_type="NATIVE_FILTER", name="A",
column="col_a", dataset_id=77, type="STRING",
targets=[FilterTarget(chart_id=128, dataset_id=77)],
),
]
result = normalize_filters(
filter_inputs=[
NormalizedFilter(filter_id="Z-filter", dataset_id=99, column="col_z",
operator="EQUALS", value=FilterValue(value="z"),
target_chart_ids=[128]),
NormalizedFilter(filter_id="A-filter", dataset_id=77, column="col_a",
operator="EQUALS", value=FilterValue(value="a"),
target_chart_ids=[128]),
],
query_model=model,
)
# A-filter (dataset_id=77) should come before Z-filter (dataset_id=99)
assert result.filters[0].filter_id == "A-filter"
assert result.filters[1].filter_id == "Z-filter"
# #endregion Test.DashboardTesting.Filters.CanonicalOrder
# #region Test.DashboardTesting.Filters.LocaleNotInHash [C:3] [TYPE Function] [SEMANTICS testing,baseline,filter,locale]
def test_locale_does_not_affect_hash():
"""T007: Locale formatting changes do NOT affect the filters_hash."""
model = _make_basic_query_model()
# Filter with locale-formatted decimal
r1 = normalize_filters(
filter_inputs=[
NormalizedFilter(
filter_id="NATIVE_FILTER-date",
dataset_id=77, column="business_date",
operator="TEMPORAL_RANGE",
value=FilterValue(from_="2026-05-29", to="2026-05-29"),
target_chart_ids=[128, 129],
)
],
query_model=model,
)
# Same filter, but with different display format should produce same hash
# (value is already canonical, locale differences resolved upstream)
r2 = normalize_filters(
filter_inputs=[
NormalizedFilter(
filter_id="NATIVE_FILTER-date",
dataset_id=77, column="business_date",
operator="TEMPORAL_RANGE",
value=FilterValue(from_="2026-05-29", to="2026-05-29"),
target_chart_ids=[128, 129],
)
],
query_model=model,
)
assert r1.filters_hash == r2.filters_hash
# #endregion Test.DashboardTesting.Filters.LocaleNotInHash
#endregion Test.DashboardTesting.Filters

View File

@@ -0,0 +1,180 @@
#region Test.DashboardTesting.QueryModel [C:3] [TYPE Module] [SEMANTICS testing,baseline,query-model,inspection]
# @defgroup Tests for BaselineEngine.QueryModel.Inspect — deterministic dashboard query model inspection.
# @LAYER Test
# @RELATION VERIFIES -> [BaselineEngine.QueryModel.Inspect]
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.schemas.dashboard_testing import DashboardQueryModel
from src.services.dashboard_testing.query_model import inspect_dashboard_query_model
FIXTURES = Path(__file__).parent.parent.parent / "fixtures" / "dashboard_testing"
def _load_fixture(name: str) -> dict:
return json.loads((FIXTURES / name).read_text())
def _make_mock_client(
*,
dashboard_result: dict | None = None,
charts_result: list[dict] | None = None,
datasets_result: list[dict] | None = None,
chart_detail_result: dict | None = None,
) -> AsyncMock:
"""Build a mock SupersetClient with get_dashboard, get_dashboard_charts, get_dashboard_datasets, get_chart."""
client = AsyncMock()
client.get_dashboard = AsyncMock(return_value={
"result": dashboard_result or {
"id": 42, "dashboard_title": "FI-0080 Finance Overview",
"slug": "fi-0080-finance-overview",
"json_metadata": json.dumps({"native_filter_configuration": []}),
"position_json": json.dumps({}),
}
})
client.get_dashboard_charts = AsyncMock(return_value=charts_result or [])
client.get_dashboard_datasets = AsyncMock(return_value=datasets_result or [])
client.get_chart = AsyncMock(return_value={"result": chart_detail_result or {}})
return client
# @region Test.DashboardTesting.QueryModel.BasicInspection [C:3] [TYPE Function] [SEMANTICS testing,baseline,query-model]
@pytest.mark.asyncio
async def test_inspect_basic_dashboard_structure():
"""T006: Verify basic structure of inspected dashboard — title, charts, datasets, filters."""
client = _make_mock_client(
dashboard_result={
"id": 42, "dashboard_title": "FI-0080 Finance Overview",
"slug": "fi-0080-finance-overview",
"json_metadata": json.dumps({
"native_filter_configuration": [
{"id": "NATIVE_FILTER-date", "name": "Business Date",
"filterType": "filter_date",
"targets": [{"datasetId": 77, "column": {"name": "business_date"}}]},
{"id": "NATIVE_FILTER-region", "name": "Region",
"filterType": "filter_select",
"targets": [{"datasetId": 77, "column": {"name": "business_region"}}]},
]
}),
"position_json": json.dumps({
"CHART-128": {"id": "CHART-128", "meta": {"chartId": 128, "uuid": "aaa",
"sliceName": "Monthly Revenue", "width": 6, "height": 12}},
}),
},
charts_result=[
{"id": 128, "uuid": "c9e2e4a8-1234-4abc-9def-0123456789ab",
"slice_name": "Monthly Revenue by Region", "viz_type": "bar",
"datasource_id": 77, "datasource_type": "table",
"datasource_name_text": "public.finance_transactions",
"params": json.dumps({"metrics": ["sum__revenue", "count"],
"groupby": ["business_region"]})},
],
datasets_result=[
{"id": 77, "uuid": "d77a1234-abcd-4efg-hijk-lmnopqrstuv",
"table_name": "public.finance_transactions",
"columns": [
{"column_name": "business_date", "type": "DATE", "groupby": True, "filterable": True},
{"column_name": "business_region", "type": "STRING", "groupby": True, "filterable": True},
],
"metrics": [
{"metric_name": "sum__revenue", "verbose_name": "SUM(revenue)", "expression": "SUM(revenue)"},
]},
],
)
result = await inspect_dashboard_query_model(client, "ss-preprod", 42)
assert isinstance(result, DashboardQueryModel)
assert result.environment_id == "ss-preprod"
assert result.dashboard_id == 42
assert result.title == "FI-0080 Finance Overview"
assert len(result.charts) >= 1
assert len(result.datasets) >= 1
assert len(result.native_filters) >= 2
assert result.capabilities.chart_data is True
assert result.query_model_fingerprint
# @endregion Test.DashboardTesting.QueryModel.BasicInspection
# @region Test.DashboardTesting.QueryModel.DeterministicOutput [C:3] [TYPE Function] [SEMANTICS testing,baseline,deterministic]
@pytest.mark.asyncio
async def test_deterministic_inspection_output():
"""T006: Two inspections of same dashboard produce identical JSON snapshots."""
client = _make_mock_client(
dashboard_result={
"id": 42, "dashboard_title": "FI-0080 Finance Overview",
"slug": "fi-0080-finance-overview",
"json_metadata": json.dumps({"native_filter_configuration": []}),
"position_json": json.dumps({
"CHART-128": {"id": "CHART-128", "meta": {"chartId": 128}},
}),
},
charts_result=[
{"id": 128, "uuid": "aaa", "slice_name": "Chart A", "viz_type": "bar",
"datasource_id": 77, "datasource_type": "table",
"datasource_name_text": "public.finance_transactions",
"params": json.dumps({"metrics": ["sum__revenue"], "groupby": []})},
],
datasets_result=[
{"id": 77, "uuid": "d77", "table_name": "public.finance_transactions",
"columns": [{"column_name": "id", "type": "INTEGER", "groupby": False, "filterable": False}],
"metrics": []},
],
)
result1 = await inspect_dashboard_query_model(client, "dev", 42)
result2 = await inspect_dashboard_query_model(client, "dev", 42)
json1 = result1.model_dump_json(exclude={"query_model_fingerprint"})
json2 = result2.model_dump_json(exclude={"query_model_fingerprint"})
assert json1 == json2, "Deterministic inspection must produce identical JSON"
# @endregion Test.DashboardTesting.QueryModel.DeterministicOutput
# @region Test.DashboardTesting.QueryModel.InaccessibleChart [C:3] [TYPE Function] [SEMANTICS testing,baseline,edge-case]
@pytest.mark.asyncio
async def test_inaccessible_chart_produces_warning():
"""T006: Inaccessible chart returns warning + execution_capable=False."""
from src.core.utils.network import SupersetAPIError
client = _make_mock_client(
dashboard_result={
"id": 42, "dashboard_title": "Test", "slug": "test",
"json_metadata": json.dumps({}),
"position_json": json.dumps({
"CHART-999": {"id": "CHART-999", "meta": {"chartId": 999}},
}),
},
charts_result=[], # no charts via dashboard endpoint
)
# get_chart will raise for chart 999
client.get_chart = AsyncMock(side_effect=SupersetAPIError("Forbidden", status_code=403))
result = await inspect_dashboard_query_model(client, "dev", 42)
assert len(result.warnings) > 0
assert any(w.code == "INACCESSIBLE_CHART" for w in result.warnings)
# @endregion Test.DashboardTesting.QueryModel.InaccessibleChart
# @region Test.DashboardTesting.QueryModel.MissingMetadataNotInvented [C:3] [TYPE Function] [SEMANTICS testing,baseline,invariant]
@pytest.mark.asyncio
async def test_missing_metadata_not_invented():
"""T006: When Superset returns empty chart list, charts are empty — never fabricated."""
client = _make_mock_client(
dashboard_result={
"id": 42, "dashboard_title": "Empty Dashboard", "slug": "empty",
"json_metadata": json.dumps({}),
"position_json": json.dumps({}),
},
charts_result=[],
)
result = await inspect_dashboard_query_model(client, "dev", 42)
assert len(result.charts) == 0
assert result.title == "Empty Dashboard"
# @endregion Test.DashboardTesting.QueryModel.MissingMetadataNotInvented
#endregion Test.DashboardTesting.QueryModel