CodingNic

Testing, Verification & Next Steps

Prompting Tests for the Core Logic

Testing, Verification & Next Steps 30 min read

Prompting Tests for the Core Logic

Objectives

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

  • Write a prompt that gets an AI assistant to set up Vitest and target the detection engine and geo helpers as the highest-value code in this app to test
  • Catch an AI assistant writing tests that only exercise the happy path and never pin down the false-to-true edge-triggering behavior that’s the entire reason the detection engine exists
  • Catch an AI assistant reusing one aircraft hex across a whole test file, which silently relies on detect.ts’s module-level state and produces order-dependent flaky results
  • Confirm a generated test suite is actually testing behavior, not just returning green, by breaking the real code on purpose and watching it fail

💡 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. An AI assistant can write a test file that runs, reports all green, and still not test the one behavior that matters. Reading generated tests with the same skepticism you’d read generated application code is the whole point of this lesson.

The Part of This App Most Worth Protecting

detectSquawkTransitions and detectOverflightTransitions aren’t pure functions in the strict sense. Each one keeps its own module-level state, a Map of currently-alerting hexes for squawks and a Set of currently-overflying hexes for overflights, so it can tell “this aircraft just started squawking 7700” apart from “this aircraft is still squawking 7700, same as last cycle.” That false-to-true transition is the entire design from the detection engine lesson: alert once, on the way in, not once per poll cycle for as long as the condition holds.

That means the thing most worth testing here isn’t “does this function return the right value for one input.” It’s “does this function correctly distinguish a new detection from a repeat of the same one,” which only shows up across two calls in sequence, not one. An AI assistant asked generically for “tests for the detection engine” has no particular reason to reach for that shape on its own. It has to be told the transition is the behavior under test, not the by-product.

The geo helpers (distanceNm, bearingDeg, viewRadiusNm) are the easier half of this lesson: genuinely pure, same input, same output, no shared state to reason about between test cases.

The Prompt

code
I'm adding Vitest tests to SKYWATCH. Set up Vitest in apps/server (vitest.config.ts including src/**/*.test.ts, a "test": "vitest run" script) and in packages/shared the same way, colocated *.test.ts files next to the code they test, not a parallel test/ directory. Write apps/server/src/poller/detect.test.ts for detectSquawkTransitions and detectOverflightTransitions (src/poller/detect.ts). Both functions keep module-level state (a Map of active squawk alerts, a Set of currently-overflying hexes) so they can tell a brand-new detection apart from the same aircraft still triggering the same condition on the next poll cycle. The behavior most worth testing is that transition itself, not just "does this return a detection for a bad input." For each function, cover: - the false-to-true transition fires a detection - calling it again with the same still-triggering input does NOT re-fire (no duplicate alert) - the condition clearing and re-triggering DOES fire again (squawk goes to normal, then back to an emergency code; aircraft leaves the overflight zone, then re-enters it) - the cases that should never fire at all (a non-emergency squawk, an aircraft outside the radius, above the altitude ceiling, or on the ground) Every test needs its own aircraft hex, since the Map/Set persist across every test in the file and a shared hex would let one test's calls pollute another's. Also write packages/shared/src/geo.test.ts for distanceNm, bearingDeg, and viewRadiusNm (packages/shared/src/geo.ts). Use a known real-world distance (JFK to LAX, ~2145nm) with a loose tolerance, not an exact float match, since the point is catching a wrong formula, not pinning down haversine's last decimal place. Show me every file.

What It Built

ts
// apps/server/vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["src/**/*.test.ts"],
  },
});
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);
  });
});
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);
  });
});

Review This

Three checks worth making by hand, because a generated test suite that runs green tells you almost nothing about whether it checked the right thing.

