CodingNic

Testing, Verification & Next Steps

Testing the Core Logic

Testing, Verification & Next Steps 30 min read

Testing the Core Logic

Objectives

By the end of this chapter, you should be able to:

  • Set up Vitest across the monorepo and explain why the detection engine and geo helpers are the highest-value code in this app to put tests around
  • Write tests for detectSquawkTransitions/detectOverflightTransitions that correctly account for their module-level state, instead of fighting it or accidentally testing something else
  • Write tests for the pure geo functions (distanceNm, bearingDeg, viewRadiusNm) and radiusNmToZoom

💡 Why this matters: Nothing in this app is more dangerous to get subtly wrong than the detection engine — a bug there doesn’t crash anything, it just silently fails to alert on an emergency squawk or an overflight, and nothing on screen tells you that happened. Pure functions with no I/O are also the cheapest code in this entire codebase to test: no database, no server, no mocking a WebSocket, just inputs and outputs.

Install and Configure Vitest

bash
cd apps/server
npm install -D vitest
ts
// apps/server/vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["src/**/*.test.ts"],
  },
});

Add "test": "vitest run" to apps/server/package.json’s scripts, alongside the dev/typecheck/lint scripts that have been there since Module 1. Colocating *.test.ts files next to the code they test (rather than a parallel test/ tree) keeps a file and its tests moving together on every rename — no separate directory structure to keep in sync by hand.

Testing the Detection Engine

detect.ts’s two functions are deliberately not pure in the strict sense — each keeps its own module-level state (activeSquawkAlerts, a Map; currentlyOverflying, a Set) so it can tell “this aircraft just started squawking 7700” apart from “this aircraft is still squawking 7700, same as last cycle” — that’s the whole point of the false→true transition design from Module 2. That state persists across every call within the same module instance, which means tests need to work with the stateful design instead of against it: give each test its own aircraft hex so its transitions can’t be polluted by another test’s calls in the same file, and where a test genuinely needs to verify “no duplicate alert on the second call,” make that sequence of two calls the test itself, rather than trying to reset hidden state between tests.

ts
// apps/server/src/poller/detect.test.ts
import { describe, expect, it } from "vitest";
import type { AircraftState } from "@skywatch/shared";
import { detectOverflightTransitions, detectSquawkTransitions } from "./detect.js";

function aircraft(overrides: Partial<AircraftState> & { hex: string }): AircraftState {
  return {
    flight: null,
    lat: null,
    lon: null,
    alt_baro: null,
    alt_geom: null,
    track: null,
    squawk: null,
    ...overrides,
  } as AircraftState;
}

describe("detectSquawkTransitions", () => {
  it("emits a detection the moment an aircraft starts squawking an emergency code", () => {
    const detections = detectSquawkTransitions([aircraft({ hex: "sqk001", squawk: "7700" })]);
    expect(detections).toHaveLength(1);
    expect(detections[0]).toMatchObject({ hex: "sqk001", squawk: "7700" });
  });

  it("does not re-emit on the next cycle while still squawking the same code", () => {
    const hex = "sqk002";
    detectSquawkTransitions([aircraft({ hex, squawk: "7600" })]); // first cycle -- consumes the transition
    const second = detectSquawkTransitions([aircraft({ hex, squawk: "7600" })]);
    expect(second).toHaveLength(0);
  });

  it("emits again if the squawk code changes between two emergency codes", () => {
    const hex = "sqk003";
    detectSquawkTransitions([aircraft({ hex, squawk: "7500" })]);
    const changed = detectSquawkTransitions([aircraft({ hex, squawk: "7700" })]);
    expect(changed).toHaveLength(1);
  });

  it("ignores non-emergency squawks entirely", () => {
    const detections = detectSquawkTransitions([aircraft({ hex: "sqk004", squawk: "1200" })]);
    expect(detections).toHaveLength(0);
  });

  it("stops alerting once the aircraft returns to a normal squawk, then re-alerts if it goes emergency again", () => {
    const hex = "sqk005";
    detectSquawkTransitions([aircraft({ hex, squawk: "7700" })]); // alerting
    detectSquawkTransitions([aircraft({ hex, squawk: "1200" })]); // back to normal -- clears state
    const again = detectSquawkTransitions([aircraft({ hex, squawk: "7700" })]);
    expect(again).toHaveLength(1); // re-alerts, doesn't stay silently "already seen"
  });
});

