ブラウザに本物の夜空を描く第9節/全10節

検証戦略

手計算できない天文量は既知値で固定し、規約の取り違え(方位を南から測るなど)は「南東なら東向きの座標が正」のような向きの主張で捕まえ、property-basedテストで全域を覆い、最後は描かれた画素そのものをブラウザで検査します。


この節が答える問いは三つです。一つ目。手計算できない天文量の期待値を、どうやってテストに書くのか。二つ目。DOMからキャンバスの中身が一切見えないとき、「ちゃんと描けている」ことをどう主張するのか。三つ目。時刻と観測地点を固定した再現可能なテストを、テスト専用の仕掛けを増やさずに作れるか。

規模を先に書きます。Node環境のVitestで走るユニットテストが二百余りのファイルに4,362件あり、全部走らせても手元の環境では5秒あまりで終わります。実際のブラウザをPlaywrightで動かすエンドツーエンドテストは別勘定で、30ファイル・314件です。このうちの一部、件数にして204件に@smokeという札が付けてあります。短時間で回す代表テストという意味の札で、Playwrightがこの札で絞り込めるので、変更のたびに走らせる部分集合として使っています。

期待値を自分で計算できないという問題

この小見出しと次の二つが、一つ目の問いに答えます。天文計算のテストは、普通の単体テストのようには書けません。「東京で2026年1月1日の21時にシリウスはどこに見えるか」の答えは、136.51°・25.00°といった連続量です。手で出そうとすれば、を自分で実装することになり、それは被テストコードそのものです。期待値を計算した瞬間、テストは同じ実装を二度書いただけのものになります。

さらに、この領域で最も起きやすい失敗は誤差ではなく規約の取り違えです。方位を北0°ではなく南0°で測る、東西を反転させる、といった間違いは星を空のまったく別の場所に置きます。それでも画面には相変わらず星空が映るので、見た目では気づけません。期待値を被テストコード自身から作っている場合、取り違えた規約は期待値の側にも同じように入り込みます。すると許容誤差をどれだけ厳しくしてもこの種の間違いは捕まりません。値のずれではなく、両側がそろって別解になっているからです。

二段構え — 値を固定する検査と、向きを主張する検査

一つ目の問いへの答えは、二段構えです。一段目は、値そのものを固定します。この形でいちばん素性がよいのが、つまりを基準にした地球の自転角を時間で表したものです。、すなわち2000年1月1日12時UTにおけるグリニッジ恒星時は、天文暦の標準値として18時41分50.5秒=約18.6971時と決まっています。テストに出てくるGMSTは、このグリニッジ恒星時のことです。

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)
})
恒星時の既知値テスト(抜粋)

この検査で大事なのは許容幅の出どころです。恒星時の許容幅については、次の四つで言い尽くせます。

  • 狙った幅: 時間にして約1秒、つまり0.00028時。恒星時は角度を時間で表すので、その1秒は地球の自転角にして15にあたり、星の位置の精度としては十分に細かい幅です。
  • 実際に許す幅: 「小数第n位までの一致」という書き方は、差がその桁の半分より小さいこと、つまり0.5×10のマイナスn乗より小さいことを求めます。第3位なら許す幅は0.0005時、時間にして約1.8秒。狙いのおよそ倍です。
  • 1秒ちょうどに絞れない理由: 比べている二つが厳密には同じ量ではありません。標準値18.6971時は平均恒星時、この関数が返すのは春分点の実際の揺れを含んだ視恒星時で、両者の差は最大でも約1.2秒。1.8秒の内側に収まります。
  • 桁を選んだ根拠: 許す幅は「小数第n位までの一致」という書き方でしか表せないので、決められるのはnをいくつにするかだけです。そこで3を選んだ理由は「だいたい近ければよい」ではありません。時間にして1秒という、星の位置の精度として意味のある幅に照らして選んでいます。

