feat(tariff): V2 — legacy-parity pricing (time-of-day, category, seasonal, flat)
Bring the legacy ParkSQL2017 pricing BREADTH onto our engine while keeping
integer-minor-unit money + immutable signed versions (rejecting legacy's
float money / mutable rows). TariffStructure becomes a discriminated union:
V1 = the original bare ladder (UNCHANGED, verbatim algorithm, golden-
regression-tested against the live version); V2 = {version:2, tz, shared
knobs, defaultCard, windowedCards[]} where each card is flat OR a block
ladder and may be scoped by wall-clock hour window / day-of-week / date
range / vehicle category.
computeFeeV2 prices by stepping one increment at a time, advancing the
ladder by ELAPSED minutes (continuous) while selecting the active card by
WALL-CLOCK time in the version's FROZEN tz. Decisions: tz is a per-site
setting (site_config.timezone, default Europe/Tirane) stamped server-side
into each version on publish — never the host clock (reproducibility);
default-card cap governs a mixed day; precedence = specificity
(date>dow>hour) -> priority -> name (total, order-independent), validation
rejects ambiguous ties; category = a card FIELD, frozen in the signed
vehicle_entry payload (site_config.default_vehicle_category default), read
at both pricing call-sites.
Composer: default card front-and-centre (flat/ladder toggle), tiers under
an "Advanced" disclosure; emits BARE V1 when no tiers (back-compat). DB:
migrations 0005 (timezone) + 0006 (default_vehicle_category). Stood up
vitest in @parking/shared (was zero tests on the ledger-feeding fee fn);
36 tests incl. golden V1 regression, happy-hour/overnight/dow/flat/category/
cap edges, precedence shuffle-invariance, Europe/Tirane DST determinism,
validation matrix — all green. No event-chain change.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -15,9 +15,11 @@
|
||||
"build": "tsc -b",
|
||||
"dev": "tsc -b --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit"
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "6.0.3"
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
+448
-38
@@ -85,6 +85,9 @@ export interface LedgerPayload {
|
||||
/** plate/vehicle from the vision service (advisory). */
|
||||
readonly plate?: string;
|
||||
readonly plateConfidence?: number;
|
||||
/** vehicle_entry: the vehicle/customer category, frozen at entry so V2 category
|
||||
* pricing reprices identically at exit. Absent on legacy entries (= default). */
|
||||
readonly category?: string;
|
||||
/** Free-form for forward-compat without a schema change. */
|
||||
readonly [k: string]: unknown;
|
||||
}
|
||||
@@ -93,11 +96,22 @@ export interface LedgerPayload {
|
||||
export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot";
|
||||
|
||||
/**
|
||||
* The composable rate card stored in a tariff_version.structure. Pure data the
|
||||
* fee function interprets — no rates in code. Stepped duration blocks + caps/grace;
|
||||
* a flat rate is just one block. See wiki/concepts/tariff.md.
|
||||
* The composable rate card stored in a tariff_version.structure.
|
||||
*
|
||||
* Two shapes, a discriminated union (see TariffStructure):
|
||||
* - V1 (TariffStructureV1): a single block ladder + cap/grace at the top level —
|
||||
* the original shape. Bare structures with no `defaultCard` are V1 and price
|
||||
* via the verbatim V1 algorithm, UNCHANGED. The one live production version is
|
||||
* V1 and must keep pricing identically.
|
||||
* - V2 (TariffStructureV2): a default card + optional WINDOWED cards selected by
|
||||
* wall-clock time-of-day / day-of-week / date and/or vehicle category, each card
|
||||
* a flat rate OR a block ladder. Adds the legacy ParkSQL2017 pricing breadth on
|
||||
* top of integer-minor-unit money + immutable versions. See wiki/concepts/tariff.md
|
||||
* and wiki/concepts/tariff-time-tiers.md.
|
||||
*
|
||||
* Pure data the fee function interprets — no rates in code, integer minor units.
|
||||
*/
|
||||
export interface TariffStructure {
|
||||
export interface TariffStructureV1 {
|
||||
/** Free if exited within this (drop-off/turnaround). */
|
||||
readonly gracePeriodEntryMin: number;
|
||||
/** Billing granularity; partial increments round UP. */
|
||||
@@ -120,6 +134,74 @@ export interface TariffBlock {
|
||||
readonly priceMinorPerIncrement: number;
|
||||
}
|
||||
|
||||
/** A wall-clock activation window for a V2 card. All parts are AND-ed; an absent
|
||||
* part is unconstrained. Evaluated in the version's frozen tz. */
|
||||
export interface TariffWindow {
|
||||
/** Days-of-week this card is active (0=Sun..6=Sat), local to tz. Absent/empty = every day. */
|
||||
readonly dow?: readonly number[];
|
||||
/** Inclusive local date window "YYYY-MM-DD" (seasonal/holiday). Absent = unbounded that side. */
|
||||
readonly dateFrom?: string;
|
||||
readonly dateTo?: string;
|
||||
/** Local hour-of-day window "HH:MM". `toHour <= fromHour` means it WRAPS past
|
||||
* midnight (e.g. 22:00→06:00 night rate). Absent pair = all day. */
|
||||
readonly fromHour?: string;
|
||||
readonly toHour?: string;
|
||||
}
|
||||
|
||||
/** A V2 pricing card: a flat rate OR a stepped block ladder (with its own cap).
|
||||
* `flatMinor` and `blocks` are mutually exclusive. The defaultCard has no window. */
|
||||
export interface TariffCard {
|
||||
/** Human label (also the final, deterministic precedence tiebreak). */
|
||||
readonly name: string;
|
||||
/** Integer precedence tiebreak among equally-specific cards; higher wins. */
|
||||
readonly priority: number;
|
||||
/** Vehicle/customer category this card prices. Absent = applies to all categories. */
|
||||
readonly category?: string;
|
||||
/** Wall-clock activation window. Absent only on the defaultCard (always active). */
|
||||
readonly window?: TariffWindow;
|
||||
/** Flat price per billing increment (mutually exclusive with `blocks`). */
|
||||
readonly flatMinor?: number;
|
||||
/** Stepped ladder (mutually exclusive with `flatMinor`); last block open-ended. */
|
||||
readonly blocks?: readonly TariffBlock[];
|
||||
/** Cap per rolling 24h for THIS card's ladder. Only the defaultCard's cap governs
|
||||
* a mixed day (see computeFeeV2). null = no cap. */
|
||||
readonly dailyCapMinor?: number | null;
|
||||
}
|
||||
|
||||
export interface TariffStructureV2 {
|
||||
/** Schema marker; presence of `defaultCard` is the real discriminant. */
|
||||
readonly version: 2;
|
||||
/** IANA zone the wall-clock windows are evaluated in, FROZEN in the version for
|
||||
* reproducibility — never read from the host clock. Copied from site config on
|
||||
* publish (default "Europe/Tirane"). */
|
||||
readonly tz: string;
|
||||
// --- shared billing knobs (same meaning as V1) ---
|
||||
readonly gracePeriodEntryMin: number;
|
||||
readonly incrementMin: number;
|
||||
readonly lostTicketMinor: number;
|
||||
readonly gracePeriodExitMin: number;
|
||||
readonly overstay: "reprice";
|
||||
/** The always-applicable fallback (no window). Its dailyCapMinor governs the day. */
|
||||
readonly defaultCard: TariffCard;
|
||||
/** Ordered, optional windowed/category cards. Absent/empty ⇒ behaves like V1. */
|
||||
readonly windowedCards?: readonly TariffCard[];
|
||||
}
|
||||
|
||||
/** The stored/wire type: legacy-bare V1 or windowed V2. computeFee + validate accept
|
||||
* both; the discriminant is the presence of `defaultCard`. */
|
||||
export type TariffStructure = TariffStructureV1 | TariffStructureV2;
|
||||
|
||||
/** True when a structure is the windowed V2 shape (has a defaultCard). */
|
||||
export function isTariffV2(t: TariffStructure): t is TariffStructureV2 {
|
||||
return (t as TariffStructureV2).defaultCard != null;
|
||||
}
|
||||
|
||||
/** The vehicle/customer category assigned to a transient entry when none is captured
|
||||
* (every transient today). A V2 card with no `category` applies to all; a card WITH a
|
||||
* category only applies to a matching session — so the default routes to the
|
||||
* category-agnostic + default cards. See wiki/concepts/tariff-time-tiers.md. */
|
||||
export const DEFAULT_VEHICLE_CATEGORY = "default";
|
||||
|
||||
/**
|
||||
* Compute the parking fee (integer minor units) for a stay, from a TariffStructure.
|
||||
* PURE + deterministic + offline — the pay station calls it with asOf = now; the
|
||||
@@ -135,7 +217,18 @@ export function computeFee(
|
||||
enteredAt: string,
|
||||
asOf: string,
|
||||
tariff: TariffStructure,
|
||||
category?: string,
|
||||
): number {
|
||||
return isTariffV2(tariff)
|
||||
? computeFeeV2(enteredAt, asOf, tariff, category)
|
||||
: computeFeeV1(enteredAt, asOf, tariff);
|
||||
}
|
||||
|
||||
/** The original (V1) fee algorithm — a single block ladder, no wall-clock. Kept
|
||||
* VERBATIM so bare/legacy structures (incl. the live production version) price
|
||||
* identically. Do not "unify" this into the V2 path: a rounding divergence would
|
||||
* corrupt repricing of already-signed sessions. */
|
||||
function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1): number {
|
||||
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||
const rawMinutes = ms / 60_000;
|
||||
@@ -161,59 +254,251 @@ export function computeFee(
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* The V2 fee algorithm — adds wall-clock time-of-day / day-of-week / date windows
|
||||
* and vehicle-category cards on top of the V1 ladder. PURE + integer + deterministic
|
||||
* (the signed ledger reprices against this; reproducibility is mandatory).
|
||||
*
|
||||
* Two decoupled clocks: ELAPSED minutes advance the block-ladder position (continuous
|
||||
* across card switches — a happy-hour boundary mid-stay does NOT reset the ladder);
|
||||
* WALL-CLOCK time (in the version's frozen tz) selects which card's rate applies to
|
||||
* each increment. Stepping one increment at a time and re-selecting the card makes the
|
||||
* boundary slicing implicit. The DEFAULT card's dailyCap governs each rolling-24h day
|
||||
* (a windowed card lowers the rate but never the day ceiling). See tariff-time-tiers.md.
|
||||
*/
|
||||
function computeFeeV2(
|
||||
enteredAt: string,
|
||||
asOf: string,
|
||||
tariff: TariffStructureV2,
|
||||
category?: string,
|
||||
): number {
|
||||
const enteredMs = Date.parse(enteredAt);
|
||||
const ms = Date.parse(asOf) - enteredMs;
|
||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||
const rawMinutes = ms / 60_000;
|
||||
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0; // grace on RAW duration (V1 rule)
|
||||
const inc = Math.max(1, tariff.incrementMin);
|
||||
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP (V1 rule)
|
||||
|
||||
// Cards in contention: the default plus any windowed card matching the category.
|
||||
// (A card with no `category` applies to all; one with a category applies only to
|
||||
// a matching session.) The defaultCard always matches and is the fallback.
|
||||
const cards = [
|
||||
tariff.defaultCard,
|
||||
...(tariff.windowedCards ?? []).filter((c) => c.category == null || c.category === category),
|
||||
];
|
||||
const dayCap = tariff.defaultCard.dailyCapMinor ?? null;
|
||||
|
||||
const DAY = 24 * 60;
|
||||
let total = 0;
|
||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||
const segEnd = Math.min(segStart + DAY, minutes);
|
||||
let segFee = 0;
|
||||
for (let within = segStart; within < segEnd; within += inc) {
|
||||
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
|
||||
const card = selectCard(cards, wall);
|
||||
if (card.flatMinor != null) {
|
||||
segFee += card.flatMinor;
|
||||
} else {
|
||||
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
|
||||
segFee += rateAt(card.blocks ?? [], within - segStart);
|
||||
}
|
||||
}
|
||||
if (dayCap != null) segFee = Math.min(segFee, dayCap);
|
||||
total += segFee;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an admin-authored tariff structure. Returns [] if valid, else a list
|
||||
* of human-readable problems. Pure — used by the composer route (and any caller)
|
||||
* so a malformed rate card can never be published. See wiki/concepts/tariff.md.
|
||||
*/
|
||||
export function validateTariffStructure(s: unknown): string[] {
|
||||
const errs: string[] = [];
|
||||
if (!s || typeof s !== "object") return ["structure must be an object"];
|
||||
const t = s as Partial<TariffStructure>;
|
||||
// Discriminate: a `defaultCard` ⇒ the windowed V2 shape; otherwise legacy bare V1.
|
||||
// The V1 branch is kept byte-identical (same messages) so the live version still
|
||||
// validates the same on any future republish.
|
||||
return (s as Partial<TariffStructureV2>).defaultCard != null
|
||||
? validateTariffV2(s as Partial<TariffStructureV2>)
|
||||
: validateTariffV1(s as Partial<TariffStructureV1>);
|
||||
}
|
||||
|
||||
const nonNegInt = (v: unknown, label: string) => {
|
||||
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) errs.push(`${label} must be a non-negative integer`);
|
||||
};
|
||||
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin");
|
||||
nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin");
|
||||
nonNegInt(t.lostTicketMinor, "lostTicketMinor");
|
||||
function nonNegInt(v: unknown, label: string, errs: string[]): void {
|
||||
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) errs.push(`${label} must be a non-negative integer`);
|
||||
}
|
||||
|
||||
/** Validate the block ladder (ascending bounds, open-ended last). `prefix` labels
|
||||
* errors (e.g. "blocks" or "defaultCard.blocks"). Shared by V1 + V2. */
|
||||
function validateBlocks(blocks: unknown, prefix: string, errs: string[]): void {
|
||||
if (!Array.isArray(blocks) || blocks.length === 0) {
|
||||
errs.push(`${prefix} must be a non-empty array`);
|
||||
return;
|
||||
}
|
||||
let prevBound = 0;
|
||||
blocks.forEach((b: Partial<TariffBlock>, i: number) => {
|
||||
const last = i === blocks.length - 1;
|
||||
nonNegInt(b?.priceMinorPerIncrement, `${prefix}[${i}].priceMinorPerIncrement`, errs);
|
||||
if (b?.uptoMin == null) {
|
||||
if (!last) errs.push(`${prefix}[${i}] is open-ended (uptoMin null) but not last`);
|
||||
} else if (typeof b.uptoMin !== "number" || !Number.isInteger(b.uptoMin) || b.uptoMin <= prevBound) {
|
||||
errs.push(`${prefix}[${i}].uptoMin must be an integer greater than the previous block's bound (${prevBound})`);
|
||||
} else {
|
||||
prevBound = b.uptoMin;
|
||||
}
|
||||
});
|
||||
// The LAST block must be open-ended (uptoMin null) so the "thereafter" rate is
|
||||
// always explicit — a bounded final block silently inherits its own rate past its
|
||||
// bound (a hidden, never-stated price). See wiki/concepts/tariff.md.
|
||||
const lastBlock = (blocks as Partial<TariffBlock>[])[blocks.length - 1];
|
||||
if (lastBlock && lastBlock.uptoMin != null) {
|
||||
errs.push(
|
||||
prefix === "blocks"
|
||||
? "the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly"
|
||||
: `${prefix}: the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateTariffV1(t: Partial<TariffStructureV1>): string[] {
|
||||
const errs: string[] = [];
|
||||
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin", errs);
|
||||
nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin", errs);
|
||||
nonNegInt(t.lostTicketMinor, "lostTicketMinor", errs);
|
||||
if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
|
||||
errs.push("incrementMin must be a positive integer");
|
||||
}
|
||||
if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor");
|
||||
if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor", errs);
|
||||
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
|
||||
validateBlocks(t.blocks, "blocks", errs);
|
||||
return errs;
|
||||
}
|
||||
|
||||
if (!Array.isArray(t.blocks) || t.blocks.length === 0) {
|
||||
errs.push("blocks must be a non-empty array");
|
||||
const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
const YMD = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** Validate one V2 card's pricing body (flat XOR ladder) + window. */
|
||||
function validateCard(c: Partial<TariffCard> | undefined, label: string, isDefault: boolean, errs: string[]): void {
|
||||
if (!c || typeof c !== "object") {
|
||||
errs.push(`${label} must be an object`);
|
||||
return;
|
||||
}
|
||||
if (typeof c.name !== "string" || c.name.length === 0) errs.push(`${label}.name is required`);
|
||||
if (typeof c.priority !== "number" || !Number.isInteger(c.priority)) errs.push(`${label}.priority must be an integer`);
|
||||
|
||||
const hasFlat = c.flatMinor != null;
|
||||
const hasBlocks = c.blocks != null;
|
||||
if (hasFlat === hasBlocks) {
|
||||
errs.push(`${label} must set exactly one of flatMinor or blocks`);
|
||||
} else if (hasFlat) {
|
||||
nonNegInt(c.flatMinor, `${label}.flatMinor`, errs);
|
||||
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor applies to a block ladder, not a flat rate`);
|
||||
} else {
|
||||
let prevBound = 0;
|
||||
t.blocks.forEach((b, i) => {
|
||||
const last = i === t.blocks!.length - 1;
|
||||
nonNegInt(b?.priceMinorPerIncrement, `blocks[${i}].priceMinorPerIncrement`);
|
||||
if (b?.uptoMin == null) {
|
||||
if (!last) errs.push(`blocks[${i}] is open-ended (uptoMin null) but not last`);
|
||||
} else {
|
||||
if (typeof b.uptoMin !== "number" || !Number.isInteger(b.uptoMin) || b.uptoMin <= prevBound) {
|
||||
errs.push(`blocks[${i}].uptoMin must be an integer greater than the previous block's bound (${prevBound})`);
|
||||
} else {
|
||||
prevBound = b.uptoMin;
|
||||
}
|
||||
}
|
||||
});
|
||||
// The LAST block must be open-ended (uptoMin null) so the "thereafter" rate is
|
||||
// always explicit. A bounded final block silently inherits its own rate past
|
||||
// its bound (a hidden, never-stated price) — forbidden on publish so the admin
|
||||
// must state what time beyond the ladder costs. See wiki/concepts/tariff.md.
|
||||
// (Read/pricing of already-published versions is unaffected — validation runs
|
||||
// only on publish; rateAt() still gracefully handles legacy bounded tails.)
|
||||
const lastBlock = t.blocks[t.blocks.length - 1];
|
||||
if (lastBlock && lastBlock.uptoMin != null) {
|
||||
errs.push("the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly");
|
||||
validateBlocks(c.blocks, `${label}.blocks`, errs);
|
||||
if (c.dailyCapMinor != null) nonNegInt(c.dailyCapMinor, `${label}.dailyCapMinor`, errs);
|
||||
}
|
||||
|
||||
if (isDefault) {
|
||||
if (c.window != null) errs.push("defaultCard must not have a window (it is the always-active fallback)");
|
||||
if (c.category != null) errs.push("defaultCard must not have a category (it is the catch-all)");
|
||||
} else {
|
||||
validateWindow(c.window, `${label}.window`, errs);
|
||||
if (c.category != null && (typeof c.category !== "string" || c.category.length === 0)) {
|
||||
errs.push(`${label}.category must be a non-empty string when present`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateWindow(w: Partial<TariffWindow> | undefined, label: string, errs: string[]): void {
|
||||
if (w == null) return; // a windowed card with no window = always-on tier (allowed)
|
||||
if (w.dow != null) {
|
||||
if (!Array.isArray(w.dow) || w.dow.some((d) => !Number.isInteger(d) || d < 0 || d > 6)) {
|
||||
errs.push(`${label}.dow must be integers 0-6 (0=Sun)`);
|
||||
}
|
||||
}
|
||||
const hasFrom = w.fromHour != null;
|
||||
const hasTo = w.toHour != null;
|
||||
if (hasFrom !== hasTo) errs.push(`${label}: fromHour and toHour must be set together`);
|
||||
if (hasFrom && hasTo) {
|
||||
if (!HHMM.test(w.fromHour!)) errs.push(`${label}.fromHour must be "HH:MM"`);
|
||||
if (!HHMM.test(w.toHour!)) errs.push(`${label}.toHour must be "HH:MM"`);
|
||||
// toHour <= fromHour is allowed (overnight wrap) — not an error.
|
||||
}
|
||||
if (w.dateFrom != null && !YMD.test(w.dateFrom)) errs.push(`${label}.dateFrom must be "YYYY-MM-DD"`);
|
||||
if (w.dateTo != null && !YMD.test(w.dateTo)) errs.push(`${label}.dateTo must be "YYYY-MM-DD"`);
|
||||
if (w.dateFrom != null && w.dateTo != null && YMD.test(w.dateFrom) && YMD.test(w.dateTo) && w.dateFrom > w.dateTo) {
|
||||
errs.push(`${label}.dateFrom must be ≤ dateTo`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateTariffV2(t: Partial<TariffStructureV2>): string[] {
|
||||
const errs: string[] = [];
|
||||
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin", errs);
|
||||
nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin", errs);
|
||||
nonNegInt(t.lostTicketMinor, "lostTicketMinor", errs);
|
||||
if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
|
||||
errs.push("incrementMin must be a positive integer");
|
||||
}
|
||||
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
|
||||
|
||||
const cards = t.windowedCards ?? [];
|
||||
// tz is required once there are windowed cards (wall-clock is meaningless without it).
|
||||
if (cards.length > 0 && (typeof t.tz !== "string" || t.tz.length === 0)) {
|
||||
errs.push("tz (IANA timezone) is required when windowedCards are present");
|
||||
}
|
||||
|
||||
validateCard(t.defaultCard, "defaultCard", true, errs);
|
||||
if (!Array.isArray(t.windowedCards) && t.windowedCards != null) {
|
||||
errs.push("windowedCards must be an array");
|
||||
} else {
|
||||
cards.forEach((c, i) => validateCard(c, `windowedCards[${i}]`, false, errs));
|
||||
}
|
||||
|
||||
// Precedence determinism: reject two cards (same category bucket) that tie on
|
||||
// (specificity, priority) with overlapping windows — the operator must break the
|
||||
// tie with priority rather than relying silently on the name tiebreak.
|
||||
detectAmbiguousPrecedence(cards, errs);
|
||||
return errs;
|
||||
}
|
||||
|
||||
/** Flag pairs of windowed cards that could BOTH be the precedence winner for some
|
||||
* instant (same category bucket, equal specificity + priority, overlapping windows).
|
||||
* Conservative overlap test; false positives are safer than a silent tie. */
|
||||
function detectAmbiguousPrecedence(cards: readonly Partial<TariffCard>[], errs: string[]): void {
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
for (let j = i + 1; j < cards.length; j++) {
|
||||
const a = cards[i]!;
|
||||
const b = cards[j]!;
|
||||
if ((a.category ?? null) !== (b.category ?? null)) continue;
|
||||
if (a.priority !== b.priority) continue;
|
||||
const sa = specificity(a as TariffCard);
|
||||
const sb = specificity(b as TariffCard);
|
||||
if (sa[0] !== sb[0] || sa[1] !== sb[1] || sa[2] !== sb[2]) continue;
|
||||
if (windowsOverlap(a.window, b.window)) {
|
||||
errs.push(
|
||||
`windowedCards "${a.name ?? i}" and "${b.name ?? j}" are equally specific with the same priority and overlapping windows — give one a higher priority to break the tie`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Conservative window-overlap: true unless a dimension provably disjoints them. */
|
||||
function windowsOverlap(a: TariffWindow | undefined, b: TariffWindow | undefined): boolean {
|
||||
if (!a || !b) return true; // an unconstrained window overlaps anything
|
||||
// dow: disjoint only if both constrain dow and share no day.
|
||||
if (a.dow && a.dow.length && b.dow && b.dow.length && !a.dow.some((d) => b.dow!.includes(d))) return false;
|
||||
// date: disjoint only if both fully bounded and ranges don't intersect.
|
||||
if (a.dateFrom && a.dateTo && b.dateFrom && b.dateTo && (a.dateTo < b.dateFrom || b.dateTo < a.dateFrom)) return false;
|
||||
// hour: disjoint only if both have non-wrapping ranges that don't intersect.
|
||||
if (a.fromHour && a.toHour && b.fromHour && b.toHour) {
|
||||
const af = hourToMin(a.fromHour), at = hourToMin(a.toHour), bf = hourToMin(b.fromHour), bt = hourToMin(b.toHour);
|
||||
if (at > af && bt > bf && (at <= bf || bt <= af)) return false; // both non-wrapping & disjoint
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Price of the increment that starts at `cumulativeMin` — the block whose range
|
||||
* [prevUpto, uptoMin) contains it; the open-ended (uptoMin=null) block catches the rest. */
|
||||
function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number {
|
||||
@@ -227,6 +512,131 @@ function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number {
|
||||
return blocks.length ? blocks[blocks.length - 1]!.priceMinorPerIncrement : 0;
|
||||
}
|
||||
|
||||
// --- V2 wall-clock helpers (pure, deterministic given the frozen tz) ----------
|
||||
|
||||
/** Wall-clock breakdown of an instant in a fixed IANA tz. Pure: the same (instant,
|
||||
* tz) always yields the same result (tz is frozen in the tariff version, never the
|
||||
* host). Uses Intl.DateTimeFormat — handles DST for the named zone. */
|
||||
export interface WallClock {
|
||||
readonly y: number;
|
||||
readonly mo: number; // 1-12
|
||||
readonly d: number; // 1-31
|
||||
readonly hour: number; // 0-23
|
||||
readonly minute: number; // 0-59
|
||||
readonly dow: number; // 0=Sun..6=Sat
|
||||
}
|
||||
|
||||
const DOW_INDEX: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
||||
|
||||
export function localBreakdown(instantMs: number, tz: string): WallClock {
|
||||
const fmt = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
weekday: "short",
|
||||
});
|
||||
const parts = fmt.formatToParts(new Date(instantMs));
|
||||
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "";
|
||||
return {
|
||||
y: Number(get("year")),
|
||||
mo: Number(get("month")),
|
||||
d: Number(get("day")),
|
||||
hour: Number(get("hour")),
|
||||
minute: Number(get("minute")),
|
||||
dow: DOW_INDEX[get("weekday")] ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** "HH:MM" → minutes-of-day (0-1439). Invalid → NaN (validation rejects those). */
|
||||
function hourToMin(hhmm: string): number {
|
||||
const m = /^(\d{2}):(\d{2})$/.exec(hhmm);
|
||||
if (!m) return NaN;
|
||||
return Number(m[1]) * 60 + Number(m[2]);
|
||||
}
|
||||
|
||||
/** "YYYY-MM-DD" → comparable integer YYYYMMDD. */
|
||||
function dateKey(w: WallClock): number {
|
||||
return w.y * 10000 + w.mo * 100 + w.d;
|
||||
}
|
||||
function isoDateKey(iso: string): number {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||||
return m ? Number(m[1]) * 10000 + Number(m[2]) * 100 + Number(m[3]) : NaN;
|
||||
}
|
||||
|
||||
/** Does a card's window cover this wall-clock instant? Absent parts are unconstrained;
|
||||
* an absent window (defaultCard) always matches. An hour range with `toHour <= fromHour`
|
||||
* is an overnight wrap (active when hour ≥ fromHour OR hour < toHour). */
|
||||
function matchesWindow(w: TariffWindow | undefined, wall: WallClock): boolean {
|
||||
if (!w) return true;
|
||||
if (w.dow && w.dow.length > 0 && !w.dow.includes(wall.dow)) return false;
|
||||
if (w.dateFrom != null && dateKey(wall) < isoDateKey(w.dateFrom)) return false;
|
||||
if (w.dateTo != null && dateKey(wall) > isoDateKey(w.dateTo)) return false;
|
||||
if (w.fromHour != null && w.toHour != null) {
|
||||
const from = hourToMin(w.fromHour);
|
||||
const to = hourToMin(w.toHour);
|
||||
const now = wall.hour * 60 + wall.minute;
|
||||
if (to <= from) {
|
||||
// overnight wrap, e.g. 22:00→06:00
|
||||
if (!(now >= from || now < to)) return false;
|
||||
} else {
|
||||
if (!(now >= from && now < to)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Specificity tuple (date, dow, hour) — more constrained windows win. Higher is
|
||||
* more specific; compared lexicographically. */
|
||||
function specificity(c: TariffCard): [number, number, number] {
|
||||
const w = c.window;
|
||||
const hasDate = w != null && (w.dateFrom != null || w.dateTo != null) ? 1 : 0;
|
||||
const hasDow = w != null && w.dow != null && w.dow.length > 0 ? 1 : 0;
|
||||
const hasHour = w != null && w.fromHour != null && w.toHour != null ? 1 : 0;
|
||||
return [hasDate, hasDow, hasHour];
|
||||
}
|
||||
|
||||
/** Pick the single active card for a wall-clock instant from the candidate cards
|
||||
* (default + category-matched). TOTAL + order-independent: most-specific wins, then
|
||||
* higher `priority`, then `name` lexicographically as the final deterministic tiebreak
|
||||
* (never array index). The defaultCard has specificity (0,0,0) so it only wins when
|
||||
* nothing more specific matches. */
|
||||
function selectCard(cards: readonly TariffCard[], wall: WallClock): TariffCard {
|
||||
let best: TariffCard | undefined;
|
||||
let bestSpec: [number, number, number] = [-1, -1, -1];
|
||||
for (const c of cards) {
|
||||
if (!matchesWindow(c.window, wall)) continue;
|
||||
const spec = specificity(c);
|
||||
if (best === undefined || compareCard(spec, c, bestSpec, best) > 0) {
|
||||
best = c;
|
||||
bestSpec = spec;
|
||||
}
|
||||
}
|
||||
// The defaultCard always matches, so `best` is never undefined in practice; the
|
||||
// fallback keeps the function total even for a pathological empty card list.
|
||||
return best ?? cards[0]!;
|
||||
}
|
||||
|
||||
/** Order: specificity desc, then priority desc, then name asc. Returns >0 if (specA,a)
|
||||
* should beat (specB,b). */
|
||||
function compareCard(
|
||||
specA: [number, number, number],
|
||||
a: TariffCard,
|
||||
specB: [number, number, number],
|
||||
b: TariffCard,
|
||||
): number {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (specA[i]! !== specB[i]!) return specA[i]! - specB[i]!;
|
||||
}
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
// Name as the final, total tiebreak. Lower name wins → invert so >0 means a beats b.
|
||||
if (a.name !== b.name) return a.name < b.name ? 1 : -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const ROLES: readonly Role[] = [
|
||||
"admin",
|
||||
"operator",
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeFee,
|
||||
validateTariffStructure,
|
||||
type TariffStructureV1,
|
||||
type TariffStructureV2,
|
||||
type TariffCard,
|
||||
} from "./index.js";
|
||||
|
||||
const entered = "2026-06-18T00:00:00.000Z";
|
||||
const at = (min: number) => new Date(Date.parse(entered) + min * 60_000).toISOString();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (a) GOLDEN V1 regression — the live production structure must reprice to these
|
||||
// exact integers. Captured from the pre-V2 engine. This is the most important
|
||||
// test: it proves a signed historical session reprices identically.
|
||||
// ---------------------------------------------------------------------------
|
||||
const liveV1: TariffStructureV1 = {
|
||||
gracePeriodEntryMin: 5,
|
||||
incrementMin: 60,
|
||||
blocks: [
|
||||
{ uptoMin: 60, priceMinorPerIncrement: 20000 },
|
||||
{ uptoMin: 180, priceMinorPerIncrement: 10000 },
|
||||
],
|
||||
dailyCapMinor: 100000,
|
||||
lostTicketMinor: 100000,
|
||||
gracePeriodExitMin: 5,
|
||||
overstay: "reprice",
|
||||
};
|
||||
|
||||
describe("V1 golden regression", () => {
|
||||
const golden: Record<number, number> = {
|
||||
3: 0, 30: 20000, 60: 20000, 61: 30000, 120: 30000, 180: 40000,
|
||||
181: 50000, 240: 50000, 1440: 100000, 1500: 120000, 2880: 200000,
|
||||
};
|
||||
for (const [min, want] of Object.entries(golden)) {
|
||||
it(`${min} min → ${want}`, () => {
|
||||
expect(computeFee(entered, at(Number(min)), liveV1)).toBe(want);
|
||||
});
|
||||
}
|
||||
it("a V1 structure ignores the category argument", () => {
|
||||
expect(computeFee(entered, at(120), liveV1, "bus")).toBe(30000);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V2 building blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
const ladder = (open: number, first?: { uptoMin: number; rate: number }) =>
|
||||
first
|
||||
? [{ uptoMin: first.uptoMin, priceMinorPerIncrement: first.rate }, { uptoMin: null, priceMinorPerIncrement: open }]
|
||||
: [{ uptoMin: null, priceMinorPerIncrement: open }];
|
||||
|
||||
const defaultCard: TariffCard = {
|
||||
name: "default",
|
||||
priority: 0,
|
||||
blocks: ladder(20000), // flat 200/h ladder (open-ended)
|
||||
dailyCapMinor: null,
|
||||
};
|
||||
|
||||
function v2(windowedCards: TariffCard[], tz = "Europe/Tirane", over: Partial<TariffStructureV2> = {}): TariffStructureV2 {
|
||||
return {
|
||||
version: 2,
|
||||
tz,
|
||||
gracePeriodEntryMin: 5,
|
||||
incrementMin: 60,
|
||||
lostTicketMinor: 100000,
|
||||
gracePeriodExitMin: 5,
|
||||
overstay: "reprice",
|
||||
defaultCard,
|
||||
windowedCards,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("V2 back-compat: a V2 with no windowed cards prices like its default ladder", () => {
|
||||
it("default-only V2 == equivalent V1", () => {
|
||||
const s = v2([]);
|
||||
// 200/h flat ladder, 3h
|
||||
expect(computeFee(entered, at(180), s)).toBe(60000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 time-of-day window (happy hour)", () => {
|
||||
// Tirane is UTC+2 in June (DST). entered 00:00Z = 02:00 local.
|
||||
// Happy hour 04:00–06:00 local = 02:00–04:00Z. Default 200/h, happy 50/h.
|
||||
const happy: TariffCard = {
|
||||
name: "happy",
|
||||
priority: 10,
|
||||
window: { fromHour: "04:00", toHour: "06:00" },
|
||||
blocks: ladder(5000),
|
||||
};
|
||||
const s = v2([happy]);
|
||||
it("a stay crossing into happy hour bills each increment by its wall-clock card", () => {
|
||||
// 0-120min elapsed = local 02:00-04:00 (default 200/h ×2 = 400),
|
||||
// 120-240min = local 04:00-06:00 (happy 50/h ×2 = 100). Total 500 = 50000.
|
||||
expect(computeFee(entered, at(240), s)).toBe(50000);
|
||||
});
|
||||
it("a stay entirely before happy hour is all default", () => {
|
||||
expect(computeFee(entered, at(120), s)).toBe(40000); // 2h × 200
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 overnight wrap window", () => {
|
||||
// night 22:00→06:00 local (wraps midnight), cheap 50/h.
|
||||
const night: TariffCard = {
|
||||
name: "night",
|
||||
priority: 10,
|
||||
window: { fromHour: "22:00", toHour: "06:00" },
|
||||
blocks: ladder(5000),
|
||||
};
|
||||
const s = v2([night]);
|
||||
it("an early-morning stay (local 02:00-04:00) is inside the wrap → night rate", () => {
|
||||
expect(computeFee(entered, at(120), s)).toBe(10000); // 2h × 50
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 day-of-week tested at the increment's wall-clock day", () => {
|
||||
// 2026-06-18 is a Thursday (dow 4). A Friday-only card must NOT apply.
|
||||
const friOnly: TariffCard = { name: "fri", priority: 10, window: { dow: [5] }, blocks: ladder(5000) };
|
||||
it("Thursday stay does not get the Friday card", () => {
|
||||
expect(computeFee(entered, at(120), v2([friOnly]))).toBe(40000); // default 200×2
|
||||
});
|
||||
const thuOnly: TariffCard = { name: "thu", priority: 10, window: { dow: [4] }, blocks: ladder(5000) };
|
||||
it("Thursday stay gets the Thursday card", () => {
|
||||
expect(computeFee(entered, at(120), v2([thuOnly]))).toBe(10000); // 50×2
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 flat card", () => {
|
||||
const flatNight: TariffCard = {
|
||||
name: "flat",
|
||||
priority: 10,
|
||||
window: { fromHour: "00:00", toHour: "23:59" }, // effectively all day here
|
||||
flatMinor: 3000,
|
||||
};
|
||||
it("flat card charges flatMinor per increment", () => {
|
||||
expect(computeFee(entered, at(180), v2([flatNight]))).toBe(9000); // 3h × 30
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 category filter", () => {
|
||||
const busCard: TariffCard = { name: "bus", priority: 10, category: "bus", blocks: ladder(40000) };
|
||||
const s = v2([busCard]);
|
||||
it("a bus session uses the bus card (400/h)", () => {
|
||||
expect(computeFee(entered, at(120), s, "bus")).toBe(80000);
|
||||
});
|
||||
it("a car session ignores the bus card → default (200/h)", () => {
|
||||
expect(computeFee(entered, at(120), s, "car")).toBe(40000);
|
||||
});
|
||||
it("no category given ignores the bus card → default", () => {
|
||||
expect(computeFee(entered, at(120), s)).toBe(40000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 daily cap uses the DEFAULT card's cap on a mixed day", () => {
|
||||
// default cap 1000/day; a cheap night card present. 24h elapsed.
|
||||
const night: TariffCard = { name: "night", priority: 10, window: { fromHour: "22:00", toHour: "06:00" }, blocks: ladder(5000) };
|
||||
const s = v2([night], "Europe/Tirane", { defaultCard: { ...defaultCard, dailyCapMinor: 100000 } });
|
||||
it("a 24h stay is capped at the default card's 1000/day", () => {
|
||||
expect(computeFee(entered, at(1440), s)).toBe(100000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 precedence is total + order-independent", () => {
|
||||
// Specificity order is date > dow > hour-only (see plan / tariff-time-tiers.md).
|
||||
// So a dow-constrained card beats an hour-only card at an overlapping instant.
|
||||
const dowCard: TariffCard = { name: "a-dow", priority: 5, window: { dow: [4] }, blocks: ladder(10000) }; // Thu, 100/h
|
||||
const hourCard: TariffCard = { name: "b-hour", priority: 5, window: { fromHour: "02:00", toHour: "04:00" }, blocks: ladder(5000) }; // local 02-04, 50/h
|
||||
it("dow (more specific than hour-only) wins at an overlapping instant", () => {
|
||||
// local 02:00-04:00 = elapsed 0-120; both match, dow ranks above hour → 100/h
|
||||
expect(computeFee(entered, at(120), v2([dowCard, hourCard]))).toBe(20000);
|
||||
});
|
||||
it("a date window beats a dow window (date is most specific)", () => {
|
||||
const dateCard: TariffCard = { name: "c-date", priority: 1, window: { dateFrom: "2026-06-18", dateTo: "2026-06-18" }, blocks: ladder(5000) }; // 50/h
|
||||
// date beats dow even with LOWER priority (specificity dominates priority)
|
||||
expect(computeFee(entered, at(120), v2([dowCard, dateCard]))).toBe(10000);
|
||||
});
|
||||
it("fee is identical when windowedCards order is shuffled", () => {
|
||||
const a = computeFee(entered, at(120), v2([dowCard, hourCard]));
|
||||
const b = computeFee(entered, at(120), v2([hourCard, dowCard]));
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 DST determinism (Europe/Tirane)", () => {
|
||||
// Spring forward 2026-03-29 03:00 local (clocks 02:00→03:00). Fall back 2026-10-25.
|
||||
const cheap: TariffCard = { name: "c", priority: 10, window: { fromHour: "00:00", toHour: "23:59" }, flatMinor: 1000 };
|
||||
it("a stay across the spring-forward boundary prices deterministically", () => {
|
||||
const e = "2026-03-29T00:00:00.000Z"; // 01:00 local pre-jump
|
||||
const a1 = computeFee(e, new Date(Date.parse(e) + 240 * 60_000).toISOString(), v2([cheap]));
|
||||
const a2 = computeFee(e, new Date(Date.parse(e) + 240 * 60_000).toISOString(), v2([cheap]));
|
||||
expect(a1).toBe(a2); // determinism
|
||||
expect(a1).toBe(4000); // 4h × flat 10
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (e) validation accept/reject matrix
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("validate V1 (unchanged messages)", () => {
|
||||
it("accepts the live structure", () => {
|
||||
expect(validateTariffStructure({ ...liveV1, blocks: [...liveV1.blocks, { uptoMin: null, priceMinorPerIncrement: 5000 }] })).toEqual([]);
|
||||
});
|
||||
it("rejects a bounded last block", () => {
|
||||
expect(validateTariffStructure(liveV1)).toContain(
|
||||
"the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validate V2", () => {
|
||||
const okDefault: TariffCard = { name: "d", priority: 0, blocks: ladder(20000) };
|
||||
const base = { version: 2 as const, tz: "Europe/Tirane", gracePeriodEntryMin: 5, incrementMin: 60, lostTicketMinor: 0, gracePeriodExitMin: 5, overstay: "reprice" as const };
|
||||
|
||||
it("accepts a minimal default-only V2", () => {
|
||||
expect(validateTariffStructure({ ...base, defaultCard: okDefault })).toEqual([]);
|
||||
});
|
||||
it("requires tz when windowedCards present", () => {
|
||||
const errs = validateTariffStructure({ ...base, tz: "", defaultCard: okDefault, windowedCards: [{ name: "w", priority: 1, window: { dow: [1] }, blocks: ladder(5000) }] });
|
||||
expect(errs).toContain("tz (IANA timezone) is required when windowedCards are present");
|
||||
});
|
||||
it("rejects a card with both flat and blocks", () => {
|
||||
const errs = validateTariffStructure({ ...base, defaultCard: { name: "d", priority: 0, flatMinor: 100, blocks: ladder(100) } });
|
||||
expect(errs).toContain("defaultCard must set exactly one of flatMinor or blocks");
|
||||
});
|
||||
it("rejects defaultCard with a window", () => {
|
||||
const errs = validateTariffStructure({ ...base, defaultCard: { ...okDefault, window: { dow: [1] } } });
|
||||
expect(errs).toContain("defaultCard must not have a window (it is the always-active fallback)");
|
||||
});
|
||||
it("rejects a bad hour format", () => {
|
||||
const errs = validateTariffStructure({ ...base, defaultCard: okDefault, windowedCards: [{ name: "w", priority: 1, window: { fromHour: "25:00", toHour: "26:00" }, blocks: ladder(5000) }] });
|
||||
expect(errs.some((e) => e.includes("fromHour"))).toBe(true);
|
||||
});
|
||||
it("rejects ambiguous precedence (equal specificity+priority, overlapping)", () => {
|
||||
const errs = validateTariffStructure({
|
||||
...base,
|
||||
defaultCard: okDefault,
|
||||
windowedCards: [
|
||||
{ name: "x", priority: 5, window: { dow: [1, 2] }, blocks: ladder(5000) },
|
||||
{ name: "y", priority: 5, window: { dow: [2, 3] }, blocks: ladder(6000) },
|
||||
],
|
||||
});
|
||||
expect(errs.some((e) => e.includes("higher priority to break the tie"))).toBe(true);
|
||||
});
|
||||
it("allows the tie to be broken by priority", () => {
|
||||
const errs = validateTariffStructure({
|
||||
...base,
|
||||
defaultCard: okDefault,
|
||||
windowedCards: [
|
||||
{ name: "x", priority: 5, window: { dow: [1, 2] }, blocks: ladder(5000) },
|
||||
{ name: "y", priority: 6, window: { dow: [2, 3] }, blocks: ladder(6000) },
|
||||
],
|
||||
});
|
||||
expect(errs).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Only run tests from src (TypeScript source). Without this, the compiled copies
|
||||
// in dist/ get picked up as duplicate (stale) test files.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user