describe("detectOverflightTransitions", () => {
  const home = { lat: 40.0, lon: -74.0, radiusNm: 10, altitudeCeilingFt: 5000, updatedAt: new Date().toISOString() };

  it("detects an aircraft inside the radius and below the ceiling", () => {
    const detections = detectOverflightTransitions(
      [aircraft({ hex: "ov001", lat: 40.02, lon: -74.02, alt_baro: 3000 })],
      home
    );
    expect(detections).toHaveLength(1);
    expect(detections[0].hex).toBe("ov001");
  });

  it("ignores an aircraft above the altitude ceiling even if it's within radius", () => {
    const detections = detectOverflightTransitions(
      [aircraft({ hex: "ov002", lat: 40.02, lon: -74.02, alt_baro: 30000 })],
      home
    );
    expect(detections).toHaveLength(0);
  });

  it("ignores an aircraft outside the radius even at a low altitude", () => {
    const detections = detectOverflightTransitions(
      [aircraft({ hex: "ov003", lat: 41.5, lon: -74.0, alt_baro: 2000 })],
      home
    );
    expect(detections).toHaveLength(0);
  });

  it("ignores aircraft on the ground", () => {
    const detections = detectOverflightTransitions(
      [aircraft({ hex: "ov004", lat: 40.02, lon: -74.02, alt_baro: "ground" })],
      home
    );
    expect(detections).toHaveLength(0);
  });

  it("does not re-emit for the same aircraft still overflying on the next cycle", () => {
    const hex = "ov005";
    const state = aircraft({ hex, lat: 40.02, lon: -74.02, alt_baro: 3000 });
    detectOverflightTransitions([state], home);
    const second = detectOverflightTransitions([state], home);
    expect(second).toHaveLength(0);
  });
});

Every squawk test uses its own hex (sqk001, sqk002, …) for exactly the reason the prose above calls out — activeSquawkAlerts is one shared Map across every test in this file, so two tests sharing a hex would see each other’s state and produce confusing, order-dependent failures. The “re-alerts after returning to normal” test is the one case worth calling out by name: it’s checking that clearing an aircraft’s alerting state on a normal squawk is itself correct, not just an oversight — without it, an aircraft that squawked 7700 once and then flew normally for the rest of the flight would never be able to alert again even if it started squawking a real emergency code an hour later.

Testing the Geo Helpers

distanceNm, bearingDeg, and viewRadiusNm are genuinely pure — same input, same output, every time, no shared state to worry about between tests.

ts
// packages/shared/src/geo.test.ts
import { describe, expect, it } from "vitest";
import { bearingDeg, distanceNm, viewRadiusNm } from "./geo.js";

describe("distanceNm", () => {
  it("returns ~0 for the same point", () => {
    expect(distanceNm(40.0, -74.0, 40.0, -74.0)).toBeCloseTo(0, 5);
  });

  it("matches a known great-circle distance within rounding tolerance", () => {
    // JFK (40.6413, -73.7781) to LAX (33.9416, -118.4085) is ~2145nm.
    const nm = distanceNm(40.6413, -73.7781, 33.9416, -118.4085);
    expect(nm).toBeGreaterThan(2130);
    expect(nm).toBeLessThan(2160);
  });
});

describe("bearingDeg", () => {
  it("returns ~0 (north) for a point due north", () => {
    expect(bearingDeg(40.0, -74.0, 41.0, -74.0)).toBeCloseTo(0, 0);
  });

  it("returns ~90 (east) for a point due east", () => {
    expect(bearingDeg(40.0, -74.0, 40.0, -73.0)).toBeCloseTo(90, 0);
  });

  it("always returns a value in [0, 360)", () => {
    const deg = bearingDeg(40.0, -74.0, 39.0, -75.0);
    expect(deg).toBeGreaterThanOrEqual(0);
    expect(deg).toBeLessThan(360);
  });
});

describe("viewRadiusNm", () => {
  it("floors small viewports at the 10nm minimum", () => {
    expect(viewRadiusNm(500)).toBe(10);
  });

  it("caps huge viewports at the 250nm adsb.lol maximum", () => {
    expect(viewRadiusNm(2_000_000)).toBe(250);
  });

  it("converts meters to nautical miles in between", () => {
    // 185,200m ≈ 100nm (1852m per nm).
    expect(viewRadiusNm(185_200)).toBe(100);
  });
});

The JFK→LAX distance test is deliberately loose (a 30nm tolerance either side of ~2145) rather than asserting an exact float — it’s there to catch a wrong formula (a sign error, a swapped lat/lon, degrees vs. radians), not to pin down haversine’s last decimal place, which would make the test brittle for no real benefit.

Try It

  1. Run npm run test -w @skywatch/server (or cd apps/server && npm test) and confirm all detect.test.ts cases pass.
  2. Add a "test": "vitest run" script to packages/shared/package.json too, then run it and confirm geo.test.ts passes.
  3. Temporarily change detectOverflightTransitions’s dist <= home.radiusNm to dist < home.radiusNm and re-run the tests — confirm at least one test fails. Revert the change. This is the real value of the suite: it should fail loudly the moment the detection logic’s actual behavior changes, not just when something crashes.
  4. Add one more test of your own: an aircraft exactly at home.radiusNm (not inside, not outside — right on the boundary) and confirm the <= in the real code counts it as an overflight.

Recap

  • detectSquawkTransitions and detectOverflightTransitions keep module-level state on purpose — tests have to be written with that in mind (distinct hexes per test, or a deliberate multi-call sequence within one test) rather than treated as if they were stateless.
  • A loose-tolerance assertion on a known real-world distance catches a broken formula without being brittle to floating-point noise — tighter isn’t always better for a test.
  • npm run test joins typecheck/lint/build as a fourth command worth running before trusting a change — the next lesson uses all four together as part of the full verification pass.

Next lesson: a full, module-by-module verification pass across the finished app.