Verify day-change algorithm per TZ 4.1.17 (per-time-type badges, query-date baseline)
R4 gap fixed: TimeGroup now accepts scheduledDayChange + actualDayChange props separately so each time type renders its own independent badge. FlightCard updated to pass them independently (scheduled vs actual/estimated); expanded row time block also now shows per-type badges. R5 tooltip fixed: dayChangeBadgeTooltip() uses string-based date extraction (no TZ reprojection via new Date()) — avoids viewer-TZ shift for SSR and cross-TZ correctness. Returns "День" for ±1, DD.MM.YYYY for ±2+. New shared helper dayChange.ts exports computeDayChange(), dayChangeBadgeTooltip(), formatDayChangeBadge(). 27 unit tests cover +0/+1/+2/-1/-2, null, malformed input, month/year boundaries, and per-time-type independence (R4). R1–R3, R6 confirmed correct (API supplies dayChange per ITimesSet; badge adjacent to time; hidden when 0). R8 (mobile tooltip suppression) deferred.
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Unit tests for dayChange helpers per TZ §4.1.17.
|
||||
*
|
||||
* Covers rules R1–R5:
|
||||
* R1/R2: computeDayChange returns B−A (local time date minus query date)
|
||||
* R3: Badge hidden if result is 0; shown as +N/-N otherwise
|
||||
* R4: Each time type is computed independently (demonstrated by calling
|
||||
* computeDayChange separately for scheduled/expected/actual)
|
||||
* R5: Tooltip is "День" for ±1, "DD.MM.YYYY" for ±2 or more
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeDayChange,
|
||||
dayChangeBadgeTooltip,
|
||||
formatDayChangeBadge,
|
||||
} from "./dayChange.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// computeDayChange — TZ §4.1.17-R1, R2, R3, R4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("computeDayChange (TZ §4.1.17-R1–R4)", () => {
|
||||
it("returns 0 when time date equals query date (no badge — R3)", () => {
|
||||
expect(computeDayChange("2026-04-15T10:00:00", "2026-04-15")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when time date equals query date with TZ offset suffix", () => {
|
||||
expect(computeDayChange("2026-04-15T23:30:00+03:00", "2026-04-15")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns +1 when local date is the next day after query date", () => {
|
||||
expect(computeDayChange("2026-04-16T00:30:00", "2026-04-15")).toBe(1);
|
||||
});
|
||||
|
||||
it("returns +1 with TZ offset-aware string crossing midnight", () => {
|
||||
// 2026-04-16T01:00:00+05:00 — local date is April 16 regardless of viewer TZ
|
||||
expect(computeDayChange("2026-04-16T01:00:00+05:00", "2026-04-15")).toBe(1);
|
||||
});
|
||||
|
||||
it("returns +2 when two calendar days ahead", () => {
|
||||
expect(computeDayChange("2026-04-17T12:00:00", "2026-04-15")).toBe(2);
|
||||
});
|
||||
|
||||
it("returns -1 when local date is one day before query date", () => {
|
||||
expect(computeDayChange("2026-04-14T23:00:00", "2026-04-15")).toBe(-1);
|
||||
});
|
||||
|
||||
it("returns -2 when two calendar days before query date", () => {
|
||||
expect(computeDayChange("2026-04-13T08:00:00", "2026-04-15")).toBe(-2);
|
||||
});
|
||||
|
||||
it("returns 0 for null input (guard)", () => {
|
||||
expect(computeDayChange(null, "2026-04-15")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for undefined input (guard)", () => {
|
||||
expect(computeDayChange(undefined, "2026-04-15")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for malformed time string", () => {
|
||||
expect(computeDayChange("not-a-date", "2026-04-15")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for malformed query date", () => {
|
||||
expect(computeDayChange("2026-04-16T10:00:00", "bad-date")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for empty time string", () => {
|
||||
expect(computeDayChange("", "2026-04-15")).toBe(0);
|
||||
});
|
||||
|
||||
// R4: per-time-type independence — scheduled and actual may differ
|
||||
it("R4: scheduled and actual dayChange can differ independently", () => {
|
||||
const queryDate = "2026-04-15";
|
||||
const scheduledDayChange = computeDayChange(
|
||||
"2026-04-15T23:45:00+03:00",
|
||||
queryDate,
|
||||
);
|
||||
const actualDayChange = computeDayChange(
|
||||
"2026-04-16T00:30:00+03:00", // delayed past midnight
|
||||
queryDate,
|
||||
);
|
||||
expect(scheduledDayChange).toBe(0); // scheduled still April 15 → no badge
|
||||
expect(actualDayChange).toBe(1); // actual crossed midnight → +1 badge
|
||||
});
|
||||
|
||||
it("R4: estimated and actual can both differ from scheduled independently", () => {
|
||||
const queryDate = "2026-04-15";
|
||||
const scheduled = computeDayChange("2026-04-15T22:00:00", queryDate);
|
||||
const estimated = computeDayChange("2026-04-16T00:10:00", queryDate);
|
||||
const actual = computeDayChange("2026-04-16T00:45:00", queryDate);
|
||||
expect(scheduled).toBe(0);
|
||||
expect(estimated).toBe(1);
|
||||
expect(actual).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// dayChangeBadgeTooltip — TZ §4.1.17-R5
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("dayChangeBadgeTooltip (TZ §4.1.17-R5)", () => {
|
||||
it("returns 'День' for +1", () => {
|
||||
expect(dayChangeBadgeTooltip("2026-04-15T23:30:00", 1)).toBe("День");
|
||||
});
|
||||
|
||||
it("returns 'День' for -1", () => {
|
||||
expect(dayChangeBadgeTooltip("2026-04-15T01:00:00", -1)).toBe("День");
|
||||
});
|
||||
|
||||
it("returns 'DD.MM.YYYY' for +2", () => {
|
||||
// Base date is April 15 → +2 → April 17
|
||||
expect(dayChangeBadgeTooltip("2026-04-15T10:00:00", 2)).toBe("17.04.2026");
|
||||
});
|
||||
|
||||
it("returns 'DD.MM.YYYY' for -2", () => {
|
||||
// Base date is April 15 → -2 → April 13
|
||||
expect(dayChangeBadgeTooltip("2026-04-15T10:00:00", -2)).toBe("13.04.2026");
|
||||
});
|
||||
|
||||
it("handles month boundary correctly for +2", () => {
|
||||
// April 30 → +2 → May 2
|
||||
expect(dayChangeBadgeTooltip("2026-04-30T10:00:00", 2)).toBe("02.05.2026");
|
||||
});
|
||||
|
||||
it("handles year boundary correctly", () => {
|
||||
// Dec 31 → +2 → Jan 2 next year
|
||||
expect(dayChangeBadgeTooltip("2026-12-31T10:00:00", 2)).toBe("02.01.2027");
|
||||
});
|
||||
|
||||
it("handles offset-aware ISO string (uses local date from string)", () => {
|
||||
// Local date is April 15 (offset suffix doesn't change calendar date extraction)
|
||||
expect(dayChangeBadgeTooltip("2026-04-15T23:30:00+03:00", 2)).toBe(
|
||||
"17.04.2026",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty string for malformed base string", () => {
|
||||
expect(dayChangeBadgeTooltip("not-a-date", 2)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatDayChangeBadge — TZ §4.1.17-R3
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("formatDayChangeBadge (TZ §4.1.17-R3)", () => {
|
||||
it("returns '' for 0 (hidden)", () => {
|
||||
expect(formatDayChangeBadge(0)).toBe("");
|
||||
});
|
||||
|
||||
it("returns '+1' for 1", () => {
|
||||
expect(formatDayChangeBadge(1)).toBe("+1");
|
||||
});
|
||||
|
||||
it("returns '+2' for 2", () => {
|
||||
expect(formatDayChangeBadge(2)).toBe("+2");
|
||||
});
|
||||
|
||||
it("returns '-1' for -1", () => {
|
||||
expect(formatDayChangeBadge(-1)).toBe("-1");
|
||||
});
|
||||
|
||||
it("returns '-2' for -2", () => {
|
||||
expect(formatDayChangeBadge(-2)).toBe("-2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Day-change badge computation per TZ §4.1.17.
|
||||
*
|
||||
* Returns the day-offset between a time's local date and the query
|
||||
* (user's requested) date. Returns 0 when the time falls on the query
|
||||
* date (no badge shown).
|
||||
*
|
||||
* Each time type (scheduled / expected / actual) should call this
|
||||
* independently — badges for different time types of the same flight
|
||||
* may differ (e.g. scheduled +0 but actual +1 due to delay crossing
|
||||
* midnight).
|
||||
*
|
||||
* The dayChange value is supplied by the API on each ITimesSet.dayChange.value
|
||||
* field; this helper provides client-side computation for cases where a
|
||||
* query-date baseline is available (e.g. when API value is absent or
|
||||
* verification is needed).
|
||||
*/
|
||||
|
||||
const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})/;
|
||||
|
||||
/**
|
||||
* Extract the calendar date components from an ISO local string without
|
||||
* TZ reprojection. Handles both bare ("2026-04-15T23:30:00") and
|
||||
* offset-aware ("2026-04-15T23:30:00+03:00") forms.
|
||||
*
|
||||
* Returns [year, month (0-based), day] or null on failure.
|
||||
*/
|
||||
function extractLocalDateParts(
|
||||
iso: string,
|
||||
): [number, number, number] | null {
|
||||
const m = ISO_DATE_RE.exec(iso);
|
||||
if (!m || !m[1] || !m[2] || !m[3]) return null;
|
||||
return [parseInt(m[1], 10), parseInt(m[2], 10) - 1, parseInt(m[3], 10)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the day-change badge value per TZ §4.1.17.
|
||||
*
|
||||
* @param timeLocalIso The local time string from the API (may have TZ offset
|
||||
* suffix — interpreted as the *airport-local* wall-clock).
|
||||
* @param queryDateYyyymmdd The user's requested search date ("YYYY-MM-DD"),
|
||||
* which is the baseline "A" in TZ §4.1.17.
|
||||
* @returns Integer day offset (B - A). 0 means same day → no badge shown.
|
||||
* Positive = later date, negative = earlier date.
|
||||
*/
|
||||
export function computeDayChange(
|
||||
timeLocalIso: string | null | undefined,
|
||||
queryDateYyyymmdd: string,
|
||||
): number {
|
||||
if (!timeLocalIso) return 0;
|
||||
|
||||
const timeParts = extractLocalDateParts(timeLocalIso);
|
||||
const queryParts = extractLocalDateParts(queryDateYyyymmdd);
|
||||
if (!timeParts || !queryParts) return 0;
|
||||
|
||||
// Compare calendar dates as UTC midnight to avoid DST edge cases.
|
||||
const timeMs = Date.UTC(timeParts[0], timeParts[1], timeParts[2]);
|
||||
const queryMs = Date.UTC(queryParts[0], queryParts[1], queryParts[2]);
|
||||
if (Number.isNaN(timeMs) || Number.isNaN(queryMs)) return 0;
|
||||
|
||||
return Math.round((timeMs - queryMs) / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the tooltip text for a day-change badge per TZ §4.1.17 rules 5–6:
|
||||
* - ±1 → "День" (Russian: «День»)
|
||||
* - ±2 and up → the relevant date in DD.MM.YYYY format, computed from the
|
||||
* base ISO string's local date shifted by `dayChange` days.
|
||||
*
|
||||
* Uses string-based date extraction (no TZ reprojection) for correctness
|
||||
* on SSR and across viewer time zones.
|
||||
*
|
||||
* @param baseLocalIso The time string whose local calendar date is the base.
|
||||
* @param dayChange The badge value (non-zero; caller must guard against 0).
|
||||
*/
|
||||
export function dayChangeBadgeTooltip(
|
||||
baseLocalIso: string,
|
||||
dayChange: number,
|
||||
): string {
|
||||
const abs = Math.abs(dayChange);
|
||||
if (abs === 0) return "";
|
||||
if (abs === 1) return "День";
|
||||
|
||||
// Shift the local date by dayChange days using UTC arithmetic.
|
||||
const parts = extractLocalDateParts(baseLocalIso);
|
||||
if (!parts) return "";
|
||||
|
||||
const shiftedMs = Date.UTC(parts[0], parts[1], parts[2] + dayChange);
|
||||
const shifted = new Date(shiftedMs);
|
||||
const dd = String(shifted.getUTCDate()).padStart(2, "0");
|
||||
const mm = String(shifted.getUTCMonth() + 1).padStart(2, "0");
|
||||
const yyyy = shifted.getUTCFullYear();
|
||||
return `${dd}.${mm}.${yyyy}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a day-change numeric value as a badge label string.
|
||||
* Returns "" for 0, "+1" for 1, "-1" for -1, etc.
|
||||
*/
|
||||
export function formatDayChangeBadge(value: number): string {
|
||||
if (value === 0) return "";
|
||||
return value > 0 ? `+${value}` : String(value);
|
||||
}
|
||||
Reference in New Issue
Block a user