Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions features/ledger-close-cadence/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Ledger Close Cadence Explorer

Measures observed spacing between recent Horizon ledger close timestamps for the selected network.

## Behavior

1. Validate a sample size between 2 and 200.
2. Fetch `GET /ledgers?order=desc&limit=N` from the selected Horizon endpoint.
3. Drop malformed records, sort the remainder by sequence ascending, and compute adjacent close-time intervals.
4. Report median / min / max interval, sequence gaps, repeated timestamps, and unusually long intervals.

A long interval in the sample is an observation, not proof of consensus failure or an endpoint outage. Sequence gaps are listed separately from time gaps.

## Data boundaries

- Network: configured testnet or mainnet Horizon only.
- History is bounded by the requested sample size; there is no unbounded scan.
- Amounts and ledger sequences are handled as strings / `BigInt`; no secret keys are accepted.

## Design decisions

- Malformed records are skipped when at least two valid ledgers remain, so one bad row cannot erase an otherwise useful sample.
- Unusual spacing is flagged relative to the sample median (3×), not a fixed SLA.
- Repeated timestamps are kept as zero-duration intervals so they remain visible.
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { renderFeature, screen } from "@/core/testing/render";
import { withMswHandlers } from "@/core/testing/msw";
import { resetHorizonClients } from "@/core/horizon/client";
import { LedgerCloseCadencePanel } from "@/features/ledger-close-cadence/components/LedgerCloseCadencePanel";
import { copy, errorCopy } from "@/features/ledger-close-cadence/copy";
import { handlers, rateLimitedHandler } from "@/features/ledger-close-cadence/msw/handlers";

const server = withMswHandlers(...handlers);

describe("LedgerCloseCadencePanel", () => {
it("shows the empty state before anything is loaded", () => {
renderFeature(<LedgerCloseCadencePanel />);
expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument();
});

it("renders cadence summary and intervals after a successful load", async () => {
resetHorizonClients();
const { user } = renderFeature(<LedgerCloseCadencePanel />, { network: "testnet" });

await user.clear(screen.getByLabelText(copy.formLabel));
await user.type(screen.getByLabelText(copy.formLabel), "4");
await user.click(screen.getByRole("button", { name: copy.submit }));

expect(await screen.findByText(copy.summaryTitle)).toBeInTheDocument();
expect(screen.getByText(copy.intervalsTitle)).toBeInTheDocument();
expect(screen.getByText(copy.medianLabel)).toBeInTheDocument();
});

it("explains invalid sample size", async () => {
const { user } = renderFeature(<LedgerCloseCadencePanel />, { network: "testnet" });

await user.clear(screen.getByLabelText(copy.formLabel));
await user.type(screen.getByLabelText(copy.formLabel), "1");
await user.click(screen.getByRole("button", { name: copy.submit }));

expect(await screen.findByText(errorCopy.invalid_sample_size.title)).toBeInTheDocument();
});

it("explains rate limiting", async () => {
server.use(rateLimitedHandler);
resetHorizonClients();
const { user } = renderFeature(<LedgerCloseCadencePanel />, { network: "testnet" });

await user.click(screen.getByRole("button", { name: copy.submit }));

expect(await screen.findByText(errorCopy.rate_limited.title)).toBeInTheDocument();
});
});
29 changes: 29 additions & 0 deletions features/ledger-close-cadence/__tests__/a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, it } from "vitest";
import { renderFeature, screen } from "@/core/testing/render";
import { expectNoAxeViolations } from "@/core/testing/axe";
import { withMswHandlers } from "@/core/testing/msw";
import { resetHorizonClients } from "@/core/horizon/client";
import { LedgerCloseCadencePanel } from "@/features/ledger-close-cadence/components/LedgerCloseCadencePanel";
import { copy } from "@/features/ledger-close-cadence/copy";
import { handlers } from "@/features/ledger-close-cadence/msw/handlers";

withMswHandlers(...handlers);

describe("LedgerCloseCadencePanel accessibility", () => {
it("has no WCAG A/AA violations in its initial state", async () => {
const { container } = renderFeature(<LedgerCloseCadencePanel />);
await expectNoAxeViolations(container);
});

it("has no WCAG A/AA violations after a successful measurement", async () => {
resetHorizonClients();
const { container, user } = renderFeature(<LedgerCloseCadencePanel />, { network: "testnet" });

await user.clear(screen.getByLabelText(copy.formLabel));
await user.type(screen.getByLabelText(copy.formLabel), "4");
await user.click(screen.getByRole("button", { name: copy.submit }));
await screen.findByText(copy.summaryTitle);

await expectNoAxeViolations(container);
});
});
34 changes: 34 additions & 0 deletions features/ledger-close-cadence/__tests__/cadence-stats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
calculateCadenceStats,
medianIntervalMs
} from "@/features/ledger-close-cadence/lib/cadence-stats";

