From 55d1f7133b0d9153ec37813b1bef2df5c739b913 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Wed, 27 May 2026 21:11:39 +0200 Subject: [PATCH] feat(data-structures/unstable): add Readonly view and clone path to RollingCounter --- data_structures/unstable_rolling_counter.ts | 75 +++++++++++++++++-- .../unstable_rolling_counter_test.ts | 42 +++++++++++ 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/data_structures/unstable_rolling_counter.ts b/data_structures/unstable_rolling_counter.ts index 37ae2ef7499f..aeefa33b3724 100644 --- a/data_structures/unstable_rolling_counter.ts +++ b/data_structures/unstable_rolling_counter.ts @@ -14,6 +14,25 @@ export interface RollingCounterSnapshot { readonly segments: readonly number[]; } +/** + * Read-only view of a {@linkcode RollingCounter}. Strips all mutation methods, + * following the `ReadonlyArray` / `ReadonlyMap` / `ReadonlySet` pattern. + * A `RollingCounter` is directly assignable to `ReadonlyRollingCounter`. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export type ReadonlyRollingCounter = Pick< + RollingCounter, + | "current" + | "total" + | "segmentCount" + | "at" + | "toArray" + | "toJSON" + | typeof Symbol.iterator + | typeof Symbol.toStringTag +>; + /** * A fixed-size rolling counter. * @@ -66,7 +85,8 @@ export interface RollingCounterSnapshot { * assertEquals([...counter], [3, 0, 0]); * ``` */ -export class RollingCounter implements Iterable { +export class RollingCounter + implements Iterable, ReadonlyRollingCounter { #segments: number[]; #cursor: number; #total: number; @@ -92,15 +112,22 @@ export class RollingCounter implements Iterable { } /** - * Creates a counter from a snapshot previously obtained via + * Creates a counter from an existing {@linkcode RollingCounter} or 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. * + * When given a `RollingCounter`, returns an independent clone with a + * canonical internal layout: segments laid out oldest-to-newest with the + * current segment at the last position. Mutating the clone does not affect + * the source. + * * @experimental **UNSTABLE**: New API, yet to be vetted. * - * @param snapshot A snapshot previously obtained from `toJSON`. + * @param source A `RollingCounter` to clone, or a snapshot previously + * obtained from `toJSON`. * @returns A new `RollingCounter` with the given state. * * @example Round-trip serialization @@ -120,16 +147,48 @@ export class RollingCounter implements Iterable { * assertEquals(restored.total, original.total); * assertEquals(restored.segmentCount, original.segmentCount); * ``` + * + * @example Cloning from a counter + * ```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 clone = RollingCounter.from(original); + * clone.rotate(); + * clone.increment(7); + * + * assertEquals([...original], [0, 5, 3]); + * assertEquals([...clone], [5, 3, 7]); + * ``` */ - static from(snapshot: RollingCounterSnapshot): RollingCounter { - if (snapshot === null || typeof snapshot !== "object") { + static from( + source: RollingCounter | RollingCounterSnapshot, + ): RollingCounter { + if (source instanceof RollingCounter) { + const src = source.#segments; + const len = src.length; + const counter = new RollingCounter(len); + let start = source.#cursor + 1; + if (start >= len) start = 0; + const firstLen = len - start; + for (let i = 0; i < firstLen; i++) counter.#segments[i] = src[start + i]!; + for (let i = 0; i < start; i++) counter.#segments[firstLen + i] = src[i]!; + counter.#total = source.#total; + return counter; + } + if (source === null || typeof source !== "object") { throw new TypeError( - `Cannot restore RollingCounter: "snapshot" must be an object, got ${ - snapshot === null ? "null" : typeof snapshot + `Cannot restore RollingCounter: "source" must be a RollingCounter or snapshot object, got ${ + source === null ? "null" : typeof source }`, ); } - const { segments } = snapshot; + const { segments } = source; if (!Array.isArray(segments)) { throw new TypeError( `Cannot restore RollingCounter: "segments" must be an array, got ${typeof segments}`, diff --git a/data_structures/unstable_rolling_counter_test.ts b/data_structures/unstable_rolling_counter_test.ts index 0f7b2da64d07..4138797c2c69 100644 --- a/data_structures/unstable_rolling_counter_test.ts +++ b/data_structures/unstable_rolling_counter_test.ts @@ -384,6 +384,48 @@ Deno.test("RollingCounter.from() with single-segment counter", () => { assertEquals(restored.rotate(), 42); }); +Deno.test("RollingCounter.from() with a RollingCounter instance produces an independent clone", () => { + const original = new RollingCounter(3); + original.increment(5); + original.rotate(); + original.increment(3); + + const clone = RollingCounter.from(original); + assertEquals([...clone], [...original]); + assertEquals(clone.total, original.total); + assertEquals(clone.segmentCount, original.segmentCount); + + clone.rotate(); + clone.increment(7); + assertEquals([...original], [0, 5, 3]); + assertEquals(original.total, 8); + assertEquals([...clone], [5, 3, 7]); + assertEquals(clone.total, 15); +}); + +Deno.test("RollingCounter.from() with a RollingCounter instance matches from(c.toJSON())", () => { + const original = new RollingCounter(4); + original.increment(10); + original.rotate(); + original.increment(20); + original.rotate(); + original.increment(30); + + const viaInstance = RollingCounter.from(original); + const viaSnapshot = RollingCounter.from(original.toJSON()); + + assertEquals([...viaInstance], [...viaSnapshot]); + assertEquals(viaInstance.total, viaSnapshot.total); + assertEquals(viaInstance.segmentCount, viaSnapshot.segmentCount); + + viaInstance.rotate(2); + viaSnapshot.rotate(2); + viaInstance.increment(99); + viaSnapshot.increment(99); + assertEquals([...viaInstance], [...viaSnapshot]); + assertEquals(viaInstance.total, viaSnapshot.total); +}); + Deno.test("RollingCounter.from() throws on empty segments", () => { assertThrows(() => RollingCounter.from({ segments: [] }), RangeError); });