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 pixel — One of the small squares a screen is divided into; a picture is the whole array of them. Colour is decided one pixel at a time, so the cost of drawing follows roughly from how many of them have to be filled. live inside a The web standard that lets a page draw 3D graphics with the GPU, the chip built for rendering, straight from the browser. No plugin is involved. 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 — The angle measured along the observer's horizon eastward from due north: east is 90 degrees, south 180, west 270. 136.51°, The angle of an object above the observer's horizon: 0 degrees at the horizon, 90 degrees straight overhead, negative when the object is below it. 25.00°. Deriving it by hand means implementing The slow circling of Earth's rotation axis, one turn in about 26,000 years. It carries the vernal equinox, the zero point of sky coordinates, along with it, so every star's coordinates drift as well., aberration of light — The small tilt of an incoming ray towards the observer's own direction of travel, caused by that motion. Earth's orbital motion displaces a star by up to about 20 arcseconds, roughly a 180th of a degree. and atmospheric refraction — The bending of light by Earth's atmosphere, which makes an object appear higher than it really is. The lift is largest near the horizon, where it reaches about 34 arcminutes, or 0.57 degrees. 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 Earth's rotation angle expressed as a clock, measured against the vernal equinox, the crossing of the celestial equator and the Sun's yearly path, rather than against the Sun. Because it is an angle written in units of time, 24 hours is 360 degrees and one hour is 15 degrees. In almanac terms, where a star appears is set by this value rather than by solar time, though whether a given implementation uses it directly is another question., the Earth's rotation angle measured from the The point where the Sun's yearly path crosses the celestial equator, the projection of Earth's equator onto the sky, moving from south to north. Longitude-like angles on the sky are all measured from it. and expressed in hours. At the One of the reference epochs that say when a set of astronomical coordinates applies: noon Universal Time on 1 January 2000. Most star catalogues give their positions in it. The reference moment that says when a set of coordinates or orbital values applies. J2000 is the one most widely used for star positions. — 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)
})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 arcsecond — A unit of angle equal to one 3600th of a degree. The full Moon is about 1800 arcseconds across. 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 The fraction of a body's apparent disc that looks lit: 0 at new Moon, 0.5 at a half Moon, 1 at full. 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.
| Quantity | Reference | Criterion |
|---|---|---|
| Greenwich sidereal time at the J2000 reference epoch | Standard almanac value, 18.6971 h | Agreement to three decimals |
| Sirius azimuth and altitude, Tokyo, 2026-01-01 21:00 | Computed once through the library's own path: register the star, take its right ascension and declination of date, convert those to azimuth and altitude | 2 decimals = 0.005° |
| Sunrise and sunset, Tokyo, June solstice 2026 | Computed once with the same library: 04:25 and 19:00 local time | A 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 2026 | Instants 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 The name of the axis convention Asterarium's 3D scene uses. With the observer at the origin, the X axis points true north, the Y axis to the zenith and the Z axis true east. The name is the initials of North, Up and East. 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 The stretch of time when the Sun is below the horizon but the sky is still lit. It deepens as the Sun sinks, so an evening runs civil twilight, down to 6 degrees below the horizon, then nautical to 12, then astronomical to 18. A morning takes the same stages in reverse, from astronomical through nautical and civil to sunrise. must arrive as astronomical, then nautical, then civil, then sunrise; the The angle between the Sun and the observer as seen from the body itself. For the Moon, 0 degrees is full and 180 degrees is new. How much of the disc looks lit follows from this angle alone, so it cannot tell a waxing Moon from a waning one. at full moon must be close to zero; lunar The slight rocking of the Moon as seen from Earth. Because of it, about 59 per cent of the lunar surface becomes visible over time. 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 property-based testing — Testing that generates many inputs automatically and checks a property that should hold for all of them, instead of listing cases one by one. A failing input is shrunk down to its smallest form. files, part of the file count above. The library that generates the inputs is A JavaScript library for tests that generate their inputs and check a property over all of them.. 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: The longitude-like angle on the sky, measured eastward along the celestial equator, which is Earth's equator projected outward, starting from the vernal equinox where the Sun's yearly path crosses it. It is conventionally written in hours, where 24 hours is 360 degrees. and The latitude-like angle on the sky, measured from the celestial equator, which is Earth's equator projected outward: positive to the north, negative to the south, up to 90 degrees. to a A vector whose length is exactly 1, so that it carries a direction and nothing else. It is the natural way to hold a quantity like where a star appears, which is a direction without a distance. and back must return the original direction anywhere on the In astronomy, an imaginary sphere centred on the observer, used to describe the direction in which something appears. It carries no distance at all: it keeps the direction to an object and discards how far away it really is. In this article the word also names the container of 3D objects standing in for it, and since one scene unit is about a metre, the stars sit on a sphere of 1000 units, a kilometre in radius., 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()
}),
)
})The kinematics of Moonwalk — the In Asterarium, a 3D page that shares no scene code with the main sky or with the other such pages, and there are eight of them: the Moon globe, the moonwalk, the Mars globe, the deep field, the orrery, the interstellar flight, the eclipse viewer and the projector. Each one ships as its own bundle of code, so a visitor downloads only the page they opened. 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 In Asterarium, the modules that reference neither three.js, nor React, nor any state store: the astronomy maths and the code around it. They run without a browser, so testing them is a matter of handing in an input and checking the value that comes back.. That layer is kept free of The standard JavaScript library layered over WebGL, the browser 3D interface. It expresses a picture as scenes, cameras, geometries and materials instead of raw draw calls., 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 The difference between a star's brightness measured through a blue filter and through a yellow one. A larger value means a redder star and a smaller one a bluer star, which makes it a proxy for surface temperature. 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 Building the whole site into HTML and JavaScript files ahead of time, so nothing is assembled per request; delivery is only the handing out of files that already exist. 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 Cloudflare's object storage, the place for large files such as textures and binaries. Unlike most comparable services it charges nothing for data leaving the bucket.. 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 The colour standard set for high-definition television, sharing its primaries and white point with sRGB, the standard for computer displays. The weights for collapsing a colour into a single brightness figure, 0.2126 red, 0.7152 green and 0.0722 blue, come from it. 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 A design stance of refusing rather than allowing whenever the outcome is uncertain. For a test it means never writing an assertion loose enough to pass on a blank screen.. Every assertion here must fail on an all-black frame where nothing was drawn. Polling is used to wait for textures and atmosphere lookup table — A precomputed array of values that is read instead of recomputed. On the GPU it is held as an image, turning an expensive calculation into a single fetch., 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.