summaryrefslogtreecommitdiff
path: root/frontend/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/lib')
-rw-r--r--frontend/src/lib/chartResize.test.ts193
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();
});
});