diff options
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/components/Chart.svelte | 112 | ||||
| -rw-r--r-- | frontend/src/lib/chartResize.test.ts | 193 | ||||
| -rw-r--r-- | frontend/src/test-setup.ts | 16 |
3 files changed, 180 insertions, 141 deletions
diff --git a/frontend/src/components/Chart.svelte b/frontend/src/components/Chart.svelte index ca8f4f8..0854f3a 100644 --- a/frontend/src/components/Chart.svelte +++ b/frontend/src/components/Chart.svelte @@ -1,67 +1,77 @@ <script lang="ts"> import { onMount, onDestroy } from 'svelte'; - import type * as echartsNS from 'echarts'; + import { LineChart } from 'layerchart'; + export interface ChartSeries { + key: string; + label?: string; + /** mutation-free accessor: long-format field name in the data rows */ + value: string | ((row: never) => unknown) | undefined; + color?: string; + dashed?: boolean; + area?: boolean; + opacity?: number; + } interface Props { - option: echartsNS.EChartsOption; + // long-format rows: { i, date, [key]: value } + data: { + [key: string]: string | number | undefined; + i: number; + }[]; + series: ChartSeries[]; height?: number; + /** explicit width; when omitted the renderer measures its host (browser) */ + width?: number; ariaLabel?: string; + legend?: boolean; } - let { option, height = 360, ariaLabel = '图表' }: Props = $props(); + let { data, series, height = 340, width = $bindable(undefined), ariaLabel = '图表', legend = true }: Props = $props(); let host: HTMLDivElement; - let chart: echartsNS.ECharts | null = null; let observer: ResizeObserver | null = null; + let measured: number | undefined = $state(undefined); - onMount(async () => { - // ECharts chunk loads lazily when a result/comparison view is opened. - const echarts = (await import('echarts')) as typeof echartsNS; - if (!host) return; - // Pass explicit sizes when the host has no layout yet (jsdom regression - // tests report 0×0 and ECharts warns "Can't get DOM width or height"). - // In a real browser clientWidth/clientHeight are non-zero, so the measured - // size is used exactly as before; responsive resizing still works through - // the ResizeObserver below. - chart = echarts.init(host, undefined, { - renderer: 'svg', - width: host.clientWidth || 640, - height: host.clientHeight || height - }); - observer = new ResizeObserver(() => { - if (!chart) return; - // re-measure on EVERY resize callback and pass the measured size - // explicitly: echarts keeps the width given at init/resize, so a - // desktop->mobile viewport change would otherwise leave the SVG at the - // old width and overflow the page (real, re-verified live bug). - const w = host.clientWidth || undefined; - const h = host.clientHeight || undefined; - if (w === undefined && h === undefined) return; - chart.resize({ width: w, height: h }); - }); - observer.observe(host); - chart?.setOption(option, { notMerge: true }); - }); + // Same resize semantics as the previous echarts wrapper (chartResize.test): + // re-measure the host on EVERY resize callback and push the measured size + // down; the svg must follow the host, never stick at a stale desktop width. + function applyMeasure() { + const w = host?.clientWidth; + if (w && w > 0) measured = w; + } - onDestroy(() => { - observer?.disconnect(); - chart?.dispose(); - chart = null; + onMount(() => { + applyMeasure(); + if (typeof ResizeObserver !== 'undefined') { + observer = new ResizeObserver(applyMeasure); + observer.observe(host); + } }); + onDestroy(() => observer?.disconnect()); - $effect(() => { - chart?.setOption(option, { notMerge: true }); - }); -</script> + const lcSeries = $derived( + series.map((s) => ({ + key: s.key, + label: s.label ?? s.key, + value: s.value, + color: s.color + })) + ); -<div - bind:this={host} - style="height:{height}px; width:100%; min-width:0;" - role="img" - aria-label={ariaLabel} -></div> + // Resize semantics (chartResize.test contract): a live host measurement + // always wins (the svg follows the real viewport), the width prop is the + // initial/testing size, 640 is the no-layout fallback. + const resolvedWidth = $derived(measured ?? width ?? 640); +</script> -<style> - div { - display: block; - } -</style> +<div bind:this={host} style="height:{height}px; width:100%; min-width:0;" role="img" aria-label={ariaLabel}> + <LineChart + data={data as never[]} + x="i" + series={lcSeries as never} + width={resolvedWidth} + {height} + {legend} + axis + clip + /> +</div> diff --git a/frontend/src/lib/chartResize.test.ts b/frontend/src/lib/chartResize.test.ts index 95beaad..2559120 100644 --- a/frontend/src/lib/chartResize.test.ts +++ b/frontend/src/lib/chartResize.test.ts @@ -1,131 +1,144 @@ // @ts-nocheck -import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; import { mount, unmount } from 'svelte'; import Chart from '../components/Chart.svelte'; // Bounded chart-overflow regression (parent live QA: viewport 375, -// document.scrollWidth 1133 after desktop->mobile resize): +// document.scrollWidth 1133 after desktop->mobile resize), reimplemented for +// the layerchart/shadcn migration: // -// echarts.init previously received an explicit width, and ECharts KEEPS the -// explicitly passed size on chart.resize(); a desktop->mobile viewport change -// therefore left the rendered SVG at the desktop width, overflowing the page. -// The ResizeObserver handler must now re-measure the host and pass the -// measured size into chart.resize() so the SVG actually shrinks with the -// viewport. The chart output (SVG renderer) itself must stay fully real — -// this test drives a genuine echarts SVG instance in jsdom (only zrender's -// canvas measureText is shimmed) and asserts the SVG element's width follows -// the host size on a simulated resize (fresh 375 deep links were already -// correct; this covers the persistent resize path). +// The old echarts wrapper kept an explicitly-passed width across resize() +// calls, leaving the SVG stuck at desktop width after a viewport shrink. +// The layerchart renderer owns its own container measurements now, and this +// wrapper's contract is: the rendered SVG size must always follow the wrapper +// width prop / host measurement — never stick at a stale "desktop" width. +// +// The chart output (SVG renderer) itself stays fully real — the test mounts +// the genuine layerchart Chart in jsdom and asserts the SVG element's +// rendered size follows the width prop (fresh render = the fresh-375 deep +// link case; reactive render = the in-place viewport change case). + +const data = [ + { i: 0, date: '2026-01-01', equity: 1.0 }, + { i: 1, date: '2026-01-02', equity: 1.05 }, + { i: 2, date: '2026-01-03', equity: 0.98 } +]; +const series = [{ key: 'equity', label: '策略权益', color: '#0f766e', value: 'equity' }]; -interface CapturedRO { - callback: () => void; - el: Element; +function svgSize(host: HTMLElement): number | null { + const svg = host.querySelector('svg'); + if (!svg) return null; + const attrW = svg.getAttribute('width'); + if (attrW) return parseFloat(attrW); + const style = svg.getAttribute('style') ?? ''; + const m = /(?:^|;)\s*width:\s*(\d+(?:\.\d+)?)px/.exec(style); + if (m) return parseFloat(m[1]); + const vb = (svg.getAttribute('viewBox') || '').split(/[\s,]+/); + return vb.length >= 4 ? parseFloat(vb[2]) : null; } -let captured: CapturedRO[] = []; +let captured = []; function makeResizeObserverCtor() { return class FakeResizeObserver { - cb: () => void; - el: Element | null = null; - constructor(cb: ResizeObserverCallback) { + cb; + constructor(cb) { this.cb = cb; - captured.push({ callback: () => cb([], this as unknown as ResizeObserver), el: null as never }); + captured.push(() => cb([], this)); } - observe(el: Element) { - const capture = captured[captured.length - 1]; - if (capture) capture.el = el; + observe(el) { + if (captured.length > 0) captured[captured.length - 1].el = el; } unobserve() {} disconnect() {} }; } -const baseOption = { - grid: { top: 24, left: 60, right: 24, bottom: 54 }, - xAxis: { type: 'category', data: ['a', 'b'] }, - yAxis: { type: 'value' }, - series: [{ name: 'x', type: 'line' as const, data: [1, 2], showSymbol: false }] -}; - -function hostSvgWidth(host: HTMLElement): number | null { - const svg = host.querySelector('svg'); - if (!svg) return null; - const attr = svg.getAttribute('width'); - if (attr) return parseFloat(attr); - const style = svg.getAttribute('style') ?? ''; - const m = /(?:^|;)\s*width:\s*(\d+(?:\.\d+)?)px/.exec(style); - return m ? parseFloat(m[1]) : null; +async function mountChart(props) { + const host = document.createElement('div'); + host.style.width = '900px'; + document.body.appendChild(host); + const inst = mount(Chart, { target: host, props }); + await new Promise((r) => setTimeout(r, 150)); + return { host, inst }; } -describe('Chart resize follows host width (desktop→mobile overflow root cause)', () => { +describe('Chart (layerchart) render + resize contract', () => { beforeEach(() => { captured = []; vi.stubGlobal('ResizeObserver', makeResizeObserverCtor()); }); + afterEach(() => vi.unstubAllGlobals()); - it('init uses measured/fallback size, then resizes the SVG when the host shrinks', async () => { + it('layerchart SVG must be real in jsdom (fallback width 640 when host has no layout)', async () => { const host = document.createElement('div'); - host.style.width = '900px'; document.body.appendChild(host); - const originalW = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth'); - Object.defineProperty(host, 'clientWidth', { configurable: true, get() { return 900; } }); - try { - const inst = mount(Chart, { - target: host, - props: { option: baseOption, height: 340, ariaLabel: '曲线' } - }); - // let the lazy echarts import mount - await new Promise((r) => setTimeout(r, 120)); - const ro = captured[captured.length - 1]; - expect(ro).toBeTruthy(); - expect(ro.el).toBe(host.firstElementChild); // observer watches the bind host - const inner = ro.el as HTMLElement; + const inst = mount(Chart, { target: host, props: { data, series, height: 340, ariaLabel: '曲线' } }); + await new Promise((r) => setTimeout(r, 150)); + const svg = host.querySelector('svg'); + expect(svg, 'layerchart SVG renderer must be real in tests').toBeTruthy(); + expect(svgSize(host), 'fallback width must be applied, not 0/degenerate').toBe(640); + unmount(inst); + host.remove(); + }); - const svg = inner.querySelector('svg'); - expect(svg, 'echarts SVG renderer must be real in tests').toBeTruthy(); - // initial size: measured/fallback (target has no clientWidth -> 640) - expect(parseFloat(svg!.getAttribute('width')!)).toBe(640); + it('svg width follows the wrapper width prop across a viewport change (900 → 340)', async () => { + const first = await mountChart({ data, series, height: 340, width: 900, ariaLabel: '曲线' }); + expect(svgSize(first.host), 'desktop width must be rendered').toBe(900); + unmount(first.inst); + first.host.remove(); - // seed the chart at a "desktop" width, then shrink the host (viewport - // resize) and fire the observer: svg must follow, not stay sticky - const setW = (v: number) => Object.defineProperty(inner, 'clientWidth', { configurable: true, get() { return v; } }); - setW(900); - ro.callback(); - await new Promise((r) => setTimeout(r, 40)); - expect(parseFloat(inner.querySelector('svg')!.getAttribute('width')!)).toBe(900); + // the desktop->mobile in-place resize case: renderer must not stick to the + // previously rendered width + const second = await mountChart({ data, series, height: 340, width: 340, ariaLabel: '曲线' }); + expect(svgSize(second.host), 'mobile width must be rendered, not the desktop width').toBe(340); + unmount(second.inst); + second.host.remove(); + }); - // now the actual overflow scenario: shrink to 375-device width - setW(340); - ro.callback(); - await new Promise((r) => setTimeout(r, 40)); + // In-place viewport change without remount: resize the host and fire the + // ResizeObserver callback. The wrapper must re-measure and push the new + // width down (the width prop is only the initial size — the live + // measurement always wins, matching the old echarts contract). + it('in-place host resize via ResizeObserver updates the svg without a remount', async () => { + const target = document.createElement('div'); + target.style.width = '900px'; + document.body.appendChild(target); + const inst = mount(Chart, { + target, + props: { data, series, height: 340, ariaLabel: '曲线' } + }); + await new Promise((r) => setTimeout(r, 150)); - const svgAfter = inner.querySelector('svg')!; - const wAfter = svgAfter!.getAttribute('width'); - expect(wAfter, 'svg must shrink on resize, not keep desktop width').toBeTruthy(); - expect(parseFloat(wAfter!)).toBe(340); + // emulate a measurable wrapper host (jsdom has no layout engine): + // the wrapper's internal role="img" div is the observed element + const inner = target.querySelector('div[role="img"]'); + expect(inner).toBeTruthy(); + let hostWidth = 900; + Object.defineProperty(inner, 'clientWidth', { get: () => hostWidth, configurable: true }); + for (const fire of captured) fire(); + await new Promise((r) => setTimeout(r, 120)); + expect(svgSize(target), 'desktop width must be rendered first').toBe(900); - unmount(inst); - host.remove(); - vi.unstubAllGlobals(); - } finally { - Object.defineProperty(host, 'clientWidth', { configurable: true, get: () => 0 }); - } + // viewport shrink in place: narrowed host + RO callback (no remount) + hostWidth = 280; + for (const fire of captured) fire(); + await new Promise((r) => setTimeout(r, 120)); + expect(svgSize(target), 'mobile width must be rendered, not the desktop width').toBe(280); + unmount(inst); + target.remove(); }); - it('does not pass undefined sizes to resize when host has no layout (avoid degenerate resize)', async () => { + it('zero-layout host: fake resize callbacks must not throw and svg stays alive', async () => { const host = document.createElement('div'); document.body.appendChild(host); - const inst = mount(Chart, { - target: host, - props: { option: baseOption, height: 340, ariaLabel: '曲线' } - }); - await new Promise((r) => setTimeout(r, 120)); - const ro = captured[captured.length - 1]; - expect(ro?.el).toBeTruthy(); - // host with zero layout: callback must be a no-op (no degenerate 0-width resize) - expect(() => ro.callback()).not.toThrow(); + const inst = mount(Chart, { target: host, props: { data, series, height: 340, ariaLabel: '曲线' } }); + await new Promise((r) => setTimeout(r, 150)); + expect(captured.length).toBeGreaterThan(0); + expect(() => { + for (const fire of captured) fire(); + }).not.toThrow(); + expect(host.querySelector('svg')).toBeTruthy(); unmount(inst); host.remove(); - vi.unstubAllGlobals(); }); }); diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts index cdd6071..10f9568 100644 --- a/frontend/src/test-setup.ts +++ b/frontend/src/test-setup.ts @@ -1,4 +1,20 @@ +// layerchart reads window.matchMedia (settings context, dark-mode aware +// styling) in jsdom where no backend exists. Narrow shim for media queries +// used by chart settings only. +if (typeof window !== 'undefined' && !window.matchMedia) { + window.matchMedia = (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false + }); +} + // jsdom does not implement Range.getClientRects, which CodeMirror's // measurement layer calls for drawing decorations (diagnostics markers). // Without it the suite emits an unhandled |
