feat(06-07): PURE projectTurnCards + makeStreetAt + the 21 turn-card copy keys (GREEN)
- cron.ts: linear progress projection (clamped), stateless 180 s cadence (due = nowSec % 180 < 60), dead-trip gate, classifyTurn-authority next-turn detection over consecutive-leg bearings, 4-line HTML caption anatomy - D-12 street by DEFAULT: makeStreetAt hops snapToGraph → matched segment's nameId → nameAt (never the node index); snap miss → bare-verb degradation; matched-unnamed → cardStreetUnnamed - strings.ts: cardAction* (8) + cardActionBare* (8) + cardStreetUnnamed + cardEtaTemplate + cardHonesty + cardOpenButton, en + ms verbatim - GC1-GC8 + CS7 green: 116 passed, 0 failed; bot:typecheck exit 0
This commit is contained in:
@@ -0,0 +1,261 @@
|
|||||||
|
/*
|
||||||
|
* cron.ts — the minimized turn-card projection (06-07, GUID-05; D-11/D-12/D-13).
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* PURE LAW: projectTurnCards and makeStreetAt are PURE — injected nowSec and
|
||||||
|
* graph, no KV, no R2, no Telegram, no clock reads — Node-testable. The
|
||||||
|
* scheduled() wiring lives in webhook.ts (the ExportedHandler's scheduled
|
||||||
|
* arm): it lists guidance:* keys, reads each envelope through the
|
||||||
|
* defensively-validated getGuidanceSession, and pushes due cards. ALL logging
|
||||||
|
* lives there too — this module has no console at all.
|
||||||
|
*
|
||||||
|
* D-12 (street by DEFAULT): the FROZEN envelope carries geometry only (GE2 —
|
||||||
|
* no steps field, and no envelope extension: a 5th property means drift), so
|
||||||
|
* the cron reconstructs the street itself. makeStreetAt snaps the CARD TURN's
|
||||||
|
* vertex to the memoized R2 graph — snapToGraph → the matched segment's
|
||||||
|
* nameId → graph.nameAt(nameId) — the SAME one-authority hop the app-side
|
||||||
|
* banner relies on. Edges carry names; nodes don't: the node index is NEVER
|
||||||
|
* fed to nameAt. DEGRADATION (D-03's designed-degradation pattern): the graph
|
||||||
|
* absent/corrupt in the cron isolate, or a snap miss → the resolver returns
|
||||||
|
* null → the card composes street-less with the bare verbs — honest absence,
|
||||||
|
* never a guessed name. A MATCHED but unnamed edge composes with
|
||||||
|
* cardStreetUnnamed ("UNNAMED ROAD") — still street-bearing, instrument voice.
|
||||||
|
*
|
||||||
|
* D-13 (honesty): the caption's strongest claim is "best-effort projection";
|
||||||
|
* the copy never promises live tracking (banned-word gate in the cron suite).
|
||||||
|
* CADENCE (D-11 discretion, RESEARCH Open Q 3): the crons config ticks every
|
||||||
|
* 60 s; a card is DUE on the first tick of each CARD_CADENCE_S window —
|
||||||
|
* stateless (zero cursor keys, zero KV writes; the 1,000-writes/day free
|
||||||
|
* budget holds — pinned structurally in guidance-cron.test.mjs).
|
||||||
|
*
|
||||||
|
* Card language: the envelope contract carries NO language field (GE2), so
|
||||||
|
* per pickLocale's fallback law the cards are EN; the ms table is kept in
|
||||||
|
* parity by the bot copy gate (CS7) for the day the contract grows a
|
||||||
|
* language surface.
|
||||||
|
*
|
||||||
|
* HTML-escape surface: ZERO. Every caption part is code-composed from
|
||||||
|
* numbers and string-table constants — no envelope-derived string
|
||||||
|
* (destinationName included) enters the caption; street text arrives only
|
||||||
|
* through makeStreetAt's graph lookup (the repo's own OSM-derived graph).
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
classifyTurn,
|
||||||
|
decodePolyline6,
|
||||||
|
deriveCumulativeDistanceM,
|
||||||
|
haversineM,
|
||||||
|
snapToGraph,
|
||||||
|
type GuidanceTurnKind,
|
||||||
|
type RoadGraph,
|
||||||
|
type RoutePoint,
|
||||||
|
} from "@araharah/routing/src/index.ts";
|
||||||
|
import type { GuidanceSessionEnvelope } from "../kv/guidance-sessions.ts";
|
||||||
|
import { strings } from "../i18n/strings.ts";
|
||||||
|
|
||||||
|
/** The chat card's cadence (D-11/D-13): one card window every 3 minutes;
|
||||||
|
* the window's first tick is the due tick. */
|
||||||
|
export const CARD_CADENCE_S = 180;
|
||||||
|
|
||||||
|
/** The Worker's cron scan tick — wrangler's crons entry ticks every 60 s. */
|
||||||
|
const SCAN_TICK_S = 60;
|
||||||
|
|
||||||
|
/** Segments shorter than this are GPS/codec noise — no bearing is derived from them. */
|
||||||
|
const DEGENERATE_SEGMENT_M = 1;
|
||||||
|
|
||||||
|
/** v1 is KL-metro only and Malaysia has no DST — the card's ETA clock is KL
|
||||||
|
* wall time (the envelope carries no driver timezone; the Worker's clock is
|
||||||
|
* UTC). Named constant, replay-tunable. */
|
||||||
|
const KL_UTC_OFFSET_S = 8 * 60 * 60;
|
||||||
|
|
||||||
|
/** The turn-card's glyph vocabulary — cron.ts's local union mirroring the 8
|
||||||
|
* committed PNG names (web/public/glyphs/<kind>.png); structurally the
|
||||||
|
* RouteStep.kind union (one authority with engine A's classifyTurn). */
|
||||||
|
export type GlyphKindName = GuidanceTurnKind;
|
||||||
|
|
||||||
|
/** One projection outcome: due + the card to push, or all-null. */
|
||||||
|
export interface TurnCard {
|
||||||
|
due: boolean;
|
||||||
|
glyph: GlyphKindName | null;
|
||||||
|
caption: string | null;
|
||||||
|
maneuverIdx: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function noCard(): TurnCard {
|
||||||
|
return { due: false, glyph: null, caption: null, maneuverIdx: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The instrument register prefix — GUIDE // T-{nn} is code-composed (UI-SPEC). */
|
||||||
|
const GUIDE_REGISTER = "GUIDE // T-";
|
||||||
|
|
||||||
|
/** kind → the strings key carrying that maneuver's street-bearing verb. */
|
||||||
|
const CARD_ACTION_KEYS: Record<GlyphKindName, keyof typeof strings.en> = {
|
||||||
|
depart: "cardActionDepart",
|
||||||
|
straight: "cardActionStraight",
|
||||||
|
left: "cardActionLeft",
|
||||||
|
right: "cardActionRight",
|
||||||
|
"slight-left": "cardActionSlightLeft",
|
||||||
|
"slight-right": "cardActionSlightRight",
|
||||||
|
uturn: "cardActionUturn",
|
||||||
|
arrive: "cardActionArrive",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** kind → the strings key carrying that maneuver's street-less (bare) verb. */
|
||||||
|
const CARD_ACTION_BARE_KEYS: Record<GlyphKindName, keyof typeof strings.en> = {
|
||||||
|
depart: "cardActionBareDepart",
|
||||||
|
straight: "cardActionBareStraight",
|
||||||
|
left: "cardActionBareLeft",
|
||||||
|
right: "cardActionBareRight",
|
||||||
|
"slight-left": "cardActionBareSlightLeft",
|
||||||
|
"slight-right": "cardActionBareSlightRight",
|
||||||
|
uturn: "cardActionBareUturn",
|
||||||
|
arrive: "cardActionBareArrive",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEG_TO_RAD = Math.PI / 180;
|
||||||
|
|
||||||
|
/** Initial compass bearing a → b, degrees clockwise (the astar.ts convention). */
|
||||||
|
function initialBearingDeg(a: RoutePoint, b: RoutePoint): number {
|
||||||
|
const lat1 = a.lat * DEG_TO_RAD;
|
||||||
|
const lat2 = b.lat * DEG_TO_RAD;
|
||||||
|
const dLng = (b.lng - a.lng) * DEG_TO_RAD;
|
||||||
|
const y = Math.sin(dLng) * Math.cos(lat2);
|
||||||
|
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng);
|
||||||
|
return (Math.atan2(y, x) / DEG_TO_RAD + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A maneuver register entry: the carded kind, its geometry vertex (the
|
||||||
|
* streetAt argument) and its meters-along-the-line. */
|
||||||
|
interface TurnEntry {
|
||||||
|
kind: GlyphKindName;
|
||||||
|
vertexIdx: number;
|
||||||
|
alongM: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maneuver register from geometry ALONE (the envelope carries no steps
|
||||||
|
* field): depart at the origin, one classified entry per bearing-change
|
||||||
|
* vertex (classifyTurn is the ONE turn authority — "straight" results are
|
||||||
|
* cardable CONTINUE-ON maneuvers), arrive at the drawn end.
|
||||||
|
*/
|
||||||
|
function buildTurnRegister(points: readonly RoutePoint[], cumulative: readonly number[]): TurnEntry[] {
|
||||||
|
const register: TurnEntry[] = [{ kind: "depart", vertexIdx: 0, alongM: 0 }];
|
||||||
|
let prevBearing: number | null = null;
|
||||||
|
let prevSegEnd = -1;
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
const segM = cumulative[i + 1]! - cumulative[i]!;
|
||||||
|
if (segM < DEGENERATE_SEGMENT_M) continue; // sub-m noise — no bearing, no maneuver
|
||||||
|
const bearing = initialBearingDeg(points[i]!, points[i + 1]!);
|
||||||
|
if (prevBearing !== null) {
|
||||||
|
register.push({
|
||||||
|
kind: classifyTurn(prevBearing, bearing),
|
||||||
|
vertexIdx: prevSegEnd,
|
||||||
|
alongM: cumulative[prevSegEnd]!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prevBearing = bearing;
|
||||||
|
prevSegEnd = i + 1;
|
||||||
|
}
|
||||||
|
register.push({
|
||||||
|
kind: "arrive",
|
||||||
|
vertexIdx: points.length - 1,
|
||||||
|
alongM: cumulative[cumulative.length - 1]!,
|
||||||
|
});
|
||||||
|
return register;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "{n} M" / "{n.n} KM" — the app's instrument distance formats (UI-SPEC). */
|
||||||
|
function formatDistance(m: number): string {
|
||||||
|
const meters = Math.max(0, Math.round(m));
|
||||||
|
if (meters < 1000) return `${meters} M`;
|
||||||
|
return `${(meters / 1000).toFixed(1)} KM`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 24-hour HH:MM arrival clock, KL wall time (see KL_UTC_OFFSET_S). */
|
||||||
|
function etaClock(etaSec: number): string {
|
||||||
|
const at = new Date((etaSec + KL_UTC_OFFSET_S) * 1000);
|
||||||
|
const hh = String(at.getUTCHours()).padStart(2, "0");
|
||||||
|
const mm = String(at.getUTCMinutes()).padStart(2, "0");
|
||||||
|
return `${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project one guidance envelope at `nowSec` into its turn-card decision —
|
||||||
|
* PURE: no KV, no R2, no Telegram, no clock reads.
|
||||||
|
*
|
||||||
|
* - progress: linear along the snapshot (elapsed/duration → distanceM),
|
||||||
|
* clamped to [0, distanceM]
|
||||||
|
* - dead trip (progress ≥ distanceM) → due false, no card (D-13)
|
||||||
|
* - cadence: nowSec % CARD_CADENCE_S < SCAN_TICK_S (D-11, stateless)
|
||||||
|
* - next maneuver: the FIRST register entry whose along-distance ≥ progress
|
||||||
|
* ("straight" results included — CONTINUE ON is a real card); none → the
|
||||||
|
* "straight" fallback at register index 00 (geometry/distance drift)
|
||||||
|
* - maneuver line: street present → "{verb} {STREET}"; null/absent resolver
|
||||||
|
* → the bare verb (honest absence, D-03)
|
||||||
|
*/
|
||||||
|
export function projectTurnCards(
|
||||||
|
envelope: GuidanceSessionEnvelope,
|
||||||
|
nowSec: number,
|
||||||
|
streetAt?: (vertexIdx: number) => string | null,
|
||||||
|
): TurnCard {
|
||||||
|
const snap = envelope.route_snapshot;
|
||||||
|
let points: RoutePoint[];
|
||||||
|
try {
|
||||||
|
points = decodePolyline6(snap.geometry);
|
||||||
|
} catch {
|
||||||
|
return noCard(); // malformed geometry — the mint-side validation refuses it; honest skip
|
||||||
|
}
|
||||||
|
if (points.length < 2) return noCard();
|
||||||
|
|
||||||
|
const elapsedS = nowSec - snap.startedAtSec;
|
||||||
|
const progressM =
|
||||||
|
snap.durationS > 0
|
||||||
|
? Math.max(0, Math.min(snap.distanceM, (snap.distanceM * elapsedS) / snap.durationS))
|
||||||
|
: snap.distanceM;
|
||||||
|
if (progressM >= snap.distanceM) return noCard(); // the trip would have ended — never card a dead trip
|
||||||
|
const due = nowSec % CARD_CADENCE_S < SCAN_TICK_S;
|
||||||
|
if (!due) return noCard(); // a non-due tick composes nothing — the card IS the due decision
|
||||||
|
|
||||||
|
const cumulative = deriveCumulativeDistanceM(points);
|
||||||
|
const register = buildTurnRegister(points, cumulative);
|
||||||
|
const entry = register.find((e) => e.alongM >= progressM) ?? null;
|
||||||
|
const maneuverIdx = entry !== null ? register.indexOf(entry) : 0;
|
||||||
|
const glyph: GlyphKindName = entry !== null ? entry.kind : "straight";
|
||||||
|
const vertexIdx = entry !== null ? entry.vertexIdx : points.length - 1;
|
||||||
|
|
||||||
|
const loc = strings.en;
|
||||||
|
const street = streetAt ? streetAt(vertexIdx) : null;
|
||||||
|
const verb = street ? loc[CARD_ACTION_KEYS[glyph]] : loc[CARD_ACTION_BARE_KEYS[glyph]];
|
||||||
|
// The line's number: the carded turn's remaining meters — or, on the
|
||||||
|
// overshoot fallback, the trip's remaining meters (the only honest number).
|
||||||
|
const distToTurn = entry !== null ? entry.alongM - progressM : snap.distanceM - progressM;
|
||||||
|
|
||||||
|
const remainingM = snap.distanceM - progressM;
|
||||||
|
const remainingS = (snap.durationS * remainingM) / snap.distanceM;
|
||||||
|
const caption = [
|
||||||
|
`${GUIDE_REGISTER}${String(maneuverIdx).padStart(2, "0")}`,
|
||||||
|
`<b>${formatDistance(distToTurn)} — ${verb}${street ? ` ${street.toUpperCase()}` : ""}</b>`,
|
||||||
|
loc.cardEtaTemplate.replace("{time}", etaClock(nowSec + remainingS)).replace("{km}", (remainingM / 1000).toFixed(1)),
|
||||||
|
loc.cardHonesty,
|
||||||
|
].join("\n");
|
||||||
|
return { due, glyph, caption, maneuverIdx };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The graph→street resolver factory — PURE: graph + decoded points in,
|
||||||
|
* resolver out; snapToGraph + nameAt imported from @araharah/routing, zero
|
||||||
|
* I/O. The cron wiring builds ONE resolver per carded envelope (its own
|
||||||
|
* geometry) and calls it ONLY for the card turn's vertex — one snap per
|
||||||
|
* carded tick, never a walk of the whole line (the bounded-snapping law;
|
||||||
|
* the CPU tradeoff is a named ratification item in 06-08's checkpoint).
|
||||||
|
*/
|
||||||
|
export function makeStreetAt(
|
||||||
|
graph: RoadGraph,
|
||||||
|
points: readonly RoutePoint[],
|
||||||
|
): (vertexIdx: number) => string | null {
|
||||||
|
return (vertexIdx: number): string | null => {
|
||||||
|
const point = points[vertexIdx];
|
||||||
|
if (!point) return null;
|
||||||
|
const snapped = snapToGraph(graph, point);
|
||||||
|
if (snapped === null) return null; // snap miss — honest absence (D-03), never a guessed name
|
||||||
|
if (snapped.nameId === null) return strings.en.cardStreetUnnamed; // matched but unnamed — instrument fallback
|
||||||
|
return graph.nameAt(snapped.nameId) ?? null; // the matched segment's nameId — NEVER the node index
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -49,6 +49,36 @@ export interface BotStrings {
|
|||||||
* both locales (legal attribution, not prose; same string as
|
* both locales (legal attribution, not prose; same string as
|
||||||
* web/src/i18n/strings.ts). */
|
* web/src/i18n/strings.ts). */
|
||||||
osmAttribution: string;
|
osmAttribution: string;
|
||||||
|
/* ————— GUID-05 turn-card copy (06-07; D-12/D-13, UI-SPEC verbatim) ————— */
|
||||||
|
|
||||||
|
/** The street-BEARING maneuver verbs (the DEFAULT card line "{dist} — {verb} {STREET}"):
|
||||||
|
* en carries ONTO, ms carries KE. */
|
||||||
|
cardActionDepart: string;
|
||||||
|
cardActionStraight: string;
|
||||||
|
cardActionLeft: string;
|
||||||
|
cardActionRight: string;
|
||||||
|
cardActionSlightLeft: string;
|
||||||
|
cardActionSlightRight: string;
|
||||||
|
cardActionUturn: string;
|
||||||
|
cardActionArrive: string;
|
||||||
|
/** The street-LESS degradation verbs (graph absent / snap miss — D-03's honest
|
||||||
|
* absence pattern): bare verbs, never a guessed name. */
|
||||||
|
cardActionBareDepart: string;
|
||||||
|
cardActionBareStraight: string;
|
||||||
|
cardActionBareLeft: string;
|
||||||
|
cardActionBareRight: string;
|
||||||
|
cardActionBareSlightLeft: string;
|
||||||
|
cardActionBareSlightRight: string;
|
||||||
|
cardActionBareUturn: string;
|
||||||
|
cardActionBareArrive: string;
|
||||||
|
/** nameAt's unnamed id → the instrument fallback — still street-bearing. */
|
||||||
|
cardStreetUnnamed: string;
|
||||||
|
/** The projected-ETA telemetry template ({time} → HH:MM, {km} → one-decimal km). */
|
||||||
|
cardEtaTemplate: string;
|
||||||
|
/** D-13: the strongest claim the mechanism supports — never promises live tracking. */
|
||||||
|
cardHonesty: string;
|
||||||
|
/** The inline button resuming a killed webview from its envelope (?g=<id>, GUID-06). */
|
||||||
|
cardOpenButton: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Locale = "en" | "ms";
|
export type Locale = "en" | "ms";
|
||||||
@@ -71,6 +101,26 @@ export const strings: Record<Locale, BotStrings> = {
|
|||||||
searchQueryTooLong: "That query is too long. Send just the place name.",
|
searchQueryTooLong: "That query is too long. Send just the place name.",
|
||||||
searchOpenButton: "OPEN IN ARAHARAH",
|
searchOpenButton: "OPEN IN ARAHARAH",
|
||||||
osmAttribution: "© OpenStreetMap contributors",
|
osmAttribution: "© OpenStreetMap contributors",
|
||||||
|
cardActionDepart: "DEPART",
|
||||||
|
cardActionStraight: "CONTINUE ON",
|
||||||
|
cardActionLeft: "TURN LEFT ONTO",
|
||||||
|
cardActionRight: "TURN RIGHT ONTO",
|
||||||
|
cardActionSlightLeft: "KEEP LEFT ONTO",
|
||||||
|
cardActionSlightRight: "KEEP RIGHT ONTO",
|
||||||
|
cardActionUturn: "MAKE A U-TURN ONTO",
|
||||||
|
cardActionArrive: "ARRIVE AT",
|
||||||
|
cardActionBareDepart: "DEPART",
|
||||||
|
cardActionBareStraight: "CONTINUE",
|
||||||
|
cardActionBareLeft: "TURN LEFT",
|
||||||
|
cardActionBareRight: "TURN RIGHT",
|
||||||
|
cardActionBareSlightLeft: "KEEP LEFT",
|
||||||
|
cardActionBareSlightRight: "KEEP RIGHT",
|
||||||
|
cardActionBareUturn: "MAKE A U-TURN",
|
||||||
|
cardActionBareArrive: "ARRIVE",
|
||||||
|
cardStreetUnnamed: "UNNAMED ROAD",
|
||||||
|
cardEtaTemplate: "ETA {time} · {km} KM LEFT",
|
||||||
|
cardHonesty: "Best-effort projection — updates while the app is minimized.",
|
||||||
|
cardOpenButton: "OPEN GUIDANCE",
|
||||||
},
|
},
|
||||||
ms: {
|
ms: {
|
||||||
startIntro:
|
startIntro:
|
||||||
@@ -89,6 +139,26 @@ export const strings: Record<Locale, BotStrings> = {
|
|||||||
searchQueryTooLong: "Pertanyaan itu terlalu panjang. Hantar nama tempat sahaja.",
|
searchQueryTooLong: "Pertanyaan itu terlalu panjang. Hantar nama tempat sahaja.",
|
||||||
searchOpenButton: "OPEN IN ARAHARAH",
|
searchOpenButton: "OPEN IN ARAHARAH",
|
||||||
osmAttribution: "© OpenStreetMap contributors",
|
osmAttribution: "© OpenStreetMap contributors",
|
||||||
|
cardActionDepart: "MULA",
|
||||||
|
cardActionStraight: "TERUS KE",
|
||||||
|
cardActionLeft: "BELOK KIRI KE",
|
||||||
|
cardActionRight: "BELOK KANAN KE",
|
||||||
|
cardActionSlightLeft: "SEDAKIT KIRI KE",
|
||||||
|
cardActionSlightRight: "SEDAKIT KANAN KE",
|
||||||
|
cardActionUturn: "BUAT PUSARAN KE",
|
||||||
|
cardActionArrive: "TIBA DI",
|
||||||
|
cardActionBareDepart: "MULA",
|
||||||
|
cardActionBareStraight: "TERUS",
|
||||||
|
cardActionBareLeft: "BELOK KIRI",
|
||||||
|
cardActionBareRight: "BELOK KANAN",
|
||||||
|
cardActionBareSlightLeft: "SEDAKIT KIRI",
|
||||||
|
cardActionBareSlightRight: "SEDAKIT KANAN",
|
||||||
|
cardActionBareUturn: "BUAT PUSARAN",
|
||||||
|
cardActionBareArrive: "TIBA",
|
||||||
|
cardStreetUnnamed: "JALAN TANPA NAMA",
|
||||||
|
cardEtaTemplate: "ETA {masa} · {km} KM LAGI",
|
||||||
|
cardHonesty: "Unjuran cuba terbaik — dikemas kini semasa aplikasi diminimumkan.",
|
||||||
|
cardOpenButton: "BUKA PANDUAN",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -96,22 +96,22 @@ function envelope({ points, distanceM, durationS, startedAtSec, chatId = 101 })
|
|||||||
|
|
||||||
/* ————— GC1 — the projection math (fixed nowSec, fake street resolver) ————— */
|
/* ————— GC1 — the projection math (fixed nowSec, fake street resolver) ————— */
|
||||||
|
|
||||||
test("GC1 projection: T+300 of 900 → the 90° left carded at T-01, 667 M, ETA 13:15 · 6.7 KM LEFT", async () => {
|
test("GC1 projection: elapsed 180 s of 900 → the 90° left carded at T-01, 2.0 KM, ETA 13:15 · 8.0 KM LEFT", async () => {
|
||||||
const cron = await import("../src/guidance/cron.ts");
|
const cron = await import("../src/guidance/cron.ts");
|
||||||
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 });
|
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 });
|
||||||
const card = cron.projectTurnCards(env, T0 + 300, () => "Jalan Sultan Ismail");
|
const card = cron.projectTurnCards(env, T0 + 180, () => "Jalan Sultan Ismail");
|
||||||
assert.equal(card.due, true, "T0 % 180 === 0 → the window's first tick is due");
|
assert.equal(card.due, true, "T0 + 180 % 180 === 0 → the window's first tick is due");
|
||||||
assert.equal(card.glyph, "left", "the first classified turn ahead (progress 3333 < along 4000) is the 90° left");
|
assert.equal(card.glyph, "left", "the first classified turn ahead (progress 2000 < along 4000) is the 90° left");
|
||||||
assert.equal(card.maneuverIdx, 1, "register: depart(00) → left(01)");
|
assert.equal(card.maneuverIdx, 1, "register: depart(00) → left(01)");
|
||||||
const lines = card.caption.split("\n");
|
const lines = card.caption.split("\n");
|
||||||
assert.equal(lines.length, 4, "the anatomy is exactly four lines");
|
assert.equal(lines.length, 4, "the anatomy is exactly four lines");
|
||||||
assert.equal(lines[0], "GUIDE // T-01", "instrument register, zero-padded index");
|
assert.equal(lines[0], "GUIDE // T-01", "instrument register, zero-padded index");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
lines[1],
|
lines[1],
|
||||||
"<b>667 M — TURN LEFT ONTO JALAN SULTAN ISMAIL</b>",
|
"<b>2.0 KM — TURN LEFT ONTO JALAN SULTAN ISMAIL</b>",
|
||||||
"maneuver line: street-bearing DEFAULT — {dist} — {verb} {STREET} (D-12)",
|
"maneuver line: street-bearing DEFAULT — {dist} — {verb} {STREET} (D-12)",
|
||||||
);
|
);
|
||||||
assert.equal(lines[2], "ETA 13:15 · 6.7 KM LEFT", "telemetry line from cardEtaTemplate: remaining 6667 m / 600 s");
|
assert.equal(lines[2], "ETA 13:15 · 8.0 KM LEFT", "telemetry line from cardEtaTemplate: remaining 8000 m / 720 s");
|
||||||
assert.equal(lines[3], strings.en.cardHonesty, "honesty line verbatim (D-13)");
|
assert.equal(lines[3], strings.en.cardHonesty, "honesty line verbatim (D-13)");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ test("GC3 dead trip: elapsed ≥ duration → due false, no card — the cron ne
|
|||||||
test("GC4 caption law: ≤ 1024 chars, honesty verbatim, register zero-padded, no envelope string, D-13 banned words absent", async () => {
|
test("GC4 caption law: ≤ 1024 chars, honesty verbatim, register zero-padded, no envelope string, D-13 banned words absent", async () => {
|
||||||
const cron = await import("../src/guidance/cron.ts");
|
const cron = await import("../src/guidance/cron.ts");
|
||||||
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 });
|
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 });
|
||||||
const card = cron.projectTurnCards(env, T0 + 300, () => "Jalan Sultan Ismail");
|
const card = cron.projectTurnCards(env, T0 + 180, () => "Jalan Sultan Ismail");
|
||||||
assert.ok(card.caption.length <= 1024, "sendPhoto's caption budget is 1024 chars");
|
assert.ok(card.caption.length <= 1024, "sendPhoto's caption budget is 1024 chars");
|
||||||
assert.ok(card.caption.includes(strings.en.cardHonesty), "the honesty line rides verbatim");
|
assert.ok(card.caption.includes(strings.en.cardHonesty), "the honesty line rides verbatim");
|
||||||
assert.match(card.caption, /^GUIDE \/\/ T-\d{2}\n/m, "the register is GUIDE // T-{zero-padded nn}");
|
assert.match(card.caption, /^GUIDE \/\/ T-\d{2}\n/m, "the register is GUIDE // T-{zero-padded nn}");
|
||||||
@@ -165,34 +165,34 @@ test("GC4 caption law: ≤ 1024 chars, honesty verbatim, register zero-padded, n
|
|||||||
test("GC5 street-less DEGRADATION: omitted resolver → the bare verb, same 4-line anatomy (honest absence, D-03)", async () => {
|
test("GC5 street-less DEGRADATION: omitted resolver → the bare verb, same 4-line anatomy (honest absence, D-03)", async () => {
|
||||||
const cron = await import("../src/guidance/cron.ts");
|
const cron = await import("../src/guidance/cron.ts");
|
||||||
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 });
|
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 });
|
||||||
const card = cron.projectTurnCards(env, T0 + 300); // no streetAt
|
const card = cron.projectTurnCards(env, T0 + 180); // no streetAt
|
||||||
assert.equal(card.due, true);
|
assert.equal(card.due, true);
|
||||||
assert.equal(card.glyph, "left");
|
assert.equal(card.glyph, "left");
|
||||||
const lines = card.caption.split("\n");
|
const lines = card.caption.split("\n");
|
||||||
assert.equal(lines.length, 4, "the degraded form keeps the anatomy");
|
assert.equal(lines.length, 4, "the degraded form keeps the anatomy");
|
||||||
assert.equal(lines[1], "<b>667 M — TURN LEFT</b>", "bare verb — street-less honest absence, never a guessed name");
|
assert.equal(lines[1], "<b>2.0 KM — TURN LEFT</b>", "bare verb — street-less honest absence, never a guessed name");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("GC5b street resolver returning null → the bare verb too (a snap miss is honest absence)", async () => {
|
test("GC5b street resolver returning null → the bare verb too (a snap miss is honest absence)", async () => {
|
||||||
const cron = await import("../src/guidance/cron.ts");
|
const cron = await import("../src/guidance/cron.ts");
|
||||||
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 });
|
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 });
|
||||||
const card = cron.projectTurnCards(env, T0 + 300, () => null);
|
const card = cron.projectTurnCards(env, T0 + 180, () => null);
|
||||||
const lines = card.caption.split("\n");
|
const lines = card.caption.split("\n");
|
||||||
assert.equal(lines[1], "<b>667 M — TURN LEFT</b>", "null street → the bare-verb degradation");
|
assert.equal(lines[1], "<b>2.0 KM — TURN LEFT</b>", "null street → the bare-verb degradation");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("GC5c a 180° reversal carded as uturn — classifyTurn is the ONE turn authority", async () => {
|
test("GC5c a 180° reversal carded as uturn — classifyTurn is the ONE turn authority", async () => {
|
||||||
const cron = await import("../src/guidance/cron.ts");
|
const cron = await import("../src/guidance/cron.ts");
|
||||||
const p0 = { lat: KL_LAT, lng: KL_LNG };
|
const p0 = { lat: KL_LAT, lng: KL_LNG };
|
||||||
const p1 = { lat: KL_LAT, lng: KL_LNG + 2000 / M_PER_DEG_LNG }; // east, bearing 90
|
const p1 = { lat: KL_LAT, lng: KL_LNG + 2500 / M_PER_DEG_LNG }; // east, bearing 90
|
||||||
const p2 = { lat: KL_LAT, lng: KL_LNG }; // back west, bearing 270 → delta +180 → uturn
|
const p2 = { lat: KL_LAT, lng: KL_LNG }; // back west, bearing 270 → delta +180 → uturn
|
||||||
const env = envelope({ points: [p0, p1, p2], distanceM: 4000, durationS: 360, startedAtSec: T0 });
|
const env = envelope({ points: [p0, p1, p2], distanceM: 5000, durationS: 400, startedAtSec: T0 });
|
||||||
const card = cron.projectTurnCards(env, T0 + 90, () => "Jalan Sultan Ismail");
|
const card = cron.projectTurnCards(env, T0 + 180, () => "Jalan Sultan Ismail");
|
||||||
assert.equal(card.glyph, "uturn", "delta 180 ≥ TURN_UTURN_MIN_DEG 150 → uturn");
|
assert.equal(card.glyph, "uturn", "delta 180 ≥ TURN_UTURN_MIN_DEG 150 → uturn");
|
||||||
assert.equal(card.maneuverIdx, 1);
|
assert.equal(card.maneuverIdx, 1);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
card.caption.split("\n")[1],
|
card.caption.split("\n")[1],
|
||||||
"<b>1000 M — MAKE A U-TURN ONTO JALAN SULTAN ISMAIL</b>",
|
"<b>250 M — MAKE A U-TURN ONTO JALAN SULTAN ISMAIL</b>",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -295,13 +295,13 @@ test("GC7 overshoot: progress past the drawn geometry (below distanceM) → stra
|
|||||||
const cron = await import("../src/guidance/cron.ts");
|
const cron = await import("../src/guidance/cron.ts");
|
||||||
const points = threeLegPoints(); // drawn length ≈ 10000 m
|
const points = threeLegPoints(); // drawn length ≈ 10000 m
|
||||||
const env = envelope({ points, distanceM: 20000, durationS: 900, startedAtSec: T0 });
|
const env = envelope({ points, distanceM: 20000, durationS: 900, startedAtSec: T0 });
|
||||||
const card = cron.projectTurnCards(env, T0 + 675, () => "Jalan Sultan Ismail"); // progress 15000 > geometry
|
const card = cron.projectTurnCards(env, T0 + 720, () => "Jalan Sultan Ismail"); // progress 16000 > drawn
|
||||||
assert.equal(card.due, true);
|
assert.equal(card.due, true, "T0 + 720 % 180 === 0 → due");
|
||||||
assert.equal(card.glyph, "straight", "no maneuver ahead on the drawn line → the straight fallback");
|
assert.equal(card.glyph, "straight", "no maneuver ahead on the drawn line → the straight fallback");
|
||||||
assert.equal(card.maneuverIdx, 0, "the fallback register index is 00");
|
assert.equal(card.maneuverIdx, 0, "the fallback register index is 00");
|
||||||
const lines = card.caption.split("\n");
|
const lines = card.caption.split("\n");
|
||||||
assert.equal(lines[0], "GUIDE // T-00");
|
assert.equal(lines[0], "GUIDE // T-00");
|
||||||
assert.equal(lines[1], "<b>5.0 KM — CONTINUE ON JALAN SULTAN ISMAIL</b>", "the honest remaining trip distance is the line's number");
|
assert.equal(lines[1], "<b>4.0 KM — CONTINUE ON JALAN SULTAN ISMAIL</b>", "the honest remaining trip distance is the line's number");
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ————— GC8 — structural laws (comment-stripped source introspection) ————— */
|
/* ————— GC8 — structural laws (comment-stripped source introspection) ————— */
|
||||||
|
|||||||
Reference in New Issue
Block a user