From e3c7798252d1914c2d11a2f47a7b54ddec360d92 Mon Sep 17 00:00:00 2001
From: alxkm <19151554+alxkm@users.noreply.github.com>
Date: Sat, 5 Sep 2026 22:05:44 +0200
Subject: [PATCH] feat: add SlidingWindowAggregator, a ring buffer with
windowed aggregation
A fixed-capacity circular buffer that additionally keeps an aggregate of
every element it currently holds, so a sliding window sum, minimum or
maximum is available in constant time.
Rather than the usual "add the new value, subtract the evicted one"
trick, which only works for invertible operators, it uses the two-stack
sliding window aggregation algorithm: the window is split into a front
section holding suffix aggregates and a back section summarised by a
single running aggregate. Every element takes part in at most one
rotation between the two, which makes insertion and removal O(1)
amortised with no allocation after construction. Any associative
operator works, commutative or not, and no identity element is needed.
Comes with factories for sum, minimum and maximum, indexed access, a
fail-fast iterator, and tests that check the aggregate against a brute
force reference under randomly interleaved insertions and removals.
Co-authored-by: Oleksandr Klymenko <19151554+alxkm@users.noreply.github.com>
Signed-off-by: alxkm <19151554+alxkm@users.noreply.github.com>
---
.../buffers/SlidingWindowAggregator.java | 447 +++++++++++++++++
.../buffers/SlidingWindowAggregatorTest.java | 449 ++++++++++++++++++
2 files changed, 896 insertions(+)
create mode 100644 src/main/java/com/thealgorithms/datastructures/buffers/SlidingWindowAggregator.java
create mode 100644 src/test/java/com/thealgorithms/datastructures/buffers/SlidingWindowAggregatorTest.java
diff --git a/src/main/java/com/thealgorithms/datastructures/buffers/SlidingWindowAggregator.java b/src/main/java/com/thealgorithms/datastructures/buffers/SlidingWindowAggregator.java
new file mode 100644
index 000000000000..ae20c88cd334
--- /dev/null
+++ b/src/main/java/com/thealgorithms/datastructures/buffers/SlidingWindowAggregator.java
@@ -0,0 +1,447 @@
+package com.thealgorithms.datastructures.buffers;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.ConcurrentModificationException;
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.function.BinaryOperator;
+import java.util.function.Function;
+
+/**
+ * A fixed-capacity ring (circular) buffer that additionally keeps an aggregate of every element it
+ * currently holds, i.e. a sliding window with aggregation.
+ *
+ *
The window slides automatically: once the buffer is full, adding a new element evicts the
+ * oldest one, and the aggregate is kept in sync without rescanning the window.
+ *
+ *
How the aggregate is maintained
+ *
+ * The naive way to keep a windowed sum is "add the new value, subtract the evicted one". That
+ * trick only works for invertible operators: there is no way to subtract a value from a
+ * minimum. This class instead uses the classic two-stack sliding window aggregation
+ * algorithm, which needs nothing but associativity:
+ *
+ *
+ * - The window is split into a front section {@code [head, flip)} and a back
+ * section {@code [flip, tail)}.
+ * - For every slot of the front section the buffer stores the aggregate of that element and all
+ * younger elements of the front section (a suffix aggregate), so the aggregate of the
+ * whole front section is readable from the single slot pointed at by {@code head}.
+ * - The back section is summarised by one running aggregate, updated on every insertion.
+ * - {@link #aggregate()} combines the two, in that order, so the operator does not need to be
+ * commutative.
+ * - When the front section runs out, the back section is turned into a new front section by one
+ * backwards pass. Every element takes part in at most one such pass, which makes the cost
+ * O(1) amortised per element.
+ *
+ *
+ * A pleasant side effect is numerical stability: because aggregates are recomputed from scratch
+ * on every flip, floating-point error cannot accumulate indefinitely the way it does with the
+ * add-then-subtract approach.
+ *
+ *
Complexity
+ *
+ *
+ * Time and space complexity
+ * | Operation | Complexity |
+ * | {@link #add(Object)} | O(1) amortised, O(n) worst case |
+ * | {@link #removeOldest()} | O(1) amortised, O(n) worst case |
+ * | {@link #aggregate()} | O(1) |
+ * | {@link #get(int)}, {@link #peekOldest()}, {@link #peekNewest()} | O(1) |
+ * | memory | O(capacity), no allocation after construction |
+ *
+ *
+ * Usage
+ *
+ * {@code
+ * // Maximum of the last three readings.
+ * SlidingWindowAggregator window = SlidingWindowAggregator.maximum(3, Comparator.naturalOrder());
+ * for (int value : new int[] {1, 3, 2, 5, 0}) {
+ * window.add(value);
+ * System.out.println(window.aggregate());
+ * }
+ * // prints 1, 3, 3, 5, 5
+ *
+ * // Moving average of the last 100 samples: the window knows both the sum and its own size.
+ * SlidingWindowAggregator sum = SlidingWindowAggregator.sumOfDoubles(100);
+ * sum.add(sample);
+ * double movingAverage = sum.aggregate() / sum.size();
+ * }
+ *
+ * Contract
+ *
+ *
+ * - {@code null} elements are rejected, and neither the mapper nor the combiner may return
+ * {@code null}.
+ * - The combiner must be associative; it does not have to be commutative and no
+ * identity element is required. Elements are always combined oldest-first.
+ * - The mapper is applied exactly once per element, at insertion time.
+ * - This class is not thread-safe.
+ *
+ *
+ * @param type of the elements stored in the window
+ * @param type of the aggregate; use {@code A == E} for operators such as min, max or sum
+ * @see CircularBuffer
+ * @see Circular buffer
+ * @see K. Tangwongsan, M. Hirzel, S. Schneider, K.-L. Wu, General incremental sliding-window aggregation (VLDB 2015)
+ */
+public final class SlidingWindowAggregator implements Iterable {
+
+ private final Object[] elements;
+
+ /**
+ * Parallel ring holding one aggregate per occupied slot. The meaning depends on the section the
+ * slot belongs to: inside the front section it is the aggregate of the element and every younger
+ * element of that section, inside the back section it is simply the mapped element itself.
+ */
+ private final Object[] aggregates;
+
+ private final Function super E, ? extends A> mapper;
+ private final BinaryOperator combiner;
+ private final int capacity;
+
+ /** Absolute index of the oldest element; {@code head <= flip <= tail} always holds. */
+ private long head;
+
+ /** Absolute index of the boundary between the front and the back section. */
+ private long flip;
+
+ /** Absolute index one past the newest element. */
+ private long tail;
+
+ /** Aggregate of the back section, or {@code null} when that section is empty. */
+ private A backAggregate;
+
+ private int modCount;
+
+ /**
+ * Creates an empty window.
+ *
+ * @param capacity maximum number of elements held at once; adding beyond it evicts the oldest
+ * @param mapper turns an element into the value the aggregate is computed over
+ * @param combiner associative operator used to merge two aggregates
+ * @throws IllegalArgumentException if {@code capacity} is not positive
+ * @throws NullPointerException if {@code mapper} or {@code combiner} is {@code null}
+ */
+ public SlidingWindowAggregator(int capacity, Function super E, ? extends A> mapper, BinaryOperator combiner) {
+ if (capacity <= 0) {
+ throw new IllegalArgumentException("Window capacity must be positive, but was " + capacity);
+ }
+ this.capacity = capacity;
+ this.elements = new Object[capacity];
+ this.aggregates = new Object[capacity];
+ this.mapper = Objects.requireNonNull(mapper, "mapper must not be null");
+ this.combiner = Objects.requireNonNull(combiner, "combiner must not be null");
+ }
+
+ /**
+ * Creates a window whose aggregate has the same type as its elements.
+ *
+ * @param capacity maximum number of elements held at once
+ * @param combiner associative operator used to merge two elements
+ * @param type of the elements and of the aggregate
+ * @return a new window
+ */
+ public static SlidingWindowAggregator of(int capacity, BinaryOperator combiner) {
+ return new SlidingWindowAggregator<>(capacity, Function.identity(), combiner);
+ }
+
+ /**
+ * Creates a window that keeps the sum of the last {@code capacity} values.
+ *
+ * @param capacity maximum number of elements held at once
+ * @return a new window aggregating with {@link Long#sum(long, long)}
+ */
+ public static SlidingWindowAggregator sumOfLongs(int capacity) {
+ return of(capacity, Long::sum);
+ }
+
+ /**
+ * Creates a window that keeps the sum of the last {@code capacity} values. Combined with
+ * {@link #size()} this is the cheapest way to obtain a moving average.
+ *
+ * @param capacity maximum number of elements held at once
+ * @return a new window aggregating with {@link Double#sum(double, double)}
+ */
+ public static SlidingWindowAggregator sumOfDoubles(int capacity) {
+ return of(capacity, Double::sum);
+ }
+
+ /**
+ * Creates a window that keeps the smallest of the last {@code capacity} values.
+ *
+ * @param capacity maximum number of elements held at once
+ * @param comparator ordering used to pick the minimum
+ * @param type of the elements
+ * @return a new window aggregating with {@link BinaryOperator#minBy(Comparator)}
+ */
+ public static SlidingWindowAggregator minimum(int capacity, Comparator super E> comparator) {
+ return of(capacity, BinaryOperator.minBy(comparator));
+ }
+
+ /**
+ * Creates a window that keeps the largest of the last {@code capacity} values.
+ *
+ * @param capacity maximum number of elements held at once
+ * @param comparator ordering used to pick the maximum
+ * @param type of the elements
+ * @return a new window aggregating with {@link BinaryOperator#maxBy(Comparator)}
+ */
+ public static SlidingWindowAggregator maximum(int capacity, Comparator super E> comparator) {
+ return of(capacity, BinaryOperator.maxBy(comparator));
+ }
+
+ /**
+ * Appends an element, evicting the oldest one if the window is already full.
+ *
+ * @param element the element to append
+ * @return the evicted element, or {@code null} if the window was not full
+ * @throws NullPointerException if {@code element} is {@code null} or the mapper returns {@code null}
+ */
+ public E add(E element) {
+ Objects.requireNonNull(element, "This window does not accept null elements");
+ E evicted = isFull() ? removeOldest() : null;
+
+ int slot = slotOf(tail);
+ elements[slot] = element;
+ A mapped = requireNonNullResult(mapper.apply(element), "mapper");
+ aggregates[slot] = mapped;
+ backAggregate = tail == flip ? mapped : requireNonNullResult(combiner.apply(backAggregate, mapped), "combiner");
+ tail++;
+ modCount++;
+ return evicted;
+ }
+
+ /**
+ * Appends every given element in order. The insertion is not atomic: if an element turns out to be
+ * {@code null}, the ones before it are already in the window.
+ *
+ * @param items the elements to append
+ * @throws NullPointerException if {@code items} or any of its elements is {@code null}
+ */
+ public void addAll(Iterable extends E> items) {
+ Objects.requireNonNull(items, "items must not be null");
+ for (E item : items) {
+ add(item);
+ }
+ }
+
+ /**
+ * Removes the oldest element, shrinking the window.
+ *
+ * @return the removed element
+ * @throws NoSuchElementException if the window is empty
+ */
+ public E removeOldest() {
+ if (isEmpty()) {
+ throw new NoSuchElementException("The window is empty");
+ }
+ if (head == flip) {
+ rotateBackSectionToFront();
+ }
+
+ int slot = slotOf(head);
+ E oldest = elementAt(slot);
+ elements[slot] = null;
+ aggregates[slot] = null;
+ head++;
+ modCount++;
+ return oldest;
+ }
+
+ /**
+ * Returns the aggregate of every element currently in the window, combined from the oldest to
+ * the newest.
+ *
+ * @return the aggregate of the window
+ * @throws NoSuchElementException if the window is empty
+ */
+ public A aggregate() {
+ if (isEmpty()) {
+ throw new NoSuchElementException("The aggregate of an empty window is undefined");
+ }
+ if (head == flip) {
+ return backAggregate;
+ }
+ A frontAggregate = aggregateAt(slotOf(head));
+ return tail == flip ? frontAggregate : requireNonNullResult(combiner.apply(frontAggregate, backAggregate), "combiner");
+ }
+
+ /**
+ * Returns the element at the given position, counting from the oldest one.
+ *
+ * @param index zero-based position, {@code 0} being the oldest element in the window
+ * @return the element at that position
+ * @throws IndexOutOfBoundsException if {@code index} is negative or not smaller than {@link #size()}
+ */
+ public E get(int index) {
+ if (index < 0 || index >= size()) {
+ throw new IndexOutOfBoundsException("Index " + index + " is out of bounds for a window of size " + size());
+ }
+ return elementAt(slotOf(head + index));
+ }
+
+ /**
+ * Returns the oldest element without removing it.
+ *
+ * @return the element that would be evicted next
+ * @throws NoSuchElementException if the window is empty
+ */
+ public E peekOldest() {
+ if (isEmpty()) {
+ throw new NoSuchElementException("The window is empty");
+ }
+ return elementAt(slotOf(head));
+ }
+
+ /**
+ * Returns the most recently added element without removing it.
+ *
+ * @return the newest element
+ * @throws NoSuchElementException if the window is empty
+ */
+ public E peekNewest() {
+ if (isEmpty()) {
+ throw new NoSuchElementException("The window is empty");
+ }
+ return elementAt(slotOf(tail - 1));
+ }
+
+ /**
+ * Returns the number of elements currently in the window.
+ *
+ * @return the current size, never greater than {@link #capacity()}
+ */
+ public int size() {
+ return (int) (tail - head);
+ }
+
+ /**
+ * Returns the maximum number of elements the window can hold.
+ *
+ * @return the capacity given at construction time
+ */
+ public int capacity() {
+ return capacity;
+ }
+
+ /**
+ * Tells whether the window holds no elements.
+ *
+ * @return {@code true} if the window is empty
+ */
+ public boolean isEmpty() {
+ return head == tail;
+ }
+
+ /**
+ * Tells whether the next insertion will evict the oldest element.
+ *
+ * @return {@code true} if the window is saturated
+ */
+ public boolean isFull() {
+ return size() == capacity;
+ }
+
+ /**
+ * Discards every element, leaving the window as if freshly constructed.
+ */
+ public void clear() {
+ Arrays.fill(elements, null);
+ Arrays.fill(aggregates, null);
+ head = 0;
+ flip = 0;
+ tail = 0;
+ backAggregate = null;
+ modCount++;
+ }
+
+ /**
+ * Returns a snapshot of the window, ordered from the oldest element to the newest one. The list
+ * is detached from the window: later insertions do not affect it.
+ *
+ * @return a new list holding the current contents of the window
+ */
+ public List toList() {
+ List snapshot = new ArrayList<>(size());
+ for (long i = head; i < tail; i++) {
+ snapshot.add(elementAt(slotOf(i)));
+ }
+ return snapshot;
+ }
+
+ /**
+ * Returns a fail-fast iterator walking the window from the oldest element to the newest one.
+ *
+ * @return an iterator over the current contents of the window
+ */
+ @Override
+ public Iterator iterator() {
+ return new WindowIterator();
+ }
+
+ @Override
+ public String toString() {
+ return toList().toString();
+ }
+
+ /**
+ * Turns the back section into the front section by walking it backwards and storing, for every
+ * slot, the aggregate of that element and all younger ones. Called only when the front section is
+ * exhausted, so each element takes part in at most one such pass.
+ */
+ private void rotateBackSectionToFront() {
+ A suffix = null;
+ for (long i = tail - 1; i >= head; i--) {
+ int slot = slotOf(i);
+ A own = aggregateAt(slot);
+ suffix = suffix == null ? own : requireNonNullResult(combiner.apply(own, suffix), "combiner");
+ aggregates[slot] = suffix;
+ }
+ flip = tail;
+ backAggregate = null;
+ }
+
+ private int slotOf(long absoluteIndex) {
+ return (int) (absoluteIndex % capacity);
+ }
+
+ @SuppressWarnings("unchecked")
+ private E elementAt(int slot) {
+ return (E) elements[slot];
+ }
+
+ @SuppressWarnings("unchecked")
+ private A aggregateAt(int slot) {
+ return (A) aggregates[slot];
+ }
+
+ private static T requireNonNullResult(T value, String producer) {
+ return Objects.requireNonNull(value, "The " + producer + " of a SlidingWindowAggregator must not return null");
+ }
+
+ private final class WindowIterator implements Iterator {
+ private long cursor = head;
+ private final int expectedModCount = modCount;
+
+ @Override
+ public boolean hasNext() {
+ return cursor < tail;
+ }
+
+ @Override
+ public E next() {
+ if (expectedModCount != modCount) {
+ throw new ConcurrentModificationException();
+ }
+ if (!hasNext()) {
+ throw new NoSuchElementException("The iterator has been exhausted");
+ }
+ return elementAt(slotOf(cursor++));
+ }
+ }
+}
diff --git a/src/test/java/com/thealgorithms/datastructures/buffers/SlidingWindowAggregatorTest.java b/src/test/java/com/thealgorithms/datastructures/buffers/SlidingWindowAggregatorTest.java
new file mode 100644
index 000000000000..2595678961ed
--- /dev/null
+++ b/src/test/java/com/thealgorithms/datastructures/buffers/SlidingWindowAggregatorTest.java
@@ -0,0 +1,449 @@
+package com.thealgorithms.datastructures.buffers;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.ConcurrentModificationException;
+import java.util.Deque;
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Random;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BinaryOperator;
+import java.util.function.Function;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class SlidingWindowAggregatorTest {
+
+ private static final Comparator NATURAL = Comparator.naturalOrder();
+
+ private static SlidingWindowAggregator sumWindow(int capacity) {
+ return SlidingWindowAggregator.of(capacity, Integer::sum);
+ }
+
+ @Nested
+ @DisplayName("construction")
+ class Construction {
+
+ @ParameterizedTest
+ @ValueSource(ints = {0, -1, Integer.MIN_VALUE})
+ void rejectsNonPositiveCapacity(int capacity) {
+ assertThrows(IllegalArgumentException.class, () -> sumWindow(capacity));
+ }
+
+ @Test
+ void rejectsNullMapper() {
+ assertThrows(NullPointerException.class, () -> new SlidingWindowAggregator(4, null, Integer::sum));
+ }
+
+ @Test
+ void rejectsNullCombiner() {
+ assertThrows(NullPointerException.class, () -> new SlidingWindowAggregator(4, Function.identity(), null));
+ }
+
+ @Test
+ void startsEmpty() {
+ SlidingWindowAggregator window = sumWindow(3);
+ assertTrue(window.isEmpty());
+ assertFalse(window.isFull());
+ assertEquals(0, window.size());
+ assertEquals(3, window.capacity());
+ assertTrue(window.toList().isEmpty());
+ assertEquals("[]", window.toString());
+ }
+ }
+
+ @Nested
+ @DisplayName("empty window")
+ class EmptyWindow {
+
+ private final SlidingWindowAggregator window = sumWindow(3);
+
+ @Test
+ void aggregateThrows() {
+ assertThrows(NoSuchElementException.class, window::aggregate);
+ }
+
+ @Test
+ void peeksThrow() {
+ assertThrows(NoSuchElementException.class, window::peekOldest);
+ assertThrows(NoSuchElementException.class, window::peekNewest);
+ }
+
+ @Test
+ void removeThrows() {
+ assertThrows(NoSuchElementException.class, window::removeOldest);
+ }
+
+ @Test
+ void getThrows() {
+ assertThrows(IndexOutOfBoundsException.class, () -> window.get(0));
+ }
+
+ @Test
+ void iteratorIsExhausted() {
+ Iterator iterator = window.iterator();
+ assertFalse(iterator.hasNext());
+ assertThrows(NoSuchElementException.class, iterator::next);
+ }
+ }
+
+ @Nested
+ @DisplayName("aggregation")
+ class Aggregation {
+
+ @Test
+ void aggregatesWhileFilling() {
+ SlidingWindowAggregator window = sumWindow(4);
+ window.add(1);
+ assertEquals(1, window.aggregate());
+ window.add(2);
+ assertEquals(3, window.aggregate());
+ window.add(3);
+ assertEquals(6, window.aggregate());
+ window.add(4);
+ assertEquals(10, window.aggregate());
+ assertTrue(window.isFull());
+ }
+
+ @Test
+ void slidesOnceFull() {
+ SlidingWindowAggregator window = sumWindow(3);
+ window.addAll(List.of(1, 2, 3));
+ assertEquals(6, window.aggregate());
+
+ assertEquals(1, window.add(4));
+ assertEquals(9, window.aggregate());
+ assertEquals(List.of(2, 3, 4), window.toList());
+
+ assertEquals(2, window.add(5));
+ assertEquals(12, window.aggregate());
+ }
+
+ @Test
+ void addReturnsNullWhileTheWindowIsNotFull() {
+ SlidingWindowAggregator window = sumWindow(2);
+ Assertions.assertNull(window.add(1));
+ Assertions.assertNull(window.add(2));
+ assertEquals(1, window.add(3));
+ }
+
+ @Test
+ @DisplayName("keeps the maximum of the last three readings")
+ void tracksMaximum() {
+ SlidingWindowAggregator window = SlidingWindowAggregator.maximum(3, NATURAL);
+ List observed = new ArrayList<>();
+ for (int value : new int[] {1, 3, 2, 5, 0, 0, 0}) {
+ window.add(value);
+ observed.add(window.aggregate());
+ }
+ assertEquals(List.of(1, 3, 3, 5, 5, 5, 0), observed);
+ }
+
+ @Test
+ void tracksMinimum() {
+ SlidingWindowAggregator window = SlidingWindowAggregator.minimum(3, NATURAL);
+ window.addAll(List.of(5, 4, 6, 7, 8));
+ assertEquals(6, window.aggregate());
+ }
+
+ @Test
+ @DisplayName("combines oldest first, so non-commutative operators work")
+ void preservesOrderForNonCommutativeOperators() {
+ SlidingWindowAggregator window = SlidingWindowAggregator.of(3, (a, b) -> a + b);
+ window.addAll(List.of("a", "b", "c", "d", "e"));
+ assertEquals("cde", window.aggregate());
+ window.removeOldest();
+ assertEquals("de", window.aggregate());
+ }
+
+ @Test
+ void supportsAggregatesOfADifferentType() {
+ SlidingWindowAggregator lengths = new SlidingWindowAggregator<>(3, String::length, Integer::sum);
+ lengths.addAll(List.of("a", "bb", "ccc", "dddd"));
+ assertEquals(2 + 3 + 4, lengths.aggregate());
+ }
+
+ @Test
+ void movingAverageIsSumOverSize() {
+ SlidingWindowAggregator window = SlidingWindowAggregator.sumOfDoubles(3);
+ window.addAll(List.of(1.0, 2.0, 3.0, 6.0));
+ assertEquals(11.0 / 3.0, window.aggregate() / window.size(), 1e-12);
+ }
+
+ @Test
+ void sumOfLongsFactoryWorks() {
+ SlidingWindowAggregator window = SlidingWindowAggregator.sumOfLongs(2);
+ window.addAll(List.of(1L, 2L, 3L));
+ assertEquals(5L, window.aggregate());
+ }
+
+ @Test
+ @DisplayName("the mapper runs exactly once per element")
+ void mapperIsCalledOncePerElement() {
+ AtomicInteger calls = new AtomicInteger();
+ SlidingWindowAggregator window = new SlidingWindowAggregator<>(3, value -> {
+ calls.incrementAndGet();
+ return value;
+ }, Integer::sum);
+ for (int i = 0; i < 100; i++) {
+ window.add(i);
+ window.aggregate();
+ }
+ assertEquals(100, calls.get());
+ }
+ }
+
+ @Nested
+ @DisplayName("null handling")
+ class NullHandling {
+
+ @Test
+ void rejectsNullElements() {
+ SlidingWindowAggregator window = sumWindow(2);
+ assertThrows(NullPointerException.class, () -> window.add(null));
+ }
+
+ @Test
+ @DisplayName("a null hidden inside a collection is rejected, and the elements before it stay")
+ void rejectsNullElementsInsideACollection() {
+ SlidingWindowAggregator window = sumWindow(2);
+ List items = new ArrayList<>();
+ items.add(1);
+ items.add(null);
+
+ assertThrows(NullPointerException.class, () -> window.addAll(items));
+ assertEquals(1, window.size());
+ assertEquals(1, window.aggregate());
+ }
+
+ @Test
+ void rejectsMappersReturningNull() {
+ SlidingWindowAggregator window = new SlidingWindowAggregator<>(2, value -> null, Integer::sum);
+ assertThrows(NullPointerException.class, () -> window.add(1));
+ }
+
+ @Test
+ void rejectsCombinersReturningNull() {
+ SlidingWindowAggregator window = SlidingWindowAggregator.of(2, (a, b) -> null);
+ window.add(1);
+ assertThrows(NullPointerException.class, () -> window.add(2));
+ }
+ }
+
+ @Nested
+ @DisplayName("access and iteration")
+ class Access {
+
+ @Test
+ void indexedAccessStartsAtTheOldestElement() {
+ SlidingWindowAggregator window = sumWindow(3);
+ window.addAll(List.of(10, 20, 30, 40));
+ assertEquals(20, window.get(0));
+ assertEquals(30, window.get(1));
+ assertEquals(40, window.get(2));
+ assertEquals(20, window.peekOldest());
+ assertEquals(40, window.peekNewest());
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {-1, 3, 100})
+ void rejectsOutOfBoundsIndices(int index) {
+ SlidingWindowAggregator window = sumWindow(5);
+ window.addAll(List.of(1, 2, 3));
+ assertThrows(IndexOutOfBoundsException.class, () -> window.get(index));
+ }
+
+ @Test
+ void iteratesFromOldestToNewest() {
+ SlidingWindowAggregator window = sumWindow(3);
+ window.addAll(List.of(1, 2, 3, 4, 5));
+ List seen = new ArrayList<>();
+ for (int value : window) {
+ seen.add(value);
+ }
+ assertEquals(List.of(3, 4, 5), seen);
+ assertEquals("[3, 4, 5]", window.toString());
+ }
+
+ @Test
+ void iteratorIsFailFast() {
+ SlidingWindowAggregator window = sumWindow(4);
+ window.addAll(List.of(1, 2, 3));
+ Iterator iterator = window.iterator();
+ assertEquals(1, iterator.next());
+ window.add(4);
+ assertThrows(ConcurrentModificationException.class, iterator::next);
+ }
+
+ @Test
+ void toListIsASnapshot() {
+ SlidingWindowAggregator window = sumWindow(3);
+ window.addAll(List.of(1, 2, 3));
+ List snapshot = window.toList();
+ window.add(4);
+ assertEquals(List.of(1, 2, 3), snapshot);
+ }
+ }
+
+ @Nested
+ @DisplayName("removal and reuse")
+ class Removal {
+
+ @Test
+ void removeOldestShrinksTheWindow() {
+ SlidingWindowAggregator window = sumWindow(4);
+ window.addAll(List.of(1, 2, 3, 4));
+ assertEquals(1, window.removeOldest());
+ assertEquals(9, window.aggregate());
+ assertEquals(3, window.size());
+ assertFalse(window.isFull());
+
+ assertEquals(2, window.removeOldest());
+ assertEquals(3, window.removeOldest());
+ assertEquals(4, window.aggregate());
+ assertEquals(4, window.removeOldest());
+ assertTrue(window.isEmpty());
+ assertThrows(NoSuchElementException.class, window::aggregate);
+ }
+
+ @Test
+ void clearRestoresTheInitialState() {
+ SlidingWindowAggregator window = sumWindow(3);
+ window.addAll(List.of(1, 2, 3, 4, 5));
+ window.clear();
+ assertTrue(window.isEmpty());
+ assertEquals(0, window.size());
+ assertThrows(NoSuchElementException.class, window::aggregate);
+
+ window.addAll(List.of(7, 8));
+ assertEquals(15, window.aggregate());
+ assertEquals(List.of(7, 8), window.toList());
+ }
+
+ @Test
+ @DisplayName("a capacity of one keeps only the latest element")
+ void capacityOfOne() {
+ SlidingWindowAggregator window = sumWindow(1);
+ window.add(1);
+ assertEquals(1, window.aggregate());
+ assertEquals(1, window.add(2));
+ assertEquals(2, window.aggregate());
+ assertEquals(2, window.add(3));
+ assertEquals(3, window.aggregate());
+ assertEquals(1, window.size());
+ }
+
+ @Test
+ @DisplayName("survives far more insertions than its capacity")
+ void survivesManyRotations() {
+ SlidingWindowAggregator window = sumWindow(3);
+ for (int i = 1; i <= 100_000; i++) {
+ window.add(i);
+ }
+ assertEquals(99_998 + 99_999 + 100_000, window.aggregate());
+ assertEquals(3, window.size());
+ }
+ }
+
+ @Nested
+ @DisplayName("agreement with a brute force reference")
+ class BruteForceAgreement {
+
+ @ParameterizedTest
+ @ValueSource(ints = {1, 2, 3, 5, 8, 13})
+ void matchesBruteForceSum(int capacity) {
+ assertMatchesBruteForce(capacity, Integer::sum);
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {1, 2, 3, 5, 8, 13})
+ void matchesBruteForceMaximum(int capacity) {
+ assertMatchesBruteForce(capacity, BinaryOperator.maxBy(NATURAL));
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {1, 2, 3, 5, 8, 13})
+ void matchesBruteForceMinimum(int capacity) {
+ assertMatchesBruteForce(capacity, BinaryOperator.minBy(NATURAL));
+ }
+
+ private void assertMatchesBruteForce(int capacity, BinaryOperator combiner) {
+ SlidingWindowAggregator window = SlidingWindowAggregator.of(capacity, combiner);
+ Deque reference = new ArrayDeque<>();
+ Random random = new Random(20240517L + capacity);
+
+ for (int step = 0; step < 2_000; step++) {
+ int value = random.nextInt(2_000) - 1_000;
+ window.add(value);
+ reference.addLast(value);
+ if (reference.size() > capacity) {
+ reference.removeFirst();
+ }
+ assertEquals(fold(reference, combiner), window.aggregate(), "after step " + step);
+ }
+ }
+
+ @Test
+ @DisplayName("interleaved insertions and removals stay in sync with the reference")
+ void matchesBruteForceUnderInterleavedOperations() {
+ int capacity = 7;
+ SlidingWindowAggregator window = SlidingWindowAggregator.of(capacity, Integer::sum);
+ Deque reference = new ArrayDeque<>();
+ Random random = new Random(987654321L);
+
+ for (int step = 0; step < 20_000; step++) {
+ if (reference.isEmpty() || random.nextInt(3) > 0) {
+ int value = random.nextInt(100);
+ window.add(value);
+ reference.addLast(value);
+ if (reference.size() > capacity) {
+ reference.removeFirst();
+ }
+ } else {
+ assertEquals(reference.removeFirst(), window.removeOldest());
+ }
+
+ assertEquals(reference.size(), window.size());
+ assertEquals(new ArrayList<>(reference), window.toList());
+ if (reference.isEmpty()) {
+ assertThrows(NoSuchElementException.class, window::aggregate);
+ } else {
+ assertEquals(fold(reference, Integer::sum), window.aggregate(), "after step " + step);
+ }
+ }
+ }
+
+ private Integer fold(Iterable values, BinaryOperator combiner) {
+ Integer accumulator = null;
+ for (Integer value : values) {
+ accumulator = accumulator == null ? value : combiner.apply(accumulator, value);
+ }
+ return accumulator;
+ }
+ }
+
+ @Test
+ @DisplayName("the documented example from the class javadoc")
+ void documentedExample() {
+ SlidingWindowAggregator window = SlidingWindowAggregator.maximum(3, Comparator.naturalOrder());
+ List printed = new ArrayList<>();
+ for (int value : new int[] {1, 3, 2, 5, 0}) {
+ window.add(value);
+ printed.add(window.aggregate());
+ }
+ assertEquals(List.of(1, 3, 3, 5, 5), printed);
+ Assertions.assertDoesNotThrow(window::clear);
+ }
+}