diff --git a/src/index.js b/src/index.js index 94ed3b4..5f064f9 100644 --- a/src/index.js +++ b/src/index.js @@ -185,7 +185,19 @@ export function projectSchedule(schedule) { // Periods. The uncapped counts keep the drift signal alive after the // schedule ends; the capped ones are what you would print next to "of N". const periodsElapsedUncapped = Math.floor(blocksSoFar / periodBlocks); - const periodsExpectedUncapped = Math.floor(secondsSoFar / intendedPeriodSeconds); + let periodsExpectedUncapped = Math.floor(secondsSoFar / intendedPeriodSeconds); + // A rounded period duration can put the quotient on either side of a boundary. + // Check adjacent deadlines using the same start + total blocks * rate + // calculation as intendedEnd, so unfinished periods are not counted. + const nextPeriodEnd = startTimestamp + + (periodsExpectedUncapped + 1) * periodBlocks * designSecondsPerBlock; + const currentPeriodEnd = startTimestamp + + periodsExpectedUncapped * periodBlocks * designSecondsPerBlock; + if (currentTimestamp >= nextPeriodEnd) { + periodsExpectedUncapped += 1; + } else if (currentTimestamp < currentPeriodEnd) { + periodsExpectedUncapped -= 1; + } const periodsElapsed = Math.min(periods, periodsElapsedUncapped); const periodsExpectedByNow = Math.min(periods, periodsExpectedUncapped); diff --git a/test/design-clock-boundaries.test.js b/test/design-clock-boundaries.test.js new file mode 100644 index 0000000..3bb3ea5 --- /dev/null +++ b/test/design-clock-boundaries.test.js @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: 2026 1932819331hy-debug +// SPDX-License-Identifier: MIT + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { projectSchedule, checkSchedule } from '../src/index.js'; + +const startTimestamp = 1_700_000_000; +const startBlock = 1_000_000; +const schedule = { + startBlock, startTimestamp, periodBlocks: 1003, periods: 10, + designSecondsPerBlock: 0.2, observedSecondsPerBlock: 0.2, + currentBlock: startBlock + 5015, currentTimestamp: startTimestamp + 1003, +}; + +// The immediately preceding representable timestamp, not an arbitrary epsilon. +function previousNumber(value) { + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, value); + view.setBigUint64(0, view.getBigUint64(0) - 1n); + return view.getFloat64(0); +} + +for (const { rate, blocks, seconds } of [ + { rate: 0.2, blocks: 1003, seconds: 1003 }, // 5015 blocks at 200 ms + { rate: 0.45, blocks: 2628, seconds: 5913 }, // 13140 blocks at 450 ms + { rate: 1.1, blocks: 100, seconds: 550 }, // 500 blocks at 1100 ms +]) { + test(`counts five periods exactly on the ${rate}s design-clock boundary`, () => { + const r = projectSchedule({ ...schedule, periodBlocks: blocks, + designSecondsPerBlock: rate, observedSecondsPerBlock: rate, + currentBlock: startBlock + blocks * 5, currentTimestamp: startTimestamp + seconds }); + assert.equal(r.periodsExpectedByNow, 5); + assert.equal(r.periodsExpectedUncapped, 5); + assert.equal(r.periodsElapsed, 5); + assert.equal(r.periodsAhead, 0); + assert.equal(r.pace, 'on plan'); + assert.equal(r.driftPercent, 0); + }); + + test(`does not count the ${rate}s boundary before its timestamp`, () => { + const deadline = startTimestamp + seconds; + for (const currentTimestamp of [deadline - 1, previousNumber(deadline)]) { + const r = projectSchedule({ ...schedule, periodBlocks: blocks, + designSecondsPerBlock: rate, currentTimestamp }); + assert.equal(r.periodsExpectedUncapped, 4); + assert.equal(r.periodsExpectedByNow, 4); + } + }); +} + +test('fractional UNIX timestamps obey the same deadline without early rounding', () => { + const start = startTimestamp + 0.25; + const deadline = start + 1003; + for (const [currentTimestamp, expected] of [[previousNumber(deadline), 4], [deadline, 5]]) { + const r = projectSchedule({ ...schedule, startTimestamp: start, currentTimestamp }); + assert.equal(r.periodsExpectedUncapped, expected); + } +}); + +test('a genesis-origin clock uses total blocks at the five-period deadline', () => { + for (const [currentTimestamp, expected] of [[previousNumber(1003), 4], [1003, 5]]) { + const r = projectSchedule({ ...schedule, startBlock: 0, currentBlock: 5015, + startTimestamp: 0, currentTimestamp }); + assert.equal(r.periodsExpectedUncapped, expected); + } +}); + +test('uncapped design counts survive completion and remain independent of actual pace', () => { + const complete = projectSchedule({ ...schedule, periods: 3 }); + assert.equal(complete.periodsExpectedByNow, 3); + assert.equal(complete.periodsExpectedUncapped, 5); + assert.equal(complete.periodsAhead, 0); + assert.equal(complete.complete, true); + + const slower = projectSchedule({ ...schedule, currentTimestamp: startTimestamp + 2006, + observedSecondsPerBlock: 0.4 }); + assert.equal(slower.periodsExpectedByNow, 10); + assert.equal(slower.periodsElapsed, 5); + assert.equal(slower.periodsAhead, -5); + assert.equal(slower.driftPercent, -100); + assert.equal(slower.pace, 'slower'); +}); + +test('checkSchedule preserves expected counts through actual block-header parsing', async () => { + const url = 'https://schedule-fixture.invalid'; + const headers = new Map([ + ['latest', { number: startBlock + 5015, timestamp: startTimestamp + 1003 }], + ['0xf4240', { number: startBlock, timestamp: startTimestamp }], + ]); + const requested = []; + const rpc = { retries: 0, fetchImpl: async (requestUrl, init) => { + assert.equal(requestUrl, url); + const { method, params: [tag, full] } = JSON.parse(init.body); + assert.equal(method, 'eth_getBlockByNumber'); + assert.equal(full, false); + requested.push(tag); + const header = headers.get(tag); + assert.ok(header, `unknown fixture tag ${tag}`); + return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { + number: '0x' + header.number.toString(16), timestamp: '0x' + header.timestamp.toString(16), + } })); + } }; + const r = await checkSchedule({ rpcUrl: url, rpc, startBlock, periodBlocks: 1003, + periods: 10, designSecondsPerBlock: 0.2, sampleBlocks: 5015 }); + assert.deepEqual(requested, ['latest', '0xf4240', '0xf4240']); + assert.equal(r.observedSecondsPerBlock, 0.2); + assert.equal(r.driftPercent, 0); + assert.equal(r.pace, 'on plan'); + assert.equal(r.periodsExpectedByNow, 5); + assert.equal(r.periodsElapsed, 5); + assert.equal(r.periodsAhead, 0); +}); diff --git a/test/design-clock-early-counts.test.js b/test/design-clock-early-counts.test.js new file mode 100644 index 0000000..6d28d03 --- /dev/null +++ b/test/design-clock-early-counts.test.js @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 1932819331hy-debug +// SPDX-License-Identifier: MIT + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { projectSchedule } from '../src/index.js'; + +function adjacentPositiveNumber(value, direction) { + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, value); + view.setBigUint64(0, view.getBigUint64(0) + BigInt(direction)); + return view.getFloat64(0); +} + +const schedule = { + startBlock: 1_000_000, currentBlock: 1_000_300, + periodBlocks: 3, periods: 100, + designSecondsPerBlock: 0.15, observedSecondsPerBlock: 0.15, +}; + +// 300 blocks at 150 ms take exactly 45 seconds. Each literal deadline below +// also equals the public report's intendedEnd; no new rounding rule is used. +for (const [startTimestamp, deadline] of [[0, 45], [0.25, 45.25], [1, 46]]) { + for (const [label, currentTimestamp, expected] of [ + ['before', adjacentPositiveNumber(deadline, -1), 99], + ['at', deadline, 100], + ['after', adjacentPositiveNumber(deadline, 1), 100], + ]) { + test(`${label} the ${deadline}s deadline from a ${startTimestamp}s origin`, () => { + const r = projectSchedule({ ...schedule, startTimestamp, currentTimestamp }); + assert.equal(r.intendedEnd, deadline); + assert.equal(r.periodsElapsedUncapped, 100); + assert.equal(r.periodsExpectedUncapped, expected); + assert.equal(r.periodsExpectedByNow, expected); + assert.equal(r.periodsAhead, 100 - expected); + assert.equal(r.pace, 'on plan'); + assert.equal(r.driftPercent, 0); + assert.equal(r.complete, true); + }); + } +} + +test('an unfinished design period remains excluded after the block schedule completes', () => { + const r = projectSchedule({ ...schedule, periods: 50, startTimestamp: 1, + currentTimestamp: adjacentPositiveNumber(46, -1) }); + assert.equal(r.periodsExpectedUncapped, 99); + assert.equal(r.periodsExpectedByNow, 50); + assert.equal(r.periodsElapsed, 50); + assert.equal(r.periodsAhead, 1); + assert.equal(r.complete, true); +}); + +test('zero elapsed time never produces a negative design count', () => { + for (const startTimestamp of [0, 0.25, 1, 1_700_000_000]) { + const r = projectSchedule({ ...schedule, startTimestamp, + currentTimestamp: startTimestamp, currentBlock: schedule.startBlock }); + assert.equal(r.periodsExpectedUncapped, 0); + assert.equal(r.periodsExpectedByNow, 0); + assert.equal(r.periodsAhead, 0); + assert.equal(r.realisedSecondsPerBlock, null); + assert.equal(r.complete, false); + } +}); + +test('the timestamp before the start is still rejected', () => { + assert.throws(() => projectSchedule({ ...schedule, startTimestamp: 1, + currentTimestamp: adjacentPositiveNumber(1, -1) }), /before startTimestamp/); +});