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
22 changes: 22 additions & 0 deletions features/ledger-protocol-transition-map/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Ledger Protocol Transition Map

Scans a bounded Horizon ledger range and maps where reported protocol versions change.

## Behavior

1. Validate start ledger and bounded count (2–200).
2. Fetch consecutive `GET /ledgers?order=asc` pages until the count is filled or history ends.
3. Group adjacent same-version ledgers into runs.
4. Mark exact transitions only when both neighboring sequences were fetched; gaps yield uncertain boundaries.

## Data boundaries

- Configured testnet/mainnet Horizon only.
- No unbounded history scan.
- Sequences and protocol versions are strings / `BigInt`; no secret keys.

## Design decisions

- A missing ledger between fetched records never invents the upgrade sequence.
- Partial pages flag the scan as incomplete so trailing inferences stay conservative.
- The copyable summary is plain text for pasting into reviews or tickets.
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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 { LedgerProtocolTransitionMapPanel } from "@/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapPanel";
import { copy, errorCopy } from "@/features/ledger-protocol-transition-map/copy";
import { handlers, rateLimitedHandler } from "@/features/ledger-protocol-transition-map/msw/handlers";

const server = withMswHandlers(...handlers);

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

it("renders runs and transitions after a successful scan", async () => {
resetHorizonClients();
const { user } = renderFeature(<LedgerProtocolTransitionMapPanel />, { network: "testnet" });

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

expect(await screen.findByText(copy.runsTitle)).toBeInTheDocument();
expect(screen.getByText(copy.transitionsTitle)).toBeInTheDocument();
expect(screen.getByText(copy.exactBadge)).toBeInTheDocument();
});

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

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

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

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

await user.click(screen.getByRole("button", { name: copy.submit }));
expect(await screen.findByText(errorCopy.rate_limited.title)).toBeInTheDocument();
});
});
33 changes: 33 additions & 0 deletions features/ledger-protocol-transition-map/__tests__/a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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 { LedgerProtocolTransitionMapPanel } from "@/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapPanel";
import { copy } from "@/features/ledger-protocol-transition-map/copy";
import { handlers } from "@/features/ledger-protocol-transition-map/msw/handlers";

withMswHandlers(...handlers);

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

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

await user.clear(screen.getByLabelText(copy.startLabel));
await user.type(screen.getByLabelText(copy.startLabel), "1000");
await user.clear(screen.getByLabelText(copy.countLabel));
await user.type(screen.getByLabelText(copy.countLabel), "4");
await user.click(screen.getByRole("button", { name: copy.submit }));
await screen.findByText(copy.runsTitle);

await expectNoAxeViolations(container);
});
});
58 changes: 58 additions & 0 deletions features/ledger-protocol-transition-map/__tests__/format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
buildSummaryText,
formatNetworkLabel,
formatRange,
formatRunLabel,
formatTransitionLabel
} from "@/features/ledger-protocol-transition-map/lib/format";

