chartwright 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {chartwright-0.2.0/chartwright.egg-info → chartwright-0.2.1}/PKG-INFO +1 -1
  2. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/__init__.py +1 -1
  3. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/compiler.py +56 -10
  4. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/smoke.py +22 -3
  5. {chartwright-0.2.0 → chartwright-0.2.1/chartwright.egg-info}/PKG-INFO +1 -1
  6. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright.egg-info/SOURCES.txt +1 -0
  7. {chartwright-0.2.0 → chartwright-0.2.1}/pyproject.toml +1 -1
  8. chartwright-0.2.1/tests/test_chart_time_range.py +200 -0
  9. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_compiler.py +5 -1
  10. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_mixed_chart.py +3 -1
  11. {chartwright-0.2.0 → chartwright-0.2.1}/LICENSE +0 -0
  12. {chartwright-0.2.0 → chartwright-0.2.1}/NOTICE +0 -0
  13. {chartwright-0.2.0 → chartwright-0.2.1}/README.md +0 -0
  14. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/absorb.py +0 -0
  15. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/apply.py +0 -0
  16. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/cli.py +0 -0
  17. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/client.py +0 -0
  18. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/dashdiff.py +0 -0
  19. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/decompile.py +0 -0
  20. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/__init__.py +0 -0
  21. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/brief.py +0 -0
  22. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/calibrate.py +0 -0
  23. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/fix.py +0 -0
  24. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/guidelines/chart-choice.md +0 -0
  25. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/guidelines/composition.md +0 -0
  26. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/model.py +0 -0
  27. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/presets.py +0 -0
  28. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/probe.py +0 -0
  29. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/redesign.py +0 -0
  30. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/rules.py +0 -0
  31. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/ids.py +0 -0
  32. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/mcp_server.py +0 -0
  33. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/profiles.py +0 -0
  34. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/resolver.py +0 -0
  35. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/sketch.py +0 -0
  36. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/spec.py +0 -0
  37. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/testing.py +0 -0
  38. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright.egg-info/dependency_links.txt +0 -0
  39. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright.egg-info/entry_points.txt +0 -0
  40. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright.egg-info/requires.txt +0 -0
  41. {chartwright-0.2.0 → chartwright-0.2.1}/chartwright.egg-info/top_level.txt +0 -0
  42. {chartwright-0.2.0 → chartwright-0.2.1}/setup.cfg +0 -0
  43. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_absorb.py +0 -0
  44. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_apply_rollback.py +0 -0
  45. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_backup_layout.py +0 -0
  46. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_calibrate.py +0 -0
  47. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_client.py +0 -0
  48. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_column_suggestions.py +0 -0
  49. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_cross_filters.py +0 -0
  50. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_dashdiff.py +0 -0
  51. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_decompile.py +0 -0
  52. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_design.py +0 -0
  53. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_design_arch.py +0 -0
  54. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_design_data.py +0 -0
  55. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_design_gate.py +0 -0
  56. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_design_rules_v2.py +0 -0
  57. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_docs.py +0 -0
  58. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_layout_footer.py +0 -0
  59. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_markdown_heights.py +0 -0
  60. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_mcp_server.py +0 -0
  61. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_nested_tabs.py +0 -0
  62. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_params_contract.py +0 -0
  63. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_pivot_formatting.py +0 -0
  64. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_plan_errors.py +0 -0
  65. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_preset_auth.py +0 -0
  66. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_profiles.py +0 -0
  67. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_property_roundtrip.py +0 -0
  68. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_range_filter.py +0 -0
  69. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_redesign.py +0 -0
  70. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_review_tier_1_2.py +0 -0
  71. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_select_filter.py +0 -0
  72. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_sketch.py +0 -0
  73. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_smoke_fit.py +0 -0
  74. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_spec.py +0 -0
  75. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_spec_v2.py +0 -0
  76. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_table_formatting.py +0 -0
  77. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_table_sort.py +0 -0
  78. {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_time_filter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartwright
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Build, verify, and maintain Apache Superset dashboards from small spec files.
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/debabsah/chartwright
@@ -4,4 +4,4 @@ Typed spec -> deterministic compiler -> guaranteed-correct Superset dashboard.
4
4
  The LLM is an untrusted parser at the edge; the guarantee lives in typed code.
5
5
  """
6
6
 
7
- __version__ = "0.2.0"
7
+ __version__ = "0.2.1"
@@ -355,20 +355,66 @@ def _chart_params(chart, spec: DashboardSpec, resolution: Resolution) -> dict:
355
355
  # (utils/mergeCustomEChartOptions.ts: arrays replace, so never a mixed chart's yAxis).
356
356
  p["echart_options"] = json.dumps(p["echart_options"])
357
357
 
358
- # Bind charts without their own TIME column to the dataset's main temporal
359
- # column. The dashboard time filter reaches a chart only through a time
360
- # binding; without one the chart silently ignores it while the filter bar
361
- # still counts it as filtered (verified live on 6.1.0: full-month total
362
- # under a one-week default). The categorical bar's x_axis is not a time
363
- # column, so it needs the binding too.
364
- timeseries = ("timeseries_line", "timeseries_bar", "timeseries_area",
365
- "timeseries_scatter", "big_number_trend")
366
- time_x = t == "mixed" and "time_grain_sqla" in p
367
- if t not in timeseries and not time_x and ds.main_dttm_col:
358
+ # A time range, the chart's own or a dashboard time filter's, reaches a query
359
+ # only through a time binding; without one the chart silently ignores it while
360
+ # the filter bar still counts it as filtered.
361
+ #
362
+ # The backend applies a query's time_range to the WHERE clause only through
363
+ # `granularity` (get_sqla_query, superset/models/helpers.py at 4.1.4, 5.0.0 and
364
+ # 6.1.0), which the frontend sends from granularity_sqla. A chart without a time
365
+ # axis binds the dataset's main time column there (verified live on 6.1.0: a
366
+ # full-month total under a one-week dashboard default before the binding). The
367
+ # categorical bar's x_axis is not a time column, so it needs the binding too.
368
+ #
369
+ # A chart drawn on a time axis can't take that binding: with granularity set,
370
+ # _apply_granularity (superset/common/query_context_factory.py) swaps the x-axis
371
+ # column for it. Superset's own charts filter the axis with an adhoc
372
+ # TEMPORAL_RANGE filter instead, applied whatever the granularity, and
373
+ # _apply_filters sets its value to the query's time_range: the chart's own, or
374
+ # the dashboard filter's. Without it, these charts returned every row on all
375
+ # three releases (seen live: a line chart limited to 2004-03-01 : 2005-03-02
376
+ # still drew 2003 to 2005).
377
+ axis = time_axis_column(chart, ds)
378
+ if axis:
379
+ temporal = _temporal_range_filter(axis, p["time_range"])
380
+ p["adhoc_filters"] = p["adhoc_filters"] + [temporal]
381
+ if t == "mixed":
382
+ p["adhoc_filters_b"] = p["adhoc_filters_b"] + [temporal]
383
+ elif ds.main_dttm_col:
368
384
  p["granularity_sqla"] = ds.main_dttm_col
369
385
  return p
370
386
 
371
387
 
388
+ # Chart types whose x axis is always their time column.
389
+ TIME_AXIS_TYPES = ("timeseries_line", "timeseries_bar", "timeseries_area",
390
+ "timeseries_scatter", "big_number_trend")
391
+
392
+
393
+ def time_axis_column(chart, ds) -> str | None:
394
+ """The time column a chart draws its x axis on, or None for a chart without a
395
+ time axis. The compiler and smoke share it, so the smoke query is filtered the
396
+ way the chart is."""
397
+ if chart.type in TIME_AXIS_TYPES:
398
+ return chart.time_column
399
+ if chart.type == "mixed" and mixed_time_axis(chart, ds):
400
+ return chart.x_column
401
+ return None
402
+
403
+
404
+ def _temporal_range_filter(column: str, time_range: str) -> dict:
405
+ # The shape Explore saves for the "Time range" filter pill (all three releases).
406
+ # Its comparator matches the chart's time_range: a non-empty time_range, "No
407
+ # filter" included, overwrites it at query time (_apply_filters), so the two
408
+ # can't disagree.
409
+ return {
410
+ "clause": "WHERE",
411
+ "expressionType": "SIMPLE",
412
+ "subject": column,
413
+ "operator": "TEMPORAL_RANGE",
414
+ "comparator": time_range,
415
+ }
416
+
417
+
372
418
  def mixed_time_axis(chart, ds) -> bool:
373
419
  """Whether a mixed chart's x column draws a time axis: the column's reported type
374
420
  decides (a column flagged temporal counts), time_grain only when it is unknown.
@@ -17,7 +17,7 @@ import math
17
17
  from dataclasses import dataclass
18
18
 
19
19
  from .client import SupersetClient
20
- from .compiler import _metric_payload, mixed_time_axis
20
+ from .compiler import _metric_payload, mixed_time_axis, time_axis_column
21
21
  from .resolver import Resolution
22
22
  from .spec import GRID_HEADER_UNITS, DashboardSpec, grid_units_for_rows
23
23
 
@@ -69,6 +69,24 @@ def _filters_payload(chart) -> list[dict]:
69
69
  return [{"col": f.column, "op": f.op, "val": f.value} for f in chart.filters]
70
70
 
71
71
 
72
+ def _with_time_range(q: dict, chart, ds) -> dict:
73
+ """The chart's own time_range, bound the way the compiled chart binds it: a
74
+ TEMPORAL_RANGE filter on a time axis (compiler.time_axis_column), else the
75
+ dataset's main time column as granularity. A chart without a time_range, or
76
+ without a binding, keeps the unfiltered query."""
77
+ time_range = getattr(chart, "time_range", None)
78
+ if not time_range:
79
+ return q
80
+ axis = time_axis_column(chart, ds)
81
+ if axis:
82
+ q["time_range"] = time_range
83
+ q["filters"] = q["filters"] + [{"col": axis, "op": "TEMPORAL_RANGE", "val": time_range}]
84
+ elif ds.main_dttm_col:
85
+ q["time_range"] = time_range
86
+ q["granularity"] = ds.main_dttm_col
87
+ return q
88
+
89
+
72
90
  def _time_axis(column: str, grain: str | None) -> dict:
73
91
  return {
74
92
  "columnType": "BASE_AXIS",
@@ -144,7 +162,7 @@ def _mixed_queries(chart, spec: DashboardSpec, ds) -> list[dict]:
144
162
  out.append({
145
163
  "filters": _filters_payload(chart),
146
164
  "extras": {"time_grain_sqla": chart.time_grain or "P1D"},
147
- "time_range": chart.time_range or "No filter",
165
+ "time_range": "No filter",
148
166
  "row_limit": chart.row_limit or 1000,
149
167
  "columns": [x] + ([series.groupby] if series.groupby else []),
150
168
  "metrics": [_metric_payload(m, spec.dashboard.slug, chart.name) for m in series.metrics],
@@ -157,7 +175,8 @@ def smoke_chart(chart, spec: DashboardSpec, resolution: Resolution, client: Supe
157
175
  ds = resolution.for_chart(chart.dataset)
158
176
  ctx = {
159
177
  "datasource": {"id": ds.id, "type": "table"},
160
- "queries": _mixed_queries(chart, spec, ds) if chart.type == "mixed" else [_query_for(chart, spec)],
178
+ "queries": [_with_time_range(q, chart, ds) for q in (
179
+ _mixed_queries(chart, spec, ds) if chart.type == "mixed" else [_query_for(chart, spec)])],
161
180
  "result_format": "json",
162
181
  "result_type": "full",
163
182
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartwright
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Build, verify, and maintain Apache Superset dashboards from small spec files.
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/debabsah/chartwright
@@ -39,6 +39,7 @@ tests/test_absorb.py
39
39
  tests/test_apply_rollback.py
40
40
  tests/test_backup_layout.py
41
41
  tests/test_calibrate.py
42
+ tests/test_chart_time_range.py
42
43
  tests/test_client.py
43
44
  tests/test_column_suggestions.py
44
45
  tests/test_compiler.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chartwright"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Build, verify, and maintain Apache Superset dashboards from small spec files."
5
5
  license = "Apache-2.0"
6
6
  readme = "README.md"
@@ -0,0 +1,200 @@
1
+ """A chart's time range, its own `time_range` or a dashboard time filter's,
2
+ reaches its query only through a time binding.
3
+
4
+ A chart without a time axis binds the dataset's main time column as
5
+ granularity_sqla. A chart drawn on a time axis (timeseries_*, big_number_trend,
6
+ mixed on a time column) gets an adhoc TEMPORAL_RANGE filter on that axis. Before
7
+ that filter (0.2.0), Superset 4.1.4, 5.0.0 and 6.1.0 applied neither the chart's
8
+ time_range nor the dashboard's time filter to those charts (seen live: a line
9
+ chart with time_range "2004-03-01 : 2005-03-02" still drew 2003 to 2005)."""
10
+
11
+ import io
12
+ import zipfile
13
+
14
+ import pytest
15
+ import yaml
16
+
17
+ from chartwright.compiler import compile_bundle
18
+ from chartwright.dashdiff import _normalize
19
+ from chartwright.decompile import decompile_bundle
20
+ from chartwright.smoke import _mixed_queries, _query_for, _with_time_range
21
+ from chartwright.spec import load_spec
22
+ from chartwright.testing import stub_resolution
23
+
24
+ DS = {"database": "examples", "table": "t"}
25
+ RANGE = "2004-03-01 : 2005-03-02"
26
+ # The charts that take their own time_range.
27
+ RANGED = [
28
+ {"name": "Line", "type": "timeseries_line", "metrics": ["COUNT(*)"], "time_column": "ts"},
29
+ {"name": "Bars", "type": "timeseries_bar", "metrics": ["COUNT(*)"], "time_column": "ts"},
30
+ {"name": "Area", "type": "timeseries_area", "metrics": ["COUNT(*)"], "time_column": "ts"},
31
+ {"name": "Dots", "type": "timeseries_scatter", "metrics": ["COUNT(*)"], "time_column": "ts"},
32
+ {"name": "Mixed by month", "type": "mixed", "x_column": "ts", "time_grain": "P1M",
33
+ "a": {"metrics": ["COUNT(*)"]},
34
+ "b": {"metrics": ["SUM(amount)"], "kind": "line", "axis": "secondary"}},
35
+ ]
36
+ TREND = {"name": "Trend", "type": "big_number_trend", "metric": "COUNT(*)", "time_column": "ts"}
37
+ MIXED_CATEGORY = {"name": "Mixed by region", "type": "mixed", "x_column": "region",
38
+ "a": {"metrics": ["COUNT(*)"]},
39
+ "b": {"metrics": ["SUM(amount)"], "kind": "line", "axis": "secondary"}}
40
+ AXIS_NAMES = {c["name"] for c in RANGED} | {TREND["name"]}
41
+ NO_AXIS = [
42
+ {"name": "Total", "type": "big_number_total", "metric": "COUNT(*)"},
43
+ {"name": "Table", "type": "table", "groupby": ["region"], "metrics": ["COUNT(*)"]},
44
+ {"name": "Pie", "type": "pie", "metric": "COUNT(*)", "groupby": "region"},
45
+ {"name": "Pivot", "type": "pivot_table", "rows": ["region"], "columns": [], "metrics": ["COUNT(*)"]},
46
+ {"name": "Bar", "type": "bar", "x_column": "region", "metrics": ["COUNT(*)"]},
47
+ MIXED_CATEGORY,
48
+ ]
49
+
50
+
51
+ def _spec(charts, time_range=None):
52
+ # Only the charts that have a time_range field take one.
53
+ takes_range = {"timeseries_line", "timeseries_bar", "timeseries_area", "timeseries_scatter", "mixed"}
54
+ charts = [{**c, "dataset": DS,
55
+ **({"time_range": time_range} if time_range and c["type"] in takes_range else {})}
56
+ for c in charts]
57
+ return load_spec({
58
+ "spec_version": "1",
59
+ "dashboard": {"title": "T", "slug": "sdc-t"},
60
+ "charts": charts,
61
+ "layout": {"rows": [[c["name"]] for c in charts]},
62
+ })
63
+
64
+
65
+ def _resolution(spec, main_dttm_col="ts"):
66
+ res = stub_resolution(spec)
67
+ for ds in res.datasets.values():
68
+ ds.main_dttm_col = main_dttm_col
69
+ ds.column_types.update({"ts": 2, "region": 1})
70
+ return res
71
+
72
+
73
+ def _bundle(spec, res=None):
74
+ return compile_bundle(spec, res or _resolution(spec))
75
+
76
+
77
+ def _params(spec, res=None):
78
+ zf = zipfile.ZipFile(io.BytesIO(_bundle(spec, res)))
79
+ return {cy["slice_name"]: cy["params"] for cy in
80
+ (yaml.safe_load(zf.read(n)) for n in zf.namelist() if "/charts/" in n)}
81
+
82
+
83
+ def _temporal(params, key="adhoc_filters"):
84
+ return [f for f in params.get(key) or [] if f.get("operator") == "TEMPORAL_RANGE"]
85
+
86
+
87
+ def _lookup(spec, res):
88
+ by_uuid = {res.for_chart(c.dataset).uuid: dict(DS, schema=None) for c in spec.charts}
89
+ return lambda u: by_uuid.get(u)
90
+
91
+
92
+ @pytest.mark.parametrize("time_range", [RANGE, None])
93
+ def test_a_time_axis_chart_filters_its_axis_with_its_range(time_range):
94
+ params = _params(_spec(RANGED, time_range))
95
+ for name, p in params.items():
96
+ expected = time_range or "No filter"
97
+ assert p["time_range"] == expected, name
98
+ assert _temporal(p) == [{"clause": "WHERE", "expressionType": "SIMPLE", "subject": "ts",
99
+ "operator": "TEMPORAL_RANGE", "comparator": expected}], name
100
+ assert "granularity_sqla" not in p, name # it would replace the axis column
101
+
102
+
103
+ def test_a_trendline_kpi_filters_its_axis():
104
+ """big_number_trend has no time_range of its own: the filter carries "No filter",
105
+ which the dashboard time filter's range overwrites at query time."""
106
+ p = _params(_spec([TREND]))["Trend"]
107
+ assert p["time_range"] == "No filter"
108
+ assert _temporal(p) == [{"clause": "WHERE", "expressionType": "SIMPLE", "subject": "ts",
109
+ "operator": "TEMPORAL_RANGE", "comparator": "No filter"}]
110
+ assert "granularity_sqla" not in p
111
+
112
+
113
+ def test_a_mixed_time_chart_filters_both_queries():
114
+ p = _params(_spec(RANGED, RANGE))["Mixed by month"]
115
+ assert p["adhoc_filters_b"] == p["adhoc_filters"] and _temporal(p, "adhoc_filters_b")
116
+
117
+
118
+ def test_a_mixed_time_chart_without_column_types_follows_its_grain():
119
+ """The stub resolution reports no column types: a time_grain makes the axis a
120
+ time axis (mixed_time_axis), and the filter follows it."""
121
+ spec = _spec(RANGED[-1:], RANGE)
122
+ p = _params(spec, stub_resolution(spec))["Mixed by month"]
123
+ assert _temporal(p) and _temporal(p, "adhoc_filters_b")
124
+
125
+
126
+ def test_the_range_filter_follows_the_charts_own_filters():
127
+ chart = {**RANGED[0], "filters": [{"column": "region", "op": "==", "value": "West"}]}
128
+ p = _params(_spec([chart], RANGE))["Line"]
129
+ assert [f["operator"] for f in p["adhoc_filters"]] == ["==", "TEMPORAL_RANGE"]
130
+
131
+
132
+ @pytest.mark.parametrize("time_range", [RANGE, None])
133
+ def test_a_chart_without_a_time_axis_keeps_the_granularity_binding(time_range):
134
+ for name, p in _params(_spec(NO_AXIS, time_range)).items():
135
+ expected = time_range if name == MIXED_CATEGORY["name"] and time_range else "No filter"
136
+ assert p["time_range"] == expected, name
137
+ assert p["granularity_sqla"] == "ts", name
138
+ assert not _temporal(p) and not _temporal(p, "adhoc_filters_b"), name
139
+
140
+
141
+ def test_a_dataset_without_a_time_column_binds_nothing_to_a_chart_without_an_axis():
142
+ spec = _spec(NO_AXIS[:1])
143
+ p = _params(spec, _resolution(spec, main_dttm_col=None))["Total"]
144
+ assert "granularity_sqla" not in p and not _temporal(p)
145
+
146
+
147
+ def test_compiles_are_deterministic():
148
+ spec = _spec(RANGED + [TREND] + NO_AXIS, RANGE)
149
+ assert _bundle(spec) == _bundle(spec)
150
+
151
+
152
+ @pytest.mark.parametrize("time_range", [RANGE, None])
153
+ def test_decompile_reads_the_range_back_without_a_chart_filter(time_range):
154
+ spec = _spec(RANGED + [TREND] + NO_AXIS, time_range)
155
+ res = _resolution(spec)
156
+ result = decompile_bundle(_bundle(spec, res), _lookup(spec, res))
157
+ assert result.losses == [], [loss.as_dict() for loss in result.losses]
158
+ ranged = {c.name for c in spec.charts if getattr(c, "time_range", None)}
159
+ for chart in result.spec["charts"]:
160
+ assert chart.get("time_range") == (time_range if chart["name"] in ranged else None), chart["name"]
161
+ assert "filters" not in chart, chart["name"]
162
+ # plan compares exactly this: no drift on an unchanged spec.
163
+ assert _normalize(load_spec(result.spec)) == _normalize(spec)
164
+
165
+
166
+ def test_decompile_keeps_the_charts_own_filters_beside_the_range():
167
+ chart = {**RANGED[-1], "filters": [{"column": "region", "op": "==", "value": "West"}]}
168
+ spec = _spec([chart], RANGE)
169
+ res = _resolution(spec)
170
+ result = decompile_bundle(_bundle(spec, res), _lookup(spec, res))
171
+ assert result.losses == [], [loss.as_dict() for loss in result.losses]
172
+ assert result.spec["charts"][0]["filters"] == [{"column": "region", "op": "==", "value": "West"}]
173
+ assert _normalize(load_spec(result.spec)) == _normalize(spec)
174
+
175
+
176
+ def test_smoke_queries_with_the_charts_range():
177
+ spec = _spec(RANGED + NO_AXIS, RANGE)
178
+ res = _resolution(spec)
179
+ for chart in spec.charts:
180
+ ds = res.for_chart(chart.dataset)
181
+ queries = _mixed_queries(chart, spec, ds) if chart.type == "mixed" else [_query_for(chart, spec)]
182
+ for q in (_with_time_range(q, chart, ds) for q in queries):
183
+ temporal = [f for f in q["filters"] if f["op"] == "TEMPORAL_RANGE"]
184
+ if chart.name in AXIS_NAMES:
185
+ assert q["time_range"] == RANGE, chart.name
186
+ assert temporal == [{"col": "ts", "op": "TEMPORAL_RANGE", "val": RANGE}], chart.name
187
+ assert "granularity" not in q, chart.name
188
+ elif chart.name == MIXED_CATEGORY["name"]:
189
+ assert q["time_range"] == RANGE and q["granularity"] == "ts" and not temporal
190
+ else: # no time_range of its own: the unfiltered query
191
+ assert q["time_range"] == "No filter" and "granularity" not in q and not temporal
192
+
193
+
194
+ def test_smoke_without_a_range_stays_unfiltered():
195
+ spec = _spec(RANGED[:1] + [TREND] + NO_AXIS[:1])
196
+ res = _resolution(spec)
197
+ for chart in spec.charts:
198
+ q = _with_time_range(_query_for(chart, spec), chart, res.for_chart(chart.dataset))
199
+ assert q["time_range"] == "No filter" and "granularity" not in q
200
+ assert not [f for f in q["filters"] if f["op"] == "TEMPORAL_RANGE"]
@@ -30,7 +30,11 @@ def test_compile_is_byte_stable():
30
30
  def test_golden_bytes():
31
31
  """The whole guarantee in one assert. Regenerate deliberately with:
32
32
  .venv/bin/python -c "from tests.test_compiler import regen; regen()"
33
- and review the diff."""
33
+ and review the diff.
34
+
35
+ Last regenerated for 0.2.1: "Sales Over Time" gained the TEMPORAL_RANGE adhoc
36
+ filter on its time axis, so its own and the dashboard's time range apply
37
+ (tests/test_chart_time_range.py). Every other member is byte-identical."""
34
38
  assert GOLDEN.exists(), "golden missing; run regen() and commit the result"
35
39
  assert _bundle() == GOLDEN.read_bytes()
36
40
 
@@ -66,7 +66,9 @@ def test_compile_time_axis_mixed():
66
66
  assert (p["seriesType"], p["seriesTypeB"]) == ("bar", "line")
67
67
  assert (p["yAxisIndex"], p["yAxisIndexB"]) == (0, 1)
68
68
  assert p["y_axis_format"] == "SMART_NUMBER" and p["y_axis_format_secondary"] == ".3f"
69
- assert p["adhoc_filters"] == p["adhoc_filters_b"] and len(p["adhoc_filters"]) == 1
69
+ # The chart's own filter, then the time-range filter on its axis (0.2.1).
70
+ assert p["adhoc_filters"] == p["adhoc_filters_b"] and len(p["adhoc_filters"]) == 2
71
+ assert p["adhoc_filters"][1]["operator"] == "TEMPORAL_RANGE"
70
72
  assert p["groupby"] == [] and p["groupby_b"] == []
71
73
 
72
74
 
File without changes
File without changes
File without changes
File without changes