describe("medianIntervalMs", () => {
it("returns the middle value for an odd sample", () => {
expect(medianIntervalMs([1000, 5000, 3000])).toBe(3000);
});

it("averages the two middle values for an even sample", () => {
expect(medianIntervalMs([1000, 2000, 4000, 8000])).toBe(3000);
});

it("returns null for an empty list", () => {
expect(medianIntervalMs([])).toBeNull();
});
});

describe("calculateCadenceStats", () => {
it("reports min, max, median and count", () => {
expect(calculateCadenceStats([5000, 5000, 5000])).toEqual({
medianMs: 5000,
minMs: 5000,
maxMs: 5000,
intervalCount: 3
});
});

it("returns null when there are no intervals", () => {
expect(calculateCadenceStats([])).toBeNull();
});
});
46 changes: 46 additions & 0 deletions features/ledger-close-cadence/__tests__/format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
formatCount,
formatDurationMs,
formatLedgerLabel,
formatNetworkLabel
} from "@/features/ledger-close-cadence/lib/format";

describe("formatDurationMs", () => {
it("formats sub-second durations in milliseconds", () => {
expect(formatDurationMs(250)).toBe("250 ms");
});

it("formats whole seconds without trailing decimals", () => {
expect(formatDurationMs(5000)).toBe("5 s");
});

it("formats fractional seconds with three digits", () => {
expect(formatDurationMs(5125)).toBe("5.125 s");
});

it("formats minutes and leftover seconds", () => {
expect(formatDurationMs(125_000)).toBe("2 m 5 s");
});
});

describe("formatNetworkLabel", () => {
it("labels configured networks", () => {
expect(formatNetworkLabel("testnet")).toBe("Testnet");
expect(formatNetworkLabel("mainnet")).toBe("Mainnet");
});
});

describe("formatLedgerLabel", () => {
it("joins sequence and close time", () => {
expect(formatLedgerLabel("1001", "2026-09-29T12:00:00.000Z")).toBe(
"#1001 · 2026-09-29T12:00:00.000Z"
);
});
});

describe("formatCount", () => {
it("stringifies counts", () => {
expect(formatCount(4)).toBe("4");
});
});
62 changes: 62 additions & 0 deletions features/ledger-close-cadence/__tests__/ledger-sample.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
parseLedgerRecord,
validateAndSortLedgerSample
} from "@/features/ledger-close-cadence/lib/ledger-sample";
import {
allMalformedRecords,
malformedMixedRecords,
recentLedgersFixture
} from "@/features/ledger-close-cadence/fixtures/ledgerCloseCadence.fixture";

describe("parseLedgerRecord", () => {
it("accepts a Horizon ledger with sequence and UTC close time", () => {
expect(parseLedgerRecord(recentLedgersFixture[0]!)).toMatchObject({
sequence: "1004"
});
});

it("rejects missing or invalid fields", () => {
expect(parseLedgerRecord({ sequence: 1 })).toBeNull();
expect(parseLedgerRecord({ closed_at: "2026-01-01T00:00:00Z" })).toBeNull();
expect(parseLedgerRecord({ sequence: "x", closed_at: "2026-01-01T00:00:00Z" })).toBeNull();
expect(parseLedgerRecord({ sequence: 1, closed_at: "not-a-date" })).toBeNull();
});
});

describe("validateAndSortLedgerSample", () => {
it("sorts descending Horizon pages into ascending sequence order", () => {
const result = validateAndSortLedgerSample(recentLedgersFixture);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.ledgers.map((ledger) => ledger.sequence)).toEqual([
"1001",
"1002",
"1003",
"1004"
]);
expect(result.value.malformedCount).toBe(0);
});

it("skips malformed records without discarding the rest", () => {
const result = validateAndSortLedgerSample(malformedMixedRecords);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.ledgers.map((ledger) => ledger.sequence)).toEqual(["4000", "4002"]);
expect(result.value.malformedCount).toBe(2);
});

it("fails when every record is malformed", () => {
expect(validateAndSortLedgerSample(allMalformedRecords)).toEqual({
ok: false,
code: "malformed_ledger"
});
});

