@celestia-island/hikari 0.55.242 → 0.55.244
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/HkFaceShell.rail.test.tsx +68 -0
- package/src/components/HkSolarLocationSetting.scss +91 -0
- package/src/components/HkSolarLocationSetting.test.tsx +379 -0
- package/src/components/HkSolarLocationSetting.tsx +267 -0
- package/src/composables/useMenuGroupState.ts +11 -0
- package/src/i18n/locales/ar/platform.json +14 -0
- package/src/i18n/locales/de/platform.json +14 -0
- package/src/i18n/locales/en/platform.json +14 -0
- package/src/i18n/locales/es/platform.json +14 -0
- package/src/i18n/locales/fr/platform.json +14 -0
- package/src/i18n/locales/ja/platform.json +14 -0
- package/src/i18n/locales/ko/platform.json +14 -0
- package/src/i18n/locales/pt/platform.json +14 -0
- package/src/i18n/locales/ru/platform.json +14 -0
- package/src/i18n/locales/zh-Hans/platform.json +14 -0
- package/src/i18n/locales/zh-Hant/platform.json +14 -0
- package/src/index.ts +25 -0
- package/src/theme/index.ts +12 -2
- package/src/theme/useSolarTime.test.ts +295 -4
- package/src/theme/useSolarTime.ts +273 -27
- package/src/theme/useTheme.test.ts +19 -0
- package/src/theme/useTheme.ts +15 -1
package/package.json
CHANGED
|
@@ -33,6 +33,7 @@ vi.mock("@celestia-island/hikari", async (importOriginal) => {
|
|
|
33
33
|
|
|
34
34
|
import { HkFaceShell } from "./HkFaceShell";
|
|
35
35
|
import { HkAdminShell } from "./HkAdminShell";
|
|
36
|
+
import { provideMenuGroupState } from "../composables/useMenuGroupState";
|
|
36
37
|
import HkMenu, { type HkMenuItem } from "./HkMenu";
|
|
37
38
|
|
|
38
39
|
/**
|
|
@@ -722,6 +723,73 @@ async function settle(container: HTMLElement, key: "rail" | "full"): Promise<HTM
|
|
|
722
723
|
return generation(container, key);
|
|
723
724
|
}
|
|
724
725
|
|
|
726
|
+
describe("rail group fold — a host that OWNS the slot provides the store", () => {
|
|
727
|
+
beforeEach(() => {
|
|
728
|
+
vi.useFakeTimers();
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("carries the fold through a rail -> peek flip (chest's AdminLayout shape)", async () => {
|
|
732
|
+
// chest defines the shell's sidebar slot from AdminLayout, so the slot
|
|
733
|
+
// content's component PARENT is AdminLayout — not the shell. A provide
|
|
734
|
+
// inside HkFaceShell therefore cannot reach it (measured on chest:
|
|
735
|
+
// 0.55.240 lost the fold there while the shell's own slot kept it).
|
|
736
|
+
// The fix for that shape is the host starting the chain itself:
|
|
737
|
+
// provideMenuGroupState() in ITS setup, above the swap. This test is
|
|
738
|
+
// that shape, and it goes red if the export stops reaching slot
|
|
739
|
+
// content or if HkMenu stops injecting.
|
|
740
|
+
const container = document.createElement("div");
|
|
741
|
+
document.body.appendChild(container);
|
|
742
|
+
const Wrapper = defineComponent({
|
|
743
|
+
name: "SlotOwnerHarness",
|
|
744
|
+
setup() {
|
|
745
|
+
provideMenuGroupState();
|
|
746
|
+
const railStyle = ref<"expanded" | "rail">("rail");
|
|
747
|
+
return () =>
|
|
748
|
+
h(HkFaceShell, {
|
|
749
|
+
navTitle: "Navigation",
|
|
750
|
+
sidebarCollapsible: true,
|
|
751
|
+
sidebarRailStyle: railStyle.value,
|
|
752
|
+
"onUpdate:sidebarRailStyle": (v: string) => {
|
|
753
|
+
railStyle.value = v as "expanded" | "rail";
|
|
754
|
+
},
|
|
755
|
+
}, {
|
|
756
|
+
header: () => null,
|
|
757
|
+
sidebar: (params: { collapsed: boolean }) =>
|
|
758
|
+
h(HkMenu, {
|
|
759
|
+
variant: "sidebar",
|
|
760
|
+
items: GROUP_ITEMS,
|
|
761
|
+
open: false,
|
|
762
|
+
defaultExpandGroups: true,
|
|
763
|
+
collapsed: params.collapsed,
|
|
764
|
+
}),
|
|
765
|
+
content: () => h("div", { class: "content-mock" }, "CONTENT"),
|
|
766
|
+
});
|
|
767
|
+
},
|
|
768
|
+
});
|
|
769
|
+
const app = createApp(Wrapper);
|
|
770
|
+
app.mount(container);
|
|
771
|
+
mounts.push({ app, container });
|
|
772
|
+
|
|
773
|
+
const railGen = await settle(container, "rail");
|
|
774
|
+
expect(groupToggle(railGen).getAttribute("aria-expanded")).toBe("true");
|
|
775
|
+
groupToggle(railGen).click();
|
|
776
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
777
|
+
expect(groupToggle(railGen).getAttribute("aria-expanded")).toBe("false");
|
|
778
|
+
|
|
779
|
+
// Peek: the slot is handed full rows — a brand-new HkMenu instance.
|
|
780
|
+
container
|
|
781
|
+
.querySelector<HTMLElement>(".hk-sidebar-rail-host")!
|
|
782
|
+
.dispatchEvent(POINTER_ENTER());
|
|
783
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
784
|
+
const peekGen = await settle(container, "full");
|
|
785
|
+
expect(
|
|
786
|
+
groupToggle(peekGen).getAttribute("aria-expanded"),
|
|
787
|
+
"the host-provided store crosses into the peek's fresh generation",
|
|
788
|
+
).toBe("false");
|
|
789
|
+
expect(peekGen.textContent, "and the peek lists no children").not.toContain("Audit log");
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
|
|
725
793
|
describe("rail group fold survives the crossfade generations (#43)", () => {
|
|
726
794
|
beforeEach(() => {
|
|
727
795
|
vi.useFakeTimers();
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/* HkSolarLocationSetting — the solar-clock location strategy row and
|
|
2
|
+
* its editor modal body (see the component docblock for the strategy
|
|
3
|
+
* ladder). AI-generated: developed with the ZCode agent (model
|
|
4
|
+
* GLM-5.3); human-directed. */
|
|
5
|
+
|
|
6
|
+
/* ── The settings-page row: live summary + Edit button ────────────────── */
|
|
7
|
+
.hk-solar-setting {
|
|
8
|
+
display: flex;
|
|
9
|
+
align-items: center;
|
|
10
|
+
gap: var(--space-8, 0.5rem);
|
|
11
|
+
min-width: 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.hk-solar-summary {
|
|
15
|
+
display: inline-flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
gap: var(--space-6, 0.375rem);
|
|
18
|
+
min-width: 0;
|
|
19
|
+
flex: 1 1 auto;
|
|
20
|
+
color: rgb(var(--color-text-secondary, var(--color-muted)));
|
|
21
|
+
font-size: var(--text-sm);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.hk-solar-summary-icon {
|
|
25
|
+
display: inline-flex;
|
|
26
|
+
align-items: center;
|
|
27
|
+
justify-content: center;
|
|
28
|
+
flex-shrink: 0;
|
|
29
|
+
color: rgb(var(--color-muted));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Zone ids ("America/Argentina/Buenos_Aires") can outrun narrow side
|
|
33
|
+
* columns — the summary clips at the ellipsis, never pushes the Edit
|
|
34
|
+
* button off the row. */
|
|
35
|
+
.hk-solar-summary-text {
|
|
36
|
+
min-width: 0;
|
|
37
|
+
overflow: hidden;
|
|
38
|
+
text-overflow: ellipsis;
|
|
39
|
+
white-space: nowrap;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.hk-solar-edit {
|
|
43
|
+
flex-shrink: 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* ── Editor body (inside the modal) ───────────────────────────────────── */
|
|
47
|
+
.hk-solar-editor {
|
|
48
|
+
display: flex;
|
|
49
|
+
flex-direction: column;
|
|
50
|
+
gap: var(--space-12, 0.75rem);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Per-strategy explanation under the segmented strip — muted caption
|
|
54
|
+
* prose, same tier as the select's field label. */
|
|
55
|
+
.hk-solar-hint {
|
|
56
|
+
margin: 0;
|
|
57
|
+
color: rgb(var(--color-muted));
|
|
58
|
+
font-size: var(--text-xs);
|
|
59
|
+
line-height: 1.5;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.hk-solar-manual {
|
|
63
|
+
display: flex;
|
|
64
|
+
flex-direction: column;
|
|
65
|
+
gap: var(--space-12, 0.75rem);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.hk-solar-hemisphere {
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-direction: column;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* Field label for the hemisphere group — the SAME metrics as
|
|
74
|
+
* .hk-select-label so the two manual fields read as one form. */
|
|
75
|
+
.hk-solar-field-label {
|
|
76
|
+
display: block;
|
|
77
|
+
margin-bottom: var(--space-4, 0.25rem);
|
|
78
|
+
font-size: var(--text-xs);
|
|
79
|
+
font-weight: 600;
|
|
80
|
+
letter-spacing: 0.02em;
|
|
81
|
+
color: rgb(var(--color-text) / var(--hk-field-label-alpha, 72%));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* Resolved-coordinates readout (manual pane only) — a quiet trace of
|
|
85
|
+
* what the engine will actually feed the sun clock. */
|
|
86
|
+
.hk-solar-resolved {
|
|
87
|
+
margin: 0;
|
|
88
|
+
color: rgb(var(--color-muted));
|
|
89
|
+
font-size: var(--text-xs);
|
|
90
|
+
font-variant-numeric: tabular-nums;
|
|
91
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// AI-generated: developed with the ZCode agent (model GLM-5.3); human-directed.
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createApp, h, nextTick } from "vue";
|
|
4
|
+
|
|
5
|
+
import { HkSolarLocationSetting } from "./HkSolarLocationSetting";
|
|
6
|
+
import { setLocale } from "../i18n/context";
|
|
7
|
+
import {
|
|
8
|
+
SOLAR_LOCATION_STRATEGY_STORAGE_KEY,
|
|
9
|
+
SOLAR_MANUAL_LOCATION_STORAGE_KEY,
|
|
10
|
+
listTimeZones,
|
|
11
|
+
manualLocationOf,
|
|
12
|
+
setSolarLocationStrategy,
|
|
13
|
+
setSolarManualLocation,
|
|
14
|
+
solarLocationStrategy,
|
|
15
|
+
solarManualLocation,
|
|
16
|
+
systemTimeZoneId,
|
|
17
|
+
type SolarLocationStrategy,
|
|
18
|
+
} from "../theme/useSolarTime";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* HkSolarLocationSetting — the solar clock's location-strategy row.
|
|
22
|
+
* The strategy engine (theme/useSolarTime.ts) has its own suite; these
|
|
23
|
+
* cases pin the COMPONENT contract: the live summary row, the secondary
|
|
24
|
+
* HkModal editor (strategy tabs / IANA zone select / explicit
|
|
25
|
+
* hemisphere), OK applying through setSolarManualLocation →
|
|
26
|
+
* setSolarLocationStrategy → refreshThemeClock with storage evidence,
|
|
27
|
+
* and cancel leaving the engine untouched.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
31
|
+
const appliedEvents: SolarLocationStrategy[] = [];
|
|
32
|
+
|
|
33
|
+
const ORIGINAL_WIDTH = window.innerWidth;
|
|
34
|
+
|
|
35
|
+
function setViewportWidth(px: number): void {
|
|
36
|
+
Object.defineProperty(window, "innerWidth", {
|
|
37
|
+
configurable: true,
|
|
38
|
+
writable: true,
|
|
39
|
+
value: px,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resetEngineState(): void {
|
|
44
|
+
// Back to the offline default; manual config cleared. The setters are
|
|
45
|
+
// the engine's own reset surface (they heal refs AND storage).
|
|
46
|
+
setSolarLocationStrategy("timezone");
|
|
47
|
+
setSolarManualLocation(null);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function mountSetting(): HTMLElement {
|
|
51
|
+
const container = document.createElement("div");
|
|
52
|
+
document.body.appendChild(container);
|
|
53
|
+
const app = createApp({
|
|
54
|
+
render: () =>
|
|
55
|
+
h(HkSolarLocationSetting, {
|
|
56
|
+
onApplied: (strategy: SolarLocationStrategy) => {
|
|
57
|
+
appliedEvents.push(strategy);
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
app.mount(container);
|
|
62
|
+
mounts.push({ app, container });
|
|
63
|
+
return container;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function settle(): Promise<void> {
|
|
67
|
+
await nextTick();
|
|
68
|
+
// Surface transitions (modal enter/leave, tab swaps) resolve through
|
|
69
|
+
// short macrotask windows in happy-dom.
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
71
|
+
await nextTick();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function rowSummary(container: HTMLElement): HTMLElement {
|
|
75
|
+
const el = container.querySelector<HTMLElement>(".hk-solar-summary");
|
|
76
|
+
expect(el, "summary row renders").toBeTruthy();
|
|
77
|
+
return el!;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function editButton(container: HTMLElement): HTMLButtonElement {
|
|
81
|
+
const btn = container.querySelector<HTMLButtonElement>(".hk-solar-edit");
|
|
82
|
+
expect(btn, "edit button renders").toBeTruthy();
|
|
83
|
+
return btn!;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The modal chrome teleports to document.body (HkModal Teleport). */
|
|
87
|
+
function modalHeader(): HTMLElement | null {
|
|
88
|
+
return document.body.querySelector<HTMLElement>(".hk-modal-header");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function strategyTriggers(): HTMLButtonElement[] {
|
|
92
|
+
// The strategy strip is the DIRECT child group of the editor body —
|
|
93
|
+
// scoped so the hemisphere strip never pollutes the query.
|
|
94
|
+
return [
|
|
95
|
+
...document.body.querySelectorAll<HTMLButtonElement>(
|
|
96
|
+
".hk-solar-editor > .hk-tabs .hk-tabs-trigger",
|
|
97
|
+
),
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function hemisphereTriggers(): HTMLButtonElement[] {
|
|
102
|
+
return [
|
|
103
|
+
...document.body.querySelectorAll<HTMLButtonElement>(
|
|
104
|
+
".hk-solar-hemisphere .hk-tabs-trigger",
|
|
105
|
+
),
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function activeStrategyOf(triggers: HTMLButtonElement[]): string | undefined {
|
|
110
|
+
return triggers.find((b) => b.hasAttribute("data-active"))?.textContent ?? undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function footerButton(label: string): HTMLButtonElement {
|
|
114
|
+
const btn = [...document.body.querySelectorAll<HTMLButtonElement>(".hk-modal-footer button")]
|
|
115
|
+
.find((b) => b.textContent?.trim() === label);
|
|
116
|
+
expect(btn, `footer button "${label}" renders`).toBeTruthy();
|
|
117
|
+
return btn!;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function clickStrategyTab(label: string): void {
|
|
121
|
+
const trigger = strategyTriggers().find((b) => b.textContent?.trim() === label);
|
|
122
|
+
expect(trigger, `strategy tab "${label}" renders`).toBeTruthy();
|
|
123
|
+
trigger!.click();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function clickStrategy(key: "timezone" | "ip" | "manual"): void {
|
|
127
|
+
clickStrategyTab({ timezone: "Time zone", ip: "IP location", manual: "Manual" }[key]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Opens the manual pane's IANA zone select and picks a zone. */
|
|
131
|
+
async function pickTimeZone(zone: string): Promise<void> {
|
|
132
|
+
const trigger = document.body.querySelector<HTMLButtonElement>(".hk-select-trigger");
|
|
133
|
+
expect(trigger, "zone select trigger renders").toBeTruthy();
|
|
134
|
+
trigger!.click();
|
|
135
|
+
await settle();
|
|
136
|
+
const option = [...document.body.querySelectorAll<HTMLElement>(".hk-select-option")]
|
|
137
|
+
.find((el) => el.textContent?.trim() === zone);
|
|
138
|
+
expect(option, `zone option "${zone}" renders`).toBeTruthy();
|
|
139
|
+
option!.click();
|
|
140
|
+
await settle();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function openEditor(container: HTMLElement): Promise<void> {
|
|
144
|
+
editButton(container).click();
|
|
145
|
+
await settle();
|
|
146
|
+
expect(modalHeader(), "modal opened with a header").toBeTruthy();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
// Hermetic geo: apply flows through refreshThemeClock, whose IP leg
|
|
151
|
+
// must never reach the network in tests.
|
|
152
|
+
vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({ ok: false })));
|
|
153
|
+
// Desktop geometry: the zone select opens as an anchored popout.
|
|
154
|
+
setViewportWidth(1280);
|
|
155
|
+
appliedEvents.length = 0;
|
|
156
|
+
resetEngineState();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
afterEach(async () => {
|
|
160
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
161
|
+
app.unmount();
|
|
162
|
+
container.remove();
|
|
163
|
+
}
|
|
164
|
+
setViewportWidth(ORIGINAL_WIDTH);
|
|
165
|
+
vi.unstubAllGlobals();
|
|
166
|
+
localStorage.clear();
|
|
167
|
+
await setLocale("en");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("HkSolarLocationSetting row", () => {
|
|
171
|
+
it("renders the live summary with the strategy icon and the edit button", async () => {
|
|
172
|
+
const container = mountSetting();
|
|
173
|
+
await settle();
|
|
174
|
+
|
|
175
|
+
const summary = rowSummary(container);
|
|
176
|
+
expect(summary.getAttribute("data-strategy")).toBe("timezone");
|
|
177
|
+
// Offline default: the summary names the strategy AND the live
|
|
178
|
+
// system zone id (systemTimeZoneId), so the row is self-explaining.
|
|
179
|
+
expect(summary.textContent).toContain("Time zone");
|
|
180
|
+
expect(summary.textContent).toContain(systemTimeZoneId());
|
|
181
|
+
expect(summary.querySelector("svg"), "strategy glyph renders").toBeTruthy();
|
|
182
|
+
|
|
183
|
+
const btn = editButton(container);
|
|
184
|
+
expect(btn.textContent?.trim()).toBe("Edit");
|
|
185
|
+
// No editor in the DOM before the first open.
|
|
186
|
+
expect(document.body.querySelector(".hk-solar-editor")).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("reflects the manual strategy in the row once applied", async () => {
|
|
190
|
+
setSolarManualLocation({ timeZone: "Australia/Sydney", hemisphere: "south" });
|
|
191
|
+
setSolarLocationStrategy("manual");
|
|
192
|
+
const container = mountSetting();
|
|
193
|
+
await settle();
|
|
194
|
+
|
|
195
|
+
const summary = rowSummary(container);
|
|
196
|
+
expect(summary.getAttribute("data-strategy")).toBe("manual");
|
|
197
|
+
expect(summary.textContent).toContain("Australia/Sydney");
|
|
198
|
+
expect(summary.textContent).toContain("Southern");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("HkSolarLocationSetting editor", () => {
|
|
203
|
+
it("opens the secondary modal with the three strategy tabs and the offline-first hint", async () => {
|
|
204
|
+
const container = mountSetting();
|
|
205
|
+
await openEditor(container);
|
|
206
|
+
|
|
207
|
+
expect(modalHeader()!.textContent).toContain("Sun clock location");
|
|
208
|
+
const triggers = strategyTriggers();
|
|
209
|
+
expect(triggers).toHaveLength(3);
|
|
210
|
+
expect(triggers.map((b) => b.textContent?.trim())).toEqual([
|
|
211
|
+
"Time zone",
|
|
212
|
+
"IP location",
|
|
213
|
+
"Manual",
|
|
214
|
+
]);
|
|
215
|
+
expect(activeStrategyOf(triggers)).toBe("Time zone");
|
|
216
|
+
// Every tab carries its lucide glyph (Clock / Globe2 / MapPin).
|
|
217
|
+
for (const trigger of triggers) {
|
|
218
|
+
expect(trigger.querySelector("svg")).toBeTruthy();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const editor = document.body.querySelector<HTMLElement>(".hk-solar-editor");
|
|
222
|
+
expect(editor!.textContent).toContain("No network request, no permission prompt");
|
|
223
|
+
// Manual controls stay hidden while the draft is not manual.
|
|
224
|
+
expect(document.body.querySelector(".hk-solar-manual")).toBeNull();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("switching strategies swaps the hint and reveals the manual pane", async () => {
|
|
228
|
+
const container = mountSetting();
|
|
229
|
+
await openEditor(container);
|
|
230
|
+
|
|
231
|
+
clickStrategy("ip");
|
|
232
|
+
await settle();
|
|
233
|
+
expect(document.body.querySelector<HTMLElement>(".hk-solar-editor")!.getAttribute("data-strategy")).toBe("ip");
|
|
234
|
+
expect(document.body.querySelector<HTMLElement>(".hk-solar-editor")!.textContent)
|
|
235
|
+
.toContain("IP lookup");
|
|
236
|
+
|
|
237
|
+
clickStrategy("manual");
|
|
238
|
+
await settle();
|
|
239
|
+
const editor = document.body.querySelector<HTMLElement>(".hk-solar-editor")!;
|
|
240
|
+
expect(editor.getAttribute("data-strategy")).toBe("manual");
|
|
241
|
+
expect(document.body.querySelector(".hk-solar-manual")).toBeTruthy();
|
|
242
|
+
expect(document.body.querySelector<HTMLButtonElement>(".hk-select-trigger")).toBeTruthy();
|
|
243
|
+
const hemi = hemisphereTriggers();
|
|
244
|
+
expect(hemi.map((b) => b.textContent?.trim())).toEqual(["Northern", "Southern"]);
|
|
245
|
+
// Draft default: the system zone, northern hemisphere (explicit
|
|
246
|
+
// pick — never inferred).
|
|
247
|
+
expect(activeStrategyOf(hemi)).toBe("Northern");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("keeps the hemisphere explicit when the zone changes; the resolved readout follows the engine", async () => {
|
|
251
|
+
const container = mountSetting();
|
|
252
|
+
await openEditor(container);
|
|
253
|
+
clickStrategy("manual");
|
|
254
|
+
await settle();
|
|
255
|
+
|
|
256
|
+
await pickTimeZone("Australia/Sydney");
|
|
257
|
+
// The zone moved to the southern hemisphere's side of the globe,
|
|
258
|
+
// but the hemisphere knob is the USER's pick — it must NOT move.
|
|
259
|
+
expect(activeStrategyOf(hemisphereTriggers())).toBe("Northern");
|
|
260
|
+
|
|
261
|
+
hemisphereTriggers()[1].click();
|
|
262
|
+
await settle();
|
|
263
|
+
expect(activeStrategyOf(hemisphereTriggers())).toBe("Southern");
|
|
264
|
+
|
|
265
|
+
const resolved = document.body.querySelector<HTMLElement>('[data-test="hk-solar-resolved"]');
|
|
266
|
+
expect(resolved, "resolved-coordinates readout renders").toBeTruthy();
|
|
267
|
+
const geo = manualLocationOf({ timeZone: "Australia/Sydney", hemisphere: "south" });
|
|
268
|
+
expect(resolved!.textContent).toContain(geo.lat.toFixed(1));
|
|
269
|
+
expect(resolved!.textContent).toContain(geo.lng.toFixed(1));
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("OK applies the manual draft: engine refs, storage, inline summary, applied emit", async () => {
|
|
273
|
+
const container = mountSetting();
|
|
274
|
+
await openEditor(container);
|
|
275
|
+
clickStrategy("manual");
|
|
276
|
+
await settle();
|
|
277
|
+
await pickTimeZone("Australia/Sydney");
|
|
278
|
+
hemisphereTriggers()[1].click();
|
|
279
|
+
await settle();
|
|
280
|
+
|
|
281
|
+
footerButton("Apply").click();
|
|
282
|
+
await settle();
|
|
283
|
+
|
|
284
|
+
// Engine state moved.
|
|
285
|
+
expect(solarLocationStrategy.value).toBe("manual");
|
|
286
|
+
expect(solarManualLocation.value).toEqual({
|
|
287
|
+
timeZone: "Australia/Sydney",
|
|
288
|
+
hemisphere: "south",
|
|
289
|
+
});
|
|
290
|
+
// Persistence evidence — the exact keys the engine owns.
|
|
291
|
+
expect(localStorage.getItem(SOLAR_LOCATION_STRATEGY_STORAGE_KEY)).toBe("manual");
|
|
292
|
+
expect(JSON.parse(localStorage.getItem(SOLAR_MANUAL_LOCATION_STORAGE_KEY) ?? "null")).toEqual({
|
|
293
|
+
timeZone: "Australia/Sydney",
|
|
294
|
+
hemisphere: "south",
|
|
295
|
+
});
|
|
296
|
+
// The row summary reflects the new strategy IMMEDIATELY.
|
|
297
|
+
const summary = rowSummary(container);
|
|
298
|
+
expect(summary.getAttribute("data-strategy")).toBe("manual");
|
|
299
|
+
expect(summary.textContent).toContain("Australia/Sydney");
|
|
300
|
+
expect(summary.textContent).toContain("Southern");
|
|
301
|
+
// The host was notified.
|
|
302
|
+
expect(appliedEvents).toEqual(["manual"]);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("OK applies the timezone strategy with no manual payload written", async () => {
|
|
306
|
+
const container = mountSetting();
|
|
307
|
+
await openEditor(container);
|
|
308
|
+
clickStrategy("timezone");
|
|
309
|
+
await settle();
|
|
310
|
+
footerButton("Apply").click();
|
|
311
|
+
await settle();
|
|
312
|
+
|
|
313
|
+
expect(solarLocationStrategy.value).toBe("timezone");
|
|
314
|
+
expect(localStorage.getItem(SOLAR_LOCATION_STRATEGY_STORAGE_KEY)).toBe("timezone");
|
|
315
|
+
expect(localStorage.getItem(SOLAR_MANUAL_LOCATION_STORAGE_KEY)).toBeNull();
|
|
316
|
+
expect(appliedEvents).toEqual(["timezone"]);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("cancel discards the draft and leaves the engine untouched", async () => {
|
|
320
|
+
const container = mountSetting();
|
|
321
|
+
await openEditor(container);
|
|
322
|
+
clickStrategy("ip");
|
|
323
|
+
await settle();
|
|
324
|
+
footerButton("Cancel").click();
|
|
325
|
+
await settle();
|
|
326
|
+
|
|
327
|
+
expect(solarLocationStrategy.value).toBe("timezone");
|
|
328
|
+
expect(localStorage.getItem(SOLAR_LOCATION_STRATEGY_STORAGE_KEY)).not.toContain("ip");
|
|
329
|
+
expect(appliedEvents).toEqual([]);
|
|
330
|
+
expect(rowSummary(container).getAttribute("data-strategy")).toBe("timezone");
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("HkSolarLocationSetting i18n", () => {
|
|
335
|
+
it("flows zh-Hans messages through the row and the editor", async () => {
|
|
336
|
+
await setLocale("zh-Hans");
|
|
337
|
+
const container = mountSetting();
|
|
338
|
+
await settle();
|
|
339
|
+
|
|
340
|
+
const summary = rowSummary(container);
|
|
341
|
+
expect(summary.textContent).toContain("时区");
|
|
342
|
+
expect(editButton(container).textContent?.trim()).toBe("编辑");
|
|
343
|
+
|
|
344
|
+
await openEditor(container);
|
|
345
|
+
expect(modalHeader()!.textContent).toContain("太阳钟定位");
|
|
346
|
+
expect(strategyTriggers().map((b) => b.textContent?.trim())).toEqual([
|
|
347
|
+
"时区",
|
|
348
|
+
"IP 定位",
|
|
349
|
+
"手动",
|
|
350
|
+
]);
|
|
351
|
+
|
|
352
|
+
clickStrategyTab("手动");
|
|
353
|
+
await settle();
|
|
354
|
+
expect(hemisphereTriggers().map((b) => b.textContent?.trim())).toEqual([
|
|
355
|
+
"北半球",
|
|
356
|
+
"南半球",
|
|
357
|
+
]);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("seeds the zone list from the engine's listTimeZones catalog", async () => {
|
|
361
|
+
const container = mountSetting();
|
|
362
|
+
await openEditor(container);
|
|
363
|
+
clickStrategy("manual");
|
|
364
|
+
await settle();
|
|
365
|
+
|
|
366
|
+
const trigger = document.body.querySelector<HTMLButtonElement>(".hk-select-trigger");
|
|
367
|
+
trigger!.click();
|
|
368
|
+
await settle();
|
|
369
|
+
|
|
370
|
+
const rendered = [...document.body.querySelectorAll<HTMLElement>(".hk-select-option")]
|
|
371
|
+
.map((el) => el.textContent?.trim());
|
|
372
|
+
const catalog = listTimeZones();
|
|
373
|
+
expect(rendered.length).toBe(catalog.length);
|
|
374
|
+
expect(rendered).toContain("Asia/Shanghai");
|
|
375
|
+
// A catalog-present zone on every ICU: "UTC" itself is NOT part of
|
|
376
|
+
// Intl.supportedValuesOf("timeZone") on some ICU builds.
|
|
377
|
+
expect(rendered).toContain("Australia/Sydney");
|
|
378
|
+
});
|
|
379
|
+
});
|