Drawing the Real Night Sky in a BrowserSection 9 of 10

Verification

Astronomical values you cannot work out by hand are pinned to known figures, inverted conventions (azimuth measured from south, say) are caught with assertions about direction, property-based tests cover the whole domain, and finally the drawn pixels themselves are checked in a real browser.


This section answers three questions. First: how do you write the expected value of an astronomical quantity you cannot work out by hand? Second: when the live inside a canvas that the DOM cannot see, how do you assert that something was actually drawn? Third: how do you pin time and place for a reproducible test without inventing test-only machinery to do it?

The scale first. Under Node, Vitest runs 4,362 unit tests across just over two hundred files, and running all of them takes a little over five seconds on the development machine. The end-to-end tests, which drive a real browser through Playwright, are a separate count: 314 tests across 30 files. A subset of them, 204 tests in all, carries a @smoke tag, which marks a test as one of the representative ones meant to be quick to run; since Playwright can filter on that tag, they serve as the subset that runs after every change.

You cannot compute the expected answer

This heading and the next two answer the first question. Astronomy tests cannot be written the way ordinary unit tests are. The answer to "where is Sirius from Tokyo at 21:00 on 2026-01-01" is a continuous quantity — 136.51°, 25.00°. Deriving it by hand means implementing , and yourself, which is the code under test. The moment you compute the expected value, the test becomes the same implementation written twice.

Worse, the most likely failure here is not a numerical error but a convention error: measuring azimuth from south instead of north, or swapping east and west. Such a mistake puts the star somewhere else entirely — and the screen still shows a plausible starry sky, so nothing looks wrong. When the expected value is produced by the code under test, the inverted convention is baked into the expectation as well. Tightening the numeric tolerance then never catches it: this is not a small deviation but the same wrong answer on both sides of the comparison.

Two layers: pinned values, and assertions about direction

The answer to the first question comes in two layers. The first layer pins values. The cleanest case is , the Earth's rotation angle measured from the and expressed in hours. At the — 2000-01-01 12:00 UT — Greenwich sidereal time has a standard almanac value of 18h 41m 50.5s, about 18.6971 hours. GMST in the test below is that same Greenwich sidereal time.

it('GMST at J2000.0 epoch = 18.6971 h (Meeus / IAU)', () => {
  // GMST at 2000-01-01 12:00 UT is a standard reference value ~ 18h 41m 50.5s
  // = 18.6971 h. Tolerance ~1 s of time = 1/3600 h ~ 0.00028 h.
  const t = Date.UTC(2000, 0, 1, 12, 0, 0)
  expect(gmstHours(t)).toBeCloseTo(18.6971, 3)
})
The known-value test for sidereal time (excerpt)

What matters in this check is where the tolerance comes from. For sidereal time, four statements cover it.

  • Intended bound: about one second of time, 0.00028 hours. Sidereal time expresses an angle as a duration, so one second of it is 15 of Earth rotation — fine enough for the accuracy of a star position.
  • Bound actually allowed: asking for agreement to n decimals means a difference smaller than half a unit in that last decimal place. Three decimals therefore allow 0.0005 hours, about 1.8 seconds of time — roughly twice the intent.
  • Why it cannot be tightened to exactly one second: the two sides are not quite the same quantity. The standard 18.6971 hours is mean sidereal time, while the function returns apparent sidereal time, which follows the real nodding of the vernal equinox. The gap between them stays under about 1.2 seconds, inside the 1.8 the check allows.
  • Why that digit: the bound can only be written in the "agreement to n decimals" form, so the single choice on offer is the value of n. Picking three was not a matter of "close enough". It was picked against one second of time, a bound that means something for the accuracy of a star position.

Of the four quantities below, sidereal time is the only one with an established outside reference. The Sirius position, the sunrise and sunset times, and the of the Moon were each computed once through a separate path in the same astronomy library, written down as constants, and checked for agreement from then on. That is not an appeal to an external answer. It is a regression test, one that only asserts the result stays what it was, so a silent change makes the test fail. When a value is meant to change, rewriting the recorded constant by hand is the procedure, and that edit is itself the record that the change was intended. Comparison against the outside world belongs to the closing heading, "The manual cross-check", where a person compares the sky against a planetarium program by eye.

QuantityReferenceCriterion
Greenwich sidereal time at the J2000 reference epochStandard almanac value, 18.6971 hAgreement to three decimals
Sirius azimuth and altitude, Tokyo, 2026-01-01 21:00Computed once through the library's own path: register the star, take its right ascension and declination of date, convert those to azimuth and altitude2 decimals = 0.005°
Sunrise and sunset, Tokyo, June solstice 2026Computed once with the same library: 04:25 and 19:00 local timeA window from 04:21 to 04:30 — from four minutes before the 04:25 reference to five minutes after
Illuminated fraction of the Moon, January 2026Instants of full and new moon from the same library's phase search (Jan 3 10:03 UTC, Jan 18 19:52 UTC)Above 0.99 at full, below 0.02 at new

