Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 62 additions & 9 deletions data_structures/unstable_rolling_counter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -37,7 +56,7 @@ export interface RollingCounterSnapshot {
* | toJSON() | Linear in the number of segments |
* | [Symbol.iterator] | Linear in the number of segments |
* | RollingCounter() | Linear in the number of segments |
* | RollingCounter.from(snapshot) | Linear in the number of segments |
* | RollingCounter.from(source) | Linear in the number of segments |
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
Expand Down Expand Up @@ -66,7 +85,8 @@ export interface RollingCounterSnapshot {
* assertEquals([...counter], [3, 0, 0]);
* ```
*/
export class RollingCounter implements Iterable<number> {
export class RollingCounter
implements Iterable<number>, ReadonlyRollingCounter {
#segments: number[];
#cursor: number;
#total: number;
Expand All @@ -92,15 +112,22 @@ export class RollingCounter implements Iterable<number> {
}

/**
* 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
Expand All @@ -120,16 +147,42 @@ export class RollingCounter implements Iterable<number> {
* 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 counter = new RollingCounter(source.segmentCount);
counter.#segments = source.toArray();
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}`,
Expand Down
45 changes: 44 additions & 1 deletion data_structures/unstable_rolling_counter_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())", () => {
Comment thread
tomas-zijdemans marked this conversation as resolved.
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);
});
Expand All @@ -396,12 +438,13 @@ Deno.test("RollingCounter.from() throws on non-array segments", () => {
);
});

Deno.test("RollingCounter.from() throws on null / undefined / non-object snapshot", () => {
Deno.test("RollingCounter.from() throws on null / undefined / non-object source", () => {
for (const bad of [null, undefined, 42, "snap", true]) {
assertThrows(
// deno-lint-ignore no-explicit-any
() => RollingCounter.from(bad as any),
TypeError,
"must be a RollingCounter or snapshot object",
);
}
});
Expand Down
Loading