diff options
Diffstat (limited to 'frontend/src/lib/chartResize.test.ts')
| -rw-r--r-- | frontend/src/lib/chartResize.test.ts | 193 |
1 files changed, 103 insertions, 90 deletions
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(); }); }); |