describe("format helpers", () => {
it("labels networks and ranges", () => {
expect(formatNetworkLabel("testnet")).toBe("Testnet");
expect(formatRange("1000", "1003")).toBe("#1000 → #1003");
});

it("formats runs and transitions", () => {
expect(
formatRunLabel({
protocolVersion: "21",
startSequence: "1002",
endSequence: "1003",
ledgerCount: "2"
})
).toBe("v21 · #1002 → #1003 (2)");

expect(
formatTransitionLabel({
fromVersion: "20",
toVersion: "21",
beforeSequence: "1001",
afterSequence: "1002",
certainty: "exact",
reason: "adjacent"
})
).toBe("v20 → v21 at #1001/#1002");
});

it("builds a copyable summary", () => {
expect(
buildSummaryText({
network: "testnet",
start: "1000",
end: "1003",
observed: 4,
transitions: [
{
fromVersion: "20",
toVersion: "21",
beforeSequence: "1001",
afterSequence: "1002",
certainty: "exact",
reason: "adjacent"
}
]
})
).toContain("exact:20->21@1001/1002");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import {
filterToRequestedRange,
normalizeFetchedLedgers,
parseProtocolLedger
} from "@/features/ledger-protocol-transition-map/lib/ledger-pages";
import {
malformedProtocolRecords,
protocolLedgersFixture
} from "@/features/ledger-protocol-transition-map/fixtures/ledgerProtocolTransitionMap.fixture";

describe("parseProtocolLedger", () => {
it("accepts sequence and protocol version", () => {
expect(parseProtocolLedger(protocolLedgersFixture[0]!)).toMatchObject({
sequence: "1000",
protocolVersion: "20"
});
});

it("rejects malformed records", () => {
expect(parseProtocolLedger(malformedProtocolRecords[0]!)).toBeNull();
});
});

describe("normalizeFetchedLedgers", () => {
it("sorts and keeps valid ledgers", () => {
const result = normalizeFetchedLedgers(protocolLedgersFixture);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.ledgers.map((ledger) => ledger.sequence)).toEqual([
"1000",
"1001",
"1002",
"1003"
]);
});

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

describe("filterToRequestedRange", () => {
it("keeps only ledgers inside the requested window", () => {
const normalized = normalizeFetchedLedgers(protocolLedgersFixture);
expect(normalized.ok).toBe(true);
if (!normalized.ok) return;
expect(
filterToRequestedRange(normalized.value.ledgers, 1001n, 2).map((ledger) => ledger.sequence)
).toEqual(["1001", "1002"]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { withMswHandlers } from "@/core/testing/msw";
import { resetHorizonClients } from "@/core/horizon/client";
import {
analyzeProtocolLedgers,
runLedgerProtocolTransitionMap
} from "@/features/ledger-protocol-transition-map/lib/ledgerProtocolTransitionMap";
import {
emptyHistoryHandler,
handlers,
malformedOnlyHandler,
rateLimitedHandler,
serverErrorHandler
} from "@/features/ledger-protocol-transition-map/msw/handlers";
import {
missingLedgersFixtureRecords,
protocolLedgersFixture
} from "@/features/ledger-protocol-transition-map/fixtures/ledgerProtocolTransitionMap.fixture";

const server = withMswHandlers(...handlers);

describe("analyzeProtocolLedgers", () => {
it("maps an exact protocol upgrade", () => {
const result = analyzeProtocolLedgers(
protocolLedgersFixture,
{ startLedger: 1000n, count: 4 },
"testnet",
false
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.transitions[0]?.certainty).toBe("exact");
expect(result.value.runs).toHaveLength(2);
});

it("keeps uncertain transitions across missing ledgers", () => {
const result = analyzeProtocolLedgers(
missingLedgersFixtureRecords,
{ startLedger: 2000n, count: 4 },
"testnet",
true
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.transitions[0]?.certainty).toBe("uncertain");
expect(result.value.gaps).toHaveLength(1);
});
});

describe("runLedgerProtocolTransitionMap", () => {
it("loads a Horizon page for the selected network", async () => {
resetHorizonClients();
const result = await runLedgerProtocolTransitionMap(
{ startLedger: 1000n, count: 4 },
"testnet"
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.observedCount).toBe(4);
});

it("maps empty history", async () => {
server.use(emptyHistoryHandler);
resetHorizonClients();
expect(
await runLedgerProtocolTransitionMap({ startLedger: 1000n, count: 4 }, "testnet")
).toEqual({ ok: false, code: "history_unavailable" });
});

it("maps malformed pages", async () => {
server.use(malformedOnlyHandler);
resetHorizonClients();
expect(
await runLedgerProtocolTransitionMap({ startLedger: 1000n, count: 4 }, "testnet")
).toEqual({ ok: false, code: "malformed_ledger" });
});

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

it("maps transport failures", async () => {
server.use(serverErrorHandler);
resetHorizonClients();
expect(
await runLedgerProtocolTransitionMap({ startLedger: 1000n, count: 4 }, "testnet")
).toEqual({ ok: false, code: "request_failed" });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { groupProtocolRuns } from "@/features/ledger-protocol-transition-map/lib/protocol-runs";
import { normalizeFetchedLedgers } from "@/features/ledger-protocol-transition-map/lib/ledger-pages";
import {
missingLedgersFixtureRecords,
protocolLedgersFixture
} from "@/features/ledger-protocol-transition-map/fixtures/ledgerProtocolTransitionMap.fixture";

describe("groupProtocolRuns", () => {
it("groups contiguous same-version ledgers", () => {
const sample = normalizeFetchedLedgers(protocolLedgersFixture);
expect(sample.ok).toBe(true);
if (!sample.ok) return;
expect(groupProtocolRuns(sample.value.ledgers)).toEqual([
{
protocolVersion: "20",
startSequence: "1000",
endSequence: "1001",
ledgerCount: "2"
},
{
protocolVersion: "21",
startSequence: "1002",
endSequence: "1003",
ledgerCount: "2"
}
]);
});

it("starts a new run after a sequence gap even with the same version", () => {
const sample = normalizeFetchedLedgers(missingLedgersFixtureRecords);
expect(sample.ok).toBe(true);
if (!sample.ok) return;
expect(groupProtocolRuns(sample.value.ledgers)).toEqual([
{
protocolVersion: "19",
startSequence: "2000",
endSequence: "2000",
ledgerCount: "1"
},
{
protocolVersion: "20",
startSequence: "2002",
endSequence: "2003",
ledgerCount: "2"
}
]);
});
});
Loading
Loading