From 4ff3f3eb5d3efb2180c139b2f7d9977d2b8c20e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 05:32:48 +0200 Subject: [PATCH] test: property tests for zones, statistics, cadence, crypto and backups fast-check properties with one shared configuration (tests/property/): a fixed seed on every pull request, a fresh one and more cases at night through FC_SEED and FC_NUM_RUNS, and a printed seed with the shrunk counterexample on failure. - Time zones: wall clocks agree with Intl in twenty awkward zones, a wall clock maps back to an instant showing it, a skipped hour resolves forward on the same day, every instant lies inside the window of the day it is filed under, and day keys shift and count as inverses. - Statistics: every median in the tree says the same number, medians and spreads obey order, shift and scale, a least-squares fit recovers an exact line and composes from stored sums exactly, and the segmented regression recovers a noiseless interrupted line. - Cadence and compliance: weekly and daily schedules expect exactly what a day-by-day oracle expects, in any zone and on transition days, and the rate stays within bounds and never falls when a missed dose is taken. - Encryption: every codec opens what it sealed under the key it names, rotation moves the key id and nothing else, and a tampered value never opens to anything but the sealed bytes. - Backups: randomly generated accounts come back from a disaster-recovery restore with an identical canonical dump of every generated table. --- src/lib/__tests__/crypto.property.test.ts | 234 ++++++++++++ .../__tests__/compliance.property.test.ts | 259 +++++++++++++ .../stats/__tests__/stats.property.test.ts | 340 ++++++++++++++++++ src/lib/tz/__tests__/tz.property.test.ts | 233 ++++++++++++ .../backup-random-account.property.test.ts | 319 ++++++++++++++++ tests/property/fast-check.ts | 93 +++++ 6 files changed, 1478 insertions(+) create mode 100644 src/lib/__tests__/crypto.property.test.ts create mode 100644 src/lib/analytics/compliance/__tests__/compliance.property.test.ts create mode 100644 src/lib/stats/__tests__/stats.property.test.ts create mode 100644 src/lib/tz/__tests__/tz.property.test.ts create mode 100644 tests/integration/backup-random-account.property.test.ts create mode 100644 tests/property/fast-check.ts diff --git a/src/lib/__tests__/crypto.property.test.ts b/src/lib/__tests__/crypto.property.test.ts new file mode 100644 index 000000000..af9f0bfe2 --- /dev/null +++ b/src/lib/__tests__/crypto.property.test.ts @@ -0,0 +1,234 @@ +/** + * Encryption at rest against its laws, on random plaintexts, key maps and + * chunkings. + * + * The hand-written cases pin each refusal once; these say it for every + * input: whatever is sealed opens to the same bytes under the key it names, + * nothing that was tampered with opens at all, a rotation moves the key id + * and nothing else, and the streamed codec does not care how the plaintext + * was cut into chunks. + */ +import { Buffer } from "node:buffer"; + +import fc from "fast-check"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + _resetCryptoCacheForTests, + createStreamEncryptor, + decrypt, + decryptBytes, + decryptStream, + encrypt, + encryptBytes, + encryptUnderKeyId, + extractKeyId, + extractKeyIdFromBytes, + extractStreamKeyId, + reencryptBytesToActive, + reencryptToActive, +} from "../crypto"; + +import { assertProperty } from "../../../tests/property/fast-check"; + +const KEYS = { + v1: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + v2: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + k2026_b: "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90", +} as const; +type KeyId = keyof typeof KEYS; +const keyIdArb = fc.constantFrom("v1", "v2", "k2026_b"); + +function useKeys(active: KeyId): void { + vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify(KEYS)); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", active); + vi.stubEnv("ENCRYPTION_KEY", ""); + _resetCryptoCacheForTests(); +} + +beforeEach(() => useKeys("v2")); +afterAll(() => { + vi.unstubAllEnvs(); + _resetCryptoCacheForTests(); +}); + +const textArb = fc.string({ unit: "grapheme", maxLength: 300 }); +const bytesArb = fc.uint8Array({ maxLength: 2_000 }).map((a) => Buffer.from(a)); +const aadArb = fc.option(fc.string({ maxLength: 40 }), { nil: undefined }); + +describe("string codec", () => { + it("opens what it sealed, under the active key it names", () => { + assertProperty( + fc.property(keyIdArb, textArb, (active, text) => { + useKeys(active); + const sealed = encrypt(text); + expect(extractKeyId(sealed)).toBe(active); + expect(decrypt(sealed)).toBe(text); + }), + ); + }); + + it("never seals the same text the same way twice", () => { + assertProperty( + fc.property(textArb, (text) => { + expect(encrypt(text)).not.toBe(encrypt(text)); + }), + ); + }); + + it("opens a value sealed under any configured key, whichever key is active", () => { + assertProperty( + fc.property(keyIdArb, keyIdArb, textArb, (sealer, active, text) => { + useKeys(active); + const sealed = encryptUnderKeyId(text, sealer); + expect(decrypt(sealed)).toBe(text); + const rotated = reencryptToActive(sealed); + expect(extractKeyId(rotated)).toBe(active); + expect(decrypt(rotated)).toBe(text); + }), + ); + }); + + it("refuses a sealed value with any one character of its payload changed", () => { + assertProperty( + fc.property(textArb, fc.nat(), fc.nat(), (text, at, pick) => { + const sealed = encrypt(text); + const dot = sealed.indexOf("."); + // A payload position, leaving the key id and the base64 padding alone. + const body = sealed.slice(dot + 1).replace(/=+$/, ""); + const index = dot + 1 + (at % body.length); + const alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const original = sealed[index]; + const replacement = alphabet.replace(original, "")[pick % 63]; + const tampered = + sealed.slice(0, index) + replacement + sealed.slice(index + 1); + // A change confined to the unused low bits of the final base64 + // character decodes to the same bytes; anything else must not open. + const sameBytes = Buffer.from(tampered.slice(dot + 1), "base64").equals( + Buffer.from(sealed.slice(dot + 1), "base64"), + ); + if (sameBytes) return; + expect(() => decrypt(tampered)).toThrow(); + }), + 300, + ); + }); +}); + +describe("binary codec", () => { + it("opens what it sealed, with the same purpose label, under the key it names", () => { + assertProperty( + fc.property(keyIdArb, bytesArb, aadArb, (active, bytes, aad) => { + useKeys(active); + const sealed = encryptBytes(bytes, aad); + expect(extractKeyIdFromBytes(sealed)).toBe(active); + expect(decryptBytes(sealed, aad).equals(bytes)).toBe(true); + }), + ); + }); + + it("refuses a different purpose label", () => { + assertProperty( + fc.property( + bytesArb, + fc.string({ maxLength: 20 }), + fc.string({ maxLength: 20 }), + (bytes, a, b) => { + fc.pre(a !== b); + const sealed = encryptBytes(bytes, a); + expect(() => decryptBytes(sealed, b)).toThrow(); + }, + ), + ); + }); + + it("never opens a payload with a flipped bit to anything but the original bytes", () => { + assertProperty( + fc.property( + bytesArb, + aadArb, + fc.nat(), + fc.integer({ min: 0, max: 7 }), + (bytes, aad, at, bit) => { + const sealed = encryptBytes(bytes, aad); + const flipped = Buffer.from(sealed); + const index = at % flipped.byteLength; + flipped[index] ^= 1 << bit; + // The ciphertext, IV and tag are authenticated, so a flip there + // throws. The key-id header is not: it only selects the key, and a + // flip of a key-id byte's top bit decodes to the same ASCII id. + // Either way nothing but the sealed bytes can come out. + let opened: Buffer; + try { + opened = decryptBytes(flipped, aad); + } catch { + return; + } + expect(index).toBeLessThan(2 + flipped[1]); + expect(opened.equals(bytes)).toBe(true); + }, + ), + 300, + ); + }); + + it("rotates to the active key without touching the bytes", () => { + assertProperty( + fc.property( + keyIdArb, + keyIdArb, + bytesArb, + aadArb, + (from, to, bytes, aad) => { + useKeys(from); + const sealed = encryptBytes(bytes, aad); + useKeys(to); + const rotated = reencryptBytesToActive(sealed, aad); + expect(extractKeyIdFromBytes(rotated)).toBe(to); + expect(decryptBytes(rotated, aad).equals(bytes)).toBe(true); + }, + ), + ); + }); +}); + +describe("streamed codec", () => { + it("does not care how the plaintext was cut into chunks", () => { + assertProperty( + fc.property( + keyIdArb, + fc.array(fc.uint8Array({ maxLength: 400 }), { maxLength: 12 }), + (active, chunks) => { + useKeys(active); + const encryptor = createStreamEncryptor(); + let stored = encryptor.header; + for (const chunk of chunks) + stored += encryptor.update(Buffer.from(chunk)); + stored += encryptor.final(); + expect(extractStreamKeyId(stored)).toBe(active); + const expected = Buffer.concat(chunks.map((c) => Buffer.from(c))); + expect(decryptStream(stored).equals(expected)).toBe(true); + }, + ), + ); + }); + + it("refuses a streamed value cut short", () => { + assertProperty( + fc.property( + fc.uint8Array({ minLength: 1, maxLength: 400 }), + fc.integer({ min: 4, max: 40 }), + (bytes, cut) => { + const encryptor = createStreamEncryptor(); + const stored = + encryptor.header + + encryptor.update(Buffer.from(bytes)) + + encryptor.final(); + const truncated = stored.slice(0, stored.length - cut); + expect(() => decryptStream(truncated)).toThrow(); + }, + ), + ); + }); +}); diff --git a/src/lib/analytics/compliance/__tests__/compliance.property.test.ts b/src/lib/analytics/compliance/__tests__/compliance.property.test.ts new file mode 100644 index 000000000..f4152968e --- /dev/null +++ b/src/lib/analytics/compliance/__tests__/compliance.property.test.ts @@ -0,0 +1,259 @@ +/** + * Medication cadence and compliance against a naive oracle and against the + * laws a rate has to obey. + * + * The oracle walks one local calendar day at a time and asks the only + * question a person asks: is today one of my dose days, and how many doses + * does it hold? The engine answers the same question through recurrence + * rules, eras, bands and the zone's transitions; any disagreement on a random + * schedule, day and zone is a defect in one of the two, and the oracle is + * small enough to be obviously right. + */ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { + buildComplianceMedicationContext, + calculateCompliance, + expectedSlotCountForDay, + type ComplianceSchedule, + type IntakeEvent, +} from "@/lib/analytics/compliance"; +import { shiftDateKey, weekdayOfDateKey } from "@/lib/tz/format"; +import { localDayWindow } from "@/lib/tz/local-day"; + +import { + assertProperty, + zoneArb, +} from "../../../../../tests/property/fast-check"; + +const BYDAY = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"] as const; + +const dayKeyArb = fc + .date({ + min: new Date("2025-01-01T00:00:00Z"), + max: new Date("2027-12-31T00:00:00Z"), + noInvalidDate: true, + }) + .map((d) => d.toISOString().slice(0, 10)); + +const timeArb = fc + .tuple(fc.integer({ min: 0, max: 23 }), fc.constantFrom(0, 15, 30, 45)) + .map( + ([h, m]) => `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`, + ); + +const timesArb = fc.uniqueArray(timeArb, { minLength: 1, maxLength: 3 }); + +const weekdaysArb = fc.uniqueArray(fc.integer({ min: 0, max: 6 }), { + minLength: 1, + maxLength: 7, +}); + +function context(tz: string) { + return buildComplianceMedicationContext( + { + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2024-06-01T00:00:00Z"), + }, + null, + tz, + ); +} + +function weeklySchedule( + weekdays: number[], + times: string[], +): ComplianceSchedule { + return { + windowStart: times[0], + windowEnd: times[0], + daysOfWeek: null, + rrule: `FREQ=WEEKLY;BYDAY=${weekdays.map((d) => BYDAY[d]).join(",")}`, + timesOfDay: [...times].sort(), + }; +} + +describe("expected doses per local day", () => { + it("a weekly schedule expects its doses exactly on its weekdays", () => { + assertProperty( + fc.property( + weekdaysArb, + timesArb, + dayKeyArb, + zoneArb, + (weekdays, times, key, tz) => { + const { dayStart, dayEnd } = localDayWindow(key, tz); + const count = expectedSlotCountForDay( + [weeklySchedule(weekdays, times)], + dayStart, + dayEnd, + context(tz), + ); + const oracle = weekdays.includes(weekdayOfDateKey(key)) + ? times.length + : 0; + expect(count).toBe(oracle); + }, + ), + 300, + ); + }); + + it("a daily schedule expects every one of its times on every day", () => { + assertProperty( + fc.property(timesArb, dayKeyArb, zoneArb, (times, key, tz) => { + const { dayStart, dayEnd } = localDayWindow(key, tz); + const count = expectedSlotCountForDay( + [ + { + windowStart: times[0], + windowEnd: times[0], + daysOfWeek: null, + rrule: "FREQ=DAILY", + timesOfDay: [...times].sort(), + }, + ], + dayStart, + dayEnd, + context(tz), + ); + expect(count).toBe(times.length); + }), + 300, + ); + }); + + it("an as-needed schedule never expects a dose", () => { + assertProperty( + fc.property(timesArb, dayKeyArb, zoneArb, (times, key, tz) => { + const { dayStart, dayEnd } = localDayWindow(key, tz); + const count = expectedSlotCountForDay( + [ + { + windowStart: times[0], + windowEnd: times[0], + daysOfWeek: null, + scheduleType: "PRN", + rrule: "FREQ=DAILY", + timesOfDay: [...times].sort(), + }, + ], + dayStart, + dayEnd, + context(tz), + ); + expect(count).toBe(0); + }), + ); + }); + + it("a week of a weekly schedule holds weekdays × times doses", () => { + assertProperty( + fc.property( + weekdaysArb, + timesArb, + dayKeyArb, + zoneArb, + (weekdays, times, firstKey, tz) => { + let total = 0; + for (let i = 0; i < 7; i++) { + const { dayStart, dayEnd } = localDayWindow( + shiftDateKey(firstKey, i), + tz, + ); + total += expectedSlotCountForDay( + [weeklySchedule(weekdays, times)], + dayStart, + dayEnd, + context(tz), + ); + } + expect(total).toBe(weekdays.length * times.length); + }, + ), + 80, + ); + }); +}); + +/** A daily 08:00 schedule and a 14-day history with random outcomes. */ +const historyArb = fc.array(fc.constantFrom("taken", "skipped", "nothing"), { + minLength: 14, + maxLength: 14, +}); + +const NOW = new Date("2026-03-15T12:00:00Z"); +const DAILY: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }, +]; + +function eventsFrom(outcomes: readonly string[]): IntakeEvent[] { + return outcomes.flatMap((outcome, i): IntakeEvent[] => { + const scheduledFor = new Date( + Date.UTC(2026, 2, 15 - (outcomes.length - 1) + i, 8, 0), + ); + if (outcome === "nothing") return []; + return [ + { + scheduledFor, + takenAt: + outcome === "taken" + ? new Date(scheduledFor.getTime() + 10 * 60_000) + : null, + skipped: outcome === "skipped", + }, + ]; + }); +} + +function rateOf(outcomes: readonly string[]) { + return calculateCompliance(eventsFrom(outcomes), DAILY, 14, undefined, { + now: NOW, + medicationContext: context("UTC"), + }); +} + +describe("the compliance rate", () => { + it("stays within 0 to 100 and never counts more doses than expected", () => { + assertProperty( + fc.property(historyArb, (outcomes) => { + const result = rateOf(outcomes); + expect(result.rate).toBeGreaterThanOrEqual(0); + expect(result.rate).toBeLessThanOrEqual(100); + expect(result.taken).toBeLessThanOrEqual(result.totalExpected); + expect(result.taken).toBe(outcomes.filter((o) => o === "taken").length); + }), + ); + }); + + it("never falls when a missed dose is taken", () => { + assertProperty( + fc.property( + historyArb, + fc.integer({ min: 0, max: 13 }), + (outcomes, index) => { + fc.pre(outcomes[index] === "nothing"); + const after = [...outcomes]; + after[index] = "taken"; + expect(rateOf(after).rate).toBeGreaterThanOrEqual( + rateOf(outcomes).rate, + ); + }, + ), + ); + }); + + it("reads 100 when every expected dose was taken and 0 when none was", () => { + expect(rateOf(Array(14).fill("taken")).rate).toBe(100); + expect(rateOf(Array(14).fill("nothing")).rate).toBe(0); + }); +}); diff --git a/src/lib/stats/__tests__/stats.property.test.ts b/src/lib/stats/__tests__/stats.property.test.ts new file mode 100644 index 000000000..15991c4a1 --- /dev/null +++ b/src/lib/stats/__tests__/stats.property.test.ts @@ -0,0 +1,340 @@ +/** + * The statistics helpers against their invariants, on random samples. + * + * Not a second implementation: each property is a law any correct version + * obeys (order does not matter, shifting the data shifts the answer, a + * spread is never negative), plus agreement between the several medians the + * tree carries, which are separate code that has to say the same number. + */ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { fitFromSums, linearFit, sumsOf } from "@/lib/stats/linear-fit"; +import { standardDeviation, variance } from "@/lib/stats/spread"; +import { + defaultHacLag, + fitSegmentedRegression, + MIN_HAC_LAG, + neweyWestLag, +} from "@/lib/stats/segmented-regression"; +import { median as baselineMedian } from "@/lib/insights/derived/baseline"; +import { median as calendarMedian } from "@/lib/activity/calendar-math"; +import { median as cycleMedian } from "@/lib/cycle/prediction"; +import { median as scoreMedian, mean } from "@/lib/analytics/score/shared"; +import { percentile } from "@/lib/insights/strain-score"; +import { quantile } from "@/lib/analytics/mood-prognosis/validate"; + +import { assertProperty } from "../../../../tests/property/fast-check"; + +/** + * Values on the scale and at the precision of health readings: hundredths + * between -1 000 and 10 000. Subnormal doubles are not readings, and near + * them the identities hold only to the last bit. + */ +const valueArb = fc + .integer({ min: -100_000, max: 1_000_000 }) + .map((hundredths) => hundredths / 100); +const sampleArb = fc.array(valueArb, { minLength: 1, maxLength: 60 }); + +function close(a: number, b: number, scale = 1): void { + expect(Math.abs(a - b)).toBeLessThanOrEqual(1e-7 * Math.max(1, scale)); +} + +function magnitude(values: readonly number[]): number { + return values.reduce((m, v) => Math.max(m, Math.abs(v)), 1); +} + +describe("medians and quantiles", () => { + it("every median in the tree says the same number", () => { + assertProperty( + fc.property(sampleArb, (values) => { + const expected = baselineMedian([...values]); + expect(cycleMedian(values)).toBe(expected); + expect(scoreMedian(values)).toBe(expected); + // The interpolating forms compute the middle pair as a + (b − a)/2 + // rather than (a + b)/2, which can differ in the last bit. + const scale = magnitude(values); + close(calendarMedian(values)!, expected, scale); + close(percentile(values, 50), expected, scale); + close(quantile(values, 0.5), expected, scale); + }), + ); + }); + + it("a median ignores order, sits between the extremes, and moves with a shift", () => { + assertProperty( + fc.property( + sampleArb, + fc.double({ min: -500, max: 500, noNaN: true }), + (values, shift) => { + const m = scoreMedian(values); + const reversed = [...values].reverse(); + expect(scoreMedian(reversed)).toBe(m); + expect(m).toBeGreaterThanOrEqual(Math.min(...values)); + expect(m).toBeLessThanOrEqual(Math.max(...values)); + close( + scoreMedian(values.map((v) => v + shift)), + m + shift, + magnitude(values) + Math.abs(shift), + ); + }, + ), + ); + }); + + it("a quantile never decreases as the level rises", () => { + assertProperty( + fc.property( + sampleArb, + fc.double({ min: 0, max: 1, noNaN: true }), + fc.double({ min: 0, max: 1, noNaN: true }), + (values, a, b) => { + const [lo, hi] = a <= b ? [a, b] : [b, a]; + expect(quantile(values, lo)).toBeLessThanOrEqual( + quantile(values, hi), + ); + expect(quantile(values, 0)).toBe(Math.min(...values)); + expect(quantile(values, 1)).toBe(Math.max(...values)); + }, + ), + ); + }); + + it("a mean sits between the extremes", () => { + assertProperty( + fc.property(sampleArb, (values) => { + const m = mean(values); + const eps = 1e-9 * magnitude(values); + expect(m).toBeGreaterThanOrEqual(Math.min(...values) - eps); + expect(m).toBeLessThanOrEqual(Math.max(...values) + eps); + }), + ); + }); +}); + +describe("spread", () => { + it("is never negative, ignores a shift, and scales with the data", () => { + assertProperty( + fc.property( + fc.array(valueArb, { minLength: 2, maxLength: 60 }), + fc.double({ min: -500, max: 500, noNaN: true }), + fc.double({ min: 0.01, max: 100, noNaN: true }), + (values, shift, scale) => { + const sd = standardDeviation(values, "population")!; + expect(sd).toBeGreaterThanOrEqual(0); + const tolerance = 1e-6 * (magnitude(values) + Math.abs(shift)); + expect( + Math.abs( + standardDeviation( + values.map((v) => v + shift), + "population", + )! - sd, + ), + ).toBeLessThanOrEqual(tolerance); + expect( + Math.abs( + standardDeviation( + values.map((v) => v * scale), + "population", + )! - + sd * scale, + ), + ).toBeLessThanOrEqual(tolerance * scale); + }, + ), + ); + }); + + it("puts the sample variance above the population variance by n/(n-1)", () => { + assertProperty( + fc.property( + fc.array(valueArb, { minLength: 2, maxLength: 60 }), + (values) => { + const n = values.length; + const population = variance(values, "population")!; + const sample = variance(values, "sample")!; + close(sample, (population * n) / (n - 1), magnitude(values) ** 2); + }, + ), + ); + }); + + it("is undefined where the divisor would be zero", () => { + expect(variance([], "population")).toBeNull(); + expect(variance([3], "sample")).toBeNull(); + expect(variance([3], "population")).toBe(0); + }); +}); + +/** Points with at least two distinct x values, so a slope exists. */ +const pointsArb = fc + .array( + fc.record({ + x: fc.integer({ min: -400, max: 400 }), + y: valueArb, + }), + { minLength: 2, maxLength: 50 }, + ) + .filter((ps) => new Set(ps.map((p) => p.x)).size >= 2); + +describe("least-squares line", () => { + it("does not depend on the order of the points", () => { + assertProperty( + fc.property(pointsArb, (points) => { + const fit = linearFit( + points.map((p) => p.x), + points.map((p) => p.y), + )!; + const reversed = [...points].reverse(); + const again = linearFit( + reversed.map((p) => p.x), + reversed.map((p) => p.y), + )!; + close(again.slope, fit.slope, magnitude(points.map((p) => p.y))); + close( + again.intercept, + fit.intercept, + magnitude(points.map((p) => p.y)), + ); + }), + ); + }); + + it("recovers an exact line, and an affine change of y changes the fit the same way", () => { + assertProperty( + fc.property( + fc.uniqueArray(fc.integer({ min: -400, max: 400 }), { + minLength: 2, + maxLength: 40, + }), + fc.double({ min: -50, max: 50, noNaN: true }), + fc.double({ min: -1000, max: 1000, noNaN: true }), + (xs, slope, intercept) => { + const ys = xs.map((x) => slope * x + intercept); + const fit = linearFit(xs, ys)!; + const scale = Math.abs(intercept) + Math.abs(slope) * 400; + close(fit.slope, slope, scale); + close(fit.intercept, intercept, scale); + // The mean point lies on the fitted line. + close(fit.intercept + fit.slope * fit.meanX, fit.meanY, scale); + }, + ), + ); + }); + + it("composes from stored sums exactly as from the points", () => { + assertProperty( + fc.property(pointsArb, (points) => { + const xs = points.map((p) => p.x); + const ys = points.map((p) => p.y); + expect(fitFromSums(sumsOf(xs, ys))).toEqual(linearFit(xs, ys)); + }), + ); + }); + + it("keeps r² within [0, 1] when y varies", () => { + assertProperty( + fc.property(pointsArb, (points) => { + const fit = linearFit( + points.map((p) => p.x), + points.map((p) => p.y), + )!; + if (fit.r2 === null) { + expect(fit.syy).toBe(0); + return; + } + expect(fit.r2).toBeGreaterThanOrEqual(0); + expect(fit.r2).toBeLessThanOrEqual(1 + 1e-9); + }), + ); + }); + + it("refuses a vertical line instead of inventing a slope", () => { + assertProperty( + fc.property( + fc.integer({ min: -400, max: 400 }), + fc.array(valueArb, { minLength: 2, maxLength: 20 }), + (x, ys) => { + expect( + linearFit( + ys.map(() => x), + ys, + ), + ).toBeNull(); + }, + ), + ); + }); +}); + +describe("segmented regression and its Newey-West lag", () => { + it("never lets the lag rule go down as the series grows, and applies at least a week", () => { + assertProperty( + fc.property( + fc.integer({ min: 5, max: 5000 }), + fc.integer({ min: 0, max: 5000 }), + (n, extra) => { + expect(neweyWestLag(n + extra)).toBeGreaterThanOrEqual( + neweyWestLag(n), + ); + expect(defaultHacLag(n)).toBeGreaterThanOrEqual(MIN_HAC_LAG); + expect(defaultHacLag(n)).toBeGreaterThanOrEqual(neweyWestLag(n)); + }, + ), + ); + }); + + it("recovers a noiseless interrupted line with intervals that contain the estimates", () => { + assertProperty( + fc.property( + fc.integer({ min: 12, max: 60 }), + fc.integer({ min: 12, max: 60 }), + fc.double({ min: -5, max: 5, noNaN: true }), + fc.double({ min: -5, max: 5, noNaN: true }), + fc.double({ min: -20, max: 20, noNaN: true }), + fc.double({ min: -2, max: 2, noNaN: true }), + (before, after, intercept, preSlope, levelChange, slopeChange) => { + const points = []; + for (let t = -before; t < after; t++) { + const d = t >= 0 ? 1 : 0; + points.push({ + t, + y: + intercept + + preSlope * t + + levelChange * d + + slopeChange * t * d, + }); + } + const fit = fitSegmentedRegression(points)!; + expect(fit).not.toBeNull(); + const scale = 100; + close(fit.intercept.estimate, intercept, scale); + close(fit.preSlope.estimate, preSlope, scale); + close(fit.levelChange.estimate, levelChange, scale); + close(fit.slopeChange.estimate, slopeChange, scale); + for (const c of [ + fit.intercept, + fit.preSlope, + fit.levelChange, + fit.slopeChange, + ]) { + expect(c.ciLow).toBeLessThanOrEqual(c.estimate); + expect(c.estimate).toBeLessThanOrEqual(c.ciHigh); + expect(c.standardError).toBeGreaterThanOrEqual(0); + } + }, + ), + 60, + ); + }); + + it("refuses a design with no points after the change", () => { + assertProperty( + fc.property(fc.array(valueArb, { minLength: 6, maxLength: 30 }), (ys) => { + const points = ys.map((y, i) => ({ t: -(i + 1), y })); + expect(fitSegmentedRegression(points)).toBeNull(); + }), + ); + }); +}); diff --git a/src/lib/tz/__tests__/tz.property.test.ts b/src/lib/tz/__tests__/tz.property.test.ts new file mode 100644 index 000000000..f8958e533 --- /dev/null +++ b/src/lib/tz/__tests__/tz.property.test.ts @@ -0,0 +1,233 @@ +/** + * Time-zone and day-boundary helpers against their laws, on random instants + * in zones chosen for their awkward offsets and transitions. + * + * The hand-written cases next door pin the transitions somebody thought of; + * these generate the ones nobody did. Each property states something a + * person would notice if it broke: a reading filed under the wrong day, a + * reminder an hour off on a transition day, a day that does not contain its + * own instants. + */ +import fc from "fast-check"; +import { describe, it, expect } from "vitest"; + +import { + daysBetweenDateKeys, + shiftDateKey, + tzOffsetMinutes, + userDayKey, + weekdayOfDateKey, +} from "@/lib/tz/format"; +import { localDayWindow, startOfLocalDayInTz } from "@/lib/tz/local-day"; +import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; +import { startOfUtcDay } from "@/lib/tz/start-of-utc-day"; +import { dateOnlyAtNoonUtc, dateOnlyKey } from "@/lib/tz/date-only"; + +import { + assertProperty, + instantArb, + zoneArb, +} from "../../../../tests/property/fast-check"; + +/** The oracle: what `Intl` itself reads off the wall clock. */ +function intlWallClock(date: Date, tz: string) { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + hourCycle: "h23", + weekday: "short", + }).formatToParts(date); + const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ""; + const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + return { + year: Number(get("year")), + month: Number(get("month")), + day: Number(get("day")), + hour: Number(get("hour")) % 24, + minute: Number(get("minute")), + second: Number(get("second")), + weekday: weekdays.indexOf(get("weekday")), + }; +} + +const dayKeyArb = fc + .date({ + min: new Date("1996-01-01T00:00:00Z"), + max: new Date("2039-12-31T00:00:00Z"), + noInvalidDate: true, + }) + .map((d) => d.toISOString().slice(0, 10)); + +describe("wall clock in a zone", () => { + it("reads the same wall clock as Intl for any instant and zone", () => { + assertProperty( + fc.property(instantArb, zoneArb, (date, tz) => { + expect(wallClockInTz(date, tz)).toEqual(intlWallClock(date, tz)); + }), + 400, + ); + }); + + it("maps a wall clock that exists back to an instant showing that wall clock", () => { + assertProperty( + fc.property(instantArb, zoneArb, (date, tz) => { + const wall = wallClockInTz(date, tz); + const back = zonedWallClockToUtc(wall, tz); + const again = wallClockInTz(back, tz); + expect(again).toEqual(wall); + // Inside a repeated hour the inverse may pick the other occurrence; + // it never lands further away than the largest DST shift. + const truncated = Math.floor(date.getTime() / 1000) * 1000; + expect(Math.abs(back.getTime() - truncated)).toBeLessThanOrEqual( + 2 * 3_600_000, + ); + }), + 400, + ); + }); + + it("resolves a wall clock inside a skipped hour forward, on the same day", () => { + assertProperty( + fc.property( + instantArb, + zoneArb, + fc.integer({ min: 0, max: 23 }), + fc.integer({ min: 0, max: 59 }), + (date, tz, hour, minute) => { + const day = wallClockInTz(date, tz); + const wanted = { ...day, hour, minute, second: 0 }; + const resolved = zonedWallClockToUtc(wanted, tz); + const shown = wallClockInTz(resolved, tz); + const asIfUtc = (w: typeof wanted) => + Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute); + const drift = asIfUtc(shown) - asIfUtc(wanted); + // Either the wall clock exists and is hit exactly, or it falls in a + // gap and is read with the offset before the gap: later, never + // earlier, by at most the size of the gap. + expect(drift).toBeGreaterThanOrEqual(0); + expect(drift).toBeLessThanOrEqual(2 * 3_600_000); + }, + ), + 400, + ); + }); + + it("agrees with the offset helper on the zone offset", () => { + assertProperty( + fc.property(instantArb, zoneArb, (date, tz) => { + const wall = intlWallClock(date, tz); + const asIfUtc = Date.UTC( + wall.year, + wall.month - 1, + wall.day, + wall.hour, + wall.minute, + wall.second, + ); + const expected = Math.round((asIfUtc - date.getTime()) / 60_000); + expect(tzOffsetMinutes(date, tz)).toBe(expected); + }), + 300, + ); + }); +}); + +describe("local days", () => { + it("files an instant under a day whose window contains it", () => { + assertProperty( + fc.property(instantArb, zoneArb, (date, tz) => { + const key = userDayKey(date, tz); + const { dayStart, dayEnd } = localDayWindow(key, tz); + expect(dayStart.getTime()).toBeLessThanOrEqual(date.getTime()); + expect(date.getTime()).toBeLessThan(dayEnd.getTime()); + expect(startOfLocalDayInTz(date, tz).getTime()).toBe( + dayStart.getTime(), + ); + }), + 400, + ); + }); + + it("never files a later instant under an earlier day", () => { + assertProperty( + fc.property(instantArb, instantArb, zoneArb, (a, b, tz) => { + const [early, late] = a <= b ? [a, b] : [b, a]; + expect(userDayKey(early, tz) <= userDayKey(late, tz)).toBe(true); + }), + 400, + ); + }); + + it("makes every local day between 22 and 26 hours long and contiguous with the next", () => { + assertProperty( + fc.property(dayKeyArb, zoneArb, (key, tz) => { + const today = localDayWindow(key, tz); + const tomorrow = localDayWindow(shiftDateKey(key, 1), tz); + const hours = + (today.dayEnd.getTime() - today.dayStart.getTime()) / 3_600_000; + expect(hours).toBeGreaterThanOrEqual(22); + expect(hours).toBeLessThanOrEqual(26); + expect(tomorrow.dayStart.getTime()).toBe(today.dayEnd.getTime()); + expect(userDayKey(today.dayStart, tz)).toBe(key); + }), + 300, + ); + }); +}); + +describe("day keys", () => { + it("shifts and counts days as inverses of each other", () => { + assertProperty( + fc.property( + dayKeyArb, + fc.integer({ min: -2000, max: 2000 }), + (key, delta) => { + const shifted = shiftDateKey(key, delta); + expect(daysBetweenDateKeys(key, shifted)).toBe(delta); + expect(shiftDateKey(shifted, -delta)).toBe(key); + }, + ), + ); + }); + + it("keeps the weekday across whole weeks and advances it by one per day", () => { + assertProperty( + fc.property( + dayKeyArb, + fc.integer({ min: -300, max: 300 }), + (key, weeks) => { + const weekday = weekdayOfDateKey(key); + expect(weekdayOfDateKey(shiftDateKey(key, weeks * 7))).toBe(weekday); + expect(weekdayOfDateKey(shiftDateKey(key, 1))).toBe( + (weekday + 1) % 7, + ); + }, + ), + ); + }); + + it("stores a date-only value at noon UTC and reads the same date back", () => { + assertProperty( + fc.property(dayKeyArb, (key) => { + expect(dateOnlyKey(dateOnlyAtNoonUtc(key))).toBe(key); + }), + ); + }); + + it("floors an instant to its UTC day, idempotently", () => { + assertProperty( + fc.property(instantArb, (date) => { + const floor = startOfUtcDay(date); + expect(floor.getTime()).toBeLessThanOrEqual(date.getTime()); + expect(date.getTime() - floor.getTime()).toBeLessThan(86_400_000); + expect(startOfUtcDay(floor).getTime()).toBe(floor.getTime()); + expect(floor.toISOString().endsWith("T00:00:00.000Z")).toBe(true); + }), + ); + }); +}); diff --git a/tests/integration/backup-random-account.property.test.ts b/tests/integration/backup-random-account.property.test.ts new file mode 100644 index 000000000..f88553000 --- /dev/null +++ b/tests/integration/backup-random-account.property.test.ts @@ -0,0 +1,319 @@ +/** + * A randomly generated account survives a disaster-recovery backup and + * restore unchanged. + * + * `backup-round-trip.test.ts` proves one hand-seeded row of every model and + * one value of every column. This file generates whole accounts instead: + * measurements of mixed types and sources, medications with weekly and daily + * schedules and intake histories, mood entries, and a handful of settings, in + * combinations nobody wrote down. Each account is exported the way the weekly + * worker exports it, the account is deleted and recreated under the same id, + * the file is restored through the operator's restore route, and a canonical + * dump of every generated table must come back identical. + * + * Seeded like every property test (tests/property/fast-check.ts); a failure + * prints the seed and the smallest account that still fails. + */ +import fc from "fast-check"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob"; +import type { Prisma } from "@/generated/prisma/client"; +import { streamFullBackupJson } from "@/lib/export/full-backup-stream"; + +import { assertAsyncProperty } from "../property/fast-check"; +import { callRoute } from "../kit/call-route"; +import { makeAdmin, makeSession, makeUser } from "../kit/factories"; +import { POST as restoreBackup } from "./restore-job-driver"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); +const OWNER_ID = "random-account-owner"; + +const MEASUREMENTS: ReadonlyArray<{ + type: Prisma.MeasurementCreateManyInput["type"]; + unit: string; + min: number; + max: number; +}> = [ + { type: "WEIGHT", unit: "kg", min: 40, max: 160 }, + { type: "BLOOD_PRESSURE_SYS", unit: "mmHg", min: 90, max: 190 }, + { type: "BLOOD_PRESSURE_DIA", unit: "mmHg", min: 50, max: 120 }, + { type: "PULSE", unit: "bpm", min: 40, max: 180 }, + { type: "BLOOD_GLUCOSE", unit: "mg/dL", min: 50, max: 300 }, + { type: "ACTIVITY_STEPS", unit: "steps", min: 0, max: 30_000 }, +]; +const SOURCES = ["MANUAL", "WITHINGS", "APPLE_HEALTH", "IMPORT"] as const; + +const instant = fc + .integer({ + min: Date.parse("2024-01-01T00:00:00Z") / 60_000, + max: Date.parse("2026-09-30T00:00:00Z") / 60_000, + }) + .map((minutes) => new Date(minutes * 60_000)); + +const measurementArb = fc.record({ + kind: fc.nat({ max: MEASUREMENTS.length - 1 }), + source: fc.constantFrom(...SOURCES), + measuredAt: instant, + fraction: fc.double({ min: 0, max: 1, noNaN: true }), + externalId: fc.option(fc.uuid(), { nil: null }), +}); + +const scheduleArb = fc.oneof( + fc.record({ + windowStart: fc.constantFrom("07:00", "08:00", "20:00"), + daysOfWeek: fc.constantFrom(null, "1,3,5", "0,6"), + rrule: fc.constant(null), + timesOfDay: fc.constant([]), + }), + fc.record({ + windowStart: fc.constantFrom("06:30", "12:00"), + daysOfWeek: fc.constant(null), + rrule: fc.constantFrom("FREQ=DAILY", "FREQ=WEEKLY;BYDAY=MO,TH"), + timesOfDay: fc.uniqueArray(fc.constantFrom("06:30", "12:00", "21:15"), { + minLength: 1, + maxLength: 2, + }), + }), +); + +const intakeArb = fc.record({ + scheduledFor: instant, + outcome: fc.constantFrom("taken", "skipped", "missed"), +}); + +const medicationArb = fc.record({ + name: fc.constantFrom("Ramipril", "Metformin", "Vitamin D", "Levothyroxin"), + dose: fc.constantFrom("5mg", "500mg", "1000 IE", "50µg"), + asNeeded: fc.boolean(), + active: fc.boolean(), + schedules: fc.array(scheduleArb, { minLength: 1, maxLength: 2 }), + intakes: fc.uniqueArray(intakeArb, { + maxLength: 5, + selector: (i) => i.scheduledFor.getTime(), + }), +}); + +const MOODS = [ + ["SUPER_GUT", 5], + ["GUT", 4], + ["OKAY", 3], + ["SCHLECHT", 2], + ["LAUSIG", 1], +] as const; + +const moodArb = fc.record({ + loggedAt: instant, + level: fc.nat({ max: 4 }), + dims: fc.option(fc.tuple(fc.nat({ max: 10 }), fc.nat({ max: 10 })), { + nil: null, + }), +}); + +const settingsArb = fc.record( + { + unitPreference: fc.constantFrom("metric", "imperial"), + glucoseUnit: fc.constantFrom("mg/dL", "mmol/L"), + timezone: fc.constantFrom( + "Europe/Berlin", + "America/New_York", + "Asia/Kolkata", + ), + heightCm: fc.integer({ min: 140, max: 210 }), + hasDiabetes: fc.boolean(), + }, + { requiredKeys: [] }, +); + +const accountArb = fc.record({ + measurements: fc.uniqueArray(measurementArb, { + maxLength: 25, + selector: (m) => `${m.kind}|${m.source}|${m.measuredAt.getTime()}`, + }), + medications: fc.array(medicationArb, { maxLength: 3 }), + moods: fc.uniqueArray(moodArb, { + maxLength: 6, + selector: (m) => m.loggedAt.toISOString().slice(0, 10), + }), + settings: settingsArb, +}); + +type Account = typeof accountArb extends fc.Arbitrary ? T : never; + +async function seed(account: Account): Promise { + await makeUser({ id: OWNER_ID, ...account.settings }); + await prisma.measurement.createMany({ + data: account.measurements.map((m) => { + const kind = MEASUREMENTS[m.kind]; + const value = + Math.round((kind.min + m.fraction * (kind.max - kind.min)) * 10) / 10; + return { + userId: OWNER_ID, + type: kind.type, + unit: kind.unit, + value, + source: m.source, + measuredAt: m.measuredAt, + externalId: m.externalId, + }; + }), + }); + for (const med of account.medications) { + const created = await prisma.medication.create({ + data: { + userId: OWNER_ID, + name: med.name, + dose: med.dose, + asNeeded: med.asNeeded, + active: med.active, + schedules: { + create: med.schedules.map((s) => ({ + windowStart: s.windowStart, + windowEnd: s.windowStart, + daysOfWeek: s.daysOfWeek, + rrule: s.rrule, + timesOfDay: s.timesOfDay, + })), + }, + }, + }); + await prisma.medicationIntakeEvent.createMany({ + data: med.intakes.map((i) => ({ + userId: OWNER_ID, + medicationId: created.id, + scheduledFor: i.scheduledFor, + takenAt: + i.outcome === "taken" + ? new Date(i.scheduledFor.getTime() + 600_000) + : null, + skipped: i.outcome === "skipped", + })), + }); + } + await prisma.moodEntry.createMany({ + data: account.moods.map((m) => { + const [mood, score] = MOODS[m.level]; + return { + userId: OWNER_ID, + date: m.loggedAt.toISOString().slice(0, 10), + mood, + score, + moodLoggedAt: m.loggedAt, + tz: "UTC", + moodA1: m.dims?.[0] ?? null, + stressA2: m.dims?.[1] ?? null, + }; + }), + }); +} + +/** + * Columns a restore legitimately rewrites: the bookkeeping Prisma stamps on + * every write, and the per-row sync counters the restore bumps so a paired + * client re-reads the rows. + */ +const VOLATILE = new Set(["updatedAt", "syncedAt", "syncVersion"]); + +function canonical(rows: ReadonlyArray): string[] { + return rows + .map((row) => + JSON.stringify( + Object.fromEntries( + Object.entries(row) + .filter(([key]) => !VOLATILE.has(key)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => [ + key, + value instanceof Date ? value.toISOString() : value, + ]), + ), + ), + ) + .sort(); +} + +async function dump(): Promise> { + const where = { userId: OWNER_ID }; + const user = await prisma.user.findUniqueOrThrow({ + where: { id: OWNER_ID }, + select: { + unitPreference: true, + glucoseUnit: true, + timezone: true, + heightCm: true, + hasDiabetes: true, + }, + }); + return { + user: canonical([user]), + measurements: canonical(await prisma.measurement.findMany({ where })), + medications: canonical(await prisma.medication.findMany({ where })), + schedules: canonical( + await prisma.medicationSchedule.findMany({ + where: { medication: where }, + }), + ), + intakes: canonical(await prisma.medicationIntakeEvent.findMany({ where })), + moods: canonical(await prisma.moodEntry.findMany({ where })), + }; +} + +beforeEach(async () => { + await truncateAllTables(prisma); +}); + +describe("a generated account", () => { + it("comes back from a disaster-recovery backup exactly as it left", async () => { + await assertAsyncProperty( + fc.asyncProperty(accountArb, async (account) => { + await truncateAllTables(prisma); + await seed(account); + const before = await dump(); + + let json = ""; + await streamFullBackupJson( + prisma, + OWNER_ID, + (chunk) => { + json += chunk; + }, + { + purpose: "disaster-recovery", + exportedAt: new Date("2026-10-01T00:00:00.000Z"), + }, + ); + + await prisma.user.delete({ where: { id: OWNER_ID } }); + await makeUser({ id: OWNER_ID }); + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "RANDOM_ACCOUNT_ROUND_TRIP", + data: await legacyStreamedBlobFrom(async (write) => { + await write(json); + }), + }, + }); + const operator = await makeSession(await makeAdmin()); + const restored = await callRoute(restoreBackup, { + method: "POST", + route: "/api/admin/backups/{id}/restore", + params: { id: backup.id }, + body: { confirm: "RESTORE" }, + auth: operator, + contract: { + skip: "the restore driver answers 200 with the job's report in place of the route's queued 202", + }, + }); + expect( + restored.status, + JSON.stringify(restored.body).slice(0, 300), + ).toBe(200); + + expect(await dump()).toEqual(before); + }), + 12, + ); + }); +}); diff --git a/tests/property/fast-check.ts b/tests/property/fast-check.ts new file mode 100644 index 000000000..f5dd632da --- /dev/null +++ b/tests/property/fast-check.ts @@ -0,0 +1,93 @@ +/** + * One set of fast-check parameters for every property test. + * + * A pull request runs each property with a fixed seed, so the same inputs + * are generated on every machine and a red run is a red run on the next + * attempt too. The nightly workflow sets `FC_SEED` to a fresh value, which is + * how new inputs get tried without making a pull request flaky; a failure + * prints the seed and the shrunk counterexample, and + * `FC_SEED= pnpm test ` replays it exactly. + * + * `FC_NUM_RUNS` scales the number of generated cases (the nightly run uses + * more). The default keeps every property file well under a second or two. + */ +import fc from "fast-check"; + +const DEFAULT_SEED = 20_261_022; + +function positiveInt(raw: string | undefined): number | null { + if (!raw) return null; + const value = Number.parseInt(raw, 10); + return Number.isInteger(value) && value > 0 ? value : null; +} + +export const PROPERTY_SEED = positiveInt(process.env.FC_SEED) ?? DEFAULT_SEED; + +const SCALE = positiveInt(process.env.FC_NUM_RUNS); + +/** Parameters for `fc.assert`; `numRuns` is the PR-run default per property. */ +export function propertyParameters( + numRuns = 100, + overrides: fc.Parameters = {}, +): fc.Parameters { + return { + seed: PROPERTY_SEED, + numRuns: SCALE ?? numRuns, + ...overrides, + }; +} + +/** Run a property with the shared parameters. */ +export function assertProperty( + property: fc.IProperty, + numRuns?: number, +): void { + fc.assert(property, propertyParameters(numRuns)); +} + +/** The async counterpart of {@link assertProperty}. */ +export async function assertAsyncProperty( + property: fc.IAsyncProperty, + numRuns?: number, +): Promise { + await fc.assert(property, propertyParameters(numRuns)); +} + +/** + * IANA zones with the awkward cases in them: half- and quarter-hour offsets + * (Kolkata, Kathmandu, Chatham, St John's), a 30-minute DST shift (Lord + * Howe), southern-hemisphere DST (Santiago, Sydney, Auckland), a zone whose + * DST starts at midnight (Santiago), the date line (Kiritimati, Apia), and a + * zone with no DST at all (UTC, Kolkata). + */ +export const AWKWARD_ZONES = [ + "UTC", + "Europe/Berlin", + "Europe/London", + "Europe/Dublin", + "America/New_York", + "America/Los_Angeles", + "America/St_Johns", + "America/Santiago", + "America/Sao_Paulo", + "Asia/Kolkata", + "Asia/Kathmandu", + "Asia/Tehran", + "Australia/Lord_Howe", + "Australia/Sydney", + "Australia/Adelaide", + "Pacific/Auckland", + "Pacific/Chatham", + "Pacific/Kiritimati", + "Pacific/Apia", + "Africa/Casablanca", +] as const; + +export const zoneArb = fc.constantFrom(...AWKWARD_ZONES); + +/** Instants between 1995 and 2040, at millisecond resolution. */ +export const instantArb = fc.date({ + min: new Date("1995-01-01T00:00:00Z"), + max: new Date("2040-12-31T23:59:59Z"), + noInvalidDate: true, +});