fuzzyworld

when-am-i / files / src / utils / shuffle.test.ts

shuffle.test.ts

Blame: the commit that last changed each line · file · history

3adbd147 2026-06-20 Pavel Safronov1import { describe, it, expect } from 'vitest';
2import { pickRandomScene } from './shuffle';
3import { pointInPolygon } from './geo';
4import { findEras } from './eras';
5import { toHistoricalDate } from './historicalDate';
6import type { CalendarRegion, Geometry } from '../types';
7
8// A 10×10 square from (0,0) to (10,10) in [lng, lat].
9const square: Geometry = {
10 type: 'Polygon',
11 coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]],
12};
13
14const regions: CalendarRegion[] = [
15 {
16 region: { name: 'Square', geo: square },
17 eras: [
18 { name: 'Julian era', start: '1600-01-01', end: '1700-01-01', calendar: 'Julian' },
19 // A Gregorian era must never be chosen — it is the default shuffle avoids.
20 { name: 'Modern', start: '1700-01-01', end: null, calendar: 'Gregorian' },
21 ],
22 },
23];
24
25// A deterministic rng cycling through fixed values.
26function seq(values: number[]): () => number {
27 let i = 0;
28 return () => values[i++ % values.length];
29}
30
38f135ad 2026-06-21 Pavel Safronov31// Two calendars where the first (Julian) is used by *more* eras than the second
32// (Persian) — to prove selection is per-calendar, not weighted by era count.
33const twoCalendars: CalendarRegion[] = [
34 {
35 region: { name: 'A', geo: square },
36 eras: [
37 { name: 'J1', start: '1600-01-01', end: '1650-01-01', calendar: 'Julian' },
38 { name: 'J2', start: '1650-01-01', end: '1700-01-01', calendar: 'Julian' },
39 { name: 'J3', start: '1700-01-01', end: '1750-01-01', calendar: 'Julian' },
40 { name: 'P1', start: '1600-01-01', end: '1700-01-01', calendar: 'Persian' },
41 ],
42 },
43];
44
3adbd147 2026-06-20 Pavel Safronov45describe('pickRandomScene', () => {
46 it('returns null when the dataset has only Gregorian (default) eras', () => {
47 const onlyGregorian: CalendarRegion[] = [
48 { region: { name: 'G', geo: square }, eras: [{ name: 'M', start: null, end: null, calendar: 'Gregorian' }] },
49 ];
50 expect(pickRandomScene(onlyGregorian, seq([0.5]))).toBeNull();
51 });
52
53 it('lands on a non-Gregorian era, inside the region, within the era span', () => {
54 const scene = pickRandomScene(regions, seq([0.3]));
55 expect(scene).not.toBeNull();
56 if (!scene) return;
57
38f135ad 2026-06-21 Pavel Safronov58 expect(scene.calendar).toBe('Julian');
3adbd147 2026-06-20 Pavel Safronov59 // The point is inside the region.
60 expect(pointInPolygon(scene.lat, scene.lng, square)).toBe(true);
61
62 // The date resolves to a non-Gregorian era covering it.
63 const eras = findEras(toHistoricalDate(scene.date), regions[0].eras);
64 expect(eras.map((e) => e.calendar)).toContain('Julian');
65 expect(eras.map((e) => e.calendar)).not.toContain('Gregorian');
66 });
67
38f135ad 2026-06-21 Pavel Safronov68 it('weights each calendar equally, not by how many eras use it', () => {
69 // The first rng call selects the calendar from [Julian, Persian]. A low
70 // value picks the first, a high value the second — regardless of the fact
71 // that Julian has 3 eras and Persian only 1.
72 expect(pickRandomScene(twoCalendars, seq([0.1]))?.calendar).toBe('Julian');
73 expect(pickRandomScene(twoCalendars, seq([0.9]))?.calendar).toBe('Persian');
74 });
75
76 it('skips calendars shown recently', () => {
77 // With Julian "recent", only Persian is eligible, so any rng yields it.
78 expect(pickRandomScene(twoCalendars, seq([0.1]), ['Julian'])?.calendar).toBe('Persian');
79 expect(pickRandomScene(twoCalendars, seq([0.9]), ['Julian'])?.calendar).toBe('Persian');
80 });
81
82 it('falls back to the full set when every calendar is recent', () => {
83 // Both calendars recent → still returns one rather than null.
84 const scene = pickRandomScene(twoCalendars, seq([0.1]), ['Julian', 'Persian']);
85 expect(scene).not.toBeNull();
86 expect(['Julian', 'Persian']).toContain(scene?.calendar);
87 });
88
3adbd147 2026-06-20 Pavel Safronov89 it('produces dates strictly before a bounded era end (half-open)', () => {
90 // rng near 1 should still keep the day before the end boundary.
91 const scene = pickRandomScene(regions, seq([0, 0.5, 0.5, 0.999999]));
92 expect(scene).not.toBeNull();
93 if (!scene) return;
94 expect(scene.date.year).toBeLessThan(1700);
95 });
96});