@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.281",
3
+ "version": "0.55.282",
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",
@@ -1,10 +1,16 @@
1
- /* HkSolarLocationSetting — the solar-clock location strategy row and
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 row: live summary + Edit button ────────────────── */
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 row.
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 live summary row, the secondary
23
- * HkModal editor (strategy tabs / time-zone globe / explicit
24
- * hemisphere), OK applying through setSolarManualLocation →
25
- * setSolarLocationStrategy → refreshThemeClock with storage evidence,
26
- * and cancel leaving the engine untouched.
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 < 20 && !option; 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 row", () => {
173
- it("renders the live summary with the strategy icon and the edit button", async () => {
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 strategy AND the live
180
- // system zone id (systemTimeZoneId), so the row is self-explaining.
181
- expect(summary.textContent).toContain("Time zone");
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
- expect(summary.textContent).toContain("Southern");
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 offline-first hint", async () => {
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("No network request, no permission prompt");
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 < 20 && !globe; 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 row and the editor", async () => {
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();