@junoflow/testkit 0.6.0 → 0.9.0

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/src/time.js ADDED
@@ -0,0 +1,131 @@
1
+ import { fail } from './assert.js';
2
+
3
+ /**
4
+ * ctx.now()'s TimeResult for one instant in one timezone: a copy of buildNow in
5
+ * sdk/src/context.ts, which the testkit cannot import (the SDK is not
6
+ * published). test/time.test.js pins it against values captured from the SDK's
7
+ * own buildNow, so a change there that is not made here fails the table.
8
+ */
9
+ export function timeResult(instantMs, timezone) {
10
+ const d = new Date(instantMs);
11
+ const fmt = new Intl.DateTimeFormat('en-US', {
12
+ timeZone: timezone,
13
+ hour: 'numeric',
14
+ minute: 'numeric',
15
+ second: 'numeric',
16
+ weekday: 'short',
17
+ month: 'numeric',
18
+ day: 'numeric',
19
+ // h23, as the SDK asks: hour12:false selects h24 in some ICU builds and
20
+ // renders midnight as 24.
21
+ hourCycle: 'h23',
22
+ });
23
+ const parts = Object.fromEntries(
24
+ fmt.formatToParts(d).map((p) => [p.type, p.value])
25
+ );
26
+ const weekdayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
27
+ const hour = parseInt(parts.hour, 10);
28
+ const minute = parseInt(parts.minute, 10);
29
+ return {
30
+ iso: d.toISOString(),
31
+ timezone,
32
+ hour,
33
+ minute,
34
+ second: parseInt(parts.second, 10),
35
+ minute_of_day: hour * 60 + minute,
36
+ weekday: weekdayMap[parts.weekday] ?? 0,
37
+ month: parseInt(parts.month, 10),
38
+ day: parseInt(parts.day, 10),
39
+ };
40
+ }
41
+
42
+ /** Fails unless Intl knows the zone; the site setting is an IANA name. */
43
+ export function assertTimezone(timezone) {
44
+ try {
45
+ new Intl.DateTimeFormat('en-US', { timeZone: timezone });
46
+ } catch {
47
+ fail(
48
+ `h.timezone(${JSON.stringify(timezone)}) — not an IANA timezone name (e.g. "Europe/Stockholm", "UTC")`
49
+ );
50
+ }
51
+ }
52
+
53
+ const WALL =
54
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/;
55
+ const OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/i;
56
+
57
+ /** Fails unless iso is something resolveInstant can read. */
58
+ export function assertISO(iso) {
59
+ if (typeof iso === 'string' && OFFSET.test(iso) && !isNaN(Date.parse(iso)))
60
+ return;
61
+ if (typeof iso === 'string' && WALL.test(iso)) return;
62
+ fail(
63
+ `h.now(${JSON.stringify(iso)}) — expected an ISO 8601 date-time: wall time in h.timezone() ` +
64
+ `("2026-10-04T18:42:00") or an instant with an offset ("2026-10-04T16:42:00Z")`
65
+ );
66
+ }
67
+
68
+ /**
69
+ * The instant h.now(iso) names. With an offset it is that instant. Without one
70
+ * it is a wall-clock time in `timezone`, resolved when the handler asks rather
71
+ * than when the test sets it, so `h.now(…).timezone(…)` reads in either order.
72
+ *
73
+ * A wall time the zone skips (spring forward) or passes twice (fall back) is
74
+ * refused rather than guessed: a test about the DST night is exactly the test
75
+ * that must say which instant it means.
76
+ */
77
+ export function resolveInstant(iso, timezone) {
78
+ if (OFFSET.test(iso)) return Date.parse(iso);
79
+ const [, y, mo, d, h, mi, s = '0', ms = '0'] = WALL.exec(iso);
80
+ const wall = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s, +ms.padEnd(3, '0'));
81
+
82
+ // The zone's offset a day either side of the wall time covers both sides of
83
+ // any transition near it; each offset proposes one instant, and only those
84
+ // whose wall time reads back as asked are real.
85
+ const day = 24 * 60 * 60 * 1000;
86
+ const candidates = new Set();
87
+ for (const probe of [wall - day, wall, wall + day]) {
88
+ const t = wall - offsetAt(probe, timezone);
89
+ if (wallAt(t, timezone) === wall) candidates.add(t);
90
+ }
91
+ const found = [...candidates].sort((a, b) => a - b);
92
+ if (found.length === 1) return found[0];
93
+ const hint = found.length
94
+ ? `it happens twice there (${found.map((t) => new Date(t).toISOString()).join(' and ')})`
95
+ : 'the clocks skip it there';
96
+ fail(
97
+ `h.now(${JSON.stringify(iso)}) is not one instant in ${timezone}: ${hint} — ` +
98
+ 'give it an offset, e.g. h.now("2026-10-25T02:30:00+02:00")'
99
+ );
100
+ }
101
+
102
+ /** The wall-clock reading at instant t in timezone, as a UTC millisecond count. */
103
+ function wallAt(t, timezone) {
104
+ const parts = Object.fromEntries(
105
+ new Intl.DateTimeFormat('en-US', {
106
+ timeZone: timezone,
107
+ year: 'numeric',
108
+ month: 'numeric',
109
+ day: 'numeric',
110
+ hour: 'numeric',
111
+ minute: 'numeric',
112
+ second: 'numeric',
113
+ hourCycle: 'h23',
114
+ })
115
+ .formatToParts(new Date(t))
116
+ .map((p) => [p.type, p.value])
117
+ );
118
+ return Date.UTC(
119
+ +parts.year,
120
+ +parts.month - 1,
121
+ +parts.day,
122
+ +parts.hour,
123
+ +parts.minute,
124
+ +parts.second,
125
+ new Date(t).getUTCMilliseconds()
126
+ );
127
+ }
128
+
129
+ function offsetAt(t, timezone) {
130
+ return wallAt(t, timezone) - t;
131
+ }