@@ -155,3 +155,152 @@ describe("instantToWall / wallToInstant — conversions à l'heure de Paris", ()
155155 ) . toBe ( "2026-10-24T22:00:00.000Z" ) ;
156156 } ) ;
157157} ) ;
158+
159+ // Les dimanches de changement d'heure : 2026 (29 mars, 25 octobre) et 2027
160+ // (28 mars, 31 octobre), pour ne pas dépendre d'une année.
161+ const BASCULES = [
162+ { annee : 2026 , jour : "2026-03-29" , libelle : "dim. 29 mars" , mois : "29 mars 2026" } ,
163+ { annee : 2026 , jour : "2026-10-25" , libelle : "dim. 25 oct." , mois : "25 oct. 2026" } ,
164+ { annee : 2027 , jour : "2027-03-28" , libelle : "dim. 28 mars" , mois : "28 mars 2027" } ,
165+ { annee : 2027 , jour : "2027-10-31" , libelle : "dim. 31 oct." , mois : "31 oct. 2027" } ,
166+ ] ;
167+
168+ describe ( "heure murale — le jour même des changements d'heure" , ( ) => {
169+ for ( const b of BASCULES ) {
170+ it ( `${ b . jour } : 02:30 saisie (heure inexistante ou doublée à Paris) se réaffiche 02:30` , async ( ) => {
171+ const d = utc ( `${ b . jour } T02:30:00` ) ;
172+ expect ( await sameInEveryZone ( ( m ) => m . formatWall ( d , "time" ) ) ) . toBe ( "02:30" ) ;
173+ expect ( await sameInEveryZone ( ( m ) => m . formatWall ( d , "dateTime" ) ) ) . toBe ( `${ b . mois } , 02:30` ) ;
174+ expect ( await sameInEveryZone ( ( m ) => m . toDatetimeLocal ( d ) ) ) . toBe ( `${ b . jour } T02:30` ) ;
175+ } ) ;
176+
177+ it ( `${ b . jour } : 01:00 → 04:00 reste sur un seul jour` , async ( ) => {
178+ const r = await sameInEveryZone ( ( m ) =>
179+ m . formatEventRange ( utc ( `${ b . jour } T01:00:00` ) , utc ( `${ b . jour } T04:00:00` ) ) ,
180+ ) ;
181+ expect ( r ) . toBe ( `${ b . libelle } · 01:00 – 04:00` ) ;
182+ } ) ;
183+ }
184+ } ) ;
185+
186+ describe ( "instant réel — aux bords exacts des changements d'heure" , ( ) => {
187+ const bords : [ string , string ] [ ] = [
188+ [ "2026-03-29T00:59:59" , "29 mars 2026, 01:59" ] ,
189+ [ "2026-03-29T01:00:00" , "29 mars 2026, 03:00" ] ,
190+ [ "2026-10-25T00:59:59" , "25 oct. 2026, 02:59" ] ,
191+ [ "2026-10-25T01:00:00" , "25 oct. 2026, 02:00" ] ,
192+ [ "2027-03-28T00:59:59" , "28 mars 2027, 01:59" ] ,
193+ [ "2027-03-28T01:00:00" , "28 mars 2027, 03:00" ] ,
194+ [ "2027-10-31T00:59:59" , "31 oct. 2027, 02:59" ] ,
195+ [ "2027-10-31T01:00:00" , "31 oct. 2027, 02:00" ] ,
196+ ] ;
197+ for ( const [ iso , attendu ] of bords ) {
198+ it ( `${ iso } Z → ${ attendu } (date et heure de Paris)` , async ( ) => {
199+ expect ( await sameInEveryZone ( ( m ) => m . formatInstant ( utc ( iso ) , "dateTime" ) ) ) . toBe ( attendu ) ;
200+ } ) ;
201+ }
202+ } ) ;
203+
204+ describe ( "toutes les clés de DateStyle — texte rendu figé" , ( ) => {
205+ // `satisfies` : ajouter un style à DateStyle sans l'ajouter ici casse le typecheck.
206+ const murale = {
207+ time : "15:46" ,
208+ dayMonth : "02 oct." ,
209+ dayMonthLong : "02 octobre" ,
210+ dayMonthNumeric : "02/10" ,
211+ date : "02 oct. 2026" ,
212+ dateLong : "02 octobre 2026" ,
213+ dateNumeric : "02/10/2026" ,
214+ dayMonthTime : "02 oct., 15:46" ,
215+ dayMonthLongTime : "02 octobre à 15:46" ,
216+ dayMonthNumericTime : "02/10 15:46" ,
217+ dateTime : "02 oct. 2026, 15:46" ,
218+ weekdayDayMonth : "ven. 02 oct." ,
219+ monthYear : "octobre 2026" ,
220+ } satisfies Record < import ( "./datetime" ) . DateStyle , string > ;
221+
222+ // 08:05 UTC en été = 10:05 à Paris.
223+ const instant = {
224+ time : "10:05" ,
225+ dayMonth : "14 juil." ,
226+ dayMonthLong : "14 juillet" ,
227+ dayMonthNumeric : "14/07" ,
228+ date : "14 juil. 2026" ,
229+ dateLong : "14 juillet 2026" ,
230+ dateNumeric : "14/07/2026" ,
231+ dayMonthTime : "14 juil., 10:05" ,
232+ dayMonthLongTime : "14 juillet à 10:05" ,
233+ dayMonthNumericTime : "14/07 10:05" ,
234+ dateTime : "14 juil. 2026, 10:05" ,
235+ weekdayDayMonth : "mar. 14 juil." ,
236+ monthYear : "juillet 2026" ,
237+ } satisfies Record < import ( "./datetime" ) . DateStyle , string > ;
238+
239+ it ( "heure murale (formatWall), sous UTC, Paris et New York" , async ( ) => {
240+ const d = utc ( "2026-10-02T15:46:00" ) ;
241+ const rendu = await sameInEveryZone ( ( m ) =>
242+ Object . fromEntries ( Object . keys ( murale ) . map ( ( s ) => [ s , m . formatWall ( d , s as never ) ] ) ) ,
243+ ) ;
244+ expect ( rendu ) . toEqual ( murale ) ;
245+ } ) ;
246+
247+ it ( "instant réel (formatInstant), sous UTC, Paris et New York" , async ( ) => {
248+ const d = utc ( "2026-07-14T08:05:00" ) ;
249+ const rendu = await sameInEveryZone ( ( m ) =>
250+ Object . fromEntries ( Object . keys ( instant ) . map ( ( s ) => [ s , m . formatInstant ( d , s as never ) ] ) ) ,
251+ ) ;
252+ expect ( rendu ) . toEqual ( instant ) ;
253+ } ) ;
254+ } ) ;
255+
256+ describe ( "wallToInstant(instantToWall(x)) = x" , ( ) => {
257+ // Hors heure inexistante (jamais produite par instantToWall) et hors première
258+ // occurrence de l'heure doublée (00:00–00:59:59 UTC le jour du passage à
259+ // l'heure d'hiver, voir le test suivant).
260+ const echantillon = [
261+ "2026-01-15T08:05:00.123" ,
262+ "2026-07-14T08:05:00" ,
263+ "2026-03-28T23:59:59" , // veille de bascule
264+ "2026-03-29T00:30:00" , // 01:30 à Paris, juste avant le saut
265+ "2026-03-29T00:59:59" , // 01:59:59, dernière seconde avant le saut
266+ "2026-03-29T01:00:00" , // 03:00, première seconde après le saut
267+ "2026-03-29T01:30:00" ,
268+ "2026-10-24T23:59:59" , // 01:59:59 à Paris, avant le doublon
269+ "2026-10-25T01:00:00" , // 02:00 (heure d'hiver), début de la 2e occurrence
270+ "2026-10-25T01:30:00" , // 02:30, 2e occurrence
271+ "2026-10-25T02:00:00" ,
272+ "2027-03-28T00:30:00" ,
273+ "2027-03-28T01:00:00" ,
274+ "2027-03-28T01:30:00" ,
275+ "2027-10-30T23:59:59" ,
276+ "2027-10-31T01:30:00" ,
277+ "2027-10-31T02:00:00" ,
278+ ] ;
279+ it ( "aller-retour exact sur l'échantillon, dont les deux nuits de bascule" , async ( ) => {
280+ for ( const iso of echantillon ) {
281+ const x = new Date ( `${ iso } ${ iso . includes ( "." ) ? "" : ".000" } Z` ) ;
282+ const retour = await sameInEveryZone ( ( m ) => m . wallToInstant ( m . instantToWall ( x ) ) . toISOString ( ) ) ;
283+ expect ( retour , iso ) . toBe ( x . toISOString ( ) ) ;
284+ }
285+ } ) ;
286+
287+ it ( "heure doublée du 25 octobre : 02:30 murale = la 2e occurrence (01:30Z, heure d'hiver)" , async ( ) => {
288+ // 02:30 existe deux fois à Paris (00:30Z en CEST, 01:30Z en CET) : wallToInstant
289+ // retient la seconde. Conséquence documentée : la première occurrence ne
290+ // fait pas l'aller-retour (00:30Z -> 02:30 -> 01:30Z).
291+ expect (
292+ await sameInEveryZone ( ( m ) => m . wallToInstant ( utc ( "2026-10-25T02:30:00" ) ) . toISOString ( ) ) ,
293+ ) . toBe ( "2026-10-25T01:30:00.000Z" ) ;
294+ expect (
295+ await sameInEveryZone ( ( m ) =>
296+ m . wallToInstant ( m . instantToWall ( utc ( "2026-10-25T00:30:00" ) ) ) . toISOString ( ) ,
297+ ) ,
298+ ) . toBe ( "2026-10-25T01:30:00.000Z" ) ;
299+ } ) ;
300+
301+ it ( "heure inexistante du 29 mars : 02:30 murale est décalée à 03:30 de Paris (01:30Z)" , async ( ) => {
302+ expect (
303+ await sameInEveryZone ( ( m ) => m . wallToInstant ( utc ( "2026-03-29T02:30:00" ) ) . toISOString ( ) ) ,
304+ ) . toBe ( "2026-03-29T01:30:00.000Z" ) ;
305+ } ) ;
306+ } ) ;
0 commit comments