1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
|
// @ts-nocheck
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), reimplemented for
// the layerchart/shadcn migration:
//
// 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' }];
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 = [];
function makeResizeObserverCtor() {
return class FakeResizeObserver {
cb;
constructor(cb) {
this.cb = cb;
captured.push(() => cb([], this));
}
observe(el) {
if (captured.length > 0) captured[captured.length - 1].el = el;
}
unobserve() {}
disconnect() {}
};
}
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 (layerchart) render + resize contract', () => {
beforeEach(() => {
captured = [];
vi.stubGlobal('ResizeObserver', makeResizeObserverCtor());
});
afterEach(() => vi.unstubAllGlobals());
it('layerchart SVG must be real in jsdom (fallback width 640 when host has no layout)', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
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();
});
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();
// 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();
});
// 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));
// 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);
// 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('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: { 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();
});
});
|