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
What It Built
// apps/server/vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.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);
});
});
// 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
- Run the prompt above against your AI coding assistant of choice, with
apps/serverandpackages/sharedalready in place from Module 1. - Read the generated
detect.test.tsandgeo.test.tsagainst the three checks above before running anything. - Run
npm run test -w @skywatch/server(orcd apps/server && npm test) and confirm alldetect.test.tscases pass. Add a"test": "vitest run"script topackages/shared/package.jsonif it isn’t already there, then run it and confirmgeo.test.tspasses. - Temporarily change
detectOverflightTransitions’sdist <= home.radiusNmtodist < home.radiusNmindetect.tsand 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. - 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.