Of those four, only the sunrise and sunset window has no stated basis for its width: it is a tuned bound, giving the minute-rounded reference some room.

The second layer states a direction rather than a value. At 21:00 in January, Sirius is low in the south-east of the Tokyo sky. The scene uses the axes: X points north, Y up and Z east. South-east therefore means a negative X and a positive Z, and being above the horizon means a positive Y, so the fact becomes a set of sign conditions — x negative, y positive, z positive. The same character shows up elsewhere: morning must arrive as astronomical, then nautical, then civil, then sunrise; the at full moon must be close to zero; lunar must stay inside ±10°. That ±10° is not a quoted reference value but a bound set as the largest swing libration can physically reach. Statements like these never trip on rounding; they trip only when a convention has been inverted, and each one is deliberately paired with a pinned value.

Property-based tests: covering the whole domain

The first question gets a third kind of answer here. As of September 2026 there are 42 files, part of the file count above. The library that generates the inputs is . Where an example test pins one input to one output, a property test states a relation that must hold for every input and checks it against many generated ones. When it finds a counterexample it shrinks it, handing back the smallest failing case.

For coordinate conversion the property is a round trip: and to a and back must return the original direction anywhere on the , and the intermediate vector must always have length one. The tolerances are not round numbers picked by feel; each is fixed by the relation between three figures. For the declination round trip they are these.

  • Predicted: the round trip should be off by at most about 7.3e-13 degrees — less than a trillionth of a degree — even at a declination of 89°, close to the pole. That estimates the size to within a power of ten; it is not a proven upper bound. The derivation runs as follows. The starting point is the rounding step of double precision, about 2.2e-16. Recovering declination through an arcsine amplifies that error by 1/cos(declination), which is about 57 at a declination of 89°: the amplified error comes to some 1.3e-14 radians, which is the 7.3e-13 degrees above.
  • Adopted: 1e-11 degrees, roughly ten times the prediction as headroom.
  • Worst observed: 8.4e-13 degrees over two million random samples. It sits inside the adopted bound and in the same power of ten as the prediction, which suggests the estimate is of the right size. Those two million samples were a one-off measurement made while choosing the bound, and the figure recorded is its outcome; an everyday run generates far fewer inputs per property: 100, the fast-check default, with individual tests raising it to a few hundred where it is worth the time.

Near the celestial poles the situation differs: right ascension becomes genuinely indeterminate. There the comparison switches to the angular distance between vectors rather than between angles.

A share URL is a link that carries a scene: the observer, the time and the camera direction written into the query string. Its decoder is fed hostile input: empty strings, Infinity, 1e400, 0x10, arbitrary Unicode, and unrelated keys mixed in. Two statements are made about all of it — the decoder never throws, and every number it returns lands inside its documented range. Latitude clamps to ±90°, longitude to ±180°, elevation to the band from −500 to 9,000 m.

// decode never throws, no matter how hostile the URLSearchParams.
it('never throws', () => {
  fc.assert(
    fc.property(adversarialParamsArb(KNOWN_KEYS), (params) => {
      expect(() => decodeShareState(params)).not.toThrow()
    }),
  )
})
A property test for the share-link decoder (excerpt)

The kinematics of Moonwalk — the where you walk the lunar surface at one-sixth gravity — shows the division of labour plainly. The property-based tests never pin the value of lunar gravity, 1.62 m/s²; they state jump apex and fall time only as relations to whatever that constant is. The split was verified by actually trying it: rewriting 1.62 as 9.81 left every property green and failed eight assertions in the example-based test file, the ones citing measurements from Apollo. The examples own the cited numbers; the properties own the shape of the physics around them.

Testing the pure layer and the build scripts

This heading answers none of the three questions but another one: why the whole suite finishes as fast as it does. A large part of it is likely that the core — the astronomy, the URL codecs — lives in the . That layer is kept free of , of React and of the stores alike, so it executes under plain Node with no DOM: no rendering, no waiting for anything to load.

The data-generation scripts sit on the same footing. The scripts that build the star catalogue, the constellation lines and the eclipse catalogue only execute their body when they are the process entry point, so importing one from a test downloads nothing and writes nothing. That leaves the pure parts — turning a into a colour, packing binary — testable without any source data.

