From 911393190ded015e384e438c26b68faf50ec260c Mon Sep 17 00:00:00 2001 From: ogt Date: Thu, 16 Jul 2026 15:55:22 +0800 Subject: [PATCH] fix(analytics): enforce trustworthy chart rendering --- config.py | 2 +- routes/sales_routes.py | 57 ++- scripts/check_analysis_chart_integrity.js | 517 ++++++++++++++++++++ templates/abc_analysis_detail.html | 11 +- templates/daily_sales.html | 1 + templates/monthly_summary_analysis.html | 11 +- templates/sales_analysis.html | 1 + tests/test_chart_fallback_contract.py | 70 +++ tests/test_sales_chart_data_contract.py | 37 ++ web/static/css/page-daily-sales.css | 8 + web/static/css/page-monthly-summary-bem.css | 11 +- web/static/css/page-sales-analysis-bem.css | 32 ++ web/static/js/analysis-chart-theme.js | 22 +- web/static/js/datatables-zh-hant.js | 31 ++ web/static/js/page-daily-sales.js | 129 ++++- web/static/js/page-growth.js | 37 +- web/static/js/page-monthly-summary.js | 231 ++++++--- web/static/js/page-sales-analysis.js | 177 +++++-- 18 files changed, 1230 insertions(+), 155 deletions(-) create mode 100644 scripts/check_analysis_chart_integrity.js create mode 100644 tests/test_sales_chart_data_contract.py create mode 100644 web/static/js/datatables-zh-hant.js diff --git a/config.py b/config.py index f88855c..c9bca1e 100644 --- a/config.py +++ b/config.py @@ -414,7 +414,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.809" +SYSTEM_VERSION = "V10.810" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/routes/sales_routes.py b/routes/sales_routes.py index 7ad8947..4f2ddc7 100644 --- a/routes/sales_routes.py +++ b/routes/sales_routes.py @@ -1097,6 +1097,49 @@ def _get_filtered_sales_data(cache_key): return target_df, cols_map, None +def _build_top_product_chart_data( + target_df, + product_id_col, + product_name_col, + value_col, + selected_metric, + limit=20, +): + """Aggregate product rows before ranking so one SKU occupies one chart slot.""" + metric_label = '銷售金額 ($)' if selected_metric == 'amount' else '銷售數量' + empty = {'labels': [], 'chart_values': [], 'metric_label': metric_label} + if target_df is None or target_df.empty or not product_name_col or not value_col: + return empty + + group_cols = [product_name_col] + if product_id_col and product_id_col in target_df.columns: + group_cols.insert(0, product_id_col) + try: + grouped = target_df.groupby(group_cols, dropna=False)[value_col].sum() + grouped = grouped[grouped > 0].nlargest(limit).reset_index(name='_chart_value') + except (KeyError, TypeError, ValueError): + return empty + if grouped.empty: + return empty + + raw_names = grouped[product_name_col].fillna('未命名商品').astype(str) + duplicate_names = raw_names.value_counts() + labels = [] + for row_index, row in grouped.iterrows(): + name = str(row.get(product_name_col) or '未命名商品') + product_id = row.get(product_id_col) if product_id_col else None + if duplicate_names.get(name, 0) > 1 and product_id is not None and not pd.isna(product_id): + labels.append(f'{name} [{product_id}]') + else: + labels.append(name) + + return { + 'labels': labels, + 'chart_values': [float(value) for value in grouped['_chart_value']], + 'metric_label': metric_label, + } + + # ========================================== # 頁面路由 # ========================================== @@ -1698,13 +1741,13 @@ def sales_analysis(): insights['margin_cats'] = get_top_3(col_category, col_amount, is_margin=True) insights['margin_prods'] = get_top_3(product_groupby, col_amount, is_margin=True) - # 📊 V-Opt: 改為橫向長條圖數據 (Top 20) - top_chart = target_df.head(20) - bar_data = { - 'labels': [str(n)[:20] + '...' if len(str(n)) > 20 else str(n) for n in top_chart[col_name]], # 稍微放寬長度限制 - 'chart_values': [float(x) for x in top_chart[sort_col]], - 'metric_label': '銷售金額 ($)' if selected_metric == 'amount' else '銷售數量' - } + bar_data = _build_top_product_chart_data( + target_df, + col_pid, + col_name, + sort_col, + selected_metric, + ) # 📋 V-Opt: 列表資料改為 AJAX 載入,這裡只傳空列表以加快初始渲染 table_items = [] diff --git a/scripts/check_analysis_chart_integrity.js b/scripts/check_analysis_chart_integrity.js new file mode 100644 index 0000000..7be66b8 --- /dev/null +++ b/scripts/check_analysis_chart_integrity.js @@ -0,0 +1,517 @@ +#!/usr/bin/env node +/* + * Runtime integrity guard for every internal analytics report. + * + * A rendered chart slot must end in exactly one trustworthy state: + * a real chart with drawable pixels and data, or a visible empty state. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const Module = require('module'); + +const DEFAULT_BASE_URL = 'http://127.0.0.1:5003'; +const DEFAULT_TIMEOUT_MS = 45000; +const DEFAULT_SETTLE_MS = 1800; +const DEFAULT_VIEWPORTS = [ + { width: 1440, height: 1000 }, + { width: 1024, height: 900 }, + { width: 390, height: 844 }, +]; + +const ROUTE_CONTRACTS = [ + { + name: 'daily', + path: '/daily_sales?month=2026-04', + renderer: 'chartjs', + selector: '.chart-container canvas', + readyDataset: 'dailyCharts', + minimumTargets: 9, + screenshotTarget: '#dodChart', + }, + { + name: 'growth', + path: '/growth_analysis?start_month=2026-04&end_month=2026-04', + renderer: 'chartjs', + selector: '.ga-chart-card__body canvas', + readyDataset: 'growthCharts', + minimumTargets: 5, + screenshotTarget: '#aovChart', + }, + { + name: 'sales', + path: '/sales_analysis?start_date=2026-04-01&end_date=2026-04-30', + renderer: 'chartjs', + selector: '.sa-chart-shell canvas', + readyDataset: 'salesCharts', + minimumTargets: 10, + screenshotTarget: '#barChart', + }, + { + name: 'monthly', + path: '/monthly_summary_analysis?start_month=2026-04&end_month=2026-04', + renderer: 'echarts', + selector: '.ms-chart-card__canvas', + readyDataset: 'monthlyCharts', + secondaryReadyDataset: 'monthlySpecialCharts', + minimumTargets: 13, + screenshotTarget: '#vendorRankingChart', + }, +]; + +function parseArgs(argv) { + const options = { + baseUrl: DEFAULT_BASE_URL, + timeoutMs: DEFAULT_TIMEOUT_MS, + settleMs: DEFAULT_SETTLE_MS, + viewports: DEFAULT_VIEWPORTS, + routeNames: [], + screenshotDir: '', + screenshotAll: false, + json: false, + loginPassword: process.env.ANALYSIS_QA_LOGIN_PASSWORD || '', + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--base-url') options.baseUrl = argv[++index]; + else if (arg === '--timeout') options.timeoutMs = Number(argv[++index]) * 1000; + else if (arg === '--settle-ms') options.settleMs = Number(argv[++index]); + else if (arg === '--route') options.routeNames.push(argv[++index]); + else if (arg === '--viewport') options.viewports = [parseViewport(argv[++index])]; + else if (arg === '--screenshot-dir') options.screenshotDir = argv[++index]; + else if (arg === '--screenshot-all') options.screenshotAll = true; + else if (arg === '--json') options.json = true; + else if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } else throw new Error(`Unknown argument: ${arg}`); + } + return options; +} + +function parseViewport(value) { + const match = /^(\d+)x(\d+)$/.exec(value || ''); + if (!match) throw new Error(`Invalid viewport: ${value}`); + return { width: Number(match[1]), height: Number(match[2]) }; +} + +function printHelp() { + console.log(`Analysis chart integrity guard + +Options: + --base-url URL Base URL, default ${DEFAULT_BASE_URL} + --route NAME daily, growth, sales, or monthly + --viewport WIDTHxHEIGHT Run one viewport instead of the default matrix + --timeout SEC Navigation timeout, default 45 + --settle-ms MS Post-ready settle wait, default 1800 + --screenshot-dir DIR Save failure screenshots + --screenshot-all Save passing screenshots too + --json Print JSON summary + +Set ANALYSIS_QA_LOGIN_PASSWORD when the target requires login. +`); +} + +function requirePlaywright() { + try { + return require('playwright'); + } catch (error) { + const candidates = [ + process.env.PLAYWRIGHT_NODE_MODULE_DIR, + process.env.NODE_PATH, + path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'), + ].filter(Boolean); + for (const candidate of candidates) { + const packageJson = path.join(candidate, 'playwright', 'package.json'); + if (fs.existsSync(packageJson)) return Module.createRequire(packageJson)('playwright'); + } + throw new Error('Cannot load playwright. Set PLAYWRIGHT_NODE_MODULE_DIR to a runtime containing playwright.'); + } +} + +function findChromeExecutable() { + return [ + process.env.ANALYSIS_CHARTS_CHROME_PATH, + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + ].filter(Boolean).find(candidate => fs.existsSync(candidate)) || ''; +} + +function fullUrl(baseUrl, routePath) { + return new URL(routePath, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString(); +} + +function safeName(value) { + return value.replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').toLowerCase(); +} + +async function loginIfNeeded(page, password, timeoutMs) { + const loginForm = page.locator('form:has(input[type="password"])').first(); + if (!await loginForm.count()) return false; + if (!password) throw new Error('Target requires login; set ANALYSIS_QA_LOGIN_PASSWORD.'); + await loginForm.locator('input[type="password"]').fill(password); + const username = loginForm.locator('input[name="username"], input[type="email"], input[type="text"]').first(); + if (await username.count()) await username.fill(''); + await Promise.all([ + page.waitForLoadState('domcontentloaded', { timeout: timeoutMs }).catch(() => null), + loginForm.locator('button[type="submit"], input[type="submit"]').first().click(), + ]); + return true; +} + +async function primeAuthentication(page, options, contract) { + await page.goto(fullUrl(options.baseUrl, contract.path), { + waitUntil: 'domcontentloaded', + timeout: options.timeoutMs, + }); + if (!await loginIfNeeded(page, options.loginPassword, options.timeoutMs)) return; + + // Leave login/dashboard requests behind before page-specific error capture starts. + await page.goto('about:blank', { waitUntil: 'domcontentloaded', timeout: options.timeoutMs }); +} + +async function collectMetrics(page, contract) { + return page.evaluate(({ name, renderer, selector, readyDataset, secondaryReadyDataset }) => { + function countPixels(canvas) { + if (!canvas || !canvas.width || !canvas.height) return { inkPixels: 0, colorPixels: 0 }; + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context) return { inkPixels: 0, colorPixels: 0 }; + const width = Math.min(canvas.width, 900); + const height = Math.min(canvas.height, 520); + const pixels = context.getImageData(0, 0, width, height).data; + let inkPixels = 0; + let colorPixels = 0; + for (let index = 0; index < pixels.length; index += 4) { + const alpha = pixels[index + 3]; + if (alpha <= 10) continue; + inkPixels += 1; + const spread = Math.max(pixels[index], pixels[index + 1], pixels[index + 2]) - + Math.min(pixels[index], pixels[index + 1], pixels[index + 2]); + if (alpha > 24 && spread > 18) colorPixels += 1; + } + return { inkPixels, colorPixels }; + } + + function chartJsStats(canvas, chart) { + const values = []; + let clippedPoints = 0; + let originalMaxAbs = 0; + (chart && chart.data && chart.data.datasets || []).forEach(dataset => { + (Array.isArray(dataset.data) ? dataset.data : []).forEach(raw => { + const display = raw && typeof raw === 'object' + ? (raw.y ?? raw.r ?? raw.v ?? raw.value) + : raw; + if (display !== null && display !== undefined && Number.isFinite(Number(display))) { + values.push(Number(display)); + } + if (raw && typeof raw === 'object') { + if (raw.clipped) clippedPoints += 1; + if (Number.isFinite(Number(raw.originalValue))) { + originalMaxAbs = Math.max(originalMaxAbs, Math.abs(Number(raw.originalValue))); + } + } + }); + }); + let visibleElements = 0; + let maxPointRadius = 0; + if (chart && typeof chart.getSortedVisibleDatasetMetas === 'function') { + chart.getSortedVisibleDatasetMetas().forEach(meta => { + (meta.data || []).forEach(item => { + if (!item) return; + const props = typeof item.getProps === 'function' ? item.getProps(['x', 'y'], true) : item; + if (Number.isFinite(Number(props.x)) && Number.isFinite(Number(props.y))) visibleElements += 1; + maxPointRadius = Math.max(maxPointRadius, Number(item.options && item.options.radius || 0)); + }); + }); + } + const shell = canvas.closest('.chart-container, .ga-chart-card__body, .sa-chart-shell'); + const empty = shell && shell.querySelector('.chart-empty-state, .ga-chart-empty, .sa-chart-empty'); + return { + dataPoints: values.length, + nonZeroPoints: values.filter(value => Math.abs(value) > 1e-9).length, + displayMaxAbs: values.length ? Math.max(...values.map(Math.abs)) : 0, + originalMaxAbs, + clippedPoints, + visibleElements, + maxPointRadius, + chartLabels: Array.isArray(chart && chart.data && chart.data.labels) + ? chart.data.labels.map(value => String(value)) + : [], + categoryTickLabels: (() => { + if (!chart || !chart.scales) return []; + const axis = chart.options && chart.options.indexAxis === 'y' ? chart.scales.y : chart.scales.x; + return axis && Array.isArray(axis.ticks) ? axis.ticks.map(tick => String(tick.label)) : []; + })(), + explicitEmpty: Boolean(shell && shell.classList.contains('chart-empty-active') && empty), + emptyVisible: Boolean(empty && empty.getBoundingClientRect().width > 0 && empty.getBoundingClientRect().height > 0), + }; + } + + function echartsStats(target, chart) { + const option = (chart && chart.getOption ? chart.getOption() : null) || {}; + const values = []; + function collect(value) { + if (Array.isArray(value)) value.forEach(collect); + else if (value && typeof value === 'object') Object.values(value).forEach(collect); + else if (typeof value === 'number' && Number.isFinite(value)) values.push(value); + } + (option.series || []).forEach(series => collect(series.data || [])); + const titles = Array.isArray(option.title) ? option.title : [option.title]; + const explicitEmpty = titles.some(title => title && title.text === '尚無可繪製資料'); + const axes = Array.isArray(option.xAxis) ? option.xAxis : [option.xAxis]; + return { + dataPoints: values.length, + nonZeroPoints: values.filter(value => Math.abs(value) > 1e-9).length, + visibleElements: values.length, + maxPointRadius: 0, + originalMaxAbs: 0, + displayMaxAbs: values.length ? Math.max(...values.map(Math.abs)) : 0, + clippedPoints: 0, + chartLabels: axes.flatMap(axis => axis && Array.isArray(axis.data) ? axis.data.map(value => String(value)) : []), + categoryTickLabels: [], + explicitEmpty, + emptyVisible: explicitEmpty && target.getBoundingClientRect().height > 0, + }; + } + + function sourceHasDrawable(targetId) { + if (name !== 'sales') return false; + const payloadNode = document.getElementById('sales-data'); + if (!payloadNode) return false; + let payload; + try { + payload = JSON.parse(payloadNode.textContent); + } catch (error) { + return false; + } + const keyByTarget = { + barChart: 'barData', + categoryChart: 'categoryData', + priceDistChart: 'priceDistData', + monthlyChart: 'monthlyData', + weeklyChart: 'weeklyData', + dowChart: 'dowData', + hourlyChart: 'hourlyData', + }; + const series = payload[keyByTarget[targetId]]; + if (!series) return false; + const values = Array.isArray(series.values) && series.values.length + ? series.values + : series.chart_values; + return Array.isArray(values) && values.some(value => Number.isFinite(Number(value)) && Math.abs(Number(value)) > 1e-9); + } + + const targets = Array.from(document.querySelectorAll(selector)).map(target => { + const isCanvas = target.tagName === 'CANVAS'; + const chart = renderer === 'chartjs' + ? (window.Chart && window.Chart.getChart ? window.Chart.getChart(target) : null) + : (window.echarts && window.echarts.getInstanceByDom ? window.echarts.getInstanceByDom(target) : null); + const canvas = isCanvas ? target : target.querySelector('canvas'); + const rect = target.getBoundingClientRect(); + const stats = renderer === 'chartjs' ? chartJsStats(target, chart) : echartsStats(target, chart); + return { + id: target.id || '', + cssWidth: Math.round(rect.width), + cssHeight: Math.round(rect.height), + hasChart: Boolean(chart), + sourceHasDrawable: sourceHasDrawable(target.id || ''), + renderer, + ...countPixels(canvas), + ...stats, + }; + }); + + const loading = document.getElementById('loadingOverlay'); + return { + finalUrl: location.href, + title: document.title, + login: Boolean(document.querySelector('form:has(input[type="password"])')), + readyState: document.documentElement.dataset[readyDataset] || '', + secondaryReadyState: secondaryReadyDataset ? document.documentElement.dataset[secondaryReadyDataset] || '' : '', + bodyOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + loadingVisible: Boolean(loading && getComputedStyle(loading).display !== 'none'), + periodLinks: Object.fromEntries(Array.from(document.querySelectorAll('[data-analysis-target]')).map(link => [ + link.dataset.analysisTarget, + link.getAttribute('href') || '', + ])), + targets, + }; + }, contract); +} + +function evaluateResult(contract, viewport, status, metrics, consoleErrors) { + const failures = []; + if (!status || status >= 400) failures.push(`HTTP ${status || 0}`); + if (metrics.login) failures.push('redirected to login'); + if (metrics.readyState !== 'ready') failures.push(`primary chart state is ${metrics.readyState || 'missing'}`); + if (contract.secondaryReadyDataset && metrics.secondaryReadyState !== 'ready') { + failures.push(`secondary chart state is ${metrics.secondaryReadyState || 'missing'}`); + } + if (metrics.loadingVisible) failures.push('loading overlay remains visible'); + if (metrics.bodyOverflow) failures.push('page has horizontal overflow'); + if (metrics.targets.length < contract.minimumTargets) { + failures.push(`only ${metrics.targets.length}/${contract.minimumTargets} chart targets rendered`); + } + const expectedPeriodLinks = { + sales: ['start_date=2026-04-01', 'end_date=2026-04-30'], + daily_sales: ['month=2026-04'], + growth: ['start_month=2026-04', 'end_month=2026-04'], + monthly: ['start_month=2026-04', 'end_month=2026-04'], + }; + Object.entries(expectedPeriodLinks).forEach(([target, fragments]) => { + const href = metrics.periodLinks[target] || ''; + if (fragments.some(fragment => !href.includes(fragment))) { + failures.push(`${target} analysis tab lost the active period: ${href || 'missing'}`); + } + }); + + for (const item of metrics.targets) { + if (item.explicitEmpty) { + if (item.sourceHasDrawable) failures.push(`${item.id} hides drawable payload behind an empty state`); + if (!item.emptyVisible) failures.push(`${item.id} empty state is not visible`); + continue; + } + if (item.cssWidth < Math.min(220, viewport.width - 40) || item.cssHeight < 180) { + failures.push(`${item.id} invalid size ${item.cssWidth}x${item.cssHeight}`); + } + if (!item.hasChart) failures.push(`${item.id} has no ${contract.renderer} instance`); + if (item.dataPoints <= 0 || item.nonZeroPoints <= 0) failures.push(`${item.id} has no drawable values`); + if (item.visibleElements <= 0) failures.push(`${item.id} has no visible chart elements`); + if (item.inkPixels <= 700) failures.push(`${item.id} canvas appears blank`); + if (item.colorPixels <= 30) failures.push(`${item.id} has no colored chart marks`); + if (contract.name === 'daily' && ['dodChart', 'wowChart'].includes(item.id) && item.originalMaxAbs > 100) { + if (item.clippedPoints <= 0 || item.displayMaxAbs > 100.01) { + failures.push(`${item.id} extreme percent values are not bounded with traceable markers`); + } + } + if (contract.name === 'daily' && ['trendChart', 'dodChart', 'wowChart', 'marginChart', 'avgQtyChart'].includes(item.id)) { + const allowedDateTicks = item.cssWidth < 500 ? 6 : 12; + if (item.categoryTickLabels.length > allowedDateTicks) { + failures.push(`${item.id} renders ${item.categoryTickLabels.length} overlapping date ticks`); + } + } + if (contract.name === 'growth' && ['aovChart', 'marginChart'].includes(item.id) && item.dataPoints === 1 && item.maxPointRadius < 5) { + failures.push(`${item.id} single point is not visibly emphasized`); + } + if (contract.name === 'sales' && item.id === 'barChart' && item.categoryTickLabels.length && item.categoryTickLabels.every(label => /^\d+$/.test(label))) { + failures.push('barChart rendered numeric indexes instead of product labels'); + } + if (contract.name === 'daily' && item.id === 'trendChart' && item.chartLabels.some(label => !/^04\//.test(label))) { + failures.push('trendChart contains labels outside the selected April period'); + } + if (contract.name === 'growth' && item.id === 'revenueChart' && item.chartLabels.some(label => label !== '2026-04')) { + failures.push('revenueChart contains labels outside the selected April period'); + } + if (contract.name === 'sales' && item.id === 'monthlyChart' && item.chartLabels.some(label => label !== '2026-04')) { + failures.push('monthlyChart contains labels outside the selected April period'); + } + if (contract.name === 'monthly' && item.id === 'compareChart' && item.chartLabels.length && item.chartLabels.some(label => label !== '2026/04')) { + failures.push('compareChart contains labels outside the selected April period'); + } + } + consoleErrors.forEach(error => failures.push(`console ${error}`)); + return { route: contract.name, viewport, status, passed: failures.length === 0, failures, metrics }; +} + +async function inspect(page, options, contract, viewport) { + const consoleErrors = []; + const onConsole = message => { if (message.type() === 'error') consoleErrors.push(message.text()); }; + const onPageError = error => consoleErrors.push(error.message || String(error)); + page.on('console', onConsole); + page.on('pageerror', onPageError); + try { + let response = await page.goto(fullUrl(options.baseUrl, contract.path), { + waitUntil: 'domcontentloaded', + timeout: options.timeoutMs, + }); + if (await loginIfNeeded(page, options.loginPassword, options.timeoutMs)) { + consoleErrors.length = 0; + response = await page.goto(fullUrl(options.baseUrl, contract.path), { + waitUntil: 'domcontentloaded', + timeout: options.timeoutMs, + }); + } + await page.waitForFunction( + ({ readyDataset, secondaryReadyDataset }) => { + const primary = document.documentElement.dataset[readyDataset]; + const secondary = secondaryReadyDataset ? document.documentElement.dataset[secondaryReadyDataset] : 'ready'; + return ['ready', 'error'].includes(primary) && ['ready', 'error'].includes(secondary); + }, + { readyDataset: contract.readyDataset, secondaryReadyDataset: contract.secondaryReadyDataset }, + { timeout: Math.min(options.timeoutMs, 30000) }, + ).catch(() => null); + if (options.settleMs > 0) await page.waitForTimeout(options.settleMs); + const metrics = await collectMetrics(page, contract); + return evaluateResult(contract, viewport, response ? response.status() : 0, metrics, consoleErrors); + } finally { + page.off('console', onConsole); + page.off('pageerror', onPageError); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const contracts = options.routeNames.length + ? ROUTE_CONTRACTS.filter(contract => options.routeNames.includes(contract.name)) + : ROUTE_CONTRACTS; + if (!contracts.length) throw new Error('No matching analysis routes.'); + const { chromium } = requirePlaywright(); + const launchOptions = { headless: true }; + const chromePath = findChromeExecutable(); + if (chromePath) launchOptions.executablePath = chromePath; + if (options.screenshotDir) fs.mkdirSync(options.screenshotDir, { recursive: true }); + + const browser = await chromium.launch(launchOptions); + const context = await browser.newContext(); + const page = await context.newPage(); + const results = []; + try { + await primeAuthentication(page, options, contracts[0]); + for (const viewport of options.viewports) { + await page.setViewportSize(viewport); + for (const contract of contracts) { + const result = await inspect(page, options, contract, viewport); + results.push(result); + if (options.screenshotDir && (!result.passed || options.screenshotAll)) { + const screenshotPath = path.join( + options.screenshotDir, + `${contract.name}_${viewport.width}x${viewport.height}.png`, + ); + if (options.screenshotAll && contract.screenshotTarget) { + const target = page.locator(contract.screenshotTarget).first(); + await target.scrollIntoViewIfNeeded().catch(() => {}); + await page.waitForTimeout(200); + const dataUrl = await target.evaluate(element => { + const canvas = element.tagName === 'CANVAS' ? element : element.querySelector('canvas'); + return canvas && typeof canvas.toDataURL === 'function' ? canvas.toDataURL('image/png') : ''; + }).catch(() => ''); + if (dataUrl.startsWith('data:image/png;base64,')) { + fs.writeFileSync(screenshotPath, Buffer.from(dataUrl.split(',', 2)[1], 'base64')); + } else { + await target.screenshot({ path: screenshotPath }).catch(() => page.screenshot({ path: screenshotPath, fullPage: false })); + } + } else { + await page.screenshot({ path: screenshotPath, fullPage: false }); + } + } + } + } + } finally { + await context.close().catch(() => {}); + await browser.close(); + } + + if (options.json) console.log(JSON.stringify(results, null, 2)); + else results.forEach(result => { + console.log(`${result.passed ? 'PASS' : 'FAIL'} ${result.route} ${result.viewport.width}x${result.viewport.height} targets=${result.metrics.targets.length}`); + result.failures.forEach(failure => console.log(` ${failure}`)); + }); + if (results.some(result => !result.passed)) process.exitCode = 1; +} + +main().catch(error => { + console.error(error.message || error); + process.exit(1); +}); diff --git a/templates/abc_analysis_detail.html b/templates/abc_analysis_detail.html index 91bc9d3..611f561 100644 --- a/templates/abc_analysis_detail.html +++ b/templates/abc_analysis_detail.html @@ -153,18 +153,15 @@ + + {% if not error %} {% set daily_sales_payload = { 'chartData': chart_data, diff --git a/templates/monthly_summary_analysis.html b/templates/monthly_summary_analysis.html index cd49834..6888d2f 100644 --- a/templates/monthly_summary_analysis.html +++ b/templates/monthly_summary_analysis.html @@ -161,6 +161,14 @@ + +