solar.test.ts15.8 KBView on GitHub
import { computeRealTheme, computeSky, type SkyStop } from '@/modules/userSettings/theme/solar';
import SunCalc from 'suncalc';

const SF = { lat: 37.7749, lng: -122.4194 };
const DAY = new Date('2026-08-26T19:00:00Z');

/**
 * The palette stop the sky is effectively sitting on, or null if it is genuinely between two.
 *
 * `computeSky` reports a segment plus a position along it, and the tolerance is what makes
 * "is it at dusk" answerable a second either side of the flip — the sun has moved a
 * ten-thousandth of a segment by then, which is the same colour to any eye and any screen.
 */
const AT_STOP_TOLERANCE = 0.02;

function nearestStop(sky: { from: SkyStop; to: SkyStop; mix: number }): SkyStop | null {
  if (sky.mix <= AT_STOP_TOLERANCE) return sky.from;
  if (sky.mix >= 1 - AT_STOP_TOLERANCE) return sky.to;
  return null;
}

/**
 * The moment the theme next changes over, asked of the driver itself.
 *
 * The flip is no longer any of SunCalc's named times — it is where the sun crosses the
 * elevation the palette stops being readable at — so the tests take it from the same place
 * the app does rather than re-deriving it and drifting apart from what actually ships.
 */
const flipAfter = (at: Date) => computeRealTheme(at, SF).nextBoundary;

/** Every minute of one real day in San Francisco, with the theme that day actually resolves to. */
function sweepDay() {
  const start = new Date('2026-08-26T07:00:00Z');
  const samples: Array<{ at: Date; half: 'light' | 'dark' }> = [];
  for (let minute = 0; minute < 24 * 60; minute++) {
    const at = new Date(start.getTime() + minute * 60_000);
    samples.push({ at, half: computeRealTheme(at, SF).theme });
  }
  return samples;
}

