fix(analytics): enforce trustworthy chart rendering
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
This commit is contained in:
@@ -46,6 +46,11 @@ def test_chart_theme_has_cdn_timeout_and_fallback_sources():
|
||||
assert "cdn.jsdelivr.net/npm/chart.js@4.4.6" in script
|
||||
assert "unpkg.com/chart.js@4.4.6" in script
|
||||
assert "cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.6" in script
|
||||
assert "!Array.isArray(ds.backgroundColor)" in script
|
||||
assert "!Array.isArray(ds.borderColor)" in script
|
||||
assert script.index("...originalScale") < script.index("border: { color: theme.faint")
|
||||
assert "callback: v => formatNumber(v)" not in script
|
||||
assert "autoSkip: true" in script
|
||||
|
||||
|
||||
def test_sales_chart_runtime_guard_rejects_axis_only_canvases():
|
||||
@@ -55,3 +60,68 @@ def test_sales_chart_runtime_guard_rejects_axis_only_canvases():
|
||||
assert "colorPixels" in script
|
||||
assert "has no colored chart marks" in script
|
||||
assert "has no non-zero dataset values" in script
|
||||
|
||||
|
||||
def test_all_analysis_pages_have_runtime_chart_integrity_contracts():
|
||||
script = (ROOT / "scripts" / "check_analysis_chart_integrity.js").read_text(encoding="utf-8")
|
||||
|
||||
for route in (
|
||||
"/daily_sales?month=2026-04",
|
||||
"/growth_analysis?start_month=2026-04&end_month=2026-04",
|
||||
"/sales_analysis?start_date=2026-04-01&end_date=2026-04-30",
|
||||
"/monthly_summary_analysis?start_month=2026-04&end_month=2026-04",
|
||||
):
|
||||
assert route in script
|
||||
for viewport in ("1440, height: 1000", "1024, height: 900", "390, height: 844"):
|
||||
assert viewport in script
|
||||
assert "loading overlay remains visible" in script
|
||||
assert "has no colored chart marks" in script
|
||||
assert "extreme percent values are not bounded with traceable markers" in script
|
||||
assert "single point is not visibly emphasized" in script
|
||||
assert "hides drawable payload behind an empty state" in script
|
||||
assert "analysis tab lost the active period" in script
|
||||
assert "rendered numeric indexes instead of product labels" in script
|
||||
assert "contains labels outside the selected April period" in script
|
||||
assert "explicitEmpty" in script
|
||||
|
||||
|
||||
def test_analysis_chart_failure_states_are_explicit_and_bounded():
|
||||
sales = (ROOT / "web/static/js/page-sales-analysis.js").read_text(encoding="utf-8")
|
||||
daily = (ROOT / "web/static/js/page-daily-sales.js").read_text(encoding="utf-8")
|
||||
growth = (ROOT / "web/static/js/page-growth.js").read_text(encoding="utf-8")
|
||||
monthly = (ROOT / "web/static/js/page-monthly-summary.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "document.documentElement.dataset.salesCharts = 'ready'" in sales
|
||||
assert "series.chart_values" in sales
|
||||
assert "renderChartEmpty(bcgEl" in sales
|
||||
assert "renderChartEmpty(hmEl" in sales
|
||||
assert "originalValue" in daily
|
||||
assert "clippedCount" in daily
|
||||
assert "function dateAxis(element)" in daily
|
||||
assert "overlapping date ticks" in (ROOT / "scripts/check_analysis_chart_integrity.js").read_text(encoding="utf-8")
|
||||
assert "beginAtZero: true" in growth
|
||||
assert "pointRadiusFor" in growth
|
||||
assert "REQUEST_TIMEOUT_MS" in monthly
|
||||
assert "activeFetchController.abort()" in monthly
|
||||
assert "const refreshForViewport = () => {" in monthly
|
||||
assert "const refreshForViewport = () => fetchData()" not in monthly
|
||||
assert "dataset.monthlyCharts = 'ready'" in monthly
|
||||
assert "safeHtmlText(p.value[3])" in monthly
|
||||
assert "safeHtmlText(ps[0].name)" in monthly
|
||||
|
||||
|
||||
def test_analysis_tables_use_local_traditional_chinese_language_contract():
|
||||
helper = (ROOT / "web/static/js/datatables-zh-hant.js").read_text(encoding="utf-8")
|
||||
assert "window.EwoooCDataTableLanguage" in helper
|
||||
assert "沒有符合條件的資料" in helper
|
||||
|
||||
assets = [
|
||||
ROOT / "web/static/js/page-daily-sales.js",
|
||||
ROOT / "web/static/js/page-sales-analysis.js",
|
||||
ROOT / "web/static/js/page-monthly-summary.js",
|
||||
ROOT / "templates/abc_analysis_detail.html",
|
||||
]
|
||||
for asset in assets:
|
||||
source = asset.read_text(encoding="utf-8")
|
||||
assert "cdn.datatables.net/plug-ins" not in source
|
||||
assert "EwoooCDataTableLanguage()" in source
|
||||
|
||||
37
tests/test_sales_chart_data_contract.py
Normal file
37
tests/test_sales_chart_data_contract.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import pandas as pd
|
||||
|
||||
from routes.sales_routes import _build_top_product_chart_data
|
||||
|
||||
|
||||
def test_top_product_chart_aggregates_sku_rows_before_ranking():
|
||||
rows = pd.DataFrame(
|
||||
[
|
||||
{'pid': 'P1', 'name': '同名商品', 'amount': 100},
|
||||
{'pid': 'P1', 'name': '同名商品', 'amount': 50},
|
||||
{'pid': 'P2', 'name': '同名商品', 'amount': 90},
|
||||
{'pid': 'P3', 'name': '獨立商品', 'amount': 200},
|
||||
{'pid': 'P4', 'name': '零業績商品', 'amount': 0},
|
||||
]
|
||||
)
|
||||
|
||||
result = _build_top_product_chart_data(rows, 'pid', 'name', 'amount', 'amount')
|
||||
|
||||
assert result['chart_values'] == [200.0, 150.0, 90.0]
|
||||
assert result['labels'] == ['獨立商品', '同名商品 [P1]', '同名商品 [P2]']
|
||||
assert result['metric_label'] == '銷售金額 ($)'
|
||||
|
||||
|
||||
def test_top_product_chart_returns_explicit_empty_contract():
|
||||
result = _build_top_product_chart_data(
|
||||
pd.DataFrame(columns=['name', 'amount']),
|
||||
None,
|
||||
'name',
|
||||
'amount',
|
||||
'qty',
|
||||
)
|
||||
|
||||
assert result == {
|
||||
'labels': [],
|
||||
'chart_values': [],
|
||||
'metric_label': '銷售數量',
|
||||
}
|
||||
Reference in New Issue
Block a user