外部に確立した参照値があるのは、下の表の四つのうち恒星時だけです。シリウスの方位・高度も日の出・日の入りも、月のも、同じ天文ライブラリの別経路で一度計算した値を定数として書き留め、以後はそれとの一致を見ています。これは外部との答え合わせではなく、以前と同じ結果が出続けることだけを確かめる回帰テストです。現状を定数として固定してあるので、値が黙って変わればテストが落ちます。だから値を意図して変えたときは、書き留めた定数を人が書き換えることが変更手順になり、その書き換え自体が「意図した変更である」という記録になります。外部との突き合わせのほうは、この節の最後の小見出し「人手による照合」で扱います。プラネタリウムソフトの表示と人が見比べる手順です。

検査する量参照判定
グリニッジ恒星時(元期J2000)天文暦の標準値18.6971時小数第3位までの一致
シリウスの方位・高度(東京、2026年1月1日21時)ライブラリに恒星として登録し、その時点の赤経・赤緯を経て方位と高度に直す、ライブラリ自身の道筋で一度求めた値小数第2位=0.005°
日の出・日の入り(東京、2026年の夏至)同じライブラリで一度求めた04:25・19:00(日本時間)04:21〜04:30の窓。参照値04:25の4分前から5分後まで
月の輝面比(2026年1月)同じライブラリの位相探索が返す満月・新月の時刻(1月3日10:03 UTC・1月18日19:52 UTC)満月は0.99超、新月は0.02未満

この四つのうち、日の出・日の入りの窓だけは幅の根拠がコードに書かれていません。分単位に丸めた参照値に余裕を持たせた調整値です。

二段目は、数値ではなく向きを主張します。シリウスは1月の21時、東京では南東の低空にあります。シーンの座標軸は、つまりXが北、Yが上、Zが東を向きます。南東なら北向きのXが負で東向きのZが正、地平線より上なら上向きのYが正。つまり「xが負、yが正、zが正」という符号の条件になります。夜明け前のが天文薄明・航海薄明・市民薄明の順に来て日の出に至る、満月付近のは0°に近い、月のは±10°以内、といった主張も同じ性格のものです。この±10°は外部の参照値ではなく、秤動が物理的に取り得る振れ幅の上限として置いた数です。こうした主張は誤差では落ちず、規約を取り違えたときだけ落ちます。必ず値の固定と対にして置いています。

property-basedテストで全域を覆う

一つ目の問いへの答えは、ここで三つ目の形を取ります。2026年9月時点で、のファイルが42あります。先に挙げた二百余りのファイルの内数です。入力を自動で作るのに使っているライブラリはです。例示テストが「この入力ではこの出力になる」を一点で固定するのに対し、property-basedテストは「どんな入力でもこの関係が成り立つ」を、自動生成した多数の入力で確かめます。反例が見つかると、その値を縮小して最小の失敗例を示してくれます。

座標変換の性質は往復です。からへ、そこから赤経・赤緯へ戻したとき、のどの向きでも元の値に戻り、途中のベクトルは常に長さ1である、という主張を球面全体で検査します。許容幅は勘で置いた丸い数ではなく、三つの数の関係として決めています。赤緯の往復を例に取ると、こうなります。

  • 理論値: 往復でずれうる大きさは、極に近い赤緯89°でも約7.3×10⁻¹³度、1度の1兆分の1より小さいと見積もれます。桁を見積もった値であって、誤差の上限を証明したものではありません。導出は次のとおりです。出発点は倍精度の丸め幅、約2.2×10⁻¹⁶。赤緯を逆正弦で復元すると、この誤差が1/cos(赤緯)倍に拡大され、赤緯89°では1÷cos89°でおよそ57倍。約1.3×10⁻¹⁴ラジアンとなり、度に直すと先の7.3×10⁻¹³度になります。
  • 採用値: 1×10⁻¹¹度。理論値におよそ10倍以上の余裕を持たせた値です。
  • 実測最悪値: 200万サンプルで観測した最大の誤差が8.4×10⁻¹³度。採用値の内側にあり、しかも理論値と同じ桁なので、見積もりの桁が実際と合っていることを示します。この200万サンプルは許容幅を決めるときに一度だけ回した計測で、書き留めてあるのはその結果です。ふだんのテストが1つの性質について生成する入力は、これよりはるかに少なく、fast-checkの既定である100件です(テストによっては200〜300件を明示して増やしています)。

