mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(environment): share exact recorded timestamp interpretation
This commit is contained in:
@@ -686,6 +686,10 @@ describe("probe request chronology", () => {
|
||||
["2026-10-20T09:00:00.0002Z", "2026-10-20T09:00:00.0001Z"],
|
||||
["2026-10-20T09:00:00.0000000002Z", "2026-10-20T09:00:00.0000000001Z"],
|
||||
["0001-01-01T00:00:00.0002Z", "0001-01-01T01:00:00.0001+01:00"],
|
||||
["2026-10-20 09:00:00.0002Z", "2026-10-20 09:00:00.0001Z"],
|
||||
["2026-10-20T10:00:00.0002+0100", "2026-10-20T09:00:00.0001Z"],
|
||||
["2026-10-20 10:00:00.0002+0100", "2026-10-20 09:00:00.0001+0000"],
|
||||
["2026-10-20T09:00:00.0002", "2026-10-20T09:00:00.0001"],
|
||||
])(
|
||||
"replays an earlier delete before a later submillisecond read (%s)",
|
||||
(later, earlier) => {
|
||||
@@ -730,6 +734,7 @@ describe("probe request chronology", () => {
|
||||
|
||||
it.each([
|
||||
["2026-10-20T09:00:00.000100Z", "2026-10-20T10:00:00.0001+01:00"],
|
||||
["2026-10-20T09:00:00.0001Z", "2026-10-20T09:00:00.000100"],
|
||||
["Tue, 20 Oct 2026 09:00:00 GMT", "2026-10-20T09:00:00Z"],
|
||||
])(
|
||||
"keeps ordinal order for equal instants and native-Date spelling compatibility (%s)",
|
||||
|
||||
@@ -15,8 +15,7 @@ import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
compareOffsetDateTimeInstants,
|
||||
offsetDateTimeInstantFromEpochMilliseconds,
|
||||
offsetDateTimeSchema,
|
||||
parseOffsetDateTimeInstant,
|
||||
readRecordedDateTime,
|
||||
type OffsetDateTimeInstant,
|
||||
} from "../../domain/offsetDateTime.js";
|
||||
import type { WebNetworkCaptureRecord } from "../../domain/webNetworkCapture.js";
|
||||
@@ -58,10 +57,16 @@ const startedInstant = (
|
||||
): OffsetDateTimeInstant | undefined => {
|
||||
const started = startedSchema.safeParse(record.reported);
|
||||
if (!started.success) return undefined;
|
||||
const timestamp = offsetDateTimeSchema.safeParse(
|
||||
started.data.startedDateTime,
|
||||
);
|
||||
if (timestamp.success) return parseOffsetDateTimeInstant(timestamp.data);
|
||||
const reading = readRecordedDateTime(started.data.startedDateTime);
|
||||
if (reading !== undefined) {
|
||||
const { spelling } = reading;
|
||||
const offsetTimestamp =
|
||||
spelling.separator === "T" &&
|
||||
(spelling.zone === "Z" || spelling.zone.includes(":"));
|
||||
const clockTimestamp =
|
||||
spelling.secondsPresent && spelling.fractionDigits <= 9;
|
||||
if (offsetTimestamp || clockTimestamp) return reading.instant;
|
||||
}
|
||||
// Preserve native-Date admission for legacy spellings, at its original precision.
|
||||
const ms = Date.parse(started.data.startedDateTime);
|
||||
return Number.isFinite(ms)
|
||||
@@ -153,16 +158,16 @@ export const probeRequests = (
|
||||
const origin = probeOrigin(lines);
|
||||
for (const record of ordered) {
|
||||
const line = lines.get(record.ordinal);
|
||||
const exchange = recordedExchange(record);
|
||||
const { ordinal } = record;
|
||||
if (line?.kind === "line" && line.origin !== origin)
|
||||
unobserved.push({ ordinal, reason: "other-origin" });
|
||||
else if (line?.kind !== "line" || exchange === undefined)
|
||||
else if (line?.kind !== "line")
|
||||
unobserved.push({
|
||||
ordinal,
|
||||
reason: line?.kind === "excluded" ? line.reason : "request-unavailable",
|
||||
});
|
||||
else {
|
||||
const exchange = recordedExchange(record, line);
|
||||
const request = {
|
||||
method: line.method,
|
||||
path: exchange.path,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
timedCaptureEvidence,
|
||||
} from "../../../tests/fixtures/analysisView.js";
|
||||
import { scenarioCaptureEvidence } from "../../../tests/fixtures/browserScenarioCapture.js";
|
||||
import { readRecordedRequestLine } from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
||||
import {
|
||||
environmentServeFixture,
|
||||
environmentServeSource,
|
||||
@@ -67,8 +68,10 @@ it.each([
|
||||
redactions: [],
|
||||
limitations: [],
|
||||
};
|
||||
const exchange = recordedExchange(record);
|
||||
if (exchange === undefined) throw new Error("Expected a recorded exchange");
|
||||
const line = readRecordedRequestLine(record.reported, true, () => true);
|
||||
if (line?.kind !== "line")
|
||||
throw new Error("Expected a recorded request line");
|
||||
const exchange = recordedExchange(record, line);
|
||||
expect(exchange.bodyText).toBe(expected);
|
||||
expect(replayedBody(exchange)).toEqual(
|
||||
expected === null ? { kind: "none" } : { kind: "text", text: expected },
|
||||
@@ -84,7 +87,7 @@ it.each([
|
||||
reason: "explicit-sensitive-value",
|
||||
scope: "property-name",
|
||||
});
|
||||
expect(recordedExchange(record)?.bodyText).toBeNull();
|
||||
expect(recordedExchange(record, line).bodyText).toBeNull();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { isJsonLike } from "../../domain/analysisView/EnvironmentManifestAccumul
|
||||
import {
|
||||
mediaTypeEssence,
|
||||
readRecordedRequestLine,
|
||||
type RecordedRequestLine,
|
||||
} from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
||||
import { retainedBrowserScenarioCaptureSchema } from "../../domain/browserScenarioCapture.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
@@ -170,21 +171,18 @@ const schemaInputError = (
|
||||
})),
|
||||
);
|
||||
|
||||
/** One capture record reduced to its request line and retained response body, or undefined without a request line. */
|
||||
/** Combine an already parsed request line with its record's retained response body. */
|
||||
export const recordedExchange = (
|
||||
record: WebNetworkCaptureRecord,
|
||||
): RecordedExchange | undefined => {
|
||||
const line = readRecordedRequestLine(record.reported, true, () => true);
|
||||
if (line === null || line.kind !== "line") return undefined;
|
||||
return {
|
||||
ordinal: record.ordinal,
|
||||
path: line.path,
|
||||
query: line.query === undefined || line.query === "" ? null : line.query,
|
||||
status: line.status,
|
||||
contentType: line.rawMime === "" ? null : line.rawMime,
|
||||
...readRecordedResponseBody(record, REPLAY_BODY_LIMIT_BYTES),
|
||||
};
|
||||
};
|
||||
line: Extract<RecordedRequestLine, { readonly kind: "line" }>,
|
||||
): RecordedExchange => ({
|
||||
ordinal: record.ordinal,
|
||||
path: line.path,
|
||||
query: line.query === undefined || line.query === "" ? null : line.query,
|
||||
status: line.status,
|
||||
contentType: line.rawMime === "" ? null : line.rawMime,
|
||||
...readRecordedResponseBody(record, REPLAY_BODY_LIMIT_BYTES),
|
||||
});
|
||||
|
||||
/** Authenticate a view Evidence file and require one view kind; a refusal names the file role. */
|
||||
export const parseViewEvidence = <Kind extends AnalysisViewResult["kind"]>(
|
||||
@@ -416,9 +414,10 @@ const indexExchanges = (
|
||||
for (const ordinal of tool.record_ordinals) {
|
||||
if (exchanges.has(ordinal)) continue;
|
||||
const record = byOrdinal.get(ordinal);
|
||||
const exchange =
|
||||
record === undefined ? undefined : recordedExchange(record);
|
||||
if (exchange !== undefined) exchanges.set(ordinal, exchange);
|
||||
if (record === undefined) continue;
|
||||
const line = readRecordedRequestLine(record.reported, true, () => true);
|
||||
if (line?.kind === "line")
|
||||
exchanges.set(ordinal, recordedExchange(record, line));
|
||||
}
|
||||
return exchanges;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import {
|
||||
offsetDateTimeSchema,
|
||||
parseOffsetDateTimeInstant,
|
||||
} from "../offsetDateTime.js";
|
||||
import { readRecordedDateTime } from "../offsetDateTime.js";
|
||||
|
||||
/** How a recorded clock value is spelled: ISO-8601 with its separator, fraction digits and zone, or an epoch number. */
|
||||
export type ClockFormat =
|
||||
@@ -28,8 +25,6 @@ export const compareClockInstants = (
|
||||
right: Pick<ClockReading, "ms" | "subMillisecond">,
|
||||
): number => left.ms - right.ms || left.subMillisecond - right.subMillisecond;
|
||||
|
||||
const ISO_INSTANT =
|
||||
/^\d{4}-\d{2}-\d{2}([T ])\d{2}:\d{2}:\d{2}(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})?$/u;
|
||||
const MINUTE_MS = 60_000;
|
||||
|
||||
/** Minutes east of UTC that a zone suffix names; none and `Z` are UTC. */
|
||||
@@ -57,30 +52,23 @@ const zoneMinutesForFormat = (zone: string): number => {
|
||||
|
||||
/** The instant of an ISO-8601 date-time and how it is spelled, or undefined when it is not one. */
|
||||
const readIsoClock = (value: string): ClockReading | undefined => {
|
||||
const match = ISO_INSTANT.exec(value);
|
||||
if (match === null) return undefined;
|
||||
const [, separator, fraction = "", zone = ""] = match;
|
||||
// Clock spelling also permits a space, compact offsets and implicit UTC.
|
||||
// Normalize those spellings before the shared owner validates the calendar.
|
||||
const offset =
|
||||
zone === ""
|
||||
? "Z"
|
||||
: zone === "Z" || zone.includes(":")
|
||||
? zone
|
||||
: `${zone.slice(0, 3)}:${zone.slice(3)}`;
|
||||
const timestamp = offsetDateTimeSchema.safeParse(
|
||||
`${value.slice(0, 10)}T${value.slice(11, 19)}${fraction === "" ? "" : `.${fraction}`}${offset}`,
|
||||
);
|
||||
if (!timestamp.success) return undefined;
|
||||
const instant = parseOffsetDateTimeInstant(timestamp.data);
|
||||
const reading = readRecordedDateTime(value);
|
||||
if (
|
||||
reading === undefined ||
|
||||
!reading.spelling.secondsPresent ||
|
||||
reading.spelling.fractionDigits > 9
|
||||
)
|
||||
return undefined;
|
||||
const { instant, spelling } = reading;
|
||||
return {
|
||||
ms: instant.epochSecond * 1000 + Number(`${fraction}000`.slice(0, 3)),
|
||||
subMillisecond: Number(fraction.padEnd(9, "0").slice(3)),
|
||||
ms:
|
||||
instant.epochSecond * 1000 + Number(`${instant.fraction}000`.slice(0, 3)),
|
||||
subMillisecond: Number(instant.fraction.padEnd(9, "0").slice(3)),
|
||||
format: {
|
||||
kind: "iso",
|
||||
separator: separator as "T" | " ",
|
||||
fraction: fraction.length,
|
||||
zone,
|
||||
separator: spelling.separator,
|
||||
fraction: spelling.fractionDigits,
|
||||
zone: spelling.zone,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { offsetDateTimeSchema } from "./offsetDateTime.js";
|
||||
import {
|
||||
offsetDateTimeSchema,
|
||||
readRecordedDateTime,
|
||||
} from "./offsetDateTime.js";
|
||||
|
||||
/** The validator this schema replaces; it remains the oracle for the accepted set. */
|
||||
const replaced = z.string().datetime({ offset: true });
|
||||
@@ -78,6 +81,137 @@ describe("offset datetime schema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("recorded datetime facts", () => {
|
||||
const epochSecond = Date.UTC(2026, 9, 20, 9) / 1000;
|
||||
|
||||
it.each<{
|
||||
text: string;
|
||||
expected: NonNullable<ReturnType<typeof readRecordedDateTime>>;
|
||||
}>([
|
||||
{
|
||||
text: "2026-10-20T09:00:00.000100Z",
|
||||
expected: {
|
||||
instant: { epochSecond, fraction: "0001" },
|
||||
spelling: {
|
||||
separator: "T",
|
||||
secondsPresent: true,
|
||||
fractionDigits: 6,
|
||||
zone: "Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "2026-10-20 10:00:00.000100+0100",
|
||||
expected: {
|
||||
instant: { epochSecond, fraction: "0001" },
|
||||
spelling: {
|
||||
separator: " ",
|
||||
secondsPresent: true,
|
||||
fractionDigits: 6,
|
||||
zone: "+0100",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "2026-10-20T09:00:00.0000000002",
|
||||
expected: {
|
||||
instant: { epochSecond, fraction: "0000000002" },
|
||||
spelling: {
|
||||
separator: "T",
|
||||
secondsPresent: true,
|
||||
fractionDigits: 10,
|
||||
zone: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "2026-10-20T08:30:00.1234567890123456789000-00:30",
|
||||
expected: {
|
||||
instant: { epochSecond, fraction: "1234567890123456789" },
|
||||
spelling: {
|
||||
separator: "T",
|
||||
secondsPresent: true,
|
||||
fractionDigits: 22,
|
||||
zone: "-00:30",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "2026-10-20T09:00-00:00",
|
||||
expected: {
|
||||
instant: { epochSecond, fraction: "" },
|
||||
spelling: {
|
||||
separator: "T",
|
||||
secondsPresent: false,
|
||||
fractionDigits: 0,
|
||||
zone: "-00:00",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "2026-10-20 10:00+0100",
|
||||
expected: {
|
||||
instant: { epochSecond, fraction: "" },
|
||||
spelling: {
|
||||
separator: " ",
|
||||
secondsPresent: false,
|
||||
fractionDigits: 0,
|
||||
zone: "+0100",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "0001-01-01T01:00:00.0000000002+0100",
|
||||
expected: {
|
||||
instant: { epochSecond: -62_135_596_800, fraction: "0000000002" },
|
||||
spelling: {
|
||||
separator: "T",
|
||||
secondsPresent: true,
|
||||
fractionDigits: 10,
|
||||
zone: "+0100",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "0000-02-29 00:00:00.000Z",
|
||||
expected: {
|
||||
instant: { epochSecond: -62_162_121_600, fraction: "" },
|
||||
spelling: {
|
||||
separator: " ",
|
||||
secondsPresent: true,
|
||||
fractionDigits: 3,
|
||||
zone: "Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
])(
|
||||
"preserves the exact instant and source spelling of $text",
|
||||
({ text, expected }) => {
|
||||
expect(readRecordedDateTime(text)).toEqual(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"0001-02-29T00:00:00Z",
|
||||
"1900-02-29 00:00:00",
|
||||
"2026-04-31 00:00:00+0100",
|
||||
"2026-13-01T00:00:00Z",
|
||||
"2026-10-20T24:00:00Z",
|
||||
"2026-10-20T09:60:00Z",
|
||||
"2026-10-20T09:00:60Z",
|
||||
"2026-10-20T09:00:00+24:00",
|
||||
"2026-10-20 09:00:00+0060",
|
||||
"2026-10-20T09:00:00+01",
|
||||
"2026-10-20T09:00.1Z",
|
||||
"2026-10-20t09:00:00Z",
|
||||
"2026-10-20T09:00:00z",
|
||||
"2026-10-20",
|
||||
"Tue, 20 Oct 2026 09:00:00 GMT",
|
||||
])("does not invent ISO facts for %s", (text) => {
|
||||
expect(readRecordedDateTime(text)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
const findPattern = (value: unknown): string | undefined => {
|
||||
if (value === null || typeof value !== "object") return undefined;
|
||||
if ("pattern" in value && typeof value.pattern === "string")
|
||||
|
||||
@@ -50,6 +50,47 @@ export const parseOffsetDateTimeInstant = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Parse recorded ISO spelling once, retaining its exact instant and formatting facts. */
|
||||
export const readRecordedDateTime = (
|
||||
value: string,
|
||||
):
|
||||
| {
|
||||
readonly instant: OffsetDateTimeInstant;
|
||||
readonly spelling: {
|
||||
readonly separator: "T" | " ";
|
||||
readonly secondsPresent: boolean;
|
||||
readonly fractionDigits: number;
|
||||
readonly zone: string;
|
||||
};
|
||||
}
|
||||
| undefined => {
|
||||
const match =
|
||||
/^(\d{4}-\d{2}-\d{2})([T ])(\d{2}:\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?$/u.exec(
|
||||
value,
|
||||
);
|
||||
if (match === null) return undefined;
|
||||
const [, date, separator, time, seconds, fraction = "", zone = ""] = match;
|
||||
const offset =
|
||||
zone === ""
|
||||
? "Z"
|
||||
: zone === "Z" || zone.includes(":")
|
||||
? zone
|
||||
: `${zone.slice(0, 3)}:${zone.slice(3)}`;
|
||||
const normalized = offsetDateTimeSchema.safeParse(
|
||||
`${date}T${time}${seconds === undefined ? "" : `:${seconds}`}${fraction === "" ? "" : `.${fraction}`}${offset}`,
|
||||
);
|
||||
if (!normalized.success) return undefined;
|
||||
return {
|
||||
instant: parseOffsetDateTimeInstant(normalized.data),
|
||||
spelling: {
|
||||
separator: separator as "T" | " ",
|
||||
secondsPresent: seconds !== undefined,
|
||||
fractionDigits: fraction.length,
|
||||
zone,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Convert a Date.now-style integer into the same exact comparison shape. */
|
||||
export const offsetDateTimeInstantFromEpochMilliseconds = (
|
||||
epochMilliseconds: number,
|
||||
|
||||
@@ -55,31 +55,40 @@ const writeExactClockFiles = async (
|
||||
["GET", "/api/books/1", BOOK_NOT_FOUND, 404],
|
||||
]);
|
||||
const fixture = environmentStateServeFixture(capture);
|
||||
const ordered = analysisViewCaptureEvidence(
|
||||
environmentManifestCaptureFixture([
|
||||
{
|
||||
startedDateTime: "0001-01-01T00:00:00.0000000002Z",
|
||||
request: { method: "GET", url: "http://app.example/api/books/1" },
|
||||
response: {
|
||||
status: 404,
|
||||
content: {
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(BOOK_NOT_FOUND),
|
||||
const chronologyProbe = (later: string, earlier: string) =>
|
||||
analysisViewCaptureEvidence(
|
||||
environmentManifestCaptureFixture([
|
||||
{
|
||||
startedDateTime: later,
|
||||
request: { method: "GET", url: "http://app.example/api/books/1" },
|
||||
response: {
|
||||
status: 404,
|
||||
content: {
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(BOOK_NOT_FOUND),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
startedDateTime: "0001-01-01T01:00:00.0000000001+01:00",
|
||||
request: {
|
||||
method: "DELETE",
|
||||
url: "http://app.example/api/books/1",
|
||||
{
|
||||
startedDateTime: earlier,
|
||||
request: {
|
||||
method: "DELETE",
|
||||
url: "http://app.example/api/books/1",
|
||||
},
|
||||
response: {
|
||||
status: 204,
|
||||
content: { mimeType: "application/json", text: "" },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
status: 204,
|
||||
content: { mimeType: "application/json", text: "" },
|
||||
},
|
||||
},
|
||||
]),
|
||||
]),
|
||||
);
|
||||
const ordered = chronologyProbe(
|
||||
"0001-01-01T00:00:00.0000000002Z",
|
||||
"0001-01-01T01:00:00.0000000001+01:00",
|
||||
);
|
||||
const legacyOrdered = chronologyProbe(
|
||||
"2026-10-20 10:00:00.0002+0100",
|
||||
"2026-10-20 09:00:00.0001+0000",
|
||||
);
|
||||
const clocks = timedCaptureEvidence([
|
||||
[
|
||||
@@ -93,6 +102,7 @@ const writeExactClockFiles = async (
|
||||
["manifest.json", fixture.manifest],
|
||||
["capture.json", capture],
|
||||
["order.json", ordered],
|
||||
["legacy-order.json", legacyOrdered],
|
||||
["clocks.json", clocks],
|
||||
] as const) {
|
||||
const path = join(root, name);
|
||||
@@ -338,6 +348,58 @@ describe("rea environment-compare value context", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("rea environment-compare recorded clock spelling", () => {
|
||||
cliTest(
|
||||
"replays compact-offset submillisecond probe timestamps in exact order",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-environment-legacy-chronology-",
|
||||
);
|
||||
const originals = await writeExactClockFiles(root);
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"environment-compare",
|
||||
join(root, "manifest.json"),
|
||||
"--evidence",
|
||||
join(root, "capture.json"),
|
||||
"--probe",
|
||||
join(root, "legacy-order.json"),
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.json).toMatchObject({
|
||||
status: "complete",
|
||||
environment: { state: "served" },
|
||||
summary: { probes: 1, steps: 2, equal: 2, agreement: 1 },
|
||||
probes: [
|
||||
{
|
||||
label: "legacy-order.json",
|
||||
results: [
|
||||
{
|
||||
ordinal: 1,
|
||||
method: "DELETE",
|
||||
live_status: 204,
|
||||
env_status: 204,
|
||||
equal: true,
|
||||
},
|
||||
{
|
||||
ordinal: 0,
|
||||
method: "GET",
|
||||
live_status: 404,
|
||||
env_status: 404,
|
||||
equal: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
for (const [path, text] of originals)
|
||||
expect(await readFile(path, "utf8")).toBe(text);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("rea environment-compare replay and chronology", () => {
|
||||
cliTest(
|
||||
"preserves exact probe chronology and first-century clock-format differences",
|
||||
|
||||
Reference in New Issue
Block a user