- vitest.config.ts: add statements/functions/branches thresholds alongside
lines so the coverage gate catches regressions in all four dimensions
- tests/stats.test.ts: replace weak "> 0n" / "toBeDefined()" assertions with
exact expected values derived from the ring buffer algebra:
- hour-advanced path: mintedLastWeek=480n, mintedLastDay=220n,
burnedLastWeek=240n, burnedLastDay=110n, mintNextHourProjected=68n,
burnNextHourProjected=34n, netSupplyChangeDay=110n,
netSupplyChangeWeek=240n
- same-hour projection path: mintNextHourProjected=140n (elapsed-seconds
scaling verified), burnNextHourProjected=0n (medium=0 fallback path)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
784 lines
31 KiB
TypeScript
784 lines
31 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
import {
|
||
makeEmptyRingBuffer,
|
||
parseRingBuffer,
|
||
serializeRingBuffer,
|
||
checkBlockHistorySufficient,
|
||
updateHourlyData,
|
||
ensureStatsExists,
|
||
recordEthReserveSnapshot,
|
||
updateEthReserve,
|
||
markPositionsUpdated,
|
||
refreshOutstandingStake,
|
||
refreshMinStake,
|
||
RING_BUFFER_SEGMENTS,
|
||
MINIMUM_BLOCKS_FOR_RINGBUFFER,
|
||
type StatsContext,
|
||
} from '../src/helpers/stats.js';
|
||
|
||
// Constants duplicated from the mock so tests don't re-import ponder:schema
|
||
const HOURS = 168;
|
||
const SECS_PER_HOUR = 3600;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helper factories
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface MockStatsRow {
|
||
ringBuffer: string[];
|
||
ringBufferPointer: number;
|
||
lastHourlyUpdateTimestamp: bigint;
|
||
holderCount: number;
|
||
minStake?: bigint;
|
||
stakeTotalSupply?: bigint;
|
||
outstandingStake?: bigint;
|
||
kraikenTotalSupply?: bigint;
|
||
}
|
||
|
||
function emptyStatsRow(overrides: Partial<MockStatsRow> = {}): MockStatsRow {
|
||
return {
|
||
ringBuffer: makeEmptyRingBuffer().map(String),
|
||
ringBufferPointer: 0,
|
||
lastHourlyUpdateTimestamp: 0n,
|
||
holderCount: 0,
|
||
minStake: 0n,
|
||
stakeTotalSupply: 0n,
|
||
outstandingStake: 0n,
|
||
kraikenTotalSupply: 0n,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
/** Build a ring buffer string[] with a known value at a specific slot. */
|
||
function ringBufferWith(
|
||
pointer: number,
|
||
slotOffset: number,
|
||
value: bigint,
|
||
hoursBack = 0,
|
||
): string[] {
|
||
const buf = makeEmptyRingBuffer();
|
||
const idx = ((pointer - hoursBack + HOURS) % HOURS) * RING_BUFFER_SEGMENTS + slotOffset;
|
||
buf[idx] = value;
|
||
return buf.map(String);
|
||
}
|
||
|
||
function createSetMock() {
|
||
return vi.fn().mockResolvedValue(undefined);
|
||
}
|
||
|
||
function createMockContext(statsRow: MockStatsRow | null = null): StatsContext {
|
||
const setFn = createSetMock();
|
||
const ctx = {
|
||
db: {
|
||
find: vi.fn().mockResolvedValue(statsRow),
|
||
update: vi.fn().mockReturnValue({ set: setFn }),
|
||
insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) }),
|
||
},
|
||
client: {
|
||
readContract: vi.fn().mockResolvedValue(0n),
|
||
},
|
||
contracts: {
|
||
Kraiken: { abi: [], address: '0x0000000000000000000000000000000000000001' as `0x${string}` },
|
||
Stake: { abi: [], address: '0x0000000000000000000000000000000000000002' as `0x${string}` },
|
||
},
|
||
network: {
|
||
contracts: {
|
||
Kraiken: { abi: [], address: '0x0000000000000000000000000000000000000001' },
|
||
},
|
||
},
|
||
logger: {
|
||
warn: vi.fn(),
|
||
info: vi.fn(),
|
||
error: vi.fn(),
|
||
},
|
||
};
|
||
// Cast: vitest uses esbuild (no type-check), so the slim mock satisfies the runtime contract.
|
||
return ctx as unknown as StatsContext;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// makeEmptyRingBuffer
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('makeEmptyRingBuffer', () => {
|
||
it('returns an array of the correct length', () => {
|
||
const buf = makeEmptyRingBuffer();
|
||
expect(buf).toHaveLength(HOURS * RING_BUFFER_SEGMENTS);
|
||
});
|
||
|
||
it('fills every element with 0n', () => {
|
||
const buf = makeEmptyRingBuffer();
|
||
expect(buf.every(v => v === 0n)).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// parseRingBuffer
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('parseRingBuffer', () => {
|
||
it('converts a string array to bigint array', () => {
|
||
const raw = ['1', '2', '3'];
|
||
expect(parseRingBuffer(raw)).toEqual([1n, 2n, 3n]);
|
||
});
|
||
|
||
it('returns empty ring buffer for null input', () => {
|
||
expect(parseRingBuffer(null)).toHaveLength(HOURS * RING_BUFFER_SEGMENTS);
|
||
expect(parseRingBuffer(null).every(v => v === 0n)).toBe(true);
|
||
});
|
||
|
||
it('returns empty ring buffer for undefined input', () => {
|
||
expect(parseRingBuffer(undefined)).toHaveLength(HOURS * RING_BUFFER_SEGMENTS);
|
||
});
|
||
|
||
it('returns empty ring buffer for empty array input', () => {
|
||
expect(parseRingBuffer([])).toHaveLength(HOURS * RING_BUFFER_SEGMENTS);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// serializeRingBuffer
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('serializeRingBuffer', () => {
|
||
it('converts bigint array to string array', () => {
|
||
expect(serializeRingBuffer([0n, 1n, 1000n])).toEqual(['0', '1', '1000']);
|
||
});
|
||
|
||
it('round-trips through parseRingBuffer', () => {
|
||
const original = makeEmptyRingBuffer();
|
||
original[0] = 42n;
|
||
original[RING_BUFFER_SEGMENTS] = 99n;
|
||
const serialized = serializeRingBuffer(original);
|
||
expect(parseRingBuffer(serialized)).toEqual(original);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// checkBlockHistorySufficient
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('checkBlockHistorySufficient', () => {
|
||
it('returns false when context has no network contracts', () => {
|
||
const ctx = { network: {} } as unknown as StatsContext;
|
||
const event = { block: { number: 200n } } as unknown as Parameters<typeof checkBlockHistorySufficient>[1];
|
||
expect(checkBlockHistorySufficient(ctx, event)).toBe(false);
|
||
});
|
||
|
||
it('returns false when context is missing network entirely', () => {
|
||
const ctx = {} as unknown as StatsContext;
|
||
const event = { block: { number: 200n } } as unknown as Parameters<typeof checkBlockHistorySufficient>[1];
|
||
expect(checkBlockHistorySufficient(ctx, event)).toBe(false);
|
||
});
|
||
|
||
it('returns false when not enough blocks have passed', () => {
|
||
const ctx = createMockContext();
|
||
const event = {
|
||
block: { number: BigInt(MINIMUM_BLOCKS_FOR_RINGBUFFER - 1) },
|
||
} as unknown as Parameters<typeof checkBlockHistorySufficient>[1];
|
||
expect(checkBlockHistorySufficient(ctx, event)).toBe(false);
|
||
});
|
||
|
||
it('uses console fallback when context has no logger', () => {
|
||
// Context with network.contracts.Kraiken but no logger — exercises logger.ts fallback branch
|
||
const ctx = {
|
||
network: { contracts: { Kraiken: {} } },
|
||
} as unknown as StatsContext;
|
||
const event = {
|
||
block: { number: BigInt(MINIMUM_BLOCKS_FOR_RINGBUFFER - 1) },
|
||
} as unknown as Parameters<typeof checkBlockHistorySufficient>[1];
|
||
// Should not throw; console.warn is used as fallback
|
||
expect(checkBlockHistorySufficient(ctx, event)).toBe(false);
|
||
});
|
||
|
||
it('returns true when sufficient blocks have passed', () => {
|
||
const ctx = createMockContext();
|
||
const event = {
|
||
block: { number: BigInt(MINIMUM_BLOCKS_FOR_RINGBUFFER + 1) },
|
||
} as unknown as Parameters<typeof checkBlockHistorySufficient>[1];
|
||
expect(checkBlockHistorySufficient(ctx, event)).toBe(true);
|
||
});
|
||
|
||
it('returns true at exact threshold', () => {
|
||
const ctx = createMockContext();
|
||
const event = {
|
||
block: { number: BigInt(MINIMUM_BLOCKS_FOR_RINGBUFFER) },
|
||
} as unknown as Parameters<typeof checkBlockHistorySufficient>[1];
|
||
expect(checkBlockHistorySufficient(ctx, event)).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// updateHourlyData
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('updateHourlyData', () => {
|
||
it('returns early when no stats row exists', async () => {
|
||
const ctx = createMockContext(null);
|
||
await updateHourlyData(ctx, BigInt(SECS_PER_HOUR * 10));
|
||
expect((ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } }).db.update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('initialises lastHourlyUpdateTimestamp when it is 0n', async () => {
|
||
const row = emptyStatsRow({ lastHourlyUpdateTimestamp: 0n });
|
||
const ctx = createMockContext(row);
|
||
const timestamp = BigInt(SECS_PER_HOUR * 5);
|
||
await updateHourlyData(ctx, timestamp);
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
expect(dbMock.db.update).toHaveBeenCalled();
|
||
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
|
||
// Should store the current hour
|
||
expect(setArg.lastHourlyUpdateTimestamp).toBe((timestamp / BigInt(SECS_PER_HOUR)) * BigInt(SECS_PER_HOUR));
|
||
});
|
||
|
||
it('advances the pointer when a new hour has started', async () => {
|
||
const baseTimestamp = BigInt(SECS_PER_HOUR * 100);
|
||
const row = emptyStatsRow({
|
||
lastHourlyUpdateTimestamp: baseTimestamp,
|
||
ringBufferPointer: 0,
|
||
});
|
||
const ctx = createMockContext(row);
|
||
// Move 3 hours forward
|
||
const newTimestamp = baseTimestamp + BigInt(SECS_PER_HOUR * 3 + 60);
|
||
await updateHourlyData(ctx, newTimestamp);
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
|
||
expect(setArg.ringBufferPointer).toBe(3);
|
||
});
|
||
|
||
it('clamps hours elapsed to HOURS_IN_RING_BUFFER to prevent full-buffer clear loops', async () => {
|
||
const baseTimestamp = BigInt(SECS_PER_HOUR * 100);
|
||
const row = emptyStatsRow({
|
||
lastHourlyUpdateTimestamp: baseTimestamp,
|
||
ringBufferPointer: 0,
|
||
});
|
||
const ctx = createMockContext(row);
|
||
// Jump 500 hours — exceeds ring buffer
|
||
const newTimestamp = baseTimestamp + BigInt(SECS_PER_HOUR * 500);
|
||
await updateHourlyData(ctx, newTimestamp);
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
|
||
// pointer should wrap around within HOURS range
|
||
expect(setArg.ringBufferPointer).toBeGreaterThanOrEqual(0);
|
||
expect(setArg.ringBufferPointer).toBeLessThan(HOURS);
|
||
});
|
||
|
||
it('computes metrics when hour has advanced (mintedDay, burnedDay, etc.)', async () => {
|
||
const pointer = 5;
|
||
// Slot 1 (minted) at index i=0 (current hour) through i=23 — 10n in each of last 48 hours
|
||
const buf = makeEmptyRingBuffer();
|
||
for (let h = 0; h < 48; h++) {
|
||
const idx = ((pointer - h + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
|
||
buf[idx + 1] = 10n; // minted
|
||
buf[idx + 2] = 5n; // burned
|
||
}
|
||
const baseTimestamp = BigInt(SECS_PER_HOUR * 200);
|
||
const row = emptyStatsRow({
|
||
lastHourlyUpdateTimestamp: baseTimestamp,
|
||
ringBufferPointer: pointer,
|
||
ringBuffer: buf.map(String),
|
||
});
|
||
const ctx = createMockContext(row);
|
||
|
||
await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR * 2));
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
|
||
// After advancing 2 hours (new pointer=7), slots 6+7 are cleared (were already 0).
|
||
// Filled slots: i=2..49 from pointer=7 (48 slots × 10n minted / 5n burned).
|
||
// mintedLastDay: i<24 → i=2..23 = 22 slots × 10n = 220n
|
||
// mintedLastWeek: 48 × 10n = 480n
|
||
// mintNextHourProjected = mintedWeek / 7n = 480n / 7n = 68n (BigInt truncation)
|
||
// burnNextHourProjected = burnedWeek / 7n = 240n / 7n = 34n
|
||
expect(setArg.mintedLastWeek).toBe(480n);
|
||
expect(setArg.mintedLastDay).toBe(220n);
|
||
expect(setArg.burnedLastWeek).toBe(240n);
|
||
expect(setArg.burnedLastDay).toBe(110n);
|
||
expect(setArg.mintNextHourProjected).toBe(68n);
|
||
expect(setArg.burnNextHourProjected).toBe(34n);
|
||
expect(setArg.netSupplyChangeDay).toBe(110n);
|
||
expect(setArg.netSupplyChangeWeek).toBe(240n);
|
||
});
|
||
|
||
it('carries forward holderCount into new ring buffer slots', async () => {
|
||
const row = emptyStatsRow({
|
||
lastHourlyUpdateTimestamp: BigInt(SECS_PER_HOUR * 10),
|
||
ringBufferPointer: 0,
|
||
holderCount: 42,
|
||
});
|
||
const ctx = createMockContext(row);
|
||
await updateHourlyData(ctx, BigInt(SECS_PER_HOUR * 12));
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
const setArgs = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
|
||
const updatedBuf = parseRingBuffer(setArgs.ringBuffer as string[]);
|
||
// pointer advanced to 2; slot 3 of new pointer should be 42n
|
||
const newPointer = 2;
|
||
expect(updatedBuf[newPointer * RING_BUFFER_SEGMENTS + 3]).toBe(42n);
|
||
});
|
||
|
||
it('computes projections when same hour (no advancement)', async () => {
|
||
const pointer = 3;
|
||
const baseTimestamp = BigInt(SECS_PER_HOUR * 100);
|
||
// Add some minted data in the current and previous slot
|
||
const buf = makeEmptyRingBuffer();
|
||
buf[pointer * RING_BUFFER_SEGMENTS + 1] = 100n; // current hour minted
|
||
const prevPointer = ((pointer - 1 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
|
||
buf[prevPointer + 1] = 80n; // previous hour minted
|
||
const row = emptyStatsRow({
|
||
lastHourlyUpdateTimestamp: baseTimestamp,
|
||
ringBufferPointer: pointer,
|
||
ringBuffer: buf.map(String),
|
||
});
|
||
const ctx = createMockContext(row);
|
||
|
||
// Same hour but 30 minutes in
|
||
const sameHourTimestamp = baseTimestamp + BigInt(SECS_PER_HOUR / 2);
|
||
await updateHourlyData(ctx, sameHourTimestamp);
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
expect(dbMock.db.update).toHaveBeenCalled();
|
||
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
|
||
// elapsed = 1800s (30 min); current minted = 100n, prev minted = 80n
|
||
// projectedTotal = (100n * 3600n) / 1800n = 200n
|
||
// medium = (80n + 200n) / 2n = 140n → mintProjection = 140n
|
||
// burn: current=0n, prev=0n → medium=0n → fallback: mintedWeek/7 = 0n
|
||
expect(setArg.mintNextHourProjected).toBe(140n);
|
||
expect(setArg.burnNextHourProjected).toBe(0n);
|
||
});
|
||
|
||
it('handles zero elapsedSeconds in projection (exact hour boundary)', async () => {
|
||
const pointer = 0;
|
||
const exactHour = BigInt(SECS_PER_HOUR * 50);
|
||
const buf = makeEmptyRingBuffer();
|
||
buf[pointer * RING_BUFFER_SEGMENTS + 1] = 50n; // some minted
|
||
const row = emptyStatsRow({
|
||
lastHourlyUpdateTimestamp: exactHour,
|
||
ringBufferPointer: pointer,
|
||
ringBuffer: buf.map(String),
|
||
});
|
||
const ctx = createMockContext(row);
|
||
|
||
// Pass exactly the same timestamp (elapsedSeconds = 0)
|
||
await updateHourlyData(ctx, exactHour);
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
expect(dbMock.db.update).toHaveBeenCalled();
|
||
});
|
||
|
||
it('computes ETH reserve change metrics when reserves are populated', async () => {
|
||
const pointer = 25;
|
||
const buf = makeEmptyRingBuffer();
|
||
// Set current hour ETH reserve (i=0)
|
||
buf[pointer * RING_BUFFER_SEGMENTS + 0] = 2000n;
|
||
// Set 24h ago ETH reserve (i=23)
|
||
const idx24h = ((pointer - 23 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
|
||
buf[idx24h + 0] = 1000n;
|
||
const baseTimestamp = BigInt(SECS_PER_HOUR * 200);
|
||
const row = emptyStatsRow({
|
||
lastHourlyUpdateTimestamp: baseTimestamp,
|
||
ringBufferPointer: pointer,
|
||
ringBuffer: buf.map(String),
|
||
});
|
||
const ctx = createMockContext(row);
|
||
await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR * 2));
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
|
||
expect(setArg.ethReserveLastDay).toBeGreaterThanOrEqual(0n);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// recordEthReserveSnapshot
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('recordEthReserveSnapshot', () => {
|
||
it('returns early when no stats row exists', async () => {
|
||
const ctx = createMockContext(null);
|
||
await recordEthReserveSnapshot(ctx, 1000n, 500n);
|
||
expect((ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } }).db.update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('writes the ETH balance into slot 0 of the current pointer', async () => {
|
||
const pointer = 2;
|
||
const row = emptyStatsRow({ ringBufferPointer: pointer });
|
||
const ctx = createMockContext(row);
|
||
const ethBalance = 12345678n;
|
||
|
||
await recordEthReserveSnapshot(ctx, 1000n, ethBalance);
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
|
||
const updatedBuf = parseRingBuffer(setArg.ringBuffer as string[]);
|
||
expect(updatedBuf[pointer * RING_BUFFER_SEGMENTS + 0]).toBe(ethBalance);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// updateEthReserve
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('updateEthReserve', () => {
|
||
it('skips update when weth balance is 0', async () => {
|
||
const ctx = createMockContext(emptyStatsRow());
|
||
// readContract returns 0n by default
|
||
await updateEthReserve(ctx, '0xpool0000000000000000000000000000000000001' as `0x${string}`);
|
||
expect((ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } }).db.update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('updates lastEthReserve when weth balance is non-zero', async () => {
|
||
const ctx = createMockContext(emptyStatsRow());
|
||
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
|
||
= vi.fn().mockResolvedValue(9999n);
|
||
|
||
await updateEthReserve(ctx, '0xpool0000000000000000000000000000000000001' as `0x${string}`);
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
expect(dbMock.db.update).toHaveBeenCalled();
|
||
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
|
||
expect(setArg.lastEthReserve).toBe(9999n);
|
||
});
|
||
|
||
it('logs warning and returns when readContract throws', async () => {
|
||
const ctx = createMockContext(emptyStatsRow());
|
||
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
|
||
= vi.fn().mockRejectedValue(new Error('rpc error'));
|
||
|
||
await updateEthReserve(ctx, '0xpool0000000000000000000000000000000000001' as `0x${string}`);
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
expect(dbMock.db.update).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ensureStatsExists
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('ensureStatsExists', () => {
|
||
it('returns existing stats without re-creating when row exists', async () => {
|
||
const row = emptyStatsRow();
|
||
const ctx = createMockContext(row);
|
||
|
||
const result = await ensureStatsExists(ctx, 1000n);
|
||
|
||
const dbMock = ctx as unknown as { db: { insert: ReturnType<typeof vi.fn> } };
|
||
expect(dbMock.db.insert).not.toHaveBeenCalled();
|
||
expect(result).toBe(row);
|
||
});
|
||
|
||
it('creates a new stats row when none exists', async () => {
|
||
// First call returns null, second call (post-insert) returns the row
|
||
const row = emptyStatsRow();
|
||
const dbFindMock = vi.fn()
|
||
.mockResolvedValueOnce(null)
|
||
.mockResolvedValueOnce(row);
|
||
const ctx = createMockContext(null);
|
||
(ctx as unknown as { db: { find: ReturnType<typeof vi.fn> } }).db.find = dbFindMock;
|
||
|
||
const result = await ensureStatsExists(ctx, 1000n);
|
||
|
||
const dbMock = ctx as unknown as { db: { insert: ReturnType<typeof vi.fn> } };
|
||
expect(dbMock.db.insert).toHaveBeenCalled();
|
||
expect(result).toBe(row);
|
||
});
|
||
|
||
it('uses fallback values when readContract throws during creation', async () => {
|
||
const row = emptyStatsRow();
|
||
const dbFindMock = vi.fn()
|
||
.mockResolvedValueOnce(null)
|
||
.mockResolvedValueOnce(row);
|
||
const ctx = createMockContext(null);
|
||
(ctx as unknown as { db: { find: ReturnType<typeof vi.fn> } }).db.find = dbFindMock;
|
||
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
|
||
= vi.fn().mockRejectedValue(new Error('rpc error'));
|
||
|
||
const result = await ensureStatsExists(ctx, 1000n);
|
||
|
||
expect(result).toBe(row);
|
||
// insert should still have been called with fallback 0n values
|
||
const dbMock = ctx as unknown as { db: { insert: ReturnType<typeof vi.fn> } };
|
||
expect(dbMock.db.insert).toHaveBeenCalled();
|
||
});
|
||
|
||
it('works without timestamp argument', async () => {
|
||
const row = emptyStatsRow();
|
||
const ctx = createMockContext(row);
|
||
const result = await ensureStatsExists(ctx);
|
||
expect(result).toBe(row);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// markPositionsUpdated
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('markPositionsUpdated', () => {
|
||
it('updates positionsUpdatedAt in the stats row', async () => {
|
||
const row = emptyStatsRow();
|
||
const ctx = createMockContext(row);
|
||
const ts = 7777n;
|
||
|
||
await markPositionsUpdated(ctx, ts);
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
expect(dbMock.db.update).toHaveBeenCalled();
|
||
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
|
||
expect(setArg.positionsUpdatedAt).toBe(ts);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// getStakeTotalSupply
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('getStakeTotalSupply', () => {
|
||
beforeEach(() => {
|
||
vi.resetModules();
|
||
});
|
||
|
||
it('reads totalSupply from contract when cache is cold', async () => {
|
||
// Fresh import to reset module-level cache
|
||
const { getStakeTotalSupply: freshGet } = await import('../src/helpers/stats.js');
|
||
const row = emptyStatsRow({ stakeTotalSupply: 0n });
|
||
const ctx = createMockContext(row);
|
||
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
|
||
= vi.fn().mockResolvedValue(555n);
|
||
|
||
const result = await freshGet(ctx);
|
||
|
||
expect(result).toBe(555n);
|
||
});
|
||
|
||
it('returns cached value without calling readContract on second call', async () => {
|
||
// Fresh import to get a clean module with null cache
|
||
const { getStakeTotalSupply: freshGet } = await import('../src/helpers/stats.js');
|
||
const row = emptyStatsRow({ stakeTotalSupply: 0n });
|
||
const readContractMock = vi.fn().mockResolvedValue(777n);
|
||
const ctx = createMockContext(row);
|
||
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
|
||
= readContractMock;
|
||
|
||
// First call — populates cache via readContract
|
||
const first = await freshGet(ctx);
|
||
expect(first).toBe(777n);
|
||
|
||
// Second call — should return cached value, readContract not called again
|
||
const second = await freshGet(ctx);
|
||
expect(second).toBe(777n);
|
||
expect(readContractMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// refreshOutstandingStake
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('refreshOutstandingStake', () => {
|
||
it('reads outstandingStake from contract and persists it', async () => {
|
||
const row = emptyStatsRow();
|
||
const ctx = createMockContext(row);
|
||
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
|
||
= vi.fn().mockResolvedValue(1234n);
|
||
|
||
await refreshOutstandingStake(ctx);
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
expect(dbMock.db.update).toHaveBeenCalled();
|
||
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
|
||
expect(setArg.outstandingStake).toBe(1234n);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// refreshMinStake
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('refreshMinStake', () => {
|
||
it('skips update when minStake is unchanged', async () => {
|
||
const row = emptyStatsRow({ minStake: 500n });
|
||
const ctx = createMockContext(row);
|
||
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
|
||
= vi.fn().mockResolvedValue(500n);
|
||
|
||
await refreshMinStake(ctx, row as unknown as Awaited<ReturnType<typeof ensureStatsExists>>);
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
expect(dbMock.db.update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('updates minStake when the on-chain value has changed', async () => {
|
||
const row = emptyStatsRow({ minStake: 100n });
|
||
const ctx = createMockContext(row);
|
||
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
|
||
= vi.fn().mockResolvedValue(200n);
|
||
|
||
await refreshMinStake(ctx, row as unknown as Awaited<ReturnType<typeof ensureStatsExists>>);
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
expect(dbMock.db.update).toHaveBeenCalled();
|
||
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
|
||
expect(setArg.minStake).toBe(200n);
|
||
});
|
||
|
||
it('fetches stats when no statsData arg is provided and row exists', async () => {
|
||
const row = emptyStatsRow({ minStake: 0n });
|
||
const ctx = createMockContext(row);
|
||
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
|
||
= vi.fn().mockResolvedValue(300n);
|
||
|
||
await refreshMinStake(ctx);
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
expect(dbMock.db.update).toHaveBeenCalled();
|
||
});
|
||
|
||
it('creates stats row when find returns null', async () => {
|
||
const row = emptyStatsRow({ minStake: 0n });
|
||
const dbFindMock = vi.fn()
|
||
.mockResolvedValueOnce(null) // first find (refresh check)
|
||
.mockResolvedValueOnce(null) // ensureStatsExists find
|
||
.mockResolvedValueOnce(row); // ensureStatsExists post-insert
|
||
const ctx = createMockContext(null);
|
||
(ctx as unknown as { db: { find: ReturnType<typeof vi.fn> } }).db.find = dbFindMock;
|
||
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
|
||
= vi.fn().mockResolvedValue(300n);
|
||
|
||
await refreshMinStake(ctx);
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
expect(dbMock.db.update).toHaveBeenCalled();
|
||
});
|
||
|
||
it('logs warning and returns when readContract throws', async () => {
|
||
const row = emptyStatsRow({ minStake: 100n });
|
||
const ctx = createMockContext(row);
|
||
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
|
||
= vi.fn().mockRejectedValue(new Error('rpc error'));
|
||
|
||
await refreshMinStake(ctx, row as unknown as Awaited<ReturnType<typeof ensureStatsExists>>);
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
expect(dbMock.db.update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('uses ringBufferWith helper to build non-trivial buffers', () => {
|
||
const pointer = 10;
|
||
const buf = parseRingBuffer(ringBufferWith(pointer, 1, 999n));
|
||
expect(buf[pointer * RING_BUFFER_SEGMENTS + 1]).toBe(999n);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// RING_BUFFER_SEGMENTS and MINIMUM_BLOCKS_FOR_RINGBUFFER exports
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('constants', () => {
|
||
it('RING_BUFFER_SEGMENTS is 4', () => {
|
||
expect(RING_BUFFER_SEGMENTS).toBe(4);
|
||
});
|
||
|
||
it('MINIMUM_BLOCKS_FOR_RINGBUFFER is 100', () => {
|
||
expect(MINIMUM_BLOCKS_FOR_RINGBUFFER).toBe(100);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// computeMetrics coverage: holderCount and ethReserve at 24h/7d boundaries
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('computeMetrics boundary coverage (via updateHourlyData)', () => {
|
||
it('captures ethReserve7dAgo from the oldest non-zero slot', async () => {
|
||
const pointer = 2;
|
||
const buf = makeEmptyRingBuffer();
|
||
// Fill a slot > 24 hours back with a non-zero ETH reserve
|
||
const oldSlot = ((pointer - 50 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
|
||
buf[oldSlot + 0] = 500n;
|
||
// Current hour also has a value
|
||
buf[pointer * RING_BUFFER_SEGMENTS + 0] = 1000n;
|
||
|
||
const baseTimestamp = BigInt(SECS_PER_HOUR * 200);
|
||
const row = emptyStatsRow({
|
||
lastHourlyUpdateTimestamp: baseTimestamp,
|
||
ringBufferPointer: pointer,
|
||
ringBuffer: buf.map(String),
|
||
});
|
||
const ctx = createMockContext(row);
|
||
await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR * 2));
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
expect(dbMock.db.update).toHaveBeenCalled();
|
||
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
|
||
expect(setArg.ethReserveLastWeek).toBeGreaterThanOrEqual(0n);
|
||
});
|
||
|
||
it('records holderCount at 24h and 7d ago markers', async () => {
|
||
const pointer = 30;
|
||
const buf = makeEmptyRingBuffer();
|
||
// Set holderCount at current pointer (i=0)
|
||
buf[pointer * RING_BUFFER_SEGMENTS + 3] = 100n;
|
||
// Set holderCount at 24h ago (i=23)
|
||
const idx24h = ((pointer - 23 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
|
||
buf[idx24h + 3] = 80n;
|
||
// Set holderCount far back (i=150, older than 7d)
|
||
const idxOld = ((pointer - 150 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
|
||
buf[idxOld + 3] = 50n;
|
||
|
||
const baseTimestamp = BigInt(SECS_PER_HOUR * 500);
|
||
const row = emptyStatsRow({
|
||
lastHourlyUpdateTimestamp: baseTimestamp,
|
||
ringBufferPointer: pointer,
|
||
ringBuffer: buf.map(String),
|
||
});
|
||
const ctx = createMockContext(row);
|
||
await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR * 2));
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
expect(dbMock.db.update).toHaveBeenCalled();
|
||
});
|
||
|
||
it('handles projection fallback when weekly totals are also zero', async () => {
|
||
// All zeros in ring buffer — medium will be 0, fallback is weekly/7
|
||
const row = emptyStatsRow({
|
||
lastHourlyUpdateTimestamp: BigInt(SECS_PER_HOUR * 100),
|
||
ringBufferPointer: 0,
|
||
});
|
||
const ctx = createMockContext(row);
|
||
const sameHourTimestamp = BigInt(SECS_PER_HOUR * 100) + BigInt(SECS_PER_HOUR / 4);
|
||
await updateHourlyData(ctx, sameHourTimestamp);
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
|
||
// Projection should be 0 (weekly/7 = 0/7 = 0)
|
||
expect(setArg.mintNextHourProjected).toBe(0n);
|
||
expect(setArg.burnNextHourProjected).toBe(0n);
|
||
});
|
||
|
||
it('computes netSupplyChange from minted minus burned', async () => {
|
||
const pointer = 1;
|
||
const buf = makeEmptyRingBuffer();
|
||
for (let h = 0; h < 24; h++) {
|
||
const idx = ((pointer - h + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
|
||
buf[idx + 1] = 20n; // minted
|
||
buf[idx + 2] = 8n; // burned
|
||
}
|
||
const baseTimestamp = BigInt(SECS_PER_HOUR * 300);
|
||
const row = emptyStatsRow({
|
||
lastHourlyUpdateTimestamp: baseTimestamp,
|
||
ringBufferPointer: pointer,
|
||
ringBuffer: buf.map(String),
|
||
});
|
||
const ctx = createMockContext(row);
|
||
await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR));
|
||
|
||
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
|
||
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
|
||
expect(setArg.netSupplyChangeDay).toBeGreaterThan(0n);
|
||
expect(setArg.netSupplyChangeWeek).toBeGreaterThan(0n);
|
||
});
|
||
});
|
||
|