天の極の近くだけは事情が違い、赤経が本質的に不定になります。そこでは角度どうしではなく、ベクトルどうしの角距離で比べます。

共有URLは、観測地点・時刻・カメラの向きをURLのクエリ文字列に書いて、場面をそのまま渡せるようにしたリンクです。そのデコーダには、敵対的な入力を投げます。空文字、Infinity1e4000x10、任意のUnicode文字列、それに無関係なキーの混入。主張は二つで、どんな入力でも例外を投げないこと、返ってくる数値が必ず文書化された範囲に収まることです。緯度は±90°、経度は±180°、標高は−500〜9,000mに切り詰められます。

// 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()
    }),
  )
})
共有リンクのデコーダのproperty-basedテスト(抜粋)

月面ウォーク(1/6重力の月面を歩く)の運動学は、例示テストとproperty-basedテストの書き分けが分かりやすいところです。月の重力加速度1.62m/s²という値そのものを、property-basedテストでは固定しません。跳躍の頂点や落下時間を、重力加速度に対する関係としてだけ主張します。この分担は実際に試して確かめてあり、1.62を9.81へ書き換えると、property-basedテストは全部通ったままで、アポロの実測値を引いてきた例示テストのほうが8件落ちました。値を守るのは例示テスト、形を守るのはproperty-basedテストという役割分担です。

純粋な計算層とビルドスクリプトを、走らせずに検査する

この小見出しは三つの問いからは外れ、テスト一式がなぜこれだけ速く終わるのかに答えます。速さの理由の大きな部分は、天文計算やURLの符号化といった中核がに置いてあることだと考えられます。その層はもReactもストアも読み込まない決まりになっていて、DOM環境なしに素のNodeでそのまま実行できます。描画も、読み込みの待ち合わせも入りません。

データを生成するビルドスクリプトも同じ土俵に上げてあります。星カタログや星座線や日食カタログを作るスクリプトは、実行されたときだけ本体が動く形に書いてあり、テストからimportしたときはダウンロードもファイル書き出しも起きません。おかげで、から色への変換やバイナリへの詰め込みといった純粋な部分だけを、生データなしで検査できます。

テスト用の道具立てが製品コードに紛れ込まないことも、別のテストが見張っています。やり方は素朴です。アプリ側のソースファイルをすべてディスクから読み、書かれているimport文の指定先を一つずつ調べて、テスト専用のディレクトリ、つまり製品コードからimportしてはいけない置き場を指すものが一つも無いことを確かめます。バンドラもネットワークも使いません。このテスト専用のディレクトリに入っているのは、固定した観測地点や固定した時刻といった検査用のデータと、入力を自動生成するfast-checkです。どれも、利用者へ配信される成果物に混ざる理由がありません。この走査に見えるのは文字列で直に書かれた指定だけで、読み込む先を実行時に組み立てる書き方は見えません。

エンドツーエンド — 「描けている」をどう主張するか

この小見出しが、二つ目と三つ目の問いにまとめて答えます。ブラウザテストは開発サーバではなく、の出力out/をそのままサーブして走ります。検査対象は本番へ出るものと同じ静的ファイルで、違うのは大きなファイルの配信元だけです。テスト中はそれらもページ本体と同じオリジンから配られ、本番ではから配ります。そのかわりビルドを忘れると古いコードを黙って検査してしまうので、疑わしいときは必ず作り直します。この作り直しは自動化されておらず、開発者の規律で守っています。

再現性は、製品機能をそのまま流用して確保しています。共有リンクのクエリ文字列は観測地点・時刻・時間の進む速さの倍率・視線方向を決定論的に固定するので、それがそのままテストの初期条件になります。テスト専用のAPIは要りません。逆に、開発ビルドにしか存在しないデバッグ用の出入口は使わない規律を敷いています。たとえばカメラが本当に目標の天体(火星など)を向いたかは、メニューから共有リンクをコピーし、その中の方位と高度を読み返すことで確かめます。製品の経路を通るので、検査そのものが機能の検査になります。

