@celestia-island/hikari 0.55.231 → 0.55.233
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 -1
- package/src/components/HkAboutBody.test.tsx +35 -0
- package/src/components/HkAboutModal.scss +8 -0
- package/src/components/HkAboutModal.styles.test.ts +58 -30
- package/src/components/HkAboutModal.tsx +16 -2
- package/src/components/HkCalendarHeatmap.scss +90 -0
- package/src/components/HkCalendarHeatmap.test.tsx +240 -0
- package/src/components/HkCalendarHeatmap.tsx +261 -0
- package/src/components/calendarHeatmap.test.ts +190 -0
- package/src/components/calendarHeatmap.ts +139 -0
- package/src/i18n/locales/ar/components.json +6 -1
- package/src/i18n/locales/de/components.json +6 -1
- package/src/i18n/locales/en/components.json +6 -1
- package/src/i18n/locales/es/components.json +6 -1
- package/src/i18n/locales/fr/components.json +6 -1
- package/src/i18n/locales/ja/components.json +6 -1
- package/src/i18n/locales/ko/components.json +6 -1
- package/src/i18n/locales/pt/components.json +6 -1
- package/src/i18n/locales/ru/components.json +6 -1
- package/src/i18n/locales/zh-Hans/components.json +6 -1
- package/src/i18n/locales/zh-Hant/components.json +6 -1
- package/src/index.ts +14 -0
package/package.json
CHANGED
|
@@ -16,6 +16,9 @@ import { HkAboutBody } from "./HkAboutModal";
|
|
|
16
16
|
* - the default slot appends host content after the copyright footer
|
|
17
17
|
* - without a backdrop factory no backdrop layer renders (a body host may
|
|
18
18
|
* not want the decorative canvas at all)
|
|
19
|
+
* - a version row's `metaTone: "danger"` reaches the pill as
|
|
20
|
+
* `data-tone="danger"`, and an EMPTY `value` drops the mono number cell
|
|
21
|
+
* entirely (the "component not wired up" row shows only its red label)
|
|
19
22
|
*
|
|
20
23
|
* (Repo test convention: raw createApp + document queries, no
|
|
21
24
|
* @vue/test-utils dependency.)
|
|
@@ -121,4 +124,36 @@ describe("HkAboutBody extraction", () => {
|
|
|
121
124
|
|
|
122
125
|
expect(document.body.querySelector(".s-about-modal-backdrop")).toBeNull();
|
|
123
126
|
});
|
|
127
|
+
|
|
128
|
+
it("tints a version row's meta pill with the danger tone", async () => {
|
|
129
|
+
mountBody({
|
|
130
|
+
componentVersions: [
|
|
131
|
+
{ label: "Edge engine", value: "1.4.0", meta: "Not connected", metaTone: "danger" },
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
await flush();
|
|
135
|
+
|
|
136
|
+
const pill = query(".s-about-modal-row-meta");
|
|
137
|
+
expect(pill.getAttribute("data-tone")).toBe("danger");
|
|
138
|
+
expect(pill.textContent).toBe("Not connected");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("drops the mono number cell when a row's value is empty", async () => {
|
|
142
|
+
mountBody({
|
|
143
|
+
componentVersions: [
|
|
144
|
+
// A host with nothing to report names the state in `meta` and leaves
|
|
145
|
+
// `value` empty (the "not wired up" row).
|
|
146
|
+
{ label: "Edge engine", value: "", meta: "Not connected", metaTone: "danger" },
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
await flush();
|
|
150
|
+
|
|
151
|
+
// No blank tabular slot: the mono cell is absent, not just empty.
|
|
152
|
+
expect(document.body.querySelector(".s-about-modal-row-number")).toBeNull();
|
|
153
|
+
// The label and the pill still carry the row.
|
|
154
|
+
expect(query(".s-about-modal-row-label").textContent).toContain("Edge engine");
|
|
155
|
+
const pill = query(".s-about-modal-row-meta");
|
|
156
|
+
expect(pill.textContent).toBe("Not connected");
|
|
157
|
+
expect(pill.getAttribute("data-tone")).toBe("danger");
|
|
158
|
+
});
|
|
124
159
|
});
|
|
@@ -228,6 +228,14 @@
|
|
|
228
228
|
color: rgb(var(--color-warning));
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
// Red state: the component is not wired up / unreachable. The row then
|
|
232
|
+
// carries no version at all, so this pill is the whole value cell.
|
|
233
|
+
.s-about-modal-row-meta[data-tone="danger"] {
|
|
234
|
+
border-color: rgb(var(--color-error) / 32%);
|
|
235
|
+
background: rgb(var(--color-error) / 10%);
|
|
236
|
+
color: rgb(var(--color-error));
|
|
237
|
+
}
|
|
238
|
+
|
|
231
239
|
/* ── Links: licenses, external links, credits names and filings ───────── */
|
|
232
240
|
|
|
233
241
|
.s-about-modal-chips {
|
|
@@ -29,6 +29,36 @@ function classesFrom(source: string): Set<string> {
|
|
|
29
29
|
return found;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Body of one compiled rule, by exact selector (Sass drops the quotes around
|
|
34
|
+
* an attribute value: `[data-face=plain]`).
|
|
35
|
+
*
|
|
36
|
+
* The selector must compile exactly once: a second copy — say inside a media
|
|
37
|
+
* query, or a later override — would otherwise stay invisible here while
|
|
38
|
+
* still winning the cascade.
|
|
39
|
+
*/
|
|
40
|
+
function ruleBody(sheet: string, selector: string): string {
|
|
41
|
+
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
42
|
+
const matches = [...sheet.matchAll(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`, "g"))];
|
|
43
|
+
expect(matches, `${selector} compiles exactly once`).toHaveLength(1);
|
|
44
|
+
return matches[0]?.[1] ?? "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One declaration's value inside a rule body.
|
|
49
|
+
*
|
|
50
|
+
* A property declared twice is a failure rather than a coincidence: CSS
|
|
51
|
+
* resolves a repeat last-wins, so a first-match reader would report the stale
|
|
52
|
+
* value and happily accept the override.
|
|
53
|
+
*/
|
|
54
|
+
function declaration(body: string, property: string): string {
|
|
55
|
+
const values = [
|
|
56
|
+
...body.matchAll(new RegExp(`(?:^|;)\\s*${property}\\s*:([^;]*)`, "g")),
|
|
57
|
+
].map((match) => match[1]!.trim());
|
|
58
|
+
expect(values.length, `${property} is declared at most once`).toBeLessThanOrEqual(1);
|
|
59
|
+
return values[0] ?? "";
|
|
60
|
+
}
|
|
61
|
+
|
|
32
62
|
describe("HkAboutModal stylesheet contract", () => {
|
|
33
63
|
const tsx = readFileSync(resolve(componentDir, "HkAboutModal.tsx"), "utf8");
|
|
34
64
|
const scss = readFileSync(resolve(componentDir, "HkAboutModal.scss"), "utf8");
|
|
@@ -67,36 +97,6 @@ describe("HkAboutModal stylesheet contract", () => {
|
|
|
67
97
|
* into chips. These assertions read the compiled declarations instead.
|
|
68
98
|
*/
|
|
69
99
|
describe("link faces", () => {
|
|
70
|
-
/**
|
|
71
|
-
* Body of one compiled rule, by exact selector (Sass drops the quotes
|
|
72
|
-
* around an attribute value: `[data-face=plain]`).
|
|
73
|
-
*
|
|
74
|
-
* The selector must compile exactly once: a second copy — say inside a
|
|
75
|
-
* media query, or a later override — would otherwise stay invisible here
|
|
76
|
-
* while still winning the cascade.
|
|
77
|
-
*/
|
|
78
|
-
function ruleBody(sheet: string, selector: string): string {
|
|
79
|
-
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
80
|
-
const matches = [...sheet.matchAll(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`, "g"))];
|
|
81
|
-
expect(matches, `${selector} compiles exactly once`).toHaveLength(1);
|
|
82
|
-
return matches[0]?.[1] ?? "";
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* One declaration's value inside a rule body.
|
|
87
|
-
*
|
|
88
|
-
* A property declared twice is a failure rather than a coincidence: CSS
|
|
89
|
-
* resolves a repeat last-wins, so a first-match reader would report the
|
|
90
|
-
* stale value and happily accept the override.
|
|
91
|
-
*/
|
|
92
|
-
function declaration(body: string, property: string): string {
|
|
93
|
-
const values = [
|
|
94
|
-
...body.matchAll(new RegExp(`(?:^|;)\\s*${property}\\s*:([^;]*)`, "g")),
|
|
95
|
-
].map((match) => match[1]!.trim());
|
|
96
|
-
expect(values.length, `${property} is declared at most once`).toBeLessThanOrEqual(1);
|
|
97
|
-
return values[0] ?? "";
|
|
98
|
-
}
|
|
99
|
-
|
|
100
100
|
const PLAIN = ".s-about-modal-link[data-face=plain]";
|
|
101
101
|
|
|
102
102
|
it("keeps the plain face a bare text link", () => {
|
|
@@ -229,4 +229,32 @@ describe("HkAboutModal stylesheet contract", () => {
|
|
|
229
229
|
expect(iconAt).toBeGreaterThan(plainAt);
|
|
230
230
|
});
|
|
231
231
|
});
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Row-meta tones are self-contained rules, so the class-name checks above
|
|
235
|
+
* cannot see them: reverting a tone to `neutral` (or dropping its rule)
|
|
236
|
+
* leaves the `data-tone` attribute intact and the runtime suite green while
|
|
237
|
+
* the pill silently loses its colour — the whole point of the tone.
|
|
238
|
+
*/
|
|
239
|
+
describe("row meta tones", () => {
|
|
240
|
+
const TONE = (tone: string) => `.s-about-modal-row-meta[data-tone=${tone}]`;
|
|
241
|
+
|
|
242
|
+
it("paints the danger tone in the theme's error colour", () => {
|
|
243
|
+
const danger = ruleBody(css, TONE("danger"));
|
|
244
|
+
expect(danger, "the danger-tone rule must survive compilation").not.toBe("");
|
|
245
|
+
expect({
|
|
246
|
+
borderColor: declaration(danger, "border-color"),
|
|
247
|
+
background: declaration(danger, "background"),
|
|
248
|
+
color: declaration(danger, "color"),
|
|
249
|
+
}).toEqual({
|
|
250
|
+
// Same geometry as the positive / caution tones, only the channel
|
|
251
|
+
// differs — `--color-error` is the theme's existing danger token.
|
|
252
|
+
// (Dart Sass compiles the seed-relative form without spaces around
|
|
253
|
+
// the slash.)
|
|
254
|
+
borderColor: "rgb(var(--color-error)/32%)",
|
|
255
|
+
background: "rgb(var(--color-error)/10%)",
|
|
256
|
+
color: "rgb(var(--color-error))",
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
});
|
|
232
260
|
});
|
|
@@ -73,12 +73,19 @@ export interface HAboutCredit {
|
|
|
73
73
|
* rendered as a separate muted pill so it never melts into the version
|
|
74
74
|
* string. `metaTone` tints that pill when the tag carries a status
|
|
75
75
|
* meaning (e.g. a production environment).
|
|
76
|
+
*
|
|
77
|
+
* `danger` is the red "not wired up / unreachable" state: a host whose
|
|
78
|
+
* component (e.g. an edge master node engine) has no version to report
|
|
79
|
+
* passes an EMPTY `value` and names the state in `meta` with
|
|
80
|
+
* `metaTone: "danger"`. The empty value then renders the pill alone —
|
|
81
|
+
* the mono number cell is dropped rather than leaving a blank tabular
|
|
82
|
+
* slot — so the row reads as one red label instead of `unknown`.
|
|
76
83
|
*/
|
|
77
84
|
export interface HAboutComponentVersion {
|
|
78
85
|
label: string;
|
|
79
86
|
value: string;
|
|
80
87
|
meta?: string;
|
|
81
|
-
metaTone?: "neutral" | "positive" | "caution";
|
|
88
|
+
metaTone?: "neutral" | "positive" | "caution" | "danger";
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
/**
|
|
@@ -288,7 +295,14 @@ export const HkAboutBody = defineComponent({
|
|
|
288
295
|
<div class="s-about-modal-row" key={component.label}>
|
|
289
296
|
<span class="s-about-modal-row-label">{component.label}</span>
|
|
290
297
|
<span class="s-about-modal-row-value">
|
|
291
|
-
|
|
298
|
+
{/* An empty value means the host has no version to
|
|
299
|
+
report (e.g. the component is not wired up) and
|
|
300
|
+
carries the state in `meta` instead — skip the mono
|
|
301
|
+
cell so the row does not open with a blank tabular
|
|
302
|
+
slot and a stray gap. */}
|
|
303
|
+
{component.value && (
|
|
304
|
+
<span class="s-about-modal-row-number">{component.value}</span>
|
|
305
|
+
)}
|
|
292
306
|
{component.meta && (
|
|
293
307
|
<span
|
|
294
308
|
class="s-about-modal-row-meta"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// HkCalendarHeatmap — GitHub-style calendar heatmap anatomy.
|
|
2
|
+
//
|
|
3
|
+
// Self-sufficient styling (the component self-sufficiency guard): every
|
|
4
|
+
// class lives here, no host atomic/utility layer. The dynamic geometry
|
|
5
|
+
// (cell size, gap, week-column count, rail row rhythm) stays inline in
|
|
6
|
+
// the component; this sheet owns the static layout and typography.
|
|
7
|
+
|
|
8
|
+
.hk-calendar-heatmap {
|
|
9
|
+
// The legend row: scale on the LEFT, toolbar slot on the RIGHT.
|
|
10
|
+
&-head {
|
|
11
|
+
display: flex;
|
|
12
|
+
flex-wrap: wrap;
|
|
13
|
+
align-items: center;
|
|
14
|
+
justify-content: space-between;
|
|
15
|
+
column-gap: 16px;
|
|
16
|
+
row-gap: 8px;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
&-legend {
|
|
20
|
+
display: inline-flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
gap: 4px;
|
|
23
|
+
font-size: 10px;
|
|
24
|
+
color: var(--hi-color-muted);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// w-max + auto margins center the whole calendar (rail included) on
|
|
28
|
+
// wide cards while letting narrow screens scroll from the left edge —
|
|
29
|
+
// auto margins resolve to zero when the content overflows, so mobile
|
|
30
|
+
// keeps its natural scroll origin.
|
|
31
|
+
&-scroll {
|
|
32
|
+
margin-top: 12px;
|
|
33
|
+
overflow-x: auto;
|
|
34
|
+
padding-bottom: 4px;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
&-body {
|
|
38
|
+
display: flex;
|
|
39
|
+
width: max-content;
|
|
40
|
+
margin-inline: auto;
|
|
41
|
+
gap: 8px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Weekday rail: the first (Monday) and last (Sunday) rows carry the
|
|
45
|
+
// labels (the wowsp convention); sticky so it survives horizontal
|
|
46
|
+
// scroll on narrow screens.
|
|
47
|
+
&-rail {
|
|
48
|
+
position: sticky;
|
|
49
|
+
left: 0;
|
|
50
|
+
z-index: 1;
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
flex-shrink: 0;
|
|
54
|
+
padding-right: 2px;
|
|
55
|
+
font-size: 10px;
|
|
56
|
+
line-height: 14px;
|
|
57
|
+
color: var(--hi-color-muted);
|
|
58
|
+
background: var(--hi-color-surface);
|
|
59
|
+
|
|
60
|
+
> span {
|
|
61
|
+
white-space: nowrap;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
&-cells {
|
|
66
|
+
flex: 1;
|
|
67
|
+
min-width: 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Month markers, one slot per week column; labels overflow their
|
|
71
|
+
// 14px column into the empty cells ahead (the GitHub convention — a
|
|
72
|
+
// month marker is always followed by unmarked columns), so no
|
|
73
|
+
// truncating: a clipped "O…" tells nothing.
|
|
74
|
+
&-markers {
|
|
75
|
+
display: grid;
|
|
76
|
+
margin-bottom: 4px;
|
|
77
|
+
font-size: 10px;
|
|
78
|
+
line-height: 12px;
|
|
79
|
+
color: var(--hi-color-muted);
|
|
80
|
+
|
|
81
|
+
> span {
|
|
82
|
+
white-space: nowrap;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The grid itself: cells flow column-first into 7 rows.
|
|
87
|
+
&-grid {
|
|
88
|
+
display: grid;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkCalendarHeatmap from "./HkCalendarHeatmap";
|
|
5
|
+
import { HEATMAP_LEVEL_OPACITY, todayIso } from "./calendarHeatmap";
|
|
6
|
+
|
|
7
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
8
|
+
|
|
9
|
+
function mount(node: ReturnType<typeof h>) {
|
|
10
|
+
const container = document.createElement("div");
|
|
11
|
+
document.body.appendChild(container);
|
|
12
|
+
const app = createApp({ render: () => node });
|
|
13
|
+
app.mount(container);
|
|
14
|
+
mounts.push({ app, container });
|
|
15
|
+
return container;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
20
|
+
app.unmount();
|
|
21
|
+
container.remove();
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** A Monday, NOT the 1st — the leading-partial-month shape. */
|
|
26
|
+
const MID_MONTH_MON = "2025-10-27";
|
|
27
|
+
/** A Monday AND the 1st — the clean head shape. */
|
|
28
|
+
const CLEAN_HEAD_MON = "2025-12-01";
|
|
29
|
+
|
|
30
|
+
const daysFrom = (start: string, count: number, value = 0) =>
|
|
31
|
+
Array.from({ length: count }, (_, i) => {
|
|
32
|
+
const d = new Date(`${start}T00:00:00Z`);
|
|
33
|
+
d.setUTCDate(d.getUTCDate() + i);
|
|
34
|
+
return { date: d.toISOString().slice(0, 10), value };
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("HkCalendarHeatmap", () => {
|
|
38
|
+
it("renders the legend (less → more, five swatches) and the grid anatomy", () => {
|
|
39
|
+
const c = mount(
|
|
40
|
+
h(HkCalendarHeatmap, {
|
|
41
|
+
days: daysFrom(CLEAN_HEAD_MON, 7),
|
|
42
|
+
first: null,
|
|
43
|
+
unit: "度",
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
|
|
47
|
+
expect(grid).toBeTruthy();
|
|
48
|
+
expect(grid.getAttribute("role")).toBe("img");
|
|
49
|
+
// One row of days pads into exactly one week.
|
|
50
|
+
expect(grid.getAttribute("aria-label")).toContain("1");
|
|
51
|
+
const legend = c.querySelector('[data-testid="hk-calendar-heatmap-legend"]');
|
|
52
|
+
expect(legend?.textContent).toContain("Less");
|
|
53
|
+
expect(legend?.textContent).toContain("More");
|
|
54
|
+
expect(c.querySelectorAll("[data-legend-level]")).toHaveLength(
|
|
55
|
+
HEATMAP_LEVEL_OPACITY.length,
|
|
56
|
+
);
|
|
57
|
+
// One week of days, all level 0 (no consumption): plain muted cells.
|
|
58
|
+
expect(grid.querySelectorAll('[data-level="0"]')).toHaveLength(7);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("suppresses the leading month marker when the window starts mid-month", async () => {
|
|
62
|
+
const c = mount(
|
|
63
|
+
h(HkCalendarHeatmap, {
|
|
64
|
+
// Trailing-October tail + November: the partial October head is
|
|
65
|
+
// unlabeled, November's Monday carries the only marker.
|
|
66
|
+
days: daysFrom(MID_MONTH_MON, 10),
|
|
67
|
+
first: null,
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
await nextTick();
|
|
71
|
+
const markers = c.querySelectorAll(
|
|
72
|
+
".hk-calendar-heatmap-markers > span",
|
|
73
|
+
) as NodeListOf<HTMLElement>;
|
|
74
|
+
expect(markers.length).toBeGreaterThanOrEqual(2);
|
|
75
|
+
expect(markers[0].textContent).toBe(""); // partial October — suppressed
|
|
76
|
+
expect(markers[1].textContent).not.toBe(""); // November — labeled
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("shows the leading month marker when the grid's first column opens its month", async () => {
|
|
80
|
+
const c = mount(
|
|
81
|
+
h(HkCalendarHeatmap, {
|
|
82
|
+
days: daysFrom(CLEAN_HEAD_MON, 9),
|
|
83
|
+
first: null,
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
await nextTick();
|
|
87
|
+
const markers = c.querySelectorAll(
|
|
88
|
+
".hk-calendar-heatmap-markers > span",
|
|
89
|
+
) as NodeListOf<HTMLElement>;
|
|
90
|
+
expect(markers[0].textContent).not.toBe(""); // Dec 1 — labeled
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("renders gap cells hatched and inert (no tooltip, no level claim)", () => {
|
|
94
|
+
// A one-day series AT today, anchored at first = today: the head
|
|
95
|
+
// padding hatches as "before", the tail as "after" — both phases in
|
|
96
|
+
// one grid, all deterministic against the real clock.
|
|
97
|
+
const today = todayIso();
|
|
98
|
+
const c = mount(
|
|
99
|
+
h(HkCalendarHeatmap, {
|
|
100
|
+
days: [{ date: today, value: 5 }],
|
|
101
|
+
first: today,
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
|
|
105
|
+
const gaps = grid.querySelectorAll("[data-gap]") as NodeListOf<HTMLElement>;
|
|
106
|
+
expect(gaps.length).toBeGreaterThan(0);
|
|
107
|
+
const phases = new Set(
|
|
108
|
+
Array.from(gaps, (gap) => gap.getAttribute("data-gap")),
|
|
109
|
+
);
|
|
110
|
+
expect(phases.has("before")).toBe(true);
|
|
111
|
+
expect(phases.has("after")).toBe(true);
|
|
112
|
+
for (const gap of gaps) {
|
|
113
|
+
expect(gap.getAttribute("aria-hidden")).toBe("true");
|
|
114
|
+
expect(gap.getAttribute("title")).toBeNull();
|
|
115
|
+
expect(gap.style.pointerEvents).toBe("none");
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("tooltips day cells with the unit when one is given", () => {
|
|
120
|
+
const c = mount(
|
|
121
|
+
h(HkCalendarHeatmap, {
|
|
122
|
+
days: daysFrom(CLEAN_HEAD_MON, 1, 5),
|
|
123
|
+
first: null,
|
|
124
|
+
unit: "度",
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
const cell = c.querySelector('[data-level]:not([data-level="0"])') as HTMLElement;
|
|
128
|
+
expect(cell).toBeTruthy();
|
|
129
|
+
// The UTC zone is explicit (R2: a negative-offset host must not
|
|
130
|
+
// land the tooltip a day off) — the date is TZ-deterministic.
|
|
131
|
+
expect(cell.getAttribute("title")).toContain("Dec 1, 2025");
|
|
132
|
+
expect(cell.getAttribute("title")).toContain("5");
|
|
133
|
+
expect(cell.getAttribute("title")).toContain("度");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("wires the max prop into the cell buckets, not just data-max", () => {
|
|
137
|
+
const c = mount(
|
|
138
|
+
h(HkCalendarHeatmap, {
|
|
139
|
+
// Peak 10 in-window; the pinned max 100 flattens both days to
|
|
140
|
+
// level 1 (R2 S1: the prop was a dead wire to data-max).
|
|
141
|
+
days: [{ date: CLEAN_HEAD_MON, value: 10 }, { date: "2025-12-02", value: 1 }],
|
|
142
|
+
first: null,
|
|
143
|
+
max: 100,
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
|
|
147
|
+
expect(grid.getAttribute("data-max")).toBe("100");
|
|
148
|
+
expect(grid.querySelectorAll('[data-level="1"]')).toHaveLength(2);
|
|
149
|
+
expect(grid.querySelectorAll('[data-level="4"]')).toHaveLength(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("tooltips day cells without a dangling separator when unitless", () => {
|
|
153
|
+
const c = mount(
|
|
154
|
+
h(HkCalendarHeatmap, {
|
|
155
|
+
days: daysFrom(CLEAN_HEAD_MON, 1, 5),
|
|
156
|
+
first: null,
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
const cell = c.querySelector('[data-level]:not([data-level="0"])') as HTMLElement;
|
|
160
|
+
// The unitless title ends at the count — no trailing "· <empty>".
|
|
161
|
+
expect(cell.getAttribute("title")).toMatch(/· 5$/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("labels the weekday rail on the first and last rows only", () => {
|
|
165
|
+
const c = mount(
|
|
166
|
+
h(HkCalendarHeatmap, { days: daysFrom(CLEAN_HEAD_MON, 7), first: null }),
|
|
167
|
+
);
|
|
168
|
+
const rail = c.querySelector(".hk-calendar-heatmap-rail") as HTMLElement;
|
|
169
|
+
const rows = rail.querySelectorAll(":scope > span") as NodeListOf<HTMLElement>;
|
|
170
|
+
expect(rows).toHaveLength(7);
|
|
171
|
+
expect(rows[0].textContent).not.toBe(""); // Monday
|
|
172
|
+
expect(rows[6].textContent).not.toBe(""); // Sunday
|
|
173
|
+
expect(rows[3].textContent).toBe(""); // middle rows unlabeled
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("renders the toolbar slot right of the legend", () => {
|
|
177
|
+
const Toolbar = defineComponent({
|
|
178
|
+
name: "FakeToolbar",
|
|
179
|
+
setup: () => () => h("div", { "data-fake-toolbar": "" }, "tabs"),
|
|
180
|
+
});
|
|
181
|
+
const c = mount(
|
|
182
|
+
h(HkCalendarHeatmap, { days: daysFrom(CLEAN_HEAD_MON, 7), first: null }, {
|
|
183
|
+
toolbar: () => h(Toolbar),
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
expect(c.querySelector("[data-fake-toolbar]")?.textContent).toBe("tabs");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("renders an empty grid without markers for an empty series", () => {
|
|
190
|
+
const c = mount(h(HkCalendarHeatmap, { days: [], first: null }));
|
|
191
|
+
const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
|
|
192
|
+
expect(grid).toBeTruthy();
|
|
193
|
+
expect(grid.querySelectorAll("[data-level]")).toHaveLength(0);
|
|
194
|
+
expect(c.querySelectorAll(".hk-calendar-heatmap-markers > span")).toHaveLength(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("re-takes today per recompute — a refetch across UTC midnight un-gaps the new today cell", async () => {
|
|
198
|
+
// R2 S2 + R3 变异留证: today frozen at setup rendered the next
|
|
199
|
+
// day as an "after" gap even after the caller refetched a series
|
|
200
|
+
// that includes it. A recompute (the refetch's new `days`) must
|
|
201
|
+
// re-evaluate todayIso() — the crossed-midnight day becomes a day.
|
|
202
|
+
// (A rerender with NO dep change legitimately keeps the cached
|
|
203
|
+
// cells — computed semantics, nothing changed.)
|
|
204
|
+
vi.useFakeTimers();
|
|
205
|
+
try {
|
|
206
|
+
vi.setSystemTime(new Date("2025-12-01T10:00:00Z"));
|
|
207
|
+
// Dense series through the mount-time today: Fri 11-28 .. Mon 12-01.
|
|
208
|
+
const days = daysFrom("2025-11-28", 4);
|
|
209
|
+
const state = reactive({ days });
|
|
210
|
+
// The vnode is built INSIDE the render so the reactive `days`
|
|
211
|
+
// re-renders — the shared eager mount helper would capture one
|
|
212
|
+
// static vnode.
|
|
213
|
+
const container = document.createElement("div");
|
|
214
|
+
document.body.appendChild(container);
|
|
215
|
+
const app = createApp({
|
|
216
|
+
render: () => h(HkCalendarHeatmap, { days: state.days, first: null, unit: "度" }),
|
|
217
|
+
});
|
|
218
|
+
app.mount(container);
|
|
219
|
+
mounts.push({ app, container });
|
|
220
|
+
const grid = container.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
|
|
221
|
+
const tomorrowAtMount = grid.querySelector('[data-date="2025-12-02"]') as HTMLElement;
|
|
222
|
+
expect(tomorrowAtMount.getAttribute("data-gap")).toBe("after");
|
|
223
|
+
|
|
224
|
+
// Cross UTC midnight; the refetched series now includes the new
|
|
225
|
+
// today (12-02, 3 度) — a new array instance, so the cells
|
|
226
|
+
// computed recomputes and re-takes today.
|
|
227
|
+
vi.setSystemTime(new Date("2025-12-02T10:00:00Z"));
|
|
228
|
+
state.days = [...days, { date: "2025-12-02", value: 3 }];
|
|
229
|
+
// Two ticks: the parent's render → the child's props update → the
|
|
230
|
+
// child's re-render land in sequence.
|
|
231
|
+
await nextTick();
|
|
232
|
+
await nextTick();
|
|
233
|
+
const nowToday = grid.querySelector('[data-date="2025-12-02"]') as HTMLElement;
|
|
234
|
+
expect(nowToday.getAttribute("data-gap")).toBeNull();
|
|
235
|
+
expect(nowToday.getAttribute("data-level")).toBe("4");
|
|
236
|
+
} finally {
|
|
237
|
+
vi.useRealTimers();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
});
|