summaryrefslogtreecommitdiff
path: root/frontend/src/lib/chartResize.test.ts
blob: 95beaad3a2b5db2531ca0b56f93a66e7e14ca222 (plain)
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
// @ts-nocheck
import { describe, expect, it, beforeEach, 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):
//
// 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).

interface CapturedRO {
  callback: () => void;
  el: Element;
}
let captured: CapturedRO[] = [];

function makeResizeObserverCtor() {
  return class FakeResizeObserver {
    cb: () => void;
    el: Element | null = null;
    constructor(cb: ResizeObserverCallback) {
      this.cb = cb;
      captured.push({ callback: () => cb([], this as unknown as ResizeObserver), el: null as never });
    }
    observe(el: Element) {
      const capture = captured[captured.length - 1];
      if (capture) capture.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;
}

describe('Chart resize follows host width (desktop→mobile overflow root cause)', () => {
  beforeEach(() => {
    captured = [];
    vi.stubGlobal('ResizeObserver', makeResizeObserverCtor());
  });

  it('init uses measured/fallback size, then resizes the SVG when the host shrinks', 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 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);

      // 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);

      // now the actual overflow scenario: shrink to 375-device width
      setW(340);
      ro.callback();
      await new Promise((r) => setTimeout(r, 40));

      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);

      unmount(inst);
      host.remove();
      vi.unstubAllGlobals();
    } finally {
      Object.defineProperty(host, 'clientWidth', { configurable: true, get: () => 0 });
    }
  });

  it('does not pass undefined sizes to resize when host has no layout (avoid degenerate resize)', 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();
    unmount(inst);
    host.remove();
    vi.unstubAllGlobals();
  });
});