describe('computeSky', () => {
  it('lands on the same colour from either side of the theme flip', () => {
    // The sky has to be the same colour a moment before the changeover and a moment after,
    // or the flip reads as a jump cut.
    const flip = flipAfter(new Date('2026-08-26T20:00:00Z')); // ~13:00 local, so: this evening

    const lastLight = computeSky(new Date(flip.getTime() - 1000), SF, 'light');
    const firstDark = computeSky(new Date(flip.getTime() + 1000), SF, 'dark');

    // Both halves must be on the same segment of the ramp, at the same point along it.
    expect(lastLight.from).toBe(firstDark.from);
    expect(lastLight.to).toBe(firstDark.to);
    expect(lastLight.mix).toBeCloseTo(firstDark.mix, 2);
    // Neither derived value may step at the flip. They are not identical because the sun
    // really does move over the two seconds between the samples; they are continuous.
    expect(lastLight.dim).toBeCloseTo(firstDark.dim, 3);
    expect(lastLight.glow).toBeCloseTo(firstDark.glow, 2);
  });

  it('mirrors that hinge in the morning', () => {
    const flip = flipAfter(new Date('2026-08-26T09:00:00Z')); // ~02:00 local, so: this morning

    const lastDark = computeSky(new Date(flip.getTime() - 1000), SF, 'dark');
    const firstLight = computeSky(new Date(flip.getTime() + 1000), SF, 'light');

    expect(lastDark.from).toBe(firstLight.from);
    expect(lastDark.to).toBe(firstLight.to);
    expect(lastDark.mix).toBeCloseTo(firstLight.mix, 2);
  });

  it('lands on the same colour at the flip wherever the sun barely clears the horizon', () => {
    /*
     * The hinge is a fixed elevation, but the ramp's ends follow the day, so a winter this
     * far north squeezes the whole ramp into a few degrees and the flip lands somewhere
     * quite different along it than it does at temperate latitudes. Wherever that is, the
     * two halves still have to meet there.
     */
    const NORTH = { Trondheim: { lat: 63.43, lng: 10.39 }, Anchorage: { lat: 61.22, lng: -149.9 } };

    for (const [, coords] of Object.entries(NORTH)) {
      for (const date of ['2026-12-21', '2026-11-15', '2026-01-25']) {
        const midnightLocal = new Date(`${date}T00:00:00Z`).getTime() - (coords.lng / 15) * 3600_000;
        const flip = computeRealTheme(new Date(midnightLocal + 12 * 3600_000), coords).nextBoundary;

        const lastLight = computeSky(new Date(flip.getTime() - 1000), coords, 'light');
        const firstDark = computeSky(new Date(flip.getTime() + 1000), coords, 'dark');

        expect(lastLight.from).toBe(firstDark.from);
        expect(lastLight.to).toBe(firstDark.to);
        expect(lastLight.mix).toBeCloseTo(firstDark.mix, 2);
      }
    }
  });

  it('takes a different shoulder on the way down than on the way up', () => {
    const times = SunCalc.getTimes(DAY, SF.lat, SF.lng);
    const beforeSunset = new Date(times.sunset.getTime() - 20 * 60_000);
    const afterSunrise = new Date(times.sunrise.getTime() + 20 * 60_000);

    // Same elevation either side of noon, different shoulder — gold going down, coral coming
    // up, the distinction the original sunrise/sunset keyframes drew.
    expect([computeSky(beforeSunset, SF, 'light').from, computeSky(beforeSunset, SF, 'light').to])
      .toContain('evening');
    expect([computeSky(afterSunrise, SF, 'light').from, computeSky(afterSunrise, SF, 'light').to])
      .toContain('morning');
  });

  it('rests flat for a theme that is not following the sun', () => {
    const midnight = new Date('2026-08-27T08:00:00Z'); // ~01:00 local, sun well below
    const noon = new Date('2026-08-26T20:00:00Z'); // ~13:00 local, sun high

    // A page that hard-codes light, or a theme the user pinned, gets the flat sky a fixed
    // theme has always shown — not a twilight one held indefinitely under the wrong chrome.
    expect(computeSky(midnight, SF, 'light')).toMatchObject({ from: 'day', to: 'day', dim: 0 });
    expect(computeSky(noon, SF, 'dark')).toMatchObject({ from: 'night', to: 'night', dim: 1 });
  });

  it('puts peak warmth at the horizon and is dark by civil twilight', () => {
    // The complaint this phasing exists to answer: an earlier ramp hung full orange on civil
    // twilight, so the backdrop was still blazing sunset once it had gone dark outside.
    const times = SunCalc.getTimes(DAY, SF.lat, SF.lng);
    const at = (from: Date, mins: number) =>
      computeSky(new Date(from.getTime() + mins * 60_000), SF, 'light');

    // Soft pink arrives before the deeper red, and the red peaks as the sun touches down.
    expect(at(times.sunset, -10).to).toBe('blush');
    expect([at(times.sunset, 0).from, at(times.sunset, 0).to]).toContain('dusk');

    // By civil twilight it is well past the warmth and most of the way to night.
    const atDusk = computeSky(times.dusk, SF, 'dark');
    expect(atDusk.to).toBe('dawn');
    expect(atDusk.mix).toBeGreaterThan(0.5);
    expect(atDusk.dim).toBeGreaterThan(0.65);
  });

  it('changes over while the sky is still the sunset, and mirrors it at sunrise', () => {
    /*
     * The complaint: at 19:55 the backdrop was the deepest, reddest stop on the ramp and the
     * app was still in light mode, putting near-black ink on it. The flip belongs where the
     * sky stops being readable that way, which is a good half-hour before civil twilight.
     */
    const times = SunCalc.getTimes(DAY, SF.lat, SF.lng);
    const altAt = (at: Date) => SunCalc.getPosition(at, SF.lat, SF.lng).altitude * (180 / Math.PI);

    // Within half a degree of the -2° the flip is solved for, not exactly on it: SunCalc's
    // time model and its position model disagree by a few tenths, which is the whole reason
    // `hingeDeg` asks the position model where the time model put the boundary.
    const goesDark = flipAfter(new Date('2026-08-26T20:00:00Z'));
    expect(goesDark.getTime()).toBeLessThan(times.dusk.getTime());
    expect(altAt(goesDark)).toBeCloseTo(-2, 0);

    // And the same moment in reverse: the morning waits for the sky to climb back to it,
    // rather than turning light while it is still deep red out.
    const goesLight = flipAfter(new Date('2026-08-26T09:00:00Z'));
    expect(goesLight.getTime()).toBeGreaterThan(times.dawn.getTime());
    expect(altAt(goesLight)).toBeCloseTo(-2, 0);

    // Both land on the warm leg of the ramp, not down among the blues.
    for (const flip of [goesDark, goesLight]) {
      const sky = computeSky(flip, SF, 'dark');
      expect(['blush', 'dusk']).toContain(sky.from);
    }
  });

  it('stays inside its ranges and off the wrong stops all day', () => {
    // The two halves share the ramp below the blush, so they overlap on `dusk` and `dawn`.
    // What must never happen is either reaching past the other's resting colour.
    const lightHalfStops: SkyStop[] = ['day', 'evening', 'morning', 'blush', 'dusk', 'dawn'];
    const darkHalfStops: SkyStop[] = ['blush', 'dusk', 'dawn', 'night'];

    for (const { at, half } of sweepDay()) {
      const sky = computeSky(at, SF, half);
      expect(sky.mix).toBeGreaterThanOrEqual(0);
      expect(sky.mix).toBeLessThanOrEqual(1);
      expect(sky.dim).toBeGreaterThanOrEqual(0);
      expect(sky.dim).toBeLessThanOrEqual(1);
      expect(sky.glow).toBeGreaterThanOrEqual(0);
      expect(sky.glow).toBeLessThanOrEqual(1);

      const allowed = half === 'light' ? lightHalfStops : darkHalfStops;
      expect(allowed).toContain(sky.from);
      expect(allowed).toContain(sky.to);
    }
  });

  it('darkens monotonically through the afternoon and lightens through the morning', () => {
    const samples = sweepDay();
    const noon = SunCalc.getTimes(DAY, SF.lat, SF.lng).solarNoon;

    let previousAfternoon = -Infinity;
    let previousMorning = Infinity;
    for (const { at, half } of samples) {
      const { dim } = computeSky(at, SF, half);
      if (at > noon) {
        expect(dim).toBeGreaterThanOrEqual(previousAfternoon - 1e-9);
        previousAfternoon = dim;
      } else {
        expect(dim).toBeLessThanOrEqual(previousMorning + 1e-9);
        previousMorning = dim;
      }
    }
  });

  it('peaks the warm bounce light at the horizon, not at noon or midnight', () => {
    const times = SunCalc.getTimes(DAY, SF.lat, SF.lng);
    const atHorizon = computeSky(times.sunset, SF, 'light').glow;
    const atNoon = computeSky(times.solarNoon, SF, 'light').glow;
    const atNight = computeSky(times.nadir, SF, 'dark').glow;

    expect(atHorizon).toBeGreaterThan(0.8);
    expect(atNoon).toBe(0);
    expect(atNight).toBe(0);
  });

  it('re-derives often near the horizon and rarely otherwise', () => {
    const times = SunCalc.getTimes(DAY, SF.lat, SF.lng);
    expect(computeSky(times.sunset, SF, 'light').nextUpdateMs).toBe(30_000);
    expect(computeSky(times.solarNoon, SF, 'light').nextUpdateMs).toBe(5 * 60_000);
    expect(computeSky(times.nadir, SF, 'dark').nextUpdateMs).toBe(5 * 60_000);
  });

  describe('holding still', () => {
    /*
     * The point of the whole feature: for most of the day and most of the night the backdrop
     * must be *identical*, not merely similar. It may only move around sunrise and sunset.
     *
     * These places and dates are the ones that break naive thresholds — near the solstices at
     * high latitude the sun never climbs to a fixed "day" elevation nor sinks to a fixed
     * "night" one, and an absolute ramp would drift continuously for twenty-four hours.
     */
    const PLACES = {
      'San Francisco': { lat: 37.7749, lng: -122.4194 },
      London: { lat: 51.5074, lng: -0.1278 },
      Stockholm: { lat: 59.3293, lng: 18.0686 },
      Singapore: { lat: 1.3521, lng: 103.8198 },
    };
    const DATES = ['2026-06-21', '2026-09-22', '2026-12-21'];

    /** A sky that is resting on an end of the ramp rather than travelling along it. */
    const isSettled = (sky: ReturnType<typeof computeSky>) =>
      sky.from === sky.to && sky.mix === 0 && (sky.dim === 0 || sky.dim === 1);

    function sweep(coords: { lat: number; lng: number }, date: string) {
      const start = new Date(`${date}T00:00:00Z`).getTime() - (coords.lng / 15) * 3600_000;
      return Array.from({ length: 1440 }, (_, minute) => {
        const at = new Date(start + minute * 60_000);
        return computeSky(at, coords, computeRealTheme(at, coords).theme);
      });
    }

    it.each(Object.entries(PLACES).flatMap(([p, c]) => DATES.map((d) => [p, d, c] as const)))(
      'moves only twice a day in %s on %s',
      (_place, date, coords) => {
        const day = sweep(coords, date);

        // Count runs of movement. Two — one around sunrise, one around sunset — and no more.
        let runs = 0;
        day.forEach((sky, i) => {
          if (!isSettled(sky) && (i === 0 || isSettled(day[i - 1]!))) runs++;
        });
        expect(runs).toBeLessThanOrEqual(2);

        // And every settled minute is byte-identical to the others of its kind, so the
        // backdrop is genuinely unchanged rather than creeping by a fraction of a per cent.
        for (const kind of ['day', 'night'] as const) {
          const settled = day.filter((s) => isSettled(s) && s.from === kind);
          for (const sky of settled) {
            expect({ ...sky, nextUpdateMs: 0 }).toEqual({ ...settled[0]!, nextUpdateMs: 0 });
          }
        }
      },
    );

    it('still settles where the sun never reaches a fixed threshold', () => {
      // London in June never sinks past -16°, and Stockholm in December never climbs past 7°.
      // Both must still come to rest, on night and on day respectively.
      const londonJune = sweep(PLACES.London, '2026-06-21');
      expect(londonJune.filter((s) => isSettled(s) && s.from === 'night').length).toBeGreaterThan(60);

      const stockholmDecember = sweep(PLACES.Stockholm, '2026-12-21');
      expect(stockholmDecember.filter((s) => isSettled(s) && s.from === 'day').length)
        .toBeGreaterThan(60);
    });
  });

  describe('inside the polar circles', () => {
    const TROMSO = { lat: 69.6492, lng: 18.9553 };

    it('reads the midnight sun as daylight', () => {
      // There is no civil twilight at all that day, so SunCalc has no dawn or dusk to give.
      // Falling through the comparisons against those would resolve every hour to dark.
      const midnight = new Date('2026-06-21T22:00:00Z'); // ~00:00 local, sun still up
      expect(computeRealTheme(midnight, TROMSO).theme).toBe('light');
      expect(nearestStop(computeSky(midnight, TROMSO, 'light'))).not.toBe('night');
    });

    it('always hands back a boundary a timer can use', () => {
      // A NaN delay is run by setTimeout as zero, which would spin instead of waiting a day.
      for (const date of ['2026-06-21', '2026-12-21', '2026-09-22']) {
        const { nextBoundary } = computeRealTheme(new Date(`${date}T12:00:00Z`), TROMSO);
        expect(Number.isNaN(nextBoundary.getTime())).toBe(false);
        expect(nextBoundary.getTime()).toBeGreaterThan(new Date(`${date}T12:00:00Z`).getTime());
      }
    });
  });

  describe('without a location', () => {
    // The stand-in sun is tuned so it crosses the flip elevation at the same 06:00/20:00
    // `computeRealTheme` falls back to — the two have to agree about when it is night.
    const local = (hour: number, minute = 0) => new Date(2026, 7, 26, hour, minute, 0, 0);

    it('agrees with the theme fallback about where the hinge is', () => {
      expect(computeRealTheme(local(19, 59), null).theme).toBe('light');
      expect(computeRealTheme(local(20, 1), null).theme).toBe('dark');

      const lastLight = computeSky(local(20), null, 'light');
      const firstDark = computeSky(local(20), null, 'dark');
      expect(lastLight.from).toBe(firstDark.from);
      expect(lastLight.to).toBe(firstDark.to);
      expect(lastLight.mix).toBeCloseTo(firstDark.mix, 2);
    });

    it('still moves through the day', () => {
      const noon = computeSky(local(13), null, 'light');
      // Inside the ramp: the stand-in sun holds flat daylight until ~19:20, then has forty
      // minutes to travel down to the 20:00 flip.
      const evening = computeSky(local(19, 45), null, 'light');
      const lateNight = computeSky(local(1), null, 'dark');

      expect(nearestStop(noon)).toBe('day');
      expect(evening.dim).toBeGreaterThan(noon.dim);
      expect(nearestStop(lateNight)).toBe('night');
    });
  });
});