Another test makes sure the test-only tooling never leaks into shipped code. The method is plain: read every application source file off disk, look at each import specifier in it, and confirm that none of them resolves into the test-only directory — the place production code is forbidden to import from. No bundler, no network. The test-only directory holds fixture data — a fixed observer, a fixed instant — and fast-check, the generator of inputs; none of that has any business in what ships. The scan sees only specifiers written out as literal strings; a path assembled at run time would be invisible to it.

End to end: asserting that something was drawn

This heading answers the second and third questions together. The browser tests run against the in out/, served as-is, rather than against a dev server. That makes the thing under test the same static output that ships, apart from where the large files come from: under test they are served from the same origin as the page, in production from . The cost is that a stale build silently tests old code, so it gets rebuilt whenever there is any doubt. Nothing automates that rebuild; it is developer discipline.

Reproducibility comes from a shipped feature rather than from test scaffolding. The share-link query string deterministically pins observer, time, time scale and view direction, so it doubles as the initial condition of a test and no test-only API is needed. The flip side is a rule against the debug entry points that exist only in a development build. To check that the camera really turned to a requested body — Mars, say — a test copies the share link from the menu and reads the azimuth and altitude back out of it, going through the product path, so the check exercises the feature too.

The rendering itself is invisible to the DOM: canvas contents never reach the accessibility tree, so no element query can reach them. Instead the tests take a screenshot, decode it to raw pixels with the image library sharp, and assert on brightness statistics.

The statistics are simple, and there are three of them. The first is per-pixel brightness: red, green and blue mixed with the weights on a 0–255 scale. It is not luminance, the amount of light itself, but a display-side brightness formed from the colour components on screen — the quantity properly called luma — with green weighing the most because the eye is most sensitive to it. Then there is the fraction of pixels below some threshold. Third, the bounding box of the bright pixels. The box is used on the Moon globe page (/moon/): the difference between its width and its height must stay under 6% of the longer side, which is to say the box must be square, and the bright pixels must fill about π/4 = 0.785 of it — between 0.7 and 0.92 — the area ratio of an inscribed disc. Neither the 6% nor the 0.7–0.92 band is derived from anything; both are tuned bounds for "round enough". A half-lit Moon fails both conditions at once: the box around its bright half is far from square, and the fill drops to around 0.4. The check is therefore run on a full moon.

Where a check crops a patch, the scene is arranged so the crop certainly falls inside the target. The zoomed-moon test is the example. At the 2° field of view it sets, the lit disc of the Moon — about 0.5° across — spans about 27% of the viewport height, some 190 pixels on a 1,280×720 screen. That 27% is the figure the test records, a little more than the 25% that 0.5° over 2° would give. A 120-pixel square at the centre has a diagonal of about 170 pixels, so it fits inside a disc 190 pixels across and the crop always lands on the lunar surface. In a correct frame almost no dark pixels appear there. What that check caught was dark patches eaten out of the magnified lunar surface; the story is in the section "Depth Rules and the Bugs Behind Them".

The governing rule is to . Every assertion here must fail on an all-black frame where nothing was drawn. Polling is used to wait for textures and atmosphere , and only on assertions with that all-black property. Polling retries until the assertion passes, so attaching it to a check that can pass on a half-loaded frame only makes a false pass more likely the longer it waits. Comparing a whole frame against a stored reference image pixel by pixel is deliberately not done: the same code produces different pixels on a different GPU, driver or font, so the verdict would be unstable. The statistics look only at properties that survive that variation.

Beyond the tests that watch the astronomy and the behaviour of the UI, five checks guard the premises of the typography and of the delivery itself. The first rejects typography slips in the strings a reader will see, and is run as its own command: looking only at the string literals in the source, it asks whether Japanese parentheses are the fullwidth ones, whether a stray space has crept in between Japanese and Latin characters, and whether English quotation marks are plain ASCII. Those slips are hard to notice by eye, so a machine finds them more reliably than a reviewer does.

The other four are the ones listed in the table of the section "Delivery and the Standalone Pages", and each guards something different. Two of them ride along with the normal test suite. The asset manifest is the hand-written list of the files that belong on R2; the check against it finds code referring to a file the manifest does not list, keeping a file nobody uploaded from becoming a 404 in production. The import check on the shared navigation keeps the menu that every page mounts from pulling in 3D scene code, which would then flow into every page bundle.

The remaining two are run as their own command. The live bucket probe finds, before a deploy, what the manifest lists but the R2 bucket lacks, and objects regenerated locally but never re-uploaded. Skip it and production is where the omission shows up. The bundle-isolation check hunts for code mixing between the standalone 3D pages and the main scene, because such mixing leaves the screen working normally while a visitor downloads pages they never opened.

The manual cross-check