diff --git a/data_structures/unstable_rolling_counter.ts b/data_structures/unstable_rolling_counter.ts index 87a47711fe15..937bae6ce422 100644 --- a/data_structures/unstable_rolling_counter.ts +++ b/data_structures/unstable_rolling_counter.ts @@ -1,6 +1,19 @@ // Copyright 2018-2026 the Deno authors. MIT license. // This module is browser compatible. +/** + * A serializable snapshot of a {@linkcode RollingCounter}'s state. + * + * Obtain one via {@linkcode RollingCounter.prototype.toJSON | `toJSON`} and + * restore it with {@linkcode RollingCounter.from}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export interface RollingCounterSnapshot { + /** Segment counts ordered from oldest to newest. */ + segments: number[]; +} + /** * A fixed-size rolling counter. * @@ -61,6 +74,57 @@ export class RollingCounter { this.#total = 0; } + /** + * Creates a counter from a snapshot previously obtained via + * {@linkcode RollingCounter.prototype.toJSON | `toJSON`}. The snapshot's + * `segments` array defines both the number of segments and their initial + * values, ordered oldest to newest (matching iteration order). The last + * element is the current (newest) segment. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param snapshot A snapshot previously obtained from `toJSON`. + * @returns A new `RollingCounter` with the given state. + * + * @example Round-trip serialization + * ```ts + * import { RollingCounter } from "@std/data-structures/unstable-rolling-counter"; + * import { assertEquals } from "@std/assert"; + * + * const original = new RollingCounter(3); + * original.increment(5); + * original.rotate(); + * original.increment(3); + * + * const snapshot = original.toJSON(); + * const restored = RollingCounter.from(snapshot); + * + * assertEquals([...restored], [...original]); + * assertEquals(restored.total, original.total); + * assertEquals(restored.segmentCount, original.segmentCount); + * ``` + */ + static from(snapshot: { segments: readonly number[] }): RollingCounter { + const { segments } = snapshot; + if (!Array.isArray(segments) || segments.length < 1) { + throw new RangeError( + "Cannot restore RollingCounter: segments must be a non-empty array", + ); + } + const counter = new RollingCounter(segments.length); + for (let i = 0; i < segments.length; i++) { + const v = segments[i]!; + if (!Number.isInteger(v) || v < 0) { + throw new RangeError( + `Cannot restore RollingCounter: segment[${i}] must be a non-negative integer, got ${v}`, + ); + } + counter.#segments[i] = v; + counter.#total += v; + } + return counter; + } + /** * Adds `n` to the current segment. * @@ -129,24 +193,71 @@ export class RollingCounter { `Cannot rotate RollingCounter: steps must be a non-negative integer, got ${steps}`, ); } - const len = this.#segments.length; + const segs = this.#segments; + const len = segs.length; if (steps >= len) { const evicted = this.#total; - this.#segments.fill(0); + segs.fill(0); this.#cursor = (this.#cursor + steps) % len; this.#total = 0; return evicted; } + + let pos = this.#cursor + 1; + if (pos >= len) pos = 0; + let evicted = 0; - for (let i = 0; i < steps; i++) { - this.#cursor = (this.#cursor + 1) % len; - evicted += this.#segments[this.#cursor]!; - this.#segments[this.#cursor] = 0; + const end = pos + steps; + + if (end <= len) { + for (let i = pos; i < end; i++) { + evicted += segs[i]!; + segs[i] = 0; + } + } else { + for (let i = pos; i < len; i++) { + evicted += segs[i]!; + segs[i] = 0; + } + const wrap = end - len; + for (let i = 0; i < wrap; i++) { + evicted += segs[i]!; + segs[i] = 0; + } } + + let newCursor = pos + steps - 1; + if (newCursor >= len) newCursor -= len; + this.#cursor = newCursor; this.#total -= evicted; return evicted; } + /** + * The count in the current (newest) segment. + * + * @returns The count in the current segment. + * + * @example Usage + * ```ts + * import { RollingCounter } from "@std/data-structures/unstable-rolling-counter"; + * import { assertEquals } from "@std/assert"; + * + * const counter = new RollingCounter(3); + * counter.increment(5); + * assertEquals(counter.current, 5); + * + * counter.rotate(); + * assertEquals(counter.current, 0); + * + * counter.increment(3); + * assertEquals(counter.current, 3); + * ``` + */ + get current(): number { + return this.#segments[this.#cursor]!; + } + /** * The sum of all segment counts. * @@ -206,6 +317,43 @@ export class RollingCounter { this.#total = 0; } + /** + * Returns a serializable snapshot of the counter state. The `segments` + * array is ordered oldest to newest (matching iteration order), so the + * last element is the current segment. + * + * The snapshot is compatible with `JSON.stringify` and can be restored + * with {@linkcode RollingCounter.from}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @returns A plain-object snapshot of the counter. + * + * @example JSON round-trip + * ```ts + * import { RollingCounter } from "@std/data-structures/unstable-rolling-counter"; + * import { assertEquals } from "@std/assert"; + * + * const counter = new RollingCounter(3); + * counter.increment(5); + * + * const json = JSON.stringify(counter); + * const restored = RollingCounter.from(JSON.parse(json)); + * assertEquals(restored.total, 5); + * ``` + */ + toJSON(): RollingCounterSnapshot { + const segs = this.#segments; + const len = segs.length; + let start = this.#cursor + 1; + if (start >= len) start = 0; + const result = new Array(len); + const firstLen = len - start; + for (let i = 0; i < firstLen; i++) result[i] = segs[start + i]!; + for (let i = 0; i < start; i++) result[firstLen + i] = segs[i]!; + return { segments: result }; + } + /** * Yields segment counts from oldest to newest. * @@ -229,4 +377,25 @@ export class RollingCounter { yield this.#segments[(this.#cursor + i) % len]!; } } + + /** + * The string tag used by `Object.prototype.toString`. + * + * @returns `"RollingCounter"`. + * + * @example Usage + * ```ts + * import { RollingCounter } from "@std/data-structures/unstable-rolling-counter"; + * import { assertEquals } from "@std/assert"; + * + * const counter = new RollingCounter(3); + * assertEquals( + * Object.prototype.toString.call(counter), + * "[object RollingCounter]", + * ); + * ``` + */ + get [Symbol.toStringTag](): string { + return "RollingCounter"; + } } diff --git a/data_structures/unstable_rolling_counter_test.ts b/data_structures/unstable_rolling_counter_test.ts index 602b2058a5dc..54d6b3268d09 100644 --- a/data_structures/unstable_rolling_counter_test.ts +++ b/data_structures/unstable_rolling_counter_test.ts @@ -165,6 +165,51 @@ Deno.test("RollingCounter.clear() resets to initial state", () => { assertEquals([...counter], [...fresh]); }); +// -- current -- + +Deno.test("RollingCounter.current returns the current segment value", () => { + const counter = new RollingCounter(3); + assertEquals(counter.current, 0); + + counter.increment(5); + assertEquals(counter.current, 5); + + counter.rotate(); + assertEquals(counter.current, 0); + + counter.increment(3); + assertEquals(counter.current, 3); +}); + +Deno.test("RollingCounter.current is 0 after clear", () => { + const counter = new RollingCounter(3); + counter.increment(10); + counter.clear(); + assertEquals(counter.current, 0); +}); + +Deno.test("RollingCounter.current tracks the newest segment after bulk rotate", () => { + const counter = new RollingCounter(3); + counter.increment(5); + counter.rotate(); + counter.increment(3); + counter.rotate(2); + assertEquals(counter.current, 0); + + counter.increment(7); + assertEquals(counter.current, 7); +}); + +// -- Symbol.toStringTag -- + +Deno.test("RollingCounter[Symbol.toStringTag] returns 'RollingCounter'", () => { + const counter = new RollingCounter(3); + assertEquals( + Object.prototype.toString.call(counter), + "[object RollingCounter]", + ); +}); + // -- Symbol.iterator -- Deno.test("RollingCounter[Symbol.iterator]() yields segments oldest to newest", () => { @@ -202,3 +247,64 @@ Deno.test("RollingCounter handles many rotations without data loss", () => { counter.increment(1); assertEquals([...counter], [0, 0, 1]); }); + +// -- toJSON / from -- + +Deno.test("RollingCounter.toJSON() and from() round-trip through JSON", () => { + const original = new RollingCounter(4); + original.increment(5); + original.rotate(); + original.increment(3); + original.rotate(); + original.increment(7); + + const json = JSON.stringify(original); + const restored = RollingCounter.from(JSON.parse(json)); + + assertEquals([...restored], [...original]); + assertEquals(restored.total, original.total); + assertEquals(restored.segmentCount, original.segmentCount); +}); + +Deno.test("RollingCounter.from() produces an independent, functional copy", () => { + const a = new RollingCounter(3); + a.increment(5); + a.rotate(); + a.increment(3); + + const b = RollingCounter.from(a.toJSON()); + b.rotate(); + b.increment(7); + + assertEquals(a.total, 8); + assertEquals([...a], [0, 5, 3]); + assertEquals(b.total, 15); + assertEquals([...b], [5, 3, 7]); +}); + +Deno.test("RollingCounter.from() with single-segment counter", () => { + const restored = RollingCounter.from({ segments: [42] }); + assertEquals(restored.total, 42); + assertEquals(restored.rotate(), 42); +}); + +Deno.test("RollingCounter.from() throws on empty segments", () => { + assertThrows(() => RollingCounter.from({ segments: [] }), RangeError); +}); + +Deno.test("RollingCounter.from() throws on non-array segments", () => { + assertThrows( + // deno-lint-ignore no-explicit-any + () => RollingCounter.from({ segments: "no" as any }), + RangeError, + ); +}); + +Deno.test("RollingCounter.from() throws on invalid segment values", () => { + for (const bad of [-1, 1.5, NaN, Infinity]) { + assertThrows( + () => RollingCounter.from({ segments: [0, bad, 0] }), + RangeError, + ); + } +});