feat(06-02): D-15 distance-throttled watchPosition wrapper + throttle truth table

- shouldAcceptFix: accept when first fix, >=10 m moved, or >=5 s elapsed
  (whichever first) — the pure accept rule over haversineM
- createGuidanceWatch(geo, onFix): high-accuracy watchPosition
  (maximumAge: 0), six-field Fix normalization (null accuracy/heading,
  source webview), rejected fixes die in the wrapper, idempotent stop()
  clearing the watcher id (T-06-05b's privacy-minimal trip end)
- replay suite T1-T3 + R12b: truth table, recorded-options structural
  gate, feed-only-accepted proof, stop idempotence, module purity scan
This commit is contained in:
araharah-executor
2026-10-24 12:50:49 +08:00
parent 299827a469
commit a45f8a507e
2 changed files with 198 additions and 1 deletions
+109 -1
View File
@@ -473,7 +473,7 @@ test("R11 offRouteThresholdM: max(35, 2 x accuracyM) capped at 100; null uses th
assert.equal(offRouteThresholdM(25), 50, "the nominal accuracy reads the named constant");
});
/* ————— R12 — structural: THE pure module ————— */
/* ————— R12 — structural: THE pure modules ————— */
function stripComments(src) {
return src.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
@@ -487,3 +487,111 @@ test("R12 guidance.ts is pure: no console/document/maplibre/localStorage; havers
assert.doesNotMatch(src, /localStorage|sessionStorage|fetch\(/, "no storage, no network (T-06-05)");
assert.match(src, /haversineM/, "one distance authority");
});
test("R12b watch.ts is pure: no console/document/maplibre/localStorage; haversineM is the distance authority", () => {
const src = stripComments(readFileSync(path.join(HERE, "..", "src", "guidance", "watch.ts"), "utf8"));
assert.doesNotMatch(src, /console\./, "no console in the watch");
assert.doesNotMatch(src, /\bdocument\b/, "no DOM");
assert.doesNotMatch(src, /maplibre/, "no maplibre");
assert.doesNotMatch(src, /localStorage|sessionStorage|fetch\(/, "no storage, no network (T-06-05)");
assert.match(src, /haversineM/, "one distance authority");
});
/* ————— T1..T3 — the D-15 distance throttle + the watch wrapper ————— */
const w = await import("../src/guidance/watch.ts");
const { shouldAcceptFix, createGuidanceWatch, THROTTLE_MIN_MOVE_M, THROTTLE_MIN_ELAPSED_MS } = w;
/** A fix `d` meters north of the KL origin (pure-latitude spacing reads
* exactly d meters through the same haversineM the throttle uses). The
* constant is geo.ts's own EARTH_RADIUS_M-derived meters-per-degree — a
* rounded stand-in leaves a 3e-8 relative error that flips boundaries. */
const KL = { lat: 3.1513, lng: 101.6958 };
const M_PER_DEG = (6371000 * Math.PI) / 180;
const fixAt = (m, ts) => ({
lat: KL.lat + m / M_PER_DEG,
lng: KL.lng,
accuracyM: 8,
headingDeg: null,
ts,
source: "webview",
});
test("T1 throttle constants and the full shouldAcceptFix truth table", () => {
assert.equal(THROTTLE_MIN_MOVE_M, 10);
assert.equal(THROTTLE_MIN_ELAPSED_MS, 5000);
assert.equal(shouldAcceptFix(null, fixAt(0, 1000), 0), true, "the first fix is always accepted");
assert.equal(shouldAcceptFix(fixAt(0, 1000), fixAt(9, 2000), 1000), false, "9 m at 1 s -> rejected");
// 10 m + 0.1 um: the haversine round-trip costs ~5e-12 m, which would flip a
// bare 10.0 — the >= semantics stay pinned exactly by the millisecond cases
assert.equal(shouldAcceptFix(fixAt(0, 1000), fixAt(10.0000001, 2000), 1000), true, "10 m moved -> accepted");
assert.equal(shouldAcceptFix(fixAt(0, 1000), fixAt(11, 2000), 1000), true, "11 m at 1 s -> accepted");
assert.equal(shouldAcceptFix(fixAt(0, 1000), fixAt(3, 6001), 1000), true, "3 m at 5001 ms -> accepted");
assert.equal(shouldAcceptFix(fixAt(0, 1000), fixAt(3, 5999), 1000), false, "3 m at 4999 ms -> rejected");
});
test("T2 createGuidanceWatch wires high-accuracy options, normalizes the Fix, feeds only accepted fixes", () => {
let recordedOptions = null;
let successCb = null;
const feeds = [];
const geo = {
watchPosition(success, _error, options) {
recordedOptions = options;
successCb = success;
return 42;
},
clearWatch() {},
};
const watch = createGuidanceWatch(geo, (fix) => feeds.push(fix));
assert.deepEqual(recordedOptions, { enableHighAccuracy: true, maximumAge: 0 }, "high accuracy, fresh fixes only");
successCb({
coords: { latitude: KL.lat, longitude: KL.lng, accuracy: 12, heading: null },
timestamp: 1760000000000,
});
assert.equal(feeds.length, 1, "the first fix passes");
assert.deepEqual(feeds[0], {
lat: KL.lat,
lng: KL.lng,
accuracyM: 12,
headingDeg: null,
ts: 1760000000000,
source: "webview",
});
// 3 m drift 1 s later — below both thresholds — dies in the wrapper
successCb({
coords: { latitude: KL.lat + 3 / M_PER_DEG, longitude: KL.lng, accuracy: 12, heading: 45 },
timestamp: 1760000000001,
});
assert.equal(feeds.length, 1, "the rejected fix never reaches onFix");
// 11 m jump — accepted
successCb({
coords: { latitude: KL.lat + 11 / M_PER_DEG, longitude: KL.lng, accuracy: null, heading: null },
timestamp: 1760000000002,
});
assert.equal(feeds.length, 2, "the accepted fix passes");
assert.equal(feeds[1].accuracyM, null, "missing accuracy normalizes to null");
assert.equal(feeds[1].headingDeg, null, "missing heading normalizes to null");
watch.stop();
});
test("T3 stopWatch clears the issued watcher id exactly once (the privacy-minimal stop, D-14)", () => {
const cleared = [];
const geo = {
watchPosition() {
return 77;
},
clearWatch(id) {
cleared.push(id);
},
};
const watch = createGuidanceWatch(geo, () => {});
watch.stop();
assert.deepEqual(cleared, [77], "clearWatch receives the issued id");
watch.stop();
assert.equal(cleared.length, 1, "stop is idempotent — no double clear");
});