Does the suite actually assert on the transition, or only on the first call? This is the mistake most worth watching for, because a test file missing it still looks complete. It’s easy for an AI assistant to write it("detects an aircraft inside the radius", ...), see it pass, and stop there, since that single-call test genuinely does verify the function returns a detection for a triggering input. What it doesn’t verify is the harder, more valuable half of the spec: that calling the function again with the same still-triggering aircraft does not re-fire. A suite with only the first kind of test would still be green if someone accidentally deleted the module-level Map/Set entirely and turned both functions into “detect anything currently triggering, every single cycle,” which is exactly the bug this app cannot afford, since it would mean an alert firing over and over, once per poll, for the entire time an aircraft sits on an emergency squawk. Check for a does not re-emit (or equivalent) test on both detectSquawkTransitions and detectOverflightTransitions, each calling the function twice with the same input and asserting the second call returns nothing. If it’s missing, the follow-up prompt is direct: “these tests only cover the happy path. Add a test that calls the function twice with the same triggering aircraft and asserts the second call returns no detections, for both detectSquawkTransitions and detectOverflightTransitions.”

Does every test use its own hex, or did one get reused across the file? activeSquawkAlerts and currentlyOverflying are one shared Map/Set for every test that runs in the same file, in whatever order Vitest happens to run them. A test suite that reuses a single hex like "AA1234" across several it blocks will often still pass, by accident, if the tests happen to run in the order they were written and each one’s state lines up with what the next test expects. It becomes order-dependent and flaky the moment that’s no longer true, for instance if a later edit reorders two tests, or if you run the suite with vitest run --sequence.shuffle. Scan the file for hex literals and confirm each it block uses a hex nothing else in the file touches, the same way the generated file above uses sqk001 through sqk005 and ov001 through ov005. If you find a shared hex, the fix is: “these tests reuse the same aircraft hex across multiple cases, and detect.ts keeps module-level state across calls in the same process. Give each test its own unique hex so they can’t see each other’s state.”

Is the JFK-to-LAX distance test loose, or did it lock in an exact float? An AI assistant reaching for toBe(2145.3) (or whatever value it happened to compute) instead of a range looks more precise, but it’s actually a worse test. It’ll fail the instant distanceNm’s implementation changes in a way that’s still correct, like swapping the earth-radius constant for a marginally more accurate one, and it doesn’t actually verify anything more than the loose version does about correctness. Check for toBeGreaterThan/toBeLessThan bounding a real but approximate range, not a single hardcoded float. If it’s an exact match, the fix is: “the JFK-to-LAX distance test shouldn’t assert an exact float, that’s brittle. Use a loose range like greater than 2130 and less than 2160, it just needs to catch a genuinely wrong formula, not pin down the last decimal place.”

None of these three would show up as a red suite. All three would show up as a suite that’s green for the wrong reason, which is arguably worse than a suite that’s red, since a red suite at least tells you to go look.

Try It

  1. Run the prompt above against your AI coding assistant of choice, with apps/server and packages/shared already in place from Module 1.
  2. Read the generated detect.test.ts and geo.test.ts against the three checks above before running anything.
  3. Run npm run test -w @skywatch/server (or cd apps/server && npm test) and confirm all detect.test.ts cases pass. Add a "test": "vitest run" script to packages/shared/package.json if it isn’t already there, then run it and confirm geo.test.ts passes.
  4. Temporarily change detectOverflightTransitions’s dist <= home.radiusNm to dist < home.radiusNm in detect.ts and re-run the tests. Confirm at least one test fails. This is the real proof the suite is testing behavior, not just returning green by habit. Revert the change.
  5. Add one 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

  • The detection engine’s module-level state means the behavior most worth testing only shows up across two calls, not one. A prompt that doesn’t say so gives an AI assistant no particular reason to write that test.
  • A test suite that only covers the happy path is indistinguishable from a complete one by looking at whether it’s green. It’s only distinguishable by reading what it actually asserts, and checking for the specific missing case, edge-triggering, that this app’s correctness most depends on.
  • Reused hexes across test cases exploit shared module state by accident and produce results that depend on run order. Distinct hexes per test aren’t a style preference, they’re what keeps the suite deterministic.
  • Breaking the real code on purpose and watching a test fail is the only way to confirm a suite tests behavior at all, not just syntax.

Next lesson: directing a full end-to-end verification pass across the finished app, and why you can’t take an AI assistant’s word for it.