描画そのものは、DOMからは見えません。canvasの中身はアクセシビリティツリーに現れないので、要素を探す形の検査は原理的に届きません。そこでスクリーンショットを撮り、画像処理ライブラリsharpで生のに展開し、明るさの統計として検査します。

統計といっても素朴なものが三つです。一つ目は画素ごとの明るさで、赤・緑・青をの重みで混ぜた0から255の値です。これは光の量そのものである輝度ではなく、画面に出た色の成分から作る表示上の明るさで、正確にはlumaと呼ばれます。緑の比重が大きいのは人の目が緑にいちばん敏感だからです。次に、ある閾値より暗い画素の割合。三つ目が、明るい画素だけを囲む矩形です。矩形は月球儀(/moon/)のページで使い、幅と高さの差が長いほうの6%未満であること、つまり正方形に近いことと、矩形のうち明るい画素が占める割合が内接円の面積比π/4=0.785付近、具体的には0.7から0.92のあいだにあることを求めます。6%にも0.7〜0.92にも導出はなく、丸さの判定に置いた調整値です。半分だけ光った月なら、二つの条件がそろって落ちます。明るい部分だけを囲む矩形は縦長になって正方形の条件を外れ、矩形に占める割合も0.4前後まで下がるからです。だからこの検査は満月の場面で使います。

切り抜きを使う検査では、切り抜いた範囲が対象の内側に確実に収まるように場面を組みます。月を視野2°まで拡大した検査がその例です。この視野設定では、視直径約0.5°の月の光っている円盤が画面高のおよそ27%、1,280×720の画面なら約190画素を占めます。この27%はテストが記録している実測値で、0.5°を2°で割った25%より少し大きい値です。中央から120画素四方を切り出すと、その正方形の対角は約170画素。直径190画素の円盤の内側に収まるので、切り抜きは必ず月面の上に載ります。正しく描けていれば、そこに暗い画素はほとんど出ません。この検査が捕まえたのは、拡大した月面に出る黒い欠けでした。経緯は「深度の規約と、踏んだ不具合」の節にあります。

書き方の原則はです。何も描けていない真っ黒なでは必ず落ちるように、アサートを組みます。テクスチャや大気のの読み込みを待つのにポーリングを使いますが、使う相手は、真っ黒なフレームでは落ちるという性質を持つアサートに限ります。ポーリングは通るまで再試行する仕組みなので、読み込み途中のフレームでも偶然通ってしまう検査に付けると、待てば待つほど誤って合格しやすくなるからです。なお、画面を丸ごと基準画像と1画素ずつ比べる方式は採っていません。同じコードでもGPUやドライバやフォントの違いで画素が変わり、判定が安定しないためです。統計で見るのは、そのぶれに左右されない性質だけです。

天文計算やUIの振る舞いを見るテストとは別に、表記と配信の前提そのものを守る検査が5つあります。1つ目は、画面に出る文字列の表記の乱れを落とすもので、専用のコマンドで走らせます。ソースの文字列リテラルだけを見て、日本語の括弧が全角か、日本語と英数字のあいだに余計な空白が無いか、英語の引用符がASCIIかを調べます。目で見て気づきにくい逸脱なので、人の目より機械のほうが安定して見つけられます。

残る4つは「配信基盤と独立ページ」の節の表にある検査で、守るものがそれぞれ違います。そのうち2つは通常のテスト一式に同乗します。アセット台帳(R2に置くファイルの一覧を手で書いた台帳)との照合は、台帳に載っていないファイルを参照するコードを見つけ、上げ忘れたファイルが本番で404になるのを防ぎます。共通ナビゲーションのimport検査は、全ページに載るメニューが3Dシーンのコードを引き込み、それが全ページのバンドルへ流れ込むのを防ぎます。

残る2つは専用のコマンドで走らせます。バケットの実在確認は、台帳に載っているのにR2のバケットに無いもの、あるいは作り直したのに上げ直していない古いものを、デプロイの前に見つけます。怠れば、取りこぼしが出るのは本番です。バンドル分離の検査は、独立3Dページどうしと本編のコードの混ざりを探します。混ざっても画面は普通に動いたままで、訪問者が開いてもいないページのぶんまでダウンロードさせるからです。

人手による照合