it("fails on an empty page", () => {
expect(validateAndSortLedgerSample([])).toEqual({
ok: false,
code: "history_unavailable"
});
});
});
101 changes: 101 additions & 0 deletions features/ledger-close-cadence/__tests__/ledgerCloseCadence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { withMswHandlers } from "@/core/testing/msw";
import { resetHorizonClients } from "@/core/horizon/client";
import {
analyzeLedgerSample,
runLedgerCloseCadence
} from "@/features/ledger-close-cadence/lib/ledgerCloseCadence";
import {
emptyHistoryHandler,
handlers,
malformedOnlyHandler,
rateLimitedHandler,
serverErrorHandler
} from "@/features/ledger-close-cadence/msw/handlers";
import {
gapsFixtureRecords,
recentLedgersFixture,
repeatedTimestampRecords
} from "@/features/ledger-close-cadence/fixtures/ledgerCloseCadence.fixture";

const server = withMswHandlers(...handlers);

describe("analyzeLedgerSample", () => {
it("computes median/min/max intervals for a contiguous sample", () => {
const result = analyzeLedgerSample(recentLedgersFixture, { sampleSize: 4 }, "testnet");
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.stats).toEqual({
medianMs: 5000,
minMs: 5000,
maxMs: 5000,
intervalCount: 3
});
expect(result.value.firstLedger.sequence).toBe("1001");
expect(result.value.lastLedger.sequence).toBe("1004");
expect(result.value.sequenceGaps).toEqual([]);
});

it("flags sequence gaps separately from long close intervals", () => {
const result = analyzeLedgerSample(gapsFixtureRecords, { sampleSize: 4 }, "testnet");
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.sequenceGaps).toHaveLength(1);
expect(result.value.intervals.some((interval) => interval.unusual)).toBe(true);
});

it("keeps repeated timestamps without corrupting the sample", () => {
const result = analyzeLedgerSample(repeatedTimestampRecords, { sampleSize: 4 }, "testnet");
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.repeatedTimestampCount).toBe(1);
expect(result.value.observedSampleSize).toBe(4);
});
});

describe("runLedgerCloseCadence", () => {
it("loads a Horizon ledger page for the selected network", async () => {
resetHorizonClients();
const result = await runLedgerCloseCadence({ sampleSize: 4 }, "testnet");
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.network).toBe("testnet");
expect(result.value.observedSampleSize).toBe(4);
});

it("maps an empty history page", async () => {
server.use(emptyHistoryHandler);
resetHorizonClients();
expect(await runLedgerCloseCadence({ sampleSize: 4 }, "testnet")).toEqual({
ok: false,
code: "history_unavailable"
});
});

it("maps a fully malformed page", async () => {
server.use(malformedOnlyHandler);
resetHorizonClients();
expect(await runLedgerCloseCadence({ sampleSize: 4 }, "testnet")).toEqual({
ok: false,
code: "malformed_ledger"
});
});

it("maps rate limiting", async () => {
server.use(rateLimitedHandler);
resetHorizonClients();
expect(await runLedgerCloseCadence({ sampleSize: 4 }, "testnet")).toEqual({
ok: false,
code: "rate_limited"
});
});

it("maps transport failures", async () => {
server.use(serverErrorHandler);
resetHorizonClients();
expect(await runLedgerCloseCadence({ sampleSize: 4 }, "testnet")).toEqual({
ok: false,
code: "request_failed"
});
});
});
33 changes: 33 additions & 0 deletions features/ledger-close-cadence/__tests__/schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { parseLedgerCloseCadenceInput } from "@/features/ledger-close-cadence/schema";
import { SAMPLE_SIZE_MAX, SAMPLE_SIZE_MIN } from "@/features/ledger-close-cadence/types";

describe("parseLedgerCloseCadenceInput", () => {
it("accepts a whole number inside the supported range", () => {
expect(parseLedgerCloseCadenceInput({ sampleSize: "20" })).toEqual({
ok: true,
value: { sampleSize: 20 }
});
});

it("accepts the inclusive boundaries", () => {
expect(parseLedgerCloseCadenceInput({ sampleSize: String(SAMPLE_SIZE_MIN) })).toEqual({
ok: true,
value: { sampleSize: SAMPLE_SIZE_MIN }
});
expect(parseLedgerCloseCadenceInput({ sampleSize: String(SAMPLE_SIZE_MAX) })).toEqual({
ok: true,
value: { sampleSize: SAMPLE_SIZE_MAX }
});
});

it.each(["", " ", "abc", "2.5", "-1", "0", "1", String(SAMPLE_SIZE_MAX + 1)])(
"rejects invalid sample size %j",
(sampleSize) => {
expect(parseLedgerCloseCadenceInput({ sampleSize })).toEqual({
ok: false,
code: "invalid_sample_size"
});
}
);
});
Loading
Loading