@celestia-island/hikari 0.55.281 → 0.55.282
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/HkSolarLocationSetting.scss +63 -2
- package/src/components/HkSolarLocationSetting.test.tsx +260 -21
- package/src/components/HkSolarLocationSetting.tsx +263 -31
- package/src/components/HkTimeZoneGlobe.tsx +8 -10
- package/src/i18n/locales/ar/platform.json +18 -2
- package/src/i18n/locales/de/platform.json +18 -2
- package/src/i18n/locales/en/platform.json +19 -3
- package/src/i18n/locales/es/platform.json +18 -2
- package/src/i18n/locales/fr/platform.json +19 -3
- package/src/i18n/locales/ja/platform.json +18 -2
- package/src/i18n/locales/ko/platform.json +18 -2
- package/src/i18n/locales/pt/platform.json +18 -2
- package/src/i18n/locales/ru/platform.json +18 -2
- package/src/i18n/locales/zh-Hans/platform.json +19 -3
- package/src/i18n/locales/zh-Hant/platform.json +19 -3
- package/src/theme/solarTimeZones.ts +16 -0
- package/src/theme/useSolarTime.test.ts +153 -0
- package/src/theme/useSolarTime.ts +135 -8
package/package.json
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
/* HkSolarLocationSetting — the solar-clock location strategy
|
|
1
|
+
/* HkSolarLocationSetting — the solar-clock location strategy card and
|
|
2
2
|
* its editor modal body (see the component docblock for the strategy
|
|
3
3
|
* ladder). AI-generated: developed with the ZCode agent (model
|
|
4
4
|
* GLM-5.3); human-directed. */
|
|
5
5
|
|
|
6
|
-
/* ── The settings-page
|
|
6
|
+
/* ── The settings-page card (HkCard root; header/body classes are the
|
|
7
|
+
* card family's own) ─────────────────────────────────────────────── */
|
|
7
8
|
.hk-solar-setting {
|
|
9
|
+
min-width: 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/* ── The card body's live summary + Edit button line ──────────────────── */
|
|
13
|
+
.hk-solar-row {
|
|
8
14
|
display: flex;
|
|
9
15
|
align-items: center;
|
|
10
16
|
gap: var(--space-8, 0.5rem);
|
|
@@ -59,6 +65,61 @@
|
|
|
59
65
|
line-height: 1.5;
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
/* ── Detected-value readout (timezone / ip panes) ───────────────────────
|
|
69
|
+
* One info item per line: a fixed label column and a value column of
|
|
70
|
+
* copyable tags, so long values keep their own row and the two columns
|
|
71
|
+
* stay aligned across rows. */
|
|
72
|
+
.hk-solar-detected {
|
|
73
|
+
display: flex;
|
|
74
|
+
flex-direction: column;
|
|
75
|
+
gap: var(--space-8, 0.5rem);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.hk-solar-detected-row {
|
|
79
|
+
display: grid;
|
|
80
|
+
grid-template-columns: max-content minmax(0, 1fr);
|
|
81
|
+
gap: var(--space-8, 0.5rem);
|
|
82
|
+
align-items: start;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.hk-solar-detected-label {
|
|
86
|
+
padding-top: var(--space-1, 0.125rem);
|
|
87
|
+
font-size: var(--text-xs);
|
|
88
|
+
color: rgb(var(--color-muted));
|
|
89
|
+
/* Long localized labels wrap instead of pushing the value column out. */
|
|
90
|
+
overflow-wrap: anywhere;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.hk-solar-detected-value {
|
|
94
|
+
display: flex;
|
|
95
|
+
flex-wrap: wrap;
|
|
96
|
+
gap: var(--space-6, 0.375rem);
|
|
97
|
+
min-width: 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* Values are labels, not prose — but a readout value that outruns the
|
|
101
|
+
* modal soft-wraps inside its chip instead of clipping or overflowing. */
|
|
102
|
+
.hk-solar-detected-value .hk-badge {
|
|
103
|
+
white-space: normal;
|
|
104
|
+
overflow-wrap: anywhere;
|
|
105
|
+
text-align: left;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* Probe status line (IP pane): muted progress / failure note, with the
|
|
109
|
+
* retry affordance inline after the text. */
|
|
110
|
+
.hk-solar-detected-status {
|
|
111
|
+
display: flex;
|
|
112
|
+
align-items: center;
|
|
113
|
+
flex-wrap: wrap;
|
|
114
|
+
gap: var(--space-6, 0.375rem);
|
|
115
|
+
color: rgb(var(--color-muted));
|
|
116
|
+
font-size: var(--text-xs);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.hk-solar-detected-retry {
|
|
120
|
+
flex-shrink: 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
62
123
|
.hk-solar-manual {
|
|
63
124
|
display: flex;
|
|
64
125
|
flex-direction: column;
|
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { createApp, h, nextTick } from "vue";
|
|
4
4
|
|
|
5
|
-
import { HkSolarLocationSetting } from "./HkSolarLocationSetting";
|
|
5
|
+
import { HkSolarLocationSetting, solarOffsetText, solarTimezoneDetectedRows } from "./HkSolarLocationSetting";
|
|
6
6
|
import { setLocale } from "../i18n/context";
|
|
7
7
|
import {
|
|
8
8
|
SOLAR_LOCATION_STRATEGY_STORAGE_KEY,
|
|
9
9
|
SOLAR_MANUAL_LOCATION_STORAGE_KEY,
|
|
10
10
|
manualLocationOf,
|
|
11
|
+
probeSolarIpInsight,
|
|
12
|
+
resetSolarIpInsight,
|
|
11
13
|
setSolarLocationStrategy,
|
|
12
14
|
setSolarManualLocation,
|
|
13
15
|
solarLocationStrategy,
|
|
@@ -15,15 +17,36 @@ import {
|
|
|
15
17
|
systemTimeZoneId,
|
|
16
18
|
type SolarLocationStrategy,
|
|
17
19
|
} from "../theme/useSolarTime";
|
|
20
|
+
import { cityKey, sectorForZone, sectorNameKey, standardOffsetHours } from "../theme/solarTimeZones";
|
|
21
|
+
|
|
22
|
+
// The locale dictionaries, loaded the same way the locales-coverage suite
|
|
23
|
+
// reads them (import.meta.glob; readFileSync resolves wrong under vitest's
|
|
24
|
+
// transform) — the same bundles the component renders, so readout
|
|
25
|
+
// expectations stay exact on any runtime zone (CI runners sit in UTC; dev
|
|
26
|
+
// machines often sit in Asia/Shanghai).
|
|
27
|
+
const localeModules = import.meta.glob<{ default: Record<string, Record<string, string>> }>(
|
|
28
|
+
"../i18n/locales/*/platform.json",
|
|
29
|
+
{ eager: true },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/** The locale's solar strings. platform.json nests legacy domain
|
|
33
|
+
* sections ("platform", "auth" — same layout the locales-coverage
|
|
34
|
+
* suite walks); flatten them so dotted `hikari::…` keys read directly. */
|
|
35
|
+
function localeBundle(locale: string): Record<string, string> {
|
|
36
|
+
const entry = Object.entries(localeModules).find(([path]) => path.includes(`/${locale}/`));
|
|
37
|
+
expect(entry, `bundle for ${locale} loads`).toBeTruthy();
|
|
38
|
+
return Object.assign({}, ...Object.values(entry![1].default));
|
|
39
|
+
}
|
|
18
40
|
|
|
19
41
|
/**
|
|
20
|
-
* HkSolarLocationSetting — the solar clock's location-strategy
|
|
42
|
+
* HkSolarLocationSetting — the solar clock's location-strategy card.
|
|
21
43
|
* The strategy engine (theme/useSolarTime.ts) has its own suite; these
|
|
22
|
-
* cases pin the COMPONENT contract: the
|
|
23
|
-
* HkModal editor (strategy tabs /
|
|
24
|
-
* hemisphere), OK
|
|
25
|
-
*
|
|
26
|
-
* and cancel leaving the
|
|
44
|
+
* cases pin the COMPONENT contract: the standard card with the live
|
|
45
|
+
* strategy summary, the secondary HkModal editor (strategy tabs /
|
|
46
|
+
* detected-value readout / time-zone globe / explicit hemisphere), OK
|
|
47
|
+
* applying through setSolarManualLocation → setSolarLocationStrategy →
|
|
48
|
+
* refreshThemeClock with storage evidence, and cancel leaving the
|
|
49
|
+
* engine untouched.
|
|
27
50
|
*/
|
|
28
51
|
|
|
29
52
|
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
@@ -44,6 +67,7 @@ function resetEngineState(): void {
|
|
|
44
67
|
// the engine's own reset surface (they heal refs AND storage).
|
|
45
68
|
setSolarLocationStrategy("timezone");
|
|
46
69
|
setSolarManualLocation(null);
|
|
70
|
+
resetSolarIpInsight();
|
|
47
71
|
}
|
|
48
72
|
|
|
49
73
|
function mountSetting(): HTMLElement {
|
|
@@ -126,12 +150,35 @@ function clickStrategy(key: "timezone" | "ip" | "manual"): void {
|
|
|
126
150
|
clickStrategyTab({ timezone: "Time zone", ip: "IP location", manual: "Manual" }[key]);
|
|
127
151
|
}
|
|
128
152
|
|
|
153
|
+
/** The editor's detected-value readout (timezone / ip panes). */
|
|
154
|
+
function detectedSection(): HTMLElement {
|
|
155
|
+
const el = document.body.querySelector<HTMLElement>('[data-test="hk-solar-detected"]');
|
|
156
|
+
expect(el, "detected-value readout renders").toBeTruthy();
|
|
157
|
+
return el!;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Label → value rows of the readout, keyed by their label text. */
|
|
161
|
+
function detectedRows(): Map<string, string> {
|
|
162
|
+
const rows = new Map<string, string>();
|
|
163
|
+
for (const row of detectedSection().querySelectorAll<HTMLElement>(".hk-solar-detected-row")) {
|
|
164
|
+
const label = row.querySelector<HTMLElement>(".hk-solar-detected-label")?.textContent?.trim();
|
|
165
|
+
rows.set(label ?? "", row.querySelector<HTMLElement>(".hk-solar-detected-value")?.textContent ?? "");
|
|
166
|
+
}
|
|
167
|
+
return rows;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Poll budget for the manual pane's defineAsyncComponent globe chunk.
|
|
171
|
+
* Full-suite runs queue the chunk's transform behind hundreds of other
|
|
172
|
+
* files, so the wait must tolerate a starved importer (≈ 120 settled
|
|
173
|
+
* ticks ≈ 3s), not just a warm isolated run. */
|
|
174
|
+
const GLOBE_POLL_BUDGET = 120;
|
|
175
|
+
|
|
129
176
|
/** Picks a zone on the manual pane's globe (the fallback sector list in
|
|
130
177
|
* the test DOM — happy-dom has no WebGL). */
|
|
131
178
|
async function pickTimeZone(zone: string): Promise<void> {
|
|
132
179
|
// The picker is a defineAsyncComponent chunk — wait it in.
|
|
133
180
|
let option: HTMLButtonElement | undefined;
|
|
134
|
-
for (let i = 0; i <
|
|
181
|
+
for (let i = 0; i < GLOBE_POLL_BUDGET && !option; i++) {
|
|
135
182
|
option = document.body.querySelector<HTMLButtonElement>(
|
|
136
183
|
'[data-test="hk-timezone-globe-option"][data-zone="' + zone + '"]',
|
|
137
184
|
) ?? undefined;
|
|
@@ -169,17 +216,17 @@ afterEach(async () => {
|
|
|
169
216
|
await setLocale("en");
|
|
170
217
|
});
|
|
171
218
|
|
|
172
|
-
describe("HkSolarLocationSetting
|
|
173
|
-
it("renders the
|
|
219
|
+
describe("HkSolarLocationSetting card", () => {
|
|
220
|
+
it("renders the standardized summary with the strategy icon and the edit button", async () => {
|
|
174
221
|
const container = mountSetting();
|
|
175
222
|
await settle();
|
|
176
223
|
|
|
177
224
|
const summary = rowSummary(container);
|
|
178
225
|
expect(summary.getAttribute("data-strategy")).toBe("timezone");
|
|
179
|
-
// Offline default: the summary names the
|
|
180
|
-
//
|
|
181
|
-
expect(summary.textContent).toContain("
|
|
182
|
-
expect(summary.textContent).toContain(systemTimeZoneId());
|
|
226
|
+
// Offline default: the summary names the POLICY in a fixed shape —
|
|
227
|
+
// no zone id in the summary (that lives in the editor's readout).
|
|
228
|
+
expect(summary.textContent).toContain("Auto · By time zone");
|
|
229
|
+
expect(summary.textContent).not.toContain(systemTimeZoneId());
|
|
183
230
|
expect(summary.querySelector("svg"), "strategy glyph renders").toBeTruthy();
|
|
184
231
|
|
|
185
232
|
const btn = editButton(container);
|
|
@@ -188,6 +235,38 @@ describe("HkSolarLocationSetting row", () => {
|
|
|
188
235
|
expect(document.body.querySelector(".hk-solar-editor")).toBeNull();
|
|
189
236
|
});
|
|
190
237
|
|
|
238
|
+
it("is a standard hikari card: localized default title, overridable, hideable", async () => {
|
|
239
|
+
const container = mountSetting();
|
|
240
|
+
await settle();
|
|
241
|
+
|
|
242
|
+
const card = container.querySelector<HTMLElement>(".hk-card.hk-solar-setting");
|
|
243
|
+
expect(card, "the row renders as an HkCard").toBeTruthy();
|
|
244
|
+
const title = card!.querySelector<HTMLElement>(".hk-card-title");
|
|
245
|
+
expect(title?.textContent?.trim()).toBe("Daylight location");
|
|
246
|
+
|
|
247
|
+
// An explicit title replaces the default; an empty string hides the
|
|
248
|
+
// header (hosts that label their own section around the card).
|
|
249
|
+
for (const [titleProp, expectedHeader] of [
|
|
250
|
+
["Custom title", "Custom title"],
|
|
251
|
+
["", null],
|
|
252
|
+
] as const) {
|
|
253
|
+
const host = document.createElement("div");
|
|
254
|
+
document.body.appendChild(host);
|
|
255
|
+
const app = createApp({
|
|
256
|
+
render: () => h(HkSolarLocationSetting, { title: titleProp }),
|
|
257
|
+
});
|
|
258
|
+
app.mount(host);
|
|
259
|
+
mounts.push({ app, container: host });
|
|
260
|
+
await settle();
|
|
261
|
+
const header = host.querySelector(".hk-card-header");
|
|
262
|
+
if (expectedHeader === null) {
|
|
263
|
+
expect(header, "empty title hides the header").toBeNull();
|
|
264
|
+
} else {
|
|
265
|
+
expect(header?.textContent?.trim()).toBe(expectedHeader);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
191
270
|
it("reflects the manual strategy in the row once applied", async () => {
|
|
192
271
|
setSolarManualLocation({ timeZone: "Australia/Sydney", hemisphere: "south" });
|
|
193
272
|
setSolarLocationStrategy("manual");
|
|
@@ -196,13 +275,77 @@ describe("HkSolarLocationSetting row", () => {
|
|
|
196
275
|
|
|
197
276
|
const summary = rowSummary(container);
|
|
198
277
|
expect(summary.getAttribute("data-strategy")).toBe("manual");
|
|
199
|
-
expect(summary.textContent).toContain("Australia/Sydney");
|
|
200
|
-
|
|
278
|
+
expect(summary.textContent).toContain("Manual · Australia/Sydney · Southern");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("never probes on mount or on the timezone pane — the offline-first contract", async () => {
|
|
282
|
+
// The strategy card's own promise: a fresh environment must not
|
|
283
|
+
// issue a network request (nor touch permissions) until the user
|
|
284
|
+
// deliberately enters the IP pane (or applies the ip strategy).
|
|
285
|
+
const fetchMock = vi.mocked(globalThis.fetch);
|
|
286
|
+
const container = mountSetting();
|
|
287
|
+
await settle();
|
|
288
|
+
expect(fetchMock, "mounting the card stays offline").not.toHaveBeenCalled();
|
|
289
|
+
|
|
290
|
+
await openEditor(container);
|
|
291
|
+
await settle();
|
|
292
|
+
expect(fetchMock, "opening the editor on the timezone pane stays offline").not.toHaveBeenCalled();
|
|
293
|
+
|
|
294
|
+
// Switching away and back (timezone ⇄ manual) never probes either.
|
|
295
|
+
clickStrategy("manual");
|
|
296
|
+
await settle();
|
|
297
|
+
clickStrategy("timezone");
|
|
298
|
+
await settle();
|
|
299
|
+
expect(fetchMock, "timezone ⇄ manual switches stay offline").not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("solarTimezoneDetectedRows + solarOffsetText (pure builders)", () => {
|
|
304
|
+
const en = localeBundle("en");
|
|
305
|
+
const translate = (key: string): string => en[key] ?? key;
|
|
306
|
+
|
|
307
|
+
function rowOf(rows: ReturnType<typeof solarTimezoneDetectedRows>, label: string): string | undefined {
|
|
308
|
+
return rows.find((row) => row.label === label)?.badges[0]?.text;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
it("bands + example cities only for zones whose offset IS a wheel sector", () => {
|
|
312
|
+
const rows = solarTimezoneDetectedRows("Asia/Shanghai", translate);
|
|
313
|
+
expect(rowOf(rows, "Time zone ID")).toBe("Asia/Shanghai");
|
|
314
|
+
expect(rowOf(rows, "Time zone")).toBe(en["hikari::theme.solar.sector.p8"]);
|
|
315
|
+
expect(rowOf(rows, "Example cities")).toContain(en["hikari::theme.solar.city.shanghai"]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("falls back to the exact offset outside the wheel — never a mislabeled band", () => {
|
|
319
|
+
// Half-hour zone: no whole-hour band.
|
|
320
|
+
const kolkata = solarTimezoneDetectedRows("Asia/Kolkata", translate);
|
|
321
|
+
expect(rowOf(kolkata, "UTC offset")).toBe("UTC+05:30");
|
|
322
|
+
// Whole-hour zones OUTSIDE the −11…+12 wheel: +13 (Samoa/Tonga/Line
|
|
323
|
+
// Islands) and POSIX-inverted Etc/GMT+12 (standard −12) must not
|
|
324
|
+
// borrow the nearest sector's band or cities.
|
|
325
|
+
for (const zone of ["Pacific/Apia", "Pacific/Kiritimati", "Etc/GMT+12"]) {
|
|
326
|
+
const rows = solarTimezoneDetectedRows(zone, translate);
|
|
327
|
+
expect(rows.find((row) => row.label === "Time zone"), zone).toBeUndefined();
|
|
328
|
+
expect(rows.find((row) => row.label === "Example cities"), zone).toBeUndefined();
|
|
329
|
+
expect(rowOf(rows, "UTC offset"), zone).toMatch(/^UTC[+-]\d{2}:\d{2}$/);
|
|
330
|
+
}
|
|
331
|
+
expect(rowOf(solarTimezoneDetectedRows("Pacific/Apia", translate), "UTC offset")).toBe("UTC+13:00");
|
|
332
|
+
expect(rowOf(solarTimezoneDetectedRows("Etc/GMT+12", translate), "UTC offset")).toBe("UTC-12:00");
|
|
333
|
+
expect(rowOf(solarTimezoneDetectedRows("Pacific/Kiritimati", translate), "UTC offset")).toBe("UTC+14:00");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("offsetText pins the exact sign and fraction formatting", () => {
|
|
337
|
+
expect(solarOffsetText(0)).toBe("UTC");
|
|
338
|
+
expect(solarOffsetText(8)).toBe("UTC+08:00");
|
|
339
|
+
expect(solarOffsetText(5.5)).toBe("UTC+05:30");
|
|
340
|
+
expect(solarOffsetText(5.75)).toBe("UTC+05:45");
|
|
341
|
+
expect(solarOffsetText(12.75)).toBe("UTC+12:45");
|
|
342
|
+
expect(solarOffsetText(-3.5)).toBe("UTC-03:30");
|
|
343
|
+
expect(solarOffsetText(-12)).toBe("UTC-12:00");
|
|
201
344
|
});
|
|
202
345
|
});
|
|
203
346
|
|
|
204
347
|
describe("HkSolarLocationSetting editor", () => {
|
|
205
|
-
it("opens the secondary modal with the three strategy tabs and the
|
|
348
|
+
it("opens the secondary modal with the three strategy tabs, the principle hint, and the detected readout", async () => {
|
|
206
349
|
const container = mountSetting();
|
|
207
350
|
await openEditor(container);
|
|
208
351
|
|
|
@@ -221,9 +364,89 @@ describe("HkSolarLocationSetting editor", () => {
|
|
|
221
364
|
}
|
|
222
365
|
|
|
223
366
|
const editor = document.body.querySelector<HTMLElement>(".hk-solar-editor");
|
|
224
|
-
expect(editor!.textContent).toContain("
|
|
367
|
+
expect(editor!.textContent).toContain("no network request and no permission prompt");
|
|
225
368
|
// Manual controls stay hidden while the draft is not manual.
|
|
226
369
|
expect(document.body.querySelector(".hk-solar-manual")).toBeNull();
|
|
370
|
+
|
|
371
|
+
// The detected-value readout: one info item per line, each value a
|
|
372
|
+
// copyable badge — the device zone id, then the translated band and
|
|
373
|
+
// example cities (whole-hour zones) or the exact UTC offset
|
|
374
|
+
// (half-hour zones, which have no whole-hour band).
|
|
375
|
+
const zone = systemTimeZoneId();
|
|
376
|
+
const sector = sectorForZone(zone);
|
|
377
|
+
const en = localeBundle("en");
|
|
378
|
+
const rows = detectedRows();
|
|
379
|
+
expect(rows.get("Time zone ID")).toContain(zone);
|
|
380
|
+
if (sector && sector.offset === standardOffsetHours(zone)) {
|
|
381
|
+
expect(rows.get("Time zone")).toContain(en[sectorNameKey(sector.offset)]!);
|
|
382
|
+
expect(rows.get("Example cities")).toContain(en[cityKey(sector.cities[0]!)]!);
|
|
383
|
+
} else {
|
|
384
|
+
expect(rows.get("UTC offset")).toMatch(/UTC[+-]\d{2}:\d{2}/);
|
|
385
|
+
expect(rows.has("Example cities")).toBe(false);
|
|
386
|
+
}
|
|
387
|
+
for (const badge of detectedSection().querySelectorAll<HTMLElement>(".hk-badge.hk-copy-badge")) {
|
|
388
|
+
expect(badge.getAttribute("role"), "values are copyable tag buttons").toBe("button");
|
|
389
|
+
}
|
|
390
|
+
expect(detectedSection().querySelector(".hk-solar-detected-status")).toBeNull();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("the ip pane probes on entry, shows a retry affordance on failure, and lists the insight", async () => {
|
|
394
|
+
const container = mountSetting();
|
|
395
|
+
await openEditor(container);
|
|
396
|
+
clickStrategy("ip");
|
|
397
|
+
await settle();
|
|
398
|
+
|
|
399
|
+
// The suite's fetch stub answers { ok: false } — the probe fails.
|
|
400
|
+
await vi.waitFor(() => {
|
|
401
|
+
expect(detectedSection().querySelector('[data-state="failed"]')).toBeTruthy();
|
|
402
|
+
});
|
|
403
|
+
const retry = detectedSection().querySelector<HTMLButtonElement>(".hk-solar-detected-retry");
|
|
404
|
+
expect(retry, "the failed probe offers a retry").toBeTruthy();
|
|
405
|
+
expect(retry!.textContent?.trim()).toBe("Retry");
|
|
406
|
+
|
|
407
|
+
// A successful re-probe fills the readout: one valid info item per
|
|
408
|
+
// line, all copyable.
|
|
409
|
+
vi.stubGlobal("fetch", vi.fn(async () => ({
|
|
410
|
+
ok: true,
|
|
411
|
+
json: async () => ({
|
|
412
|
+
ip: "203.0.113.7",
|
|
413
|
+
asn: "AS4134",
|
|
414
|
+
org: "Chinanet",
|
|
415
|
+
country_name: "China",
|
|
416
|
+
region: "Shanghai",
|
|
417
|
+
city: "Shanghai",
|
|
418
|
+
timezone: "Asia/Shanghai",
|
|
419
|
+
}),
|
|
420
|
+
})));
|
|
421
|
+
retry!.click();
|
|
422
|
+
await settle();
|
|
423
|
+
await vi.waitFor(() => {
|
|
424
|
+
expect(detectedSection().querySelector('[data-state="failed"]')).toBeNull();
|
|
425
|
+
});
|
|
426
|
+
const rows = detectedRows();
|
|
427
|
+
expect(rows.get("IP address")).toContain("203.0.113.7");
|
|
428
|
+
expect(rows.get("Autonomous system")).toContain("AS4134");
|
|
429
|
+
expect(rows.get("Organization")).toContain("Chinanet");
|
|
430
|
+
expect(rows.get("Network region")).toContain("China · Shanghai · Shanghai");
|
|
431
|
+
expect(rows.get("IP time zone")).toContain("Asia/Shanghai");
|
|
432
|
+
// Long prose values render as soft-wrapping chips (not pills).
|
|
433
|
+
const regionBadge = [...detectedSection().querySelectorAll<HTMLElement>(".hk-badge")]
|
|
434
|
+
.find((badge) => badge.textContent?.includes("China · Shanghai"));
|
|
435
|
+
expect(regionBadge?.getAttribute("data-pill")).toBe("false");
|
|
436
|
+
|
|
437
|
+
// A failed REFRESH keeps the last known values on the rows — the
|
|
438
|
+
// status copy must say "refresh failed", not contradict them with
|
|
439
|
+
// the first-probe failure text. (With state=ok the readout shows no
|
|
440
|
+
// status line at all; the forced engine probe is exactly what the
|
|
441
|
+
// retry button would drive.)
|
|
442
|
+
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false })));
|
|
443
|
+
await probeSolarIpInsight(true);
|
|
444
|
+
await vi.waitFor(() => {
|
|
445
|
+
expect(detectedSection().querySelector('[data-state="failed"]')).toBeTruthy();
|
|
446
|
+
});
|
|
447
|
+
expect(detectedSection().textContent).toContain("Refresh failed — showing the last known values");
|
|
448
|
+
expect(detectedRows().get("IP address")).toContain("203.0.113.7");
|
|
449
|
+
expect(detectedSection().querySelector(".hk-solar-detected-retry")).toBeTruthy();
|
|
227
450
|
});
|
|
228
451
|
|
|
229
452
|
it("switching strategies swaps the hint and reveals the manual pane", async () => {
|
|
@@ -243,7 +466,7 @@ describe("HkSolarLocationSetting editor", () => {
|
|
|
243
466
|
expect(document.body.querySelector(".hk-solar-manual")).toBeTruthy();
|
|
244
467
|
// The globe is a defineAsyncComponent chunk — wait it in.
|
|
245
468
|
let globe: HTMLElement | null = null;
|
|
246
|
-
for (let i = 0; i <
|
|
469
|
+
for (let i = 0; i < GLOBE_POLL_BUDGET && !globe; i++) {
|
|
247
470
|
globe = document.body.querySelector<HTMLElement>('[data-test="hk-timezone-globe"]');
|
|
248
471
|
if (!globe) await settle();
|
|
249
472
|
}
|
|
@@ -340,13 +563,16 @@ describe("HkSolarLocationSetting editor", () => {
|
|
|
340
563
|
});
|
|
341
564
|
|
|
342
565
|
describe("HkSolarLocationSetting i18n", () => {
|
|
343
|
-
it("flows zh-Hans messages through the
|
|
566
|
+
it("flows zh-Hans messages through the card and the editor", async () => {
|
|
344
567
|
await setLocale("zh-Hans");
|
|
345
568
|
const container = mountSetting();
|
|
346
569
|
await settle();
|
|
347
570
|
|
|
571
|
+
// The card's localized default title and the standardized summary.
|
|
572
|
+
const card = container.querySelector<HTMLElement>(".hk-card.hk-solar-setting");
|
|
573
|
+
expect(card?.querySelector(".hk-card-title")?.textContent?.trim()).toBe("日照位置");
|
|
348
574
|
const summary = rowSummary(container);
|
|
349
|
-
expect(summary.textContent).toContain("
|
|
575
|
+
expect(summary.textContent).toContain("自动 · 基于时区");
|
|
350
576
|
expect(editButton(container).textContent?.trim()).toBe("编辑");
|
|
351
577
|
|
|
352
578
|
await openEditor(container);
|
|
@@ -356,6 +582,19 @@ describe("HkSolarLocationSetting i18n", () => {
|
|
|
356
582
|
"IP 定位",
|
|
357
583
|
"手动",
|
|
358
584
|
]);
|
|
585
|
+
// The detected readout in zh-Hans: the translated band and example
|
|
586
|
+
// cities (whole-hour zones) or the exact offset (half-hour zones).
|
|
587
|
+
const zone = systemTimeZoneId();
|
|
588
|
+
const sector = sectorForZone(zone);
|
|
589
|
+
const zh = localeBundle("zh-Hans");
|
|
590
|
+
const rows = detectedRows();
|
|
591
|
+
expect(rows.get("时区 ID")).toContain(zone);
|
|
592
|
+
if (sector && sector.offset === standardOffsetHours(zone)) {
|
|
593
|
+
expect(rows.get("时区")).toContain(zh[sectorNameKey(sector.offset)]!);
|
|
594
|
+
expect(rows.get("代表城市")).toContain(zh[cityKey(sector.cities[0]!)]!);
|
|
595
|
+
} else {
|
|
596
|
+
expect(rows.get("UTC 偏移")).toMatch(/UTC[+-]\d{2}:\d{2}/);
|
|
597
|
+
}
|
|
359
598
|
|
|
360
599
|
clickStrategyTab("手动");
|
|
361
600
|
await settle();
|