@celestia-island/hikari 0.55.271 → 0.55.272
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -2
- package/src/components/HkModal.scss +8 -1
- package/src/components/HkScrollContainer.fade.contract.test.ts +59 -14
- package/src/components/HkScrollContainer.scss +54 -14
- package/src/components/HkSettingsDialog.phonesheet.contract.test.ts +62 -0
- package/src/components/HkSettingsDialog.scss +14 -0
- package/src/components/HkTrendChart.test.tsx +163 -0
- package/src/components/HkTrendChart.tsx +72 -176
- package/src/components/trendGeometry.test.ts +290 -0
- package/src/components/trendGeometry.ts +372 -0
- package/src/index.ts +34 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celestia-island/hikari",
|
|
3
|
-
"version": "0.55.
|
|
3
|
+
"version": "0.55.272",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
|
|
@@ -71,7 +71,6 @@
|
|
|
71
71
|
"@xterm/addon-image": "^0.10.0-beta.301",
|
|
72
72
|
"@xterm/xterm": "^6.1.0-beta.304",
|
|
73
73
|
"dompurify": "^*",
|
|
74
|
-
"echarts": "^*",
|
|
75
74
|
"highlight.js": "^*",
|
|
76
75
|
"lucide-vue-next": "^*",
|
|
77
76
|
"marked": "^*"
|
|
@@ -619,7 +619,14 @@
|
|
|
619
619
|
// padding both consume the vars, so one override moves both.
|
|
620
620
|
--hk-scroll-pad-top: 1rem;
|
|
621
621
|
--hk-scroll-pad-right: 1rem;
|
|
622
|
-
|
|
622
|
+
// The sheet docks flush on the bottom edge (bottom: 0 above), which on
|
|
623
|
+
// a gesture-nav phone is INSIDE the system bar's band — the body's
|
|
624
|
+
// last row would sit under the home pill. The footer already clears
|
|
625
|
+
// safe area below; a footer-less body gets the same guarantee through
|
|
626
|
+
// its own bottom gutter (2026-11-02 wowsp phone report). Hosts that
|
|
627
|
+
// paint edge-to-edge content still opt out per surface with
|
|
628
|
+
// --hk-scroll-pad-bottom, the same escape hatch the footer contract has.
|
|
629
|
+
--hk-scroll-pad-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
|
623
630
|
--hk-scroll-pad-left: 1rem;
|
|
624
631
|
}
|
|
625
632
|
|
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Source contract for the scroll-container edge fades (the 2026-
|
|
3
|
-
* revision): each edge owns a CSS var
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Source contract for the scroll-container edge fades (the 2026-11-02
|
|
3
|
+
* revision of the 2026-10 composition): each edge owns a CSS var, the
|
|
4
|
+
* viewport mask is built from the gradient LAYERS listed in
|
|
5
|
+
* --hk-scroll-fade-layers, and the axis rules swap that list —
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* - two-axis containers list the vertical AND horizontal gradients and
|
|
8
|
+
* intersect them, so the fade reaches zero on all four sides and the
|
|
9
|
+
* corners fall off naturally;
|
|
10
|
+
* - single-axis containers list ONE gradient on their scroll axis:
|
|
11
|
+
* a composed mask needs `mask-composite`, and engines without the
|
|
12
|
+
* standard property (Android WebViews below Chromium 120 — Tauri on
|
|
13
|
+
* Android rides the system WebView) fall back to the default ADD
|
|
14
|
+
* composition, whose union of two opaque layers is fully opaque —
|
|
15
|
+
* the fade vanished and the band ended in a hard cut at the screen
|
|
16
|
+
* edge (wowsp phone report 2026-11-02: the settings tab strip sliced
|
|
17
|
+
* its last pill mid-glyph). One layer needs no composite anywhere.
|
|
18
|
+
*
|
|
19
|
+
* The earlier regression this still pins (user report: "the light effect
|
|
20
|
+
* is clipped at the two sides"): the pre-2026-10 sheet wrote ONE
|
|
21
|
+
* `mask-image` per axis in competing cascade, so the last rule won, the
|
|
22
|
+
* other axis never faded. The axis rules now swap a VAR, never the
|
|
23
|
+
* property — the property pair is declared exactly once. Hence:
|
|
24
|
+
* - exactly ONE mask declaration pair lives in the sheet (the base
|
|
12
25
|
* block on the viewport) — a new per-axis `mask-image` rule is drift;
|
|
26
|
+
* - the single-axis axis rules set --hk-scroll-fade-layers to a single
|
|
27
|
+
* gradient and touch no mask property;
|
|
13
28
|
* - all four edge vars are declared (top/right/bottom/left, 0px base);
|
|
14
29
|
* - start/end/both rules exist for BOTH axes;
|
|
15
30
|
* - RTL swaps the horizontal vars instead of rewriting the mask.
|
|
@@ -23,22 +38,52 @@ const here = dirname(fileURLToPath(import.meta.url));
|
|
|
23
38
|
const sheet = readFileSync(join(here, "HkScrollContainer.scss"), "utf-8");
|
|
24
39
|
|
|
25
40
|
describe("scroll fade composition contract", () => {
|
|
26
|
-
it("declares one
|
|
41
|
+
it("declares one mask property pair on the viewport (no per-axis overrides)", () => {
|
|
27
42
|
// Feature 2026-10-31: a `mask-image` is authored as
|
|
28
43
|
// `@include mask-image(...)` (styles/polyfill.scss), so the include —
|
|
29
44
|
// not the raw declaration — is what a sheet may carry. The mixin
|
|
30
45
|
// itself owns the -webkit-/standard pair.
|
|
31
46
|
const declarations = sheet.match(/^\s*@include mask-image\(/gm) ?? [];
|
|
32
|
-
// Exactly THREE pairs live in the sheet, all accounted for: the
|
|
33
|
-
//
|
|
34
|
-
// selection-hint painter (the 2026-10-30 inline
|
|
35
|
-
// else is per-axis mask drift
|
|
47
|
+
// Exactly THREE pairs live in the sheet, all accounted for: the base
|
|
48
|
+
// block on the viewport (fed by --hk-scroll-fade-layers), and one
|
|
49
|
+
// feather pair per selection-hint painter (the 2026-10-30 inline
|
|
50
|
+
// falloff). Anything else is per-axis mask drift — the axis rules
|
|
51
|
+
// must swap the VAR, never re-declare the property (the 2026-10
|
|
52
|
+
// competing-cascade regression).
|
|
36
53
|
expect(declarations.length, "one include per mask pair, no per-axis drift").toBe(3);
|
|
37
54
|
expect(sheet).not.toMatch(/^\s*-webkit-mask-image:/m);
|
|
55
|
+
// The composed default (two layers, four edges) still goes through
|
|
56
|
+
// mask-composite — that pair belongs to the base rule only.
|
|
38
57
|
expect(sheet).toContain("mask-composite: intersect");
|
|
39
58
|
expect(sheet).toContain("-webkit-mask-composite: source-in");
|
|
40
59
|
});
|
|
41
60
|
|
|
61
|
+
it("declares the layer list and swaps it per axis to a single gradient", () => {
|
|
62
|
+
// The base rule lists BOTH gradients (the four-edge composition) in
|
|
63
|
+
// one var the mask property consumes.
|
|
64
|
+
expect(sheet).toMatch(/--hk-scroll-fade-layers:[\s\S]*linear-gradient\([\s\S]*to bottom[\s\S]*linear-gradient\([\s\S]*to right/);
|
|
65
|
+
// Each single-axis rule lists ONE gradient on its scroll axis — the
|
|
66
|
+
// no-composite form old WebViews render instead of an opaque union.
|
|
67
|
+
expect(
|
|
68
|
+
sheet.match(/\[data-axis="horizontal"\][^\n]*\n\s*--hk-scroll-fade-layers: linear-gradient\(\s*to right/),
|
|
69
|
+
"horizontal containers fade with one to-right gradient",
|
|
70
|
+
).toBeTruthy();
|
|
71
|
+
expect(
|
|
72
|
+
sheet.match(/\[data-axis="vertical"\][^\n]*\n\s*--hk-scroll-fade-layers: linear-gradient\(\s*to bottom/),
|
|
73
|
+
"vertical containers fade with one to-bottom gradient",
|
|
74
|
+
).toBeTruthy();
|
|
75
|
+
// The axis rules swap the var only — no composite, no mask property.
|
|
76
|
+
const axisBlocks = sheet.match(
|
|
77
|
+
/\.hk-scroll-container\[data-axis="(?:horizontal|vertical)"\]:is\([^)]*\)[^{]*\{[\s\S]*?\n\}/g,
|
|
78
|
+
) ?? [];
|
|
79
|
+
expect(axisBlocks.length, "both axis swap rules exist").toBe(2);
|
|
80
|
+
for (const block of axisBlocks) {
|
|
81
|
+
expect(block).toContain("--hk-scroll-fade-layers: linear-gradient(");
|
|
82
|
+
expect(block).not.toContain("mask-composite");
|
|
83
|
+
expect(block).not.toContain("mask-image");
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
42
87
|
it("declares the four edge vars with zero defaults", () => {
|
|
43
88
|
for (const edge of ["top", "right", "bottom", "left"]) {
|
|
44
89
|
expect(sheet).toContain(`--hk-scroll-fade-${edge}: 0px;`);
|
|
@@ -42,23 +42,37 @@
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/* ── Optional edge fades (fade=true / dock fades) ─────────────────
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
45
|
+
Each edge owns a CSS var; the viewport's mask is built from gradient
|
|
46
|
+
LAYERS listed in --hk-scroll-fade-layers. Two-axis containers list the
|
|
47
|
+
vertical and horizontal gradients and INTERSECT them (mask-composite),
|
|
48
|
+
so the fade reaches zero on every side that hides content and the
|
|
49
|
+
corners fall off naturally in all four directions (2026-10 revision;
|
|
50
|
+
the earlier per-axis rules overwrote one `mask-image` property, so a
|
|
51
|
+
two-axis container only faded one axis AND the band ended in a hard
|
|
52
|
+
cut at the other axis's edges — user report: the light effect is
|
|
53
|
+
clipped at the two sides. Vertical fades were missing entirely).
|
|
54
|
+
|
|
55
|
+
SINGLE-axis containers list ONE gradient on their scroll axis instead
|
|
56
|
+
(2026-11-02 revision): a composed mask needs `mask-composite`, which
|
|
57
|
+
older engines without the standard property (Android WebViews below
|
|
58
|
+
Chromium 120 — Tauri on Android rides the system WebView) fall back to
|
|
59
|
+
the default ADD composition on, and the union of two opaque layers is
|
|
60
|
+
fully opaque — the fade vanished and the overflow band ended in a hard
|
|
61
|
+
cut at the screen edge (wowsp phone report 2026-11-02: the settings
|
|
62
|
+
tab strip sliced its last pill mid-glyph). A single layer needs no
|
|
63
|
+
composite on any engine, and a single-axis container's cross-axis
|
|
64
|
+
gradient is the identity anyway (its edge vars stay 0px), so the
|
|
65
|
+
rendering is unchanged on engines that composed correctly. The
|
|
66
|
+
composite pair stays on the base rule: with one layer it is a no-op
|
|
67
|
+
(there is no layer below to composite against), so the axis rules only
|
|
68
|
+
swap the layer list. Dock fades ride the same vars — they feed the
|
|
69
|
+
vertical gradient, so they land on vertical and two-axis containers. */
|
|
54
70
|
.hk-scroll-container:is([data-fade="true"], [data-dock-fade]) > .hk-scroll-container-viewport {
|
|
55
71
|
--hk-scroll-fade-top: 0px;
|
|
56
72
|
--hk-scroll-fade-right: 0px;
|
|
57
73
|
--hk-scroll-fade-bottom: 0px;
|
|
58
74
|
--hk-scroll-fade-left: 0px;
|
|
59
|
-
-
|
|
60
|
-
-webkit-mask-repeat: no-repeat;
|
|
61
|
-
@include mask-image(
|
|
75
|
+
--hk-scroll-fade-layers:
|
|
62
76
|
linear-gradient(
|
|
63
77
|
to bottom,
|
|
64
78
|
transparent 0,
|
|
@@ -72,12 +86,38 @@
|
|
|
72
86
|
#000 var(--hk-scroll-fade-left),
|
|
73
87
|
#000 calc(100% - var(--hk-scroll-fade-right)),
|
|
74
88
|
transparent 100%
|
|
75
|
-
)
|
|
76
|
-
|
|
89
|
+
);
|
|
90
|
+
-webkit-mask-composite: source-in;
|
|
91
|
+
-webkit-mask-repeat: no-repeat;
|
|
92
|
+
@include mask-image(var(--hk-scroll-fade-layers));
|
|
77
93
|
mask-composite: intersect;
|
|
78
94
|
mask-repeat: no-repeat;
|
|
79
95
|
}
|
|
80
96
|
|
|
97
|
+
/* Horizontal axis (HTabs pill strips, horizontal rails): one gradient,
|
|
98
|
+
no composite required — readable on any engine that knows
|
|
99
|
+
-webkit-mask-image. */
|
|
100
|
+
.hk-scroll-container[data-axis="horizontal"]:is([data-fade="true"], [data-dock-fade]) > .hk-scroll-container-viewport {
|
|
101
|
+
--hk-scroll-fade-layers: linear-gradient(
|
|
102
|
+
to right,
|
|
103
|
+
transparent 0,
|
|
104
|
+
#000 var(--hk-scroll-fade-left),
|
|
105
|
+
#000 calc(100% - var(--hk-scroll-fade-right)),
|
|
106
|
+
transparent 100%
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* Vertical axis (the default): same treatment on the scroll axis. */
|
|
111
|
+
.hk-scroll-container[data-axis="vertical"]:is([data-fade="true"], [data-dock-fade]) > .hk-scroll-container-viewport {
|
|
112
|
+
--hk-scroll-fade-layers: linear-gradient(
|
|
113
|
+
to bottom,
|
|
114
|
+
transparent 0,
|
|
115
|
+
#000 var(--hk-scroll-fade-top),
|
|
116
|
+
#000 calc(100% - var(--hk-scroll-fade-bottom)),
|
|
117
|
+
transparent 100%
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
81
121
|
.hk-scroll-container[data-fade="true"] {
|
|
82
122
|
--hk-scroll-fade-size: var(--space-24);
|
|
83
123
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the phone settings sheet (2026-11-02): the docked
|
|
3
|
+
* sheet's settings shell must FILL the modal body instead of keeping the
|
|
4
|
+
* desktop band, and the mobile body gutter must clear the gesture-nav
|
|
5
|
+
* safe area.
|
|
6
|
+
*
|
|
7
|
+
* The regression this pins (wowsp phone report 2026-11-02, screenshots of
|
|
8
|
+
* the attributions section): the host rule kept `.hk-settings` at
|
|
9
|
+
* height: var(--hk-settings-height, calc(70vh - 6.5rem)) on phones too,
|
|
10
|
+
* so on a sheet tall enough to dwarf that band the shell ended
|
|
11
|
+
* mid-screen — the pane's viewport sliced the last card mid-row and
|
|
12
|
+
* everything below it was unreachable dead sheet background. And a
|
|
13
|
+
* footer-less sheet body docked flush on the bottom edge put its last
|
|
14
|
+
* row inside the Android gesture-nav band, which only the mobile FOOTER
|
|
15
|
+
* padding handled before.
|
|
16
|
+
*
|
|
17
|
+
* The reads normalize CRLF first: sibling source contracts match
|
|
18
|
+
* raw bytes and choke on autocrlf checkouts (they fail identically on
|
|
19
|
+
* pristine master there, and pass in CI's LF checkout) — this file must
|
|
20
|
+
* stay green on BOTH.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, expect, it } from "vitest";
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const read = (p: string) => readFileSync(join(here, p), "utf-8").replace(/\r\n/g, "\n");
|
|
29
|
+
|
|
30
|
+
describe("phone settings sheet contract", () => {
|
|
31
|
+
it("fills the shell with the modal body under the family breakpoint", () => {
|
|
32
|
+
const sheet = read("HkSettingsDialog.scss");
|
|
33
|
+
// The desktop band stays as the var-driven base…
|
|
34
|
+
expect(sheet).toContain(
|
|
35
|
+
".hk-settings-host .hk-settings {",
|
|
36
|
+
);
|
|
37
|
+
expect(sheet).toContain("height: var(--hk-settings-height, calc(70vh - 6.5rem));");
|
|
38
|
+
// …and the phone branch overrides it to a full fill. The override must
|
|
39
|
+
// live INSIDE the host rule's media query — a later standalone rule
|
|
40
|
+
// would also work, but the nested form keeps the band and the fill on
|
|
41
|
+
// one cascade line (the same-specificity later-block win).
|
|
42
|
+
expect(sheet).toMatch(
|
|
43
|
+
/\.hk-settings-host \.hk-settings \{[\s\S]*?@media \(max-width: 767px\) \{\s*height: 100%;\s*\}/,
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("adds the gesture-nav safe area to the mobile body bottom gutter", () => {
|
|
48
|
+
const sheet = read("HkModal.scss");
|
|
49
|
+
// The mobile block retunes the four gutter vars; only the bottom one
|
|
50
|
+
// spends the safe area (the sheet docks flush on that edge).
|
|
51
|
+
expect(sheet).toContain("--hk-scroll-pad-top: 1rem;");
|
|
52
|
+
expect(sheet).toContain("--hk-scroll-pad-right: 1rem;");
|
|
53
|
+
expect(sheet).toContain("--hk-scroll-pad-left: 1rem;");
|
|
54
|
+
expect(sheet).toContain(
|
|
55
|
+
"--hk-scroll-pad-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));",
|
|
56
|
+
);
|
|
57
|
+
// The desktop gutter defaults stay untouched.
|
|
58
|
+
expect(sheet).toContain(
|
|
59
|
+
"--hk-scroll-pad-bottom: var(--hk-pad, var(--hk-modal-padding-body, 1.5rem));",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -167,6 +167,20 @@
|
|
|
167
167
|
|
|
168
168
|
.hk-settings-host .hk-settings {
|
|
169
169
|
height: var(--hk-settings-height, calc(70vh - 6.5rem));
|
|
170
|
+
|
|
171
|
+
/* Phone sheet: the docked sheet's body is much taller than the
|
|
172
|
+
desktop's fixed 70vh band, and a fixed height here cannot stretch —
|
|
173
|
+
the shell ended mid-sheet, the pane's viewport sliced the last card
|
|
174
|
+
mid-row, and the whole lower sheet stayed a dead background band
|
|
175
|
+
(wowsp phone report 2026-11-02). On phones the shell fills the body
|
|
176
|
+
region instead (100% of the body inner's content box, which is the
|
|
177
|
+
scroller minus its paddings), so the pane is the only scroller all
|
|
178
|
+
the way down to the sheet's tail. The --hk-settings-height contract
|
|
179
|
+
stays desktop-only: the host's height knob still sizes the desktop
|
|
180
|
+
modal, and a page-embedded shell (no host class) is untouched. */
|
|
181
|
+
@media (max-width: 767px) {
|
|
182
|
+
height: 100%;
|
|
183
|
+
}
|
|
170
184
|
}
|
|
171
185
|
|
|
172
186
|
/* Cancel the modal body's fixed right padding so the pane's overlay
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Component-side cases for the SVG trend chart (the mirror's render-layer
|
|
2
|
+
// cases, mounted for real through happy-dom). The pure geometry cases live
|
|
3
|
+
// in ./trendGeometry.test.ts, ported from the Rust mirror's cfg(test).
|
|
4
|
+
//
|
|
5
|
+
// AI-generated (glm-5.3-flash via DeepSeek Harness), 2026-11-03 — SySL-1.0 §2.3.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { dirname, resolve } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
import { createApp, h, nextTick, ref } from "vue";
|
|
12
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
13
|
+
|
|
14
|
+
import HkTrendChart from "./HkTrendChart";
|
|
15
|
+
|
|
16
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
17
|
+
|
|
18
|
+
function mount(props: Record<string, unknown>) {
|
|
19
|
+
const container = document.createElement("div");
|
|
20
|
+
document.body.appendChild(container);
|
|
21
|
+
const app = createApp({ render: () => h(HkTrendChart, props) });
|
|
22
|
+
app.mount(container);
|
|
23
|
+
mounts.push({ app, container });
|
|
24
|
+
return container;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
29
|
+
app.unmount();
|
|
30
|
+
container.remove();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const CPU = { label: "cpu", data: [{ time: 0, value: 1 }, { time: 5000, value: 4 }] };
|
|
35
|
+
|
|
36
|
+
describe("HkTrendChart", () => {
|
|
37
|
+
it("renders the mirror's DOM contract on its own contents host", () => {
|
|
38
|
+
const el = mount({ pens: [CPU], now: 5000, timeWindowMs: 5000 });
|
|
39
|
+
// The root div: class hook + the old echarts wrapper's inline box.
|
|
40
|
+
const root = el.querySelector("div.hk-trend-chart") as HTMLElement;
|
|
41
|
+
expect(root).not.toBeNull();
|
|
42
|
+
// Vue serializes the inline style normalized; the box itself is the
|
|
43
|
+
// old wrapper's "width:100%;height:240px".
|
|
44
|
+
expect(root.style.width).toBe("100%");
|
|
45
|
+
expect(root.style.height).toBe("240px");
|
|
46
|
+
// The SVG mounts on its own display:contents host, exactly as the Rust
|
|
47
|
+
// mirror documents.
|
|
48
|
+
const host = root.firstElementChild as HTMLElement;
|
|
49
|
+
expect(host.style.display).toBe("contents");
|
|
50
|
+
const svg = host.querySelector("svg.hk-trend-chart__svg")!;
|
|
51
|
+
expect(svg).not.toBeNull();
|
|
52
|
+
expect(svg.getAttribute("viewBox")).toBe("0 0 640 240");
|
|
53
|
+
expect(svg.getAttribute("preserveAspectRatio")).toBe("none");
|
|
54
|
+
// One area + one line per pen, drawn with the token ladder.
|
|
55
|
+
expect(el.querySelectorAll("path.hk-trend-chart__area")).toHaveLength(1);
|
|
56
|
+
expect(el.querySelectorAll("polyline.hk-trend-chart__line")).toHaveLength(1);
|
|
57
|
+
const line = el.querySelector("polyline.hk-trend-chart__line")!;
|
|
58
|
+
expect(line.getAttribute("points")).toBe("50,200 620,30");
|
|
59
|
+
expect(line.getAttribute("stroke")).toBe("rgb(var(--color-primary))");
|
|
60
|
+
expect(line.getAttribute("stroke-width")).toBe("2");
|
|
61
|
+
const area = el.querySelector("path.hk-trend-chart__area")!;
|
|
62
|
+
expect(area.getAttribute("d")).toBe("M 50 200 L 620 30 L 620 200 L 50 200 Z");
|
|
63
|
+
expect(area.getAttribute("fill-opacity")).toBe("0.12");
|
|
64
|
+
// No chart library anywhere: no canvas, no echarts chrome.
|
|
65
|
+
expect(el.querySelectorAll("canvas")).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("freezes the window at the newest sample when no now prop is given", () => {
|
|
69
|
+
// now defaults to the newest sample (5000), so the visible geometry is
|
|
70
|
+
// identical to the pinned now: 5000 render above — no empty window.
|
|
71
|
+
const el = mount({ pens: [CPU], timeWindowMs: 5000 });
|
|
72
|
+
expect(el.querySelector("polyline.hk-trend-chart__line")!.getAttribute("points")).toBe(
|
|
73
|
+
"50,200 620,30",
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("renders threshold guides into the markup, and only then", () => {
|
|
78
|
+
const el = mount({
|
|
79
|
+
pens: [{ label: "cpu", data: [{ time: 0, value: 1 }, { time: 1000, value: 9 }], thresholds: { hh: 8 } }],
|
|
80
|
+
now: 1000,
|
|
81
|
+
timeWindowMs: 1000,
|
|
82
|
+
});
|
|
83
|
+
const guide = el.querySelector("line.hk-trend-chart__threshold")!;
|
|
84
|
+
expect(guide).not.toBeNull();
|
|
85
|
+
expect(guide.getAttribute("x1")).toBe("50");
|
|
86
|
+
expect(guide.getAttribute("x2")).toBe("620");
|
|
87
|
+
expect(guide.getAttribute("stroke")).toBe("#FF1744");
|
|
88
|
+
expect(guide.getAttribute("stroke-dasharray")).toBe("6 4");
|
|
89
|
+
expect(guide.getAttribute("stroke-width")).toBe("1");
|
|
90
|
+
|
|
91
|
+
const bare = mount({ pens: [CPU], now: 5000, timeWindowMs: 5000 });
|
|
92
|
+
expect(bare.querySelectorAll("line.hk-trend-chart__threshold")).toHaveLength(0);
|
|
93
|
+
// showThresholds: false wins over present limits.
|
|
94
|
+
const hidden = mount({
|
|
95
|
+
pens: [{ label: "cpu", data: [{ time: 0, value: 1 }], thresholds: { hh: 8 } }],
|
|
96
|
+
now: 0,
|
|
97
|
+
showThresholds: false,
|
|
98
|
+
});
|
|
99
|
+
expect(hidden.querySelectorAll("line.hk-trend-chart__threshold")).toHaveLength(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("lifts a px height into the viewBox and keeps non-px lengths out", () => {
|
|
103
|
+
const px = mount({ pens: [CPU], height: "120px" });
|
|
104
|
+
expect((px.querySelector("div.hk-trend-chart") as HTMLElement).style.height).toBe("120px");
|
|
105
|
+
expect(px.querySelector("svg.hk-trend-chart__svg")!.getAttribute("viewBox")).toBe("0 0 640 120");
|
|
106
|
+
|
|
107
|
+
const rem = mount({ pens: [CPU], height: "18rem" });
|
|
108
|
+
expect((rem.querySelector("div.hk-trend-chart") as HTMLElement).style.height).toBe("18rem");
|
|
109
|
+
expect(rem.querySelector("svg.hk-trend-chart__svg")!.getAttribute("viewBox")).toBe(
|
|
110
|
+
"0 0 640 240",
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// An empty height keeps the viewBox-derived px on the root box.
|
|
114
|
+
const empty = mount({ pens: [CPU], height: "" });
|
|
115
|
+
expect((empty.querySelector("div.hk-trend-chart") as HTMLElement).style.height).toBe("240px");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("is deterministic: same props, same markup (offline pre-render)", () => {
|
|
119
|
+
const a = mount({ pens: [CPU], now: 5000, timeWindowMs: 5000 });
|
|
120
|
+
const b = mount({ pens: [CPU], now: 5000, timeWindowMs: 5000 });
|
|
121
|
+
expect(a.querySelector("div.hk-trend-chart")!.innerHTML).toBe(
|
|
122
|
+
b.querySelector("div.hk-trend-chart")!.innerHTML,
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("re-renders streaming updates through reactive pens", async () => {
|
|
127
|
+
const pens = ref([{ label: "cpu", data: [{ time: 0, value: 1 }] }]);
|
|
128
|
+
const container = document.createElement("div");
|
|
129
|
+
document.body.appendChild(container);
|
|
130
|
+
const app = createApp({ render: () => h(HkTrendChart, { pens: pens.value, now: 1000, timeWindowMs: 1000 }) });
|
|
131
|
+
app.mount(container);
|
|
132
|
+
mounts.push({ app, container });
|
|
133
|
+
expect(container.querySelector("polyline.hk-trend-chart__line")!.getAttribute("points")).toBe(
|
|
134
|
+
"50,115",
|
|
135
|
+
);
|
|
136
|
+
pens.value = [{ label: "cpu", data: [{ time: 0, value: 1 }, { time: 1000, value: 4 }] }];
|
|
137
|
+
await nextTick();
|
|
138
|
+
expect(container.querySelector("polyline.hk-trend-chart__line")!.getAttribute("points")).toBe(
|
|
139
|
+
"50,200 620,30",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("stays dependency-free (no echarts import, no echarts dep)", () => {
|
|
144
|
+
// The old build lazily imported echarts/core; the rewrite must not let
|
|
145
|
+
// it back in — pinned at the source and manifest level, the same way
|
|
146
|
+
// componentSelfSufficiency reads sources.
|
|
147
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
148
|
+
for (const name of ["HkTrendChart.tsx", "trendGeometry.ts"]) {
|
|
149
|
+
const source = readFileSync(resolve(here, name), "utf8").replace(/\r\n/g, "\n");
|
|
150
|
+
expect(
|
|
151
|
+
/(?:from\s*|import\s*\(\s*)["']echarts/.test(source),
|
|
152
|
+
`${name}: chart library import`,
|
|
153
|
+
).toBe(false);
|
|
154
|
+
// No side-effect import (i.e. no stylesheet — the mirror's styles()
|
|
155
|
+
// is empty by design).
|
|
156
|
+
expect(source.includes('import "'), `${name}: side-effect import`).toBe(false);
|
|
157
|
+
}
|
|
158
|
+
const pkg = JSON.parse(
|
|
159
|
+
readFileSync(resolve(here, "../../package.json"), "utf8"),
|
|
160
|
+
) as { dependencies?: Record<string, string> };
|
|
161
|
+
expect(Object.keys(pkg.dependencies ?? {})).not.toContain("echarts");
|
|
162
|
+
});
|
|
163
|
+
});
|