tower / files / test / sky.test.ts
sky.test.ts
59c12827 2026-09-28 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { LENGTH_M } from '../src/canon/constants.js' | |
| 3 | import { defaultCalendar } from '../src/world/schema.js' | |
1d447a1a 2026-09-28 Pavel Safronov | 4 | import { dateLabel, hourWhenSunAt, hoursAt, skyAt, sunTimes, utcOffsetH } from '../src/world/sky.js' |
59c12827 2026-09-28 Pavel Safronov | 5 | |
de3d60d6 2026-09-29 Pavel Safronov | 6 | /** |
| 7 | * Sydney, which Tower kept until D-089: its published sunrise tables check the solar maths, | |
| 8 | * whichever city the world file names. | |
| 9 | */ | |
| 10 | const sydney = { ...defaultCalendar(), city: 'Sydney', timeZone: 'Australia/Sydney', latitude: -33.8688, longitude: 151.2093 } | |
| 11 | /** Pisa, which Tower keeps now (D-089). */ | |
| 12 | const pisa = defaultCalendar() | |
| 13 | /** The world day on which the city has this date (day 0 is 2026-09-23). */ | |
59c12827 2026-09-28 Pavel Safronov | 14 | const dayOf = (iso: string) => |
| 15 | Math.round((Date.parse(`${iso}T00:00:00Z`) - Date.parse(`${sydney.day0}T00:00:00Z`)) / 86_400_000) | |
| 16 | const hm = (h: number, m: number) => h + m / 60 | |
| 17 | /** Within four minutes: the published tables round, and the formula is good to one or two. */ | |
| 18 | const nearMinutes = (actual: number, expected: number) => | |
| 19 | expect(Math.abs(actual - expected) * 60).toBeLessThan(4) | |
| 20 | ||
| 21 | describe("Tower keeps Sydney's days (D-074)", () => { | |
| 22 | it('starts on the spring equinox, 2026', () => { | |
| 23 | expect(dateLabel(sydney, 0)).toContain('23 Sep 2026') | |
| 24 | }) | |
| 25 | ||
| 26 | it("rises and sets with Sydney's shortest and longest days", () => { | |
| 27 | // Geoscience Australia: 21 Jun 2027, 07:00–16:54 AEST; 21 Dec 2026, 05:41–20:05 AEDT. | |
| 28 | const winter = sunTimes(sydney, dayOf('2027-06-21')) | |
| 29 | nearMinutes(winter.sunriseH, hm(7, 0)) | |
| 30 | nearMinutes(winter.sunsetH, hm(16, 54)) | |
| 31 | const summer = sunTimes(sydney, dayOf('2026-12-21')) | |
| 32 | nearMinutes(summer.sunriseH, hm(5, 41)) | |
| 33 | nearMinutes(summer.sunsetH, hm(20, 5)) | |
| 34 | }) | |
| 35 | ||
| 36 | it('keeps daylight saving: the clock moves, the sun does not', () => { | |
| 37 | expect(utcOffsetH('Australia/Sydney', new Date('2026-09-23T02:00:00Z'))).toBe(10) | |
| 38 | expect(utcOffsetH('Australia/Sydney', new Date('2026-12-21T02:00:00Z'))).toBe(11) | |
| 39 | // Either side of the switch on 4 Oct 2026, sunrise jumps by about an hour. | |
| 40 | const before = sunTimes(sydney, dayOf('2026-10-03')).sunriseH | |
| 41 | const after = sunTimes(sydney, dayOf('2026-10-05')).sunriseH | |
| 42 | expect(after - before).toBeGreaterThan(0.9) | |
| 43 | }) | |
| 44 | }) | |
| 45 | ||
de3d60d6 2026-09-29 Pavel Safronov | 46 | describe("Tower keeps Pisa's days (D-089)", () => { |
| 47 | it('starts on the autumn equinox, a twelve-hour day', () => { | |
| 48 | const { sunriseH, sunsetH } = sunTimes(pisa, 0) | |
| 49 | expect(Math.abs(sunsetH - sunriseH - 12)).toBeLessThan(0.25) | |
| 50 | }) | |
| 51 | ||
| 52 | it('shortens toward December and lengthens toward June', () => { | |
| 53 | const len = (iso: string) => { const t = sunTimes(pisa, dayOf(iso)); return t.sunsetH - t.sunriseH } | |
| 54 | expect(len('2026-12-21')).toBeLessThan(9.25) | |
| 55 | expect(len('2027-06-21')).toBeGreaterThan(15.25) | |
| 56 | }) | |
| 57 | ||
| 58 | it("keeps Italy's daylight saving", () => { | |
| 59 | expect(utcOffsetH('Europe/Rome', new Date('2026-09-23T10:00:00Z'))).toBe(2) | |
| 60 | expect(utcOffsetH('Europe/Rome', new Date('2026-12-21T11:00:00Z'))).toBe(1) | |
| 61 | // Either side of the switch on 25 Oct 2026, sunrise jumps back by about an hour. | |
| 62 | const before = sunTimes(pisa, dayOf('2026-10-24')).sunriseH | |
| 63 | const after = sunTimes(pisa, dayOf('2026-10-26')).sunriseH | |
| 64 | expect(before - after).toBeGreaterThan(0.9) | |
| 65 | }) | |
| 66 | ||
| 67 | it('opens the clock at the time in Pisa now', () => { | |
| 68 | // 10:00 UTC on day 0 is noon in Pisa (CEST, +2). | |
| 69 | expect(hoursAt(pisa, new Date('2026-09-23T10:00:00Z'))).toBeCloseTo(12) | |
| 70 | }) | |
| 71 | }) | |
| 72 | ||
59c12827 2026-09-28 Pavel Safronov | 73 | describe('the sun runs the length of the world, sunrise to sunset', () => { |
| 74 | const day = dayOf('2026-12-21') | |
| 75 | const { sunriseH, sunsetH } = sunTimes(sydney, day) | |
| 76 | ||
| 77 | it('lights at the sun cap and goes out at the far cap', () => { | |
| 78 | expect(skyAt(sydney, day * 24 + sunriseH + 0.01, 'temperate').sunZ).toBeLessThan(1000) | |
| 79 | expect(skyAt(sydney, day * 24 + sunsetH - 0.01, 'temperate').sunZ).toBeGreaterThan(LENGTH_M - 1000) | |
| 80 | }) | |
| 81 | ||
| 82 | it('moves at a steady pace: halfway through the day it is halfway along', () => { | |
| 83 | const mid = skyAt(sydney, day * 24 + (sunriseH + sunsetH) / 2, 'temperate') | |
| 84 | expect(mid.sunZ).toBeCloseTo(LENGTH_M / 2, -2) | |
| 85 | expect(mid.power).toBe(1) | |
| 86 | expect(mid.watts).toBeCloseTo(200) | |
| 87 | }) | |
| 88 | ||
| 89 | it('is off at night, with the beam banked to a thousandth', () => { | |
| 90 | const night = skyAt(sydney, day * 24 + 23, 'temperate') | |
| 91 | expect(night.sunZ).toBeNull() | |
| 92 | expect(night.power).toBe(0) | |
42b4554e 2026-09-29 Pavel Safronov | 93 | // The rod's 0.32 W/m², and the moon's share of that again, as lit as it is (D-088). |
| 94 | expect(night.watts).toBeCloseTo(0.32 * (1 + night.moonLight), 2) | |
59c12827 2026-09-28 Pavel Safronov | 95 | }) |
| 96 | ||
| 97 | it('comes up and goes out over a quarter of an hour, not instantly', () => { | |
| 98 | const early = skyAt(sydney, day * 24 + sunriseH + 0.125, 'temperate') | |
| 99 | expect(early.power).toBeGreaterThan(0.2) | |
| 100 | expect(early.power).toBeLessThan(0.8) | |
| 101 | }) | |
| 102 | ||
| 103 | it('shuts down entirely when the dial says dark', () => { | |
| 104 | const dark = skyAt(sydney, day * 24 + 12, 'dark') | |
| 105 | expect(dark.sunZ).toBeNull() | |
| 106 | expect(dark.watts).toBe(0) | |
| 107 | }) | |
| 108 | ||
| 109 | it('can be asked when it will be over a place', () => { | |
| 110 | const h = hourWhenSunAt(sydney, day, LENGTH_M / 4) | |
| 111 | expect(skyAt(sydney, day * 24 + h, 'full').sunZ).toBeCloseTo(LENGTH_M / 4, -2) | |
| 112 | }) | |
| 113 | }) | |
| 114 | ||
| 115 | describe('the calendar is in the world file', () => { | |
de3d60d6 2026-09-29 Pavel Safronov | 116 | it('reads the committed one: Pisa, from the equinox', async () => { |
59c12827 2026-09-28 Pavel Safronov | 117 | const { readFileSync } = await import('node:fs') |
| 118 | const { parseWorld } = await import('../src/world/schema.js') | |
| 119 | const cal = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8'))).calendar | |
de3d60d6 2026-09-29 Pavel Safronov | 120 | expect(cal).toEqual(pisa) |
59c12827 2026-09-28 Pavel Safronov | 121 | }) |
| 122 | it('falls back field by field when something in it is unusable', async () => { | |
| 123 | const { parseWorld } = await import('../src/world/schema.js') | |
| 124 | const cal = parseWorld({ calendar: { city: 'Perth', timeZone: 'Not/AZone', day0: 'soon', latitude: -31.95 } }).calendar | |
| 125 | expect(cal.city).toBe('Perth') | |
de3d60d6 2026-09-29 Pavel Safronov | 126 | expect(cal.timeZone).toBe('Europe/Rome') |
59c12827 2026-09-28 Pavel Safronov | 127 | expect(cal.day0).toBe('2026-09-23') |
| 128 | expect(cal.latitude).toBe(-31.95) | |
| 129 | }) | |
| 130 | }) | |
1d447a1a 2026-09-28 Pavel Safronov | 131 | |
| 132 | describe('the clock opens at the time in Sydney now', () => { | |
| 133 | it('turns a real instant into Tower time', () => { | |
| 134 | // 02:00 UTC on day 0 is noon in Sydney (AEST, +10). | |
| 135 | expect(hoursAt(sydney, new Date('2026-09-23T02:00:00Z'))).toBeCloseTo(12) | |
| 136 | // With daylight saving, 01:00 UTC on 21 Dec is noon AEDT, 89 days on. | |
| 137 | expect(hoursAt(sydney, new Date('2026-12-21T01:00:00Z'))).toBeCloseTo(89 * 24 + 12) | |
| 138 | }) | |
| 139 | it('has no past: before day 0 it is day 0', () => { | |
| 140 | expect(hoursAt(sydney, new Date('2026-01-01T00:00:00Z'))).toBe(0) | |
| 141 | }) | |
| 142 | }) | |
42b4554e 2026-09-29 Pavel Safronov | 143 | |
| 144 | describe("Tower's moon (D-088)", () => { | |
| 145 | const sunset = sunTimes(sydney, 0).sunsetH | |
| 146 | const sunrise = sunTimes(sydney, 1).sunriseH + 24 | |
| 147 | ||
| 148 | it('is out by day, and with the beam shut down', () => { | |
| 149 | const noon = skyAt(sydney, 12, 'full') | |
| 150 | expect(noon.moonZ).toBeNull() | |
| 151 | expect(noon.moonLight).toBe(0) | |
| 152 | expect(skyAt(sydney, 23, 'dark').moonZ).toBeNull() | |
| 153 | }) | |
| 154 | ||
| 155 | it('crosses from the far cap at sunset to the sun cap at sunrise', () => { | |
| 156 | expect(skyAt(sydney, sunset + 0.01, 'full').moonZ!).toBeGreaterThan(LENGTH_M * 0.99) | |
| 157 | expect(skyAt(sydney, (sunset + sunrise) / 2, 'full').moonZ!).toBeCloseTo(LENGTH_M / 2, -3) | |
| 158 | expect(skyAt(sydney, sunrise - 0.01, 'full').moonZ!).toBeLessThan(LENGTH_M * 0.01) | |
| 159 | // And after midnight it is the same night's moon, still going. | |
| 160 | const late = skyAt(sydney, 24 + 2, 'full').moonZ! | |
| 161 | expect(late).toBeLessThan(skyAt(sydney, 23, 'full').moonZ!) | |
| 162 | }) | |
| 163 | ||
| 164 | it("is lit as Earth's Moon is lit, and brightens the night by it", () => { | |
| 165 | // Full moon 26 Sep 2026 16:49 UTC — early on the 27th in Sydney, world day 4's night. | |
| 166 | const full = skyAt(sydney, 3 * 24 + 23, 'full') | |
| 167 | expect(full.moonPhase.fraction).toBeGreaterThan(0.97) | |
| 168 | // New moon 11 Oct 2026: nothing to light. | |
| 169 | const nu = skyAt(sydney, dayOfNew() * 24 + 23, 'full') | |
| 170 | expect(nu.moonPhase.fraction).toBeLessThan(0.03) | |
| 171 | expect(full.watts).toBeGreaterThan(nu.watts * 1.9) | |
| 172 | }) | |
| 173 | }) | |
| 174 | ||
| 175 | /** The world day of Sydney's new moon, 11 Oct 2026. */ | |
| 176 | function dayOfNew(): number { | |
| 177 | return Math.round((Date.parse('2026-10-11T00:00:00Z') - Date.parse(`${sydney.day0}T00:00:00Z`)) / 86_400_000) | |
| 178 | } |