diff --git a/README.md b/README.md index 4fc5ff07f..51f01bc80 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,63 @@ new Upper( ); ``` +### Splitting Text + +To split text using Java's standard behavior: + +```java +Iterable parts = new Split("hello,world", ","); +// Result: ["hello", "world"] +``` + +### Splitting with Preserved Empty Tokens + +Java's `String.split()` discards trailing empty tokens. +Use `SplitPreserveAllTokens` when you need to preserve ALL tokens, +including empty ones created by adjacent or trailing delimiters: + +```java +// Standard split loses trailing empty token +"a,b,".split(",") // Returns: ["a", "b"] - trailing empty LOST! + +// SplitPreserveAllTokens preserves it +Iterable parts = new SplitPreserveAllTokens("a,b,", ","); +// Result: ["a", "b", ""] - all tokens preserved! +``` + +This is essential for parsing CSV/TSV data where empty fields are meaningful: + +```java +// Parse CSV with empty fields +Iterable fields = new SplitPreserveAllTokens( + "John,,Smith,," + "," +); +// Result: ["John", "", "Smith", "", ""] - all 5 fields! + +// With spaces as delimiter +Iterable words = new SplitPreserveAllTokens(" hello world "); +// Result: ["", "hello", "", "world", ""] + +// Default delimiter is space +Iterable tokens = new SplitPreserveAllTokens("a b c"); +// Result: ["a", "b", "c"] + +// With limit on number of tokens +Iterable limited = new SplitPreserveAllTokens("a,b,c,d", ",", 2); +// Result: ["a", "b"] +``` + +**Key guarantee**: With N delimiters, you always get exactly N+1 tokens. + +| Input | Delimiter | `String.split()` | `SplitPreserveAllTokens` | +|-------|-----------|------------------|--------------------------| +| `"a,b,"` | `,` | `["a", "b"]` | `["a", "b", ""]` | +| `",,"` | `,` | `[]` | `["", "", ""]` | +| `","` | `,` | `[]` | `["", ""]` | + +> **Note**: The delimiter is matched as a literal string, not a regex. + ## Iterables/Collections/Lists/Sets More about it here: @@ -254,6 +311,53 @@ final Set sorted = new org.cactoos.set.Sorted<>( ); ``` +## Maps + +To create a simple map: + +```java +Map map = new MapOf<>( + new MapEntry<>("one", 1), + new MapEntry<>("two", 2), + new MapEntry<>("three", 3) +); +``` + +### Immutable Maps + +To create an immutable (read-only) map that prevents any modifications: + +```java +Map map = new org.cactoos.map.Immutable<>( + new MapOf<>( + new MapEntry<>("one", 1), + new MapEntry<>("two", 2) + ) +); +map.get("one"); // returns 1 +map.put("three", 3); // throws UnsupportedOperationException! +map.clear(); // throws UnsupportedOperationException! +``` + +The `Immutable` map decorator guarantees that: +- All mutating methods (`put`, `remove`, `putAll`, `clear`) throw `UnsupportedOperationException` +- Views returned by `keySet()`, `values()`, and `entrySet()` are also immutable +- Even `Entry.setValue()` is blocked on entries from `entrySet()` + +This is useful when you need to pass a map to untrusted code or ensure +a map cannot be accidentally modified: + +```java +// Safe to pass to any method - cannot be modified +public Map getConfiguration() { + return new Immutable<>(this.config); +} +``` + +> **Note**: This is a decorator, not a copy. If the underlying map is modified +> through another reference, changes will be visible. For a true snapshot, +> copy the data first. + ## Funcs and Procs This is a traditional `foreach` loop: @@ -331,6 +435,7 @@ Cactoos | Guava | Apache Commons | JDK 8 `And` | `Iterables.all()` | - | - `Filtered` | `Iterables.filter()` | ? | - `FormattedText` | - | - | `String.format()` +`map.Immutable` | `ImmutableMap` | `UnmodifiableMap` | `Collections.unmodifiableMap()` `IsBlank` | - | `StringUtils.isBlank()`| - `Joined` | - | - | `String.join()` `LengthOf` | - | - | `String#length()` @@ -342,6 +447,7 @@ Cactoos | Guava | Apache Commons | JDK 8 `Reversed` | - | - | `StringBuilder#reverse()` `Rotated` | - | `StringUtils.rotate()`| - `Split` | - | - | `String#split()` +`SplitPreserveAllTokens` | - | `StringUtils.splitPreserveAllTokens()` | - `StickyList` | `Lists.newArrayList()` | ? | `Arrays.asList()` `Sub` | - | - | `String#substring()` `SwappedCase` | - | `StringUtils.swapCase()` | - diff --git a/src/main/java/org/cactoos/func/TriFuncSplitPreserve.java b/src/main/java/org/cactoos/func/TriFuncSplitPreserve.java deleted file mode 100644 index 946938bd1..000000000 --- a/src/main/java/org/cactoos/func/TriFuncSplitPreserve.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2017-2026 Yegor Bugayenko - * SPDX-License-Identifier: MIT - */ - -package org.cactoos.func; - -import java.util.Collection; -import org.cactoos.TriFunc; -import org.cactoos.list.ListOf; - -/** - * A String splitter preserving all tokens. - * Unlike regular Split, stores empty "" tokens - * created by adjacent regex separators. - * - *

- * Examples: - * 1) text - " hello there ", regex - " " - * result: ["", "hello", "there", ""] - * 2) text - "aaa", regex - "a" - * result: ["", "", "", ""] - *

- * - * @since 0.0 - */ -public final class TriFuncSplitPreserve - implements TriFunc - > { - @Override - public Collection apply( - final String str, - final String regex, - final Integer lmt - ) { - final ListOf ret = new ListOf<>(); - int start = 0; - int pos = str.indexOf(regex); - while (pos >= start) { - if (lmt > 0 && ret.size() == lmt || regex.isEmpty()) { - break; - } - ret.add(str.substring(start, pos)); - start = pos + regex.length(); - pos = str.indexOf(regex, start); - } - if (lmt <= 0 || ret.size() < lmt || regex.isEmpty()) { - if (start < str.length()) { - ret.add(str.substring(start)); - } else if (start == str.length()) { - ret.add(""); - } - } - return ret; - } -} diff --git a/src/main/java/org/cactoos/map/Immutable.java b/src/main/java/org/cactoos/map/Immutable.java new file mode 100644 index 000000000..4c32474a9 --- /dev/null +++ b/src/main/java/org/cactoos/map/Immutable.java @@ -0,0 +1,673 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2017-2026 Yegor Bugayenko + * SPDX-License-Identifier: MIT + */ +package org.cactoos.map; + +import java.util.Collection; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + +/** + * A decorator of {@link Map} that prevents any mutations. + * + *

This decorator wraps an existing map and blocks all write operations + * by throwing {@link UnsupportedOperationException}. Read operations are + * delegated to the wrapped map. The views returned by {@link #keySet()}, + * {@link #values()}, and {@link #entrySet()} are also immutable.

+ * + *

This class addresses the need for an immutable map decorator that + * follows Cactoos object-oriented patterns, similar to the existing + * {@link org.cactoos.list.Immutable}, {@link org.cactoos.set.Immutable}, + * and {@link org.cactoos.collection.Immutable} decorators.

+ * + *

Protected Operations

+ *

The following operations throw {@link UnsupportedOperationException}:

+ *
    + *
  • {@link #put(Object, Object)}
  • + *
  • {@link #remove(Object)}
  • + *
  • {@link #putAll(Map)}
  • + *
  • {@link #clear()}
  • + *
  • All mutation operations on {@link #keySet()}, {@link #values()}, + * and {@link #entrySet()}
  • + *
  • {@link Entry#setValue(Object)} on entries from {@link #entrySet()}
  • + *
  • {@link Iterator#remove()} on all returned iterators
  • + *
+ * + *

Important Note

+ *

This is a decorator, not a copy. If the underlying map + * is modified through a reference that bypasses this decorator, those changes + * will be visible through this wrapper. For truly immutable snapshots, first + * copy the map contents or use with {@link MapOf} which creates a copy.

+ * + *

Example Usage

+ *
{@code
+ * // Create an immutable map
+ * Map map = new Immutable<>(
+ *     new MapOf<>(
+ *         new MapEntry<>("one", 1),
+ *         new MapEntry<>("two", 2)
+ *     )
+ * );
+ *
+ * // Read operations work normally
+ * map.get("one");           // returns 1
+ * map.containsKey("two");   // returns true
+ * map.size();               // returns 2
+ *
+ * // Write operations are blocked
+ * map.put("three", 3);      // throws UnsupportedOperationException
+ * map.remove("one");        // throws UnsupportedOperationException
+ * map.clear();              // throws UnsupportedOperationException
+ *
+ * // Views are also immutable
+ * map.keySet().add("four"); // throws UnsupportedOperationException
+ * map.values().clear();     // throws UnsupportedOperationException
+ *
+ * // Safe to pass to untrusted code
+ * public Map getConfig() {
+ *     return new Immutable<>(this.configMap);
+ * }
+ * }
+ * + *

There is no thread-safety guarantee.

+ * + * @param Type of key + * @param Type of value + * @see org.cactoos.list.Immutable + * @see org.cactoos.set.Immutable + * @see org.cactoos.collection.Immutable + * @see MapOf + * @since 0.67 + */ +@SuppressWarnings("PMD.TooManyMethods") +public final class Immutable implements Map { + + /** + * The wrapped map. + * + *

We use {@code Map} to allow + * covariant map types to be wrapped (e.g., a {@code Map} + * can be wrapped as {@code Immutable}).

+ */ + private final Map map; + + /** + * Primary constructor. + * + * @param origin The map to wrap + */ + public Immutable(final Map origin) { + this.map = origin; + } + + @Override + public int size() { + return this.map.size(); + } + + @Override + public boolean isEmpty() { + return this.map.isEmpty(); + } + + @Override + public boolean containsKey(final Object key) { + return this.map.containsKey(key); + } + + @Override + public boolean containsValue(final Object value) { + return this.map.containsValue(value); + } + + @Override + public V get(final Object key) { + return this.map.get(key); + } + + /** + * Always throws {@link UnsupportedOperationException}. + * + *

This map is read-only. To "add" an entry, create a new + * {@link MapOf} that includes the original entries plus the new one.

+ * + * @param key Key with which the value is to be associated + * @param value Value to be associated with the key + * @return Never returns normally + * @throws UnsupportedOperationException Always + */ + @Override + public V put(final K key, final V value) { + throw new UnsupportedOperationException( + "#put(K,V): the map is read-only" + ); + } + + /** + * Always throws {@link UnsupportedOperationException}. + * + *

This map is read-only. To "remove" an entry, create a new + * map that excludes the unwanted key.

+ * + * @param key Key whose mapping is to be removed + * @return Never returns normally + * @throws UnsupportedOperationException Always + */ + @Override + public V remove(final Object key) { + throw new UnsupportedOperationException( + "#remove(Object): the map is read-only" + ); + } + + /** + * Always throws {@link UnsupportedOperationException}. + * + *

This map is read-only. To merge maps, create a new + * {@link MapOf} or use {@link Merged}.

+ * + * @param entries Mappings to be stored in this map + * @throws UnsupportedOperationException Always + */ + @Override + public void putAll(final Map entries) { + throw new UnsupportedOperationException( + "#putAll(Map): the map is read-only" + ); + } + + /** + * Always throws {@link UnsupportedOperationException}. + * + *

This map is read-only. For an empty map, use + * {@code new MapOf<>()}.

+ * + * @throws UnsupportedOperationException Always + */ + @Override + public void clear() { + throw new UnsupportedOperationException( + "#clear(): the map is read-only" + ); + } + + /** + * Returns an immutable view of the keys. + * + *

The returned set does not support {@code add()}, {@code remove()}, + * or any other mutating operation. Its iterator also does not support + * {@code remove()}.

+ * + * @return An immutable set of keys + */ + @Override + public Set keySet() { + return new org.cactoos.set.Immutable<>( + new MappedKeys<>(this.map.keySet()) + ); + } + + /** + * Returns an immutable view of the values. + * + *

The returned collection does not support {@code add()}, + * {@code remove()}, or any other mutating operation.

+ * + * @return An immutable collection of values + */ + @Override + public Collection values() { + return new org.cactoos.collection.Immutable<>( + new MappedValues<>(this.map.values()) + ); + } + + /** + * Returns an immutable view of the entries. + * + *

The returned set does not support {@code add()}, {@code remove()}, + * or any other mutating operation. Each entry's {@code setValue()} + * method will throw {@link UnsupportedOperationException}.

+ * + * @return An immutable set of map entries + */ + @Override + public Set> entrySet() { + return new org.cactoos.set.Immutable<>( + new ImmutableEntrySet<>(this.map.entrySet()) + ); + } + + @Override + public boolean equals(final Object other) { + return this.map.equals(other); + } + + @Override + public int hashCode() { + return this.map.hashCode(); + } + + @Override + public String toString() { + return this.map.toString(); + } + + /** + * Adapter that presents a {@code Set} as a {@code Set}. + * + *

This is safe because the set is only used for reading.

+ * + * @param Key type + * @since 0.67 + */ + private static final class MappedKeys implements Set { + + /** + * The original key set. + */ + private final Set keys; + + /** + * Ctor. + * @param origin The original key set + */ + MappedKeys(final Set origin) { + this.keys = origin; + } + + @Override + public int size() { + return this.keys.size(); + } + + @Override + public boolean isEmpty() { + return this.keys.isEmpty(); + } + + @Override + public boolean contains(final Object obj) { + return this.keys.contains(obj); + } + + @Override + public Iterator iterator() { + return new org.cactoos.iterator.Immutable<>(this.keys.iterator()); + } + + @Override + public Object[] toArray() { + return this.keys.toArray(); + } + + @Override + @SuppressWarnings("PMD.UseVarargs") + public T[] toArray(final T[] arr) { + return this.keys.toArray(arr); + } + + @Override + public boolean add(final K key) { + throw new UnsupportedOperationException( + "#add(K): the key set is read-only" + ); + } + + @Override + public boolean remove(final Object obj) { + throw new UnsupportedOperationException( + "#remove(Object): the key set is read-only" + ); + } + + @Override + public boolean containsAll(final Collection col) { + return this.keys.containsAll(col); + } + + @Override + public boolean addAll(final Collection col) { + throw new UnsupportedOperationException( + "#addAll(Collection): the key set is read-only" + ); + } + + @Override + public boolean retainAll(final Collection col) { + throw new UnsupportedOperationException( + "#retainAll(Collection): the key set is read-only" + ); + } + + @Override + public boolean removeAll(final Collection col) { + throw new UnsupportedOperationException( + "#removeAll(Collection): the key set is read-only" + ); + } + + @Override + public void clear() { + throw new UnsupportedOperationException( + "#clear(): the key set is read-only" + ); + } + } + + /** + * Adapter that presents a {@code Collection} as + * a {@code Collection}. + * + *

This is safe because the collection is only used for reading.

+ * + * @param Value type + * @since 0.67 + */ + private static final class MappedValues implements Collection { + + /** + * The original values collection. + */ + private final Collection vals; + + /** + * Ctor. + * @param origin The original values collection + */ + MappedValues(final Collection origin) { + this.vals = origin; + } + + @Override + public int size() { + return this.vals.size(); + } + + @Override + public boolean isEmpty() { + return this.vals.isEmpty(); + } + + @Override + public boolean contains(final Object obj) { + return this.vals.contains(obj); + } + + @Override + public Iterator iterator() { + return new org.cactoos.iterator.Immutable<>(this.vals.iterator()); + } + + @Override + public Object[] toArray() { + return this.vals.toArray(); + } + + @Override + @SuppressWarnings("PMD.UseVarargs") + public T[] toArray(final T[] arr) { + return this.vals.toArray(arr); + } + + @Override + public boolean add(final V val) { + throw new UnsupportedOperationException( + "#add(V): the values collection is read-only" + ); + } + + @Override + public boolean remove(final Object obj) { + throw new UnsupportedOperationException( + "#remove(Object): the values collection is read-only" + ); + } + + @Override + public boolean containsAll(final Collection col) { + return this.vals.containsAll(col); + } + + @Override + public boolean addAll(final Collection col) { + throw new UnsupportedOperationException( + "#addAll(Collection): the values collection is read-only" + ); + } + + @Override + public boolean retainAll(final Collection col) { + throw new UnsupportedOperationException( + "#retainAll(Collection): the values collection is read-only" + ); + } + + @Override + public boolean removeAll(final Collection col) { + throw new UnsupportedOperationException( + "#removeAll(Collection): the values collection is read-only" + ); + } + + @Override + public void clear() { + throw new UnsupportedOperationException( + "#clear(): the values collection is read-only" + ); + } + } + + /** + * An immutable view of the entry set. + * + *

Each entry returned by this set's iterator is wrapped in + * {@link ImmutableEntry} to prevent mutations via + * {@link Entry#setValue(Object)}.

+ * + * @param Key type + * @param Value type + * @since 0.67 + */ + private static final class ImmutableEntrySet + implements Set> { + + /** + * The original entry set. + */ + private final Set> entries; + + /** + * Ctor. + * @param origin The original entry set + */ + ImmutableEntrySet( + final Set> origin + ) { + this.entries = origin; + } + + @Override + public int size() { + return this.entries.size(); + } + + @Override + public boolean isEmpty() { + return this.entries.isEmpty(); + } + + @Override + public boolean contains(final Object obj) { + return this.entries.contains(obj); + } + + @Override + public Iterator> iterator() { + return new ImmutableEntryIterator<>(this.entries.iterator()); + } + + @Override + public Object[] toArray() { + return this.entries.toArray(); + } + + @Override + @SuppressWarnings("PMD.UseVarargs") + public T[] toArray(final T[] arr) { + return this.entries.toArray(arr); + } + + @Override + public boolean add(final Entry entry) { + throw new UnsupportedOperationException( + "#add(Entry): the entry set is read-only" + ); + } + + @Override + public boolean remove(final Object obj) { + throw new UnsupportedOperationException( + "#remove(Object): the entry set is read-only" + ); + } + + @Override + public boolean containsAll(final Collection col) { + return this.entries.containsAll(col); + } + + @Override + public boolean addAll(final Collection> col) { + throw new UnsupportedOperationException( + "#addAll(Collection): the entry set is read-only" + ); + } + + @Override + public boolean retainAll(final Collection col) { + throw new UnsupportedOperationException( + "#retainAll(Collection): the entry set is read-only" + ); + } + + @Override + public boolean removeAll(final Collection col) { + throw new UnsupportedOperationException( + "#removeAll(Collection): the entry set is read-only" + ); + } + + @Override + public void clear() { + throw new UnsupportedOperationException( + "#clear(): the entry set is read-only" + ); + } + } + + /** + * An iterator that wraps each entry in {@link ImmutableEntry}. + * + *

This iterator does not support {@code remove()}.

+ * + * @param Key type + * @param Value type + * @since 0.67 + */ + private static final class ImmutableEntryIterator + implements Iterator> { + + /** + * The original iterator. + */ + private final Iterator> iter; + + /** + * Ctor. + * @param origin The original iterator + */ + ImmutableEntryIterator( + final Iterator> origin + ) { + this.iter = origin; + } + + @Override + public boolean hasNext() { + return this.iter.hasNext(); + } + + @Override + public Entry next() { + return new ImmutableEntry<>(this.iter.next()); + } + + @Override + public void remove() { + throw new UnsupportedOperationException( + "#remove(): the entry iterator is read-only" + ); + } + } + + /** + * A decorator for {@link Entry} that blocks {@link #setValue(Object)}. + * + *

This ensures that even if the original map's entries support + * mutation, this wrapper prevents it.

+ * + * @param Key type + * @param Value type + * @since 0.67 + */ + private static final class ImmutableEntry implements Entry { + + /** + * The wrapped entry. + */ + private final Entry entry; + + /** + * Ctor. + * @param origin The entry to wrap + */ + ImmutableEntry(final Entry origin) { + this.entry = origin; + } + + @Override + public K getKey() { + return this.entry.getKey(); + } + + @Override + public V getValue() { + return this.entry.getValue(); + } + + @Override + public V setValue(final V value) { + throw new UnsupportedOperationException( + "#setValue(V): the entry is read-only" + ); + } + + @Override + public boolean equals(final Object other) { + return this.entry.equals(other); + } + + @Override + public int hashCode() { + return this.entry.hashCode(); + } + + @Override + public String toString() { + return this.entry.toString(); + } + } +} diff --git a/src/main/java/org/cactoos/text/SplitPreserveAllTokens.java b/src/main/java/org/cactoos/text/SplitPreserveAllTokens.java index 9da44f36c..6693ffe72 100644 --- a/src/main/java/org/cactoos/text/SplitPreserveAllTokens.java +++ b/src/main/java/org/cactoos/text/SplitPreserveAllTokens.java @@ -12,9 +12,74 @@ import org.cactoos.iterator.IteratorOf; /** - * Splits the Text into an array, including empty - * tokens created by adjacent separators. + * Splits text into tokens, preserving ALL tokens including empty ones. * + *

This class provides functionality equivalent to Apache Commons Lang's + * {@code StringUtils.splitPreserveAllTokens()}. Unlike Java's standard + * {@link String#split(String)}, this class preserves:

+ *
    + *
  • Leading empty tokens (when text starts with delimiter)
  • + *
  • Trailing empty tokens (when text ends with delimiter)
  • + *
  • Adjacent empty tokens (when delimiters appear consecutively)
  • + *
+ * + *

Key Guarantee

+ *

For a string with N occurrences of the delimiter, this class always + * returns exactly N+1 tokens (some may be empty strings).

+ * + *

Comparison with String.split()

+ * + * + * + * + * + * + *
Behavior comparison
InputString.split(",")SplitPreserveAllTokens
"a,b,"["a", "b"]["a", "b", ""]
",,"[]["", "", ""]
","[]["", ""]
+ * + *

Example Usage

+ *
{@code
+ * // Basic splitting
+ * Iterable tokens = new SplitPreserveAllTokens("a,b,c", ",");
+ * // Result: ["a", "b", "c"]
+ *
+ * // Preserves trailing empty token
+ * Iterable tokens = new SplitPreserveAllTokens("a,b,", ",");
+ * // Result: ["a", "b", ""]
+ *
+ * // Preserves multiple empty tokens
+ * Iterable tokens = new SplitPreserveAllTokens("a,,b", ",");
+ * // Result: ["a", "", "b"]
+ *
+ * // Only delimiters - produces all empty tokens
+ * Iterable tokens = new SplitPreserveAllTokens(",,", ",");
+ * // Result: ["", "", ""]
+ *
+ * // Default delimiter is space
+ * Iterable tokens = new SplitPreserveAllTokens(" hello  world ");
+ * // Result: ["", "hello", "", "world", ""]
+ *
+ * // With limit on number of tokens
+ * Iterable tokens = new SplitPreserveAllTokens("a,b,c,d", ",", 2);
+ * // Result: ["a", "b"]
+ *
+ * // Parsing CSV with empty fields
+ * Iterable fields = new SplitPreserveAllTokens("John,,Smith,,", ",");
+ * // Result: ["John", "", "Smith", "", ""] - all 5 fields preserved!
+ * }
+ * + *

Use Cases

+ *
    + *
  • CSV/TSV parsing where empty fields are meaningful
  • + *
  • Log file parsing where field position matters
  • + *
  • Protocol parsing where field count must be exact
  • + *
  • Data migration where empty values must be preserved
  • + *
+ * + *

Note: The delimiter is matched as a literal string, + * not as a regular expression.

+ * + * @see Split + * @see TriFuncSplitPreserve * @since 0.0 */ public final class SplitPreserveAllTokens extends IterableEnvelope { diff --git a/src/test/java/org/cactoos/func/TriFuncSplitPreserveTest.java b/src/test/java/org/cactoos/func/TriFuncSplitPreserveTest.java new file mode 100644 index 000000000..4f58b8410 --- /dev/null +++ b/src/test/java/org/cactoos/func/TriFuncSplitPreserveTest.java @@ -0,0 +1,179 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2017-2026 Yegor Bugayenko + * SPDX-License-Identifier: MIT + */ + +package org.cactoos.func; + +import java.util.Arrays; +import java.util.Collection; +import org.hamcrest.core.IsEqual; +import org.junit.jupiter.api.Test; +import org.llorllale.cactoos.matchers.Assertion; + +/** + * Test case for {@link TriFuncSplitPreserve}. + * + *

This class tests the low-level splitting function that preserves + * all tokens, including empty ones created by adjacent delimiters.

+ * + * @since 0.0 + */ +@SuppressWarnings({"PMD.TooManyMethods", "PMD.AvoidDuplicateLiterals"}) +final class TriFuncSplitPreserveTest { + + @Test + void splitsSimpleString() { + new Assertion<>( + "Must split simple string", + new TriFuncSplitPreserve().apply("a,b,c", ",", 0), + new IsEqual<>(Arrays.asList("a", "b", "c")) + ).affirm(); + } + + @Test + void preservesEmptyTokenInMiddle() { + new Assertion<>( + "Must preserve empty token between adjacent delimiters", + new TriFuncSplitPreserve().apply("a,,b", ",", 0), + new IsEqual<>(Arrays.asList("a", "", "b")) + ).affirm(); + } + + @Test + void preservesTrailingEmptyToken() { + new Assertion<>( + "Must preserve trailing empty token", + new TriFuncSplitPreserve().apply("a,b,", ",", 0), + new IsEqual<>(Arrays.asList("a", "b", "")) + ).affirm(); + } + + @Test + void preservesLeadingEmptyToken() { + new Assertion<>( + "Must preserve leading empty token", + new TriFuncSplitPreserve().apply(",a,b", ",", 0), + new IsEqual<>(Arrays.asList("", "a", "b")) + ).affirm(); + } + + @Test + void handlesOnlyDelimiters() { + new Assertion<>( + "Two delimiters must produce three empty tokens", + new TriFuncSplitPreserve().apply(",,", ",", 0), + new IsEqual<>(Arrays.asList("", "", "")) + ).affirm(); + } + + @Test + void handlesSingleDelimiter() { + new Assertion<>( + "Single delimiter must produce two empty tokens", + new TriFuncSplitPreserve().apply(",", ",", 0), + new IsEqual<>(Arrays.asList("", "")) + ).affirm(); + } + + @Test + void handlesEmptyString() { + new Assertion<>( + "Empty string must produce single empty token", + new TriFuncSplitPreserve().apply("", ",", 0), + new IsEqual<>(Arrays.asList("")) + ).affirm(); + } + + @Test + void handlesNoDelimiter() { + new Assertion<>( + "String without delimiter must return single token", + new TriFuncSplitPreserve().apply("abc", ",", 0), + new IsEqual<>(Arrays.asList("abc")) + ).affirm(); + } + + @Test + void handlesRepeatedDelimiterAsContent() { + new Assertion<>( + "Content same as delimiter produces correct tokens", + new TriFuncSplitPreserve().apply("aaa", "a", 0), + new IsEqual<>(Arrays.asList("", "", "", "")) + ).affirm(); + } + + @Test + void handlesMultiCharDelimiter() { + new Assertion<>( + "Multi-char delimiter must work", + new TriFuncSplitPreserve().apply("a::b::c", "::", 0), + new IsEqual<>(Arrays.asList("a", "b", "c")) + ).affirm(); + } + + @Test + void limitsTokenCount() { + new Assertion<>( + "Must limit token count", + new TriFuncSplitPreserve().apply("a,b,c,d", ",", 2), + new IsEqual<>(Arrays.asList("a", "b")) + ).affirm(); + } + + @Test + void limitWithEmptyTokens() { + new Assertion<>( + "Limit must count empty tokens", + new TriFuncSplitPreserve().apply(",,,", ",", 2), + new IsEqual<>(Arrays.asList("", "")) + ).affirm(); + } + + @Test + void limitZeroReturnsAll() { + new Assertion<>( + "Limit 0 must return all tokens", + new TriFuncSplitPreserve().apply("a,b,c", ",", 0), + new IsEqual<>(Arrays.asList("a", "b", "c")) + ).affirm(); + } + + @Test + void handlesSpaceDelimiter() { + new Assertion<>( + "Must handle space delimiter", + new TriFuncSplitPreserve().apply(" hello ", " ", 0), + new IsEqual<>(Arrays.asList("", "hello", "")) + ).affirm(); + } + + @Test + void handlesTabDelimiter() { + new Assertion<>( + "Must handle tab delimiter", + new TriFuncSplitPreserve().apply("a\t\tb", "\t", 0), + new IsEqual<>(Arrays.asList("a", "", "b")) + ).affirm(); + } + + @Test + void handlesEmptyDelimiter() { + final Collection result = + new TriFuncSplitPreserve().apply("abc", "", 0); + new Assertion<>( + "Empty delimiter must return original string", + result.size(), + new IsEqual<>(1) + ).affirm(); + } + + @Test + void handlesCsvScenario() { + new Assertion<>( + "Must handle CSV with empty fields", + new TriFuncSplitPreserve().apply("John,,Smith,,", ",", 0), + new IsEqual<>(Arrays.asList("John", "", "Smith", "", "")) + ).affirm(); + } +} diff --git a/src/test/java/org/cactoos/map/ImmutableTest.java b/src/test/java/org/cactoos/map/ImmutableTest.java new file mode 100644 index 000000000..ce885eec5 --- /dev/null +++ b/src/test/java/org/cactoos/map/ImmutableTest.java @@ -0,0 +1,737 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2017-2026 Yegor Bugayenko + * SPDX-License-Identifier: MIT + */ +package org.cactoos.map; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import org.hamcrest.core.IsEqual; +import org.hamcrest.core.IsNot; +import org.junit.jupiter.api.Test; +import org.llorllale.cactoos.matchers.Assertion; +import org.llorllale.cactoos.matchers.HasValues; +import org.llorllale.cactoos.matchers.Throws; + +/** + * Test case for {@link Immutable}. + * + * @since 0.67 + */ +@SuppressWarnings({"PMD.TooManyMethods", "PMD.AvoidDuplicateLiterals"}) +final class ImmutableTest { + + @Test + void size() { + new Assertion<>( + "size() must be equals to original", + new Immutable<>( + new MapOf( + new MapEntry<>("a", 1), + new MapEntry<>("b", 2) + ) + ).size(), + new IsEqual<>(2) + ).affirm(); + } + + @Test + void isEmpty() { + new Assertion<>( + "isEmpty() must return false for non-empty map", + new Immutable<>( + new MapOf<>(new MapEntry<>("x", 10)) + ).isEmpty(), + new IsEqual<>(false) + ).affirm(); + } + + @Test + void isEmptyOnEmptyMap() { + new Assertion<>( + "isEmpty() must return true for empty map", + new Immutable<>(new MapOf()).isEmpty(), + new IsEqual<>(true) + ).affirm(); + } + + @Test + void containsKey() { + new Assertion<>( + "containsKey() must find existing key", + new Immutable<>( + new MapOf( + new MapEntry<>("one", 1), + new MapEntry<>("two", 2) + ) + ).containsKey("two"), + new IsEqual<>(true) + ).affirm(); + } + + @Test + void containsKeyReturnsFalseForMissing() { + new Assertion<>( + "containsKey() must return false for missing key", + new Immutable<>( + new MapOf<>(new MapEntry<>("one", 1)) + ).containsKey("two"), + new IsEqual<>(false) + ).affirm(); + } + + @Test + void containsValue() { + new Assertion<>( + "containsValue() must find existing value", + new Immutable<>( + new MapOf( + new MapEntry<>("a", 100), + new MapEntry<>("b", 200) + ) + ).containsValue(200), + new IsEqual<>(true) + ).affirm(); + } + + @Test + void get() { + new Assertion<>( + "get() must return correct value", + new Immutable<>( + new MapOf<>( + new MapEntry<>("key", "value") + ) + ).get("key"), + new IsEqual<>("value") + ).affirm(); + } + + @Test + void getReturnsNullForMissingKey() { + new Assertion<>( + "get() must return null for missing key", + new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).get("missing"), + new IsEqual<>(null) + ).affirm(); + } + + @Test + void put() { + new Assertion<>( + "put() must throw exception", + () -> new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).put("b", 2), + new Throws<>( + "#put(K,V): the map is read-only", + UnsupportedOperationException.class + ) + ).affirm(); + } + + @Test + void remove() { + new Assertion<>( + "remove() must throw exception", + () -> new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).remove("a"), + new Throws<>( + "#remove(Object): the map is read-only", + UnsupportedOperationException.class + ) + ).affirm(); + } + + @Test + void putAll() { + new Assertion<>( + "putAll() must throw exception", + () -> { + new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).putAll(new MapOf<>(new MapEntry<>("b", 2))); + return new Object(); + }, + new Throws<>( + "#putAll(Map): the map is read-only", + UnsupportedOperationException.class + ) + ).affirm(); + } + + @Test + void clear() { + new Assertion<>( + "clear() must throw exception", + () -> { + new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).clear(); + return new Object(); + }, + new Throws<>( + "#clear(): the map is read-only", + UnsupportedOperationException.class + ) + ).affirm(); + } + + @Test + void keySet() { + new Assertion<>( + "keySet() must return all keys", + new Immutable<>( + new MapOf( + new MapEntry<>("a", 1), + new MapEntry<>("b", 2) + ) + ).keySet(), + new HasValues<>("a", "b") + ).affirm(); + } + + @Test + void keySetIsImmutable() { + new Assertion<>( + "keySet() must be immutable", + () -> new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).keySet().add("b"), + new Throws<>(UnsupportedOperationException.class) + ).affirm(); + } + + @Test + void keySetIteratorIsImmutable() { + new Assertion<>( + "keySet().iterator() must not support remove()", + () -> { + final Set keys = new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).keySet(); + final Iterator iter = keys.iterator(); + iter.next(); + iter.remove(); + return new Object(); + }, + new Throws<>(UnsupportedOperationException.class) + ).affirm(); + } + + @Test + void values() { + new Assertion<>( + "values() must return all values", + new Immutable<>( + new MapOf( + new MapEntry<>("x", 10), + new MapEntry<>("y", 20) + ) + ).values(), + new HasValues<>(10, 20) + ).affirm(); + } + + @Test + void valuesIsImmutable() { + new Assertion<>( + "values() must be immutable", + () -> new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).values().add(2), + new Throws<>(UnsupportedOperationException.class) + ).affirm(); + } + + @Test + void valuesIteratorIsImmutable() { + new Assertion<>( + "values().iterator() must not support remove()", + () -> { + final Collection vals = new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).values(); + final Iterator iter = vals.iterator(); + iter.next(); + iter.remove(); + return new Object(); + }, + new Throws<>(UnsupportedOperationException.class) + ).affirm(); + } + + @Test + void entrySet() { + new Assertion<>( + "entrySet() must return correct number of entries", + new Immutable<>( + new MapOf( + new MapEntry<>("a", 1), + new MapEntry<>("b", 2) + ) + ).entrySet().size(), + new IsEqual<>(2) + ).affirm(); + } + + @Test + void entrySetIsImmutable() { + new Assertion<>( + "entrySet() must be immutable", + () -> { + new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).entrySet().clear(); + return new Object(); + }, + new Throws<>(UnsupportedOperationException.class) + ).affirm(); + } + + @Test + void entrySetIteratorIsImmutable() { + new Assertion<>( + "entrySet().iterator() must not support remove()", + () -> { + final Set> entries = new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).entrySet(); + final Iterator> iter = + entries.iterator(); + iter.next(); + iter.remove(); + return new Object(); + }, + new Throws<>(UnsupportedOperationException.class) + ).affirm(); + } + + @Test + void entrySetValueIsImmutable() { + new Assertion<>( + "entrySet entry's setValue() must throw exception", + () -> { + final Set> entries = new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).entrySet(); + final Map.Entry entry = + entries.iterator().next(); + entry.setValue(999); + return new Object(); + }, + new Throws<>( + "#setValue(V): the entry is read-only", + UnsupportedOperationException.class + ) + ).affirm(); + } + + @Test + void worksWithHashMapBackedEntries() { + final Map hashmap = new HashMap<>(); + hashmap.put("a", 1); + hashmap.put("b", 2); + new Assertion<>( + "entrySet entry's setValue() must throw even for HashMap entries", + () -> { + final Set> entries = + new Immutable<>(hashmap).entrySet(); + final Map.Entry entry = + entries.iterator().next(); + entry.setValue(999); + return new Object(); + }, + new Throws<>( + "#setValue(V): the entry is read-only", + UnsupportedOperationException.class + ) + ).affirm(); + } + + @Test + void testEquals() { + new Assertion<>( + "must be equal to map with same entries", + new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ), + new IsEqual<>(new MapOf<>(new MapEntry<>("a", 1))) + ).affirm(); + } + + @Test + void testEqualsItself() { + final Map map = new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ); + new Assertion<>( + "must be equal to itself", + map, + new IsEqual<>(map) + ).affirm(); + } + + @Test + void notEqualsToMapWithDifferentEntries() { + new Assertion<>( + "must not be equal to map with different entries", + new Immutable<>(new MapOf<>(new MapEntry<>("a", 1))), + new IsNot<>( + new IsEqual<>(new MapOf<>(new MapEntry<>("b", 2))) + ) + ).affirm(); + } + + @Test + void notEqualsToObjectOfAnotherType() { + new Assertion<>( + "must not be equal to object of another type", + new Immutable<>(new MapOf<>(new MapEntry<>("a", 1))), + new IsNot<>(new IsEqual<>("not a map")) + ).affirm(); + } + + @Test + void testHashCode() { + new Assertion<>( + "hashCode() must be equal to hashCode of equivalent map", + new Immutable<>( + new MapOf( + new MapEntry<>("a", 1), + new MapEntry<>("b", 2) + ) + ).hashCode(), + new IsEqual<>( + new MapOf( + new MapEntry<>("a", 1), + new MapEntry<>("b", 2) + ).hashCode() + ) + ).affirm(); + } + + @Test + void testToString() { + new Assertion<>( + "toString() must match original map's toString()", + new Immutable<>( + new MapOf<>(new MapEntry<>("x", 42)) + ).toString(), + new IsEqual<>( + new MapOf<>(new MapEntry<>("x", 42)).toString() + ) + ).affirm(); + } + + @Test + void reflectsChangesToUnderlyingMap() { + final Map mutable = new HashMap<>(); + mutable.put("a", 1); + final Map immutable = new Immutable<>(mutable); + mutable.put("b", 2); + new Assertion<>( + "must reflect changes made to the underlying map", + immutable.size(), + new IsEqual<>(2) + ).affirm(); + } + + @Test + void emptyMapEqualsEmptyMap() { + new Assertion<>( + "empty immutable must equal empty immutable", + new Immutable<>(new MapOf()), + new IsEqual<>(new Immutable<>(new MapOf())) + ).affirm(); + } + + // ===== Additional tests for iteration correctness ===== + + @Test + void iteratesThroughAllEntries() { + final Map map = new Immutable<>( + new MapOf( + new MapEntry<>("a", 1), + new MapEntry<>("b", 2), + new MapEntry<>("c", 3) + ) + ); + int count = 0; + int sum = 0; + for (final Map.Entry entry : map.entrySet()) { + count = count + 1; + sum = sum + entry.getValue(); + } + new Assertion<>( + "must iterate through all 3 entries", + count, + new IsEqual<>(3) + ).affirm(); + new Assertion<>( + "sum of values must be 6", + sum, + new IsEqual<>(6) + ).affirm(); + } + + @Test + void iteratesThroughAllKeys() { + final Map map = new Immutable<>( + new MapOf( + new MapEntry<>("x", 10), + new MapEntry<>("y", 20) + ) + ); + int count = 0; + for (final String key : map.keySet()) { + count = count + 1; + } + new Assertion<>( + "must iterate through all 2 keys", + count, + new IsEqual<>(2) + ).affirm(); + } + + @Test + void iteratesThroughAllValues() { + final Map map = new Immutable<>( + new MapOf( + new MapEntry<>("p", 100), + new MapEntry<>("q", 200) + ) + ); + int sum = 0; + for (final Integer val : map.values()) { + sum = sum + val; + } + new Assertion<>( + "sum of iterated values must be 300", + sum, + new IsEqual<>(300) + ).affirm(); + } + + @Test + void entryIterationReadsCorrectKeyAndValue() { + final Map map = new Immutable<>( + new MapOf<>(new MapEntry<>("thekey", 42)) + ); + final Map.Entry entry = map.entrySet().iterator().next(); + new Assertion<>( + "entry key must be correct", + entry.getKey(), + new IsEqual<>("thekey") + ).affirm(); + new Assertion<>( + "entry value must be correct", + entry.getValue(), + new IsEqual<>(42) + ).affirm(); + } + + // ===== Additional tests for view mutation attempts ===== + + @Test + void keySetRemoveAllThrows() { + new Assertion<>( + "keySet().removeAll() must throw exception", + () -> { + new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).keySet().removeAll(java.util.Arrays.asList("a")); + return new Object(); + }, + new Throws<>(UnsupportedOperationException.class) + ).affirm(); + } + + @Test + void keySetRetainAllThrows() { + new Assertion<>( + "keySet().retainAll() must throw exception", + () -> { + new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).keySet().retainAll(java.util.Arrays.asList("b")); + return new Object(); + }, + new Throws<>(UnsupportedOperationException.class) + ).affirm(); + } + + @Test + void keySetClearThrows() { + new Assertion<>( + "keySet().clear() must throw exception", + () -> { + new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).keySet().clear(); + return new Object(); + }, + new Throws<>(UnsupportedOperationException.class) + ).affirm(); + } + + @Test + void valuesRemoveThrows() { + new Assertion<>( + "values().remove() must throw exception", + () -> new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).values().remove(1), + new Throws<>(UnsupportedOperationException.class) + ).affirm(); + } + + @Test + void valuesClearThrows() { + new Assertion<>( + "values().clear() must throw exception", + () -> { + new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).values().clear(); + return new Object(); + }, + new Throws<>(UnsupportedOperationException.class) + ).affirm(); + } + + @Test + void entrySetAddThrows() { + new Assertion<>( + "entrySet().add() must throw exception", + () -> new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).entrySet().add(new MapEntry<>("b", 2)), + new Throws<>(UnsupportedOperationException.class) + ).affirm(); + } + + @Test + void entrySetRemoveThrows() { + new Assertion<>( + "entrySet().remove() must throw exception", + () -> new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).entrySet().remove(new MapEntry<>("a", 1)), + new Throws<>(UnsupportedOperationException.class) + ).affirm(); + } + + // ===== Additional tests for lookup edge cases ===== + + @Test + void containsValueReturnsFalseForMissing() { + new Assertion<>( + "containsValue() must return false for missing value", + new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ).containsValue(999), + new IsEqual<>(false) + ).affirm(); + } + + @Test + void keySetContainsWorks() { + new Assertion<>( + "keySet().contains() must find existing key", + new Immutable<>( + new MapOf<>(new MapEntry<>("hello", 42)) + ).keySet().contains("hello"), + new IsEqual<>(true) + ).affirm(); + } + + @Test + void valuesContainsWorks() { + new Assertion<>( + "values().contains() must find existing value", + new Immutable<>( + new MapOf<>(new MapEntry<>("key", 123)) + ).values().contains(123), + new IsEqual<>(true) + ).affirm(); + } + + @Test + void entrySetContainsWorks() { + new Assertion<>( + "entrySet().contains() must find existing entry", + new Immutable<>( + new MapOf<>(new MapEntry<>("k", 1)) + ).entrySet().contains(new MapEntry<>("k", 1)), + new IsEqual<>(true) + ).affirm(); + } + + // ===== Test that contents remain stable after failed modification ===== + + @Test + void contentsUnchangedAfterFailedPut() { + final Map map = new Immutable<>( + new MapOf<>(new MapEntry<>("a", 1)) + ); + try { + map.put("b", 2); + } catch (final UnsupportedOperationException ex) { + // expected + } + new Assertion<>( + "size must remain 1 after failed put", + map.size(), + new IsEqual<>(1) + ).affirm(); + new Assertion<>( + "original entry must still exist after failed put", + map.get("a"), + new IsEqual<>(1) + ).affirm(); + } + + @Test + void contentsUnchangedAfterFailedRemove() { + final Map map = new Immutable<>( + new MapOf<>(new MapEntry<>("x", 99)) + ); + try { + map.remove("x"); + } catch (final UnsupportedOperationException ex) { + // expected + } + new Assertion<>( + "entry must still exist after failed remove", + map.get("x"), + new IsEqual<>(99) + ).affirm(); + } + + @Test + void contentsUnchangedAfterFailedClear() { + final Map map = new Immutable<>( + new MapOf( + new MapEntry<>("a", 1), + new MapEntry<>("b", 2) + ) + ); + try { + map.clear(); + } catch (final UnsupportedOperationException ex) { + // expected + } + new Assertion<>( + "size must remain 2 after failed clear", + map.size(), + new IsEqual<>(2) + ).affirm(); + } +} diff --git a/src/test/java/org/cactoos/text/SplitPreserveTest.java b/src/test/java/org/cactoos/text/SplitPreserveTest.java index 5c1ebc523..0e17dd751 100644 --- a/src/test/java/org/cactoos/text/SplitPreserveTest.java +++ b/src/test/java/org/cactoos/text/SplitPreserveTest.java @@ -6,149 +6,393 @@ package org.cactoos.text; import java.util.ArrayList; -import java.util.Iterator; +import java.util.List; import org.cactoos.Text; -import org.cactoos.iterable.IterableOf; import org.hamcrest.Matchers; -import org.hamcrest.core.IsNot; +import org.hamcrest.core.IsEqual; import org.junit.jupiter.api.Test; import org.llorllale.cactoos.matchers.Assertion; /** - * Testing correctness of SplitPreserveAllTokens. - * Compare with Split class in specified cases. + * Test case for {@link SplitPreserveAllTokens}. + * + *

This class tests the "preserve all tokens" splitting behavior, + * which differs from Java's {@link String#split(String)} by preserving + * trailing empty tokens and ensuring that N delimiters always produce + * N+1 tokens.

* * @since 0.0 */ +@SuppressWarnings({"PMD.TooManyMethods", "PMD.AvoidDuplicateLiterals"}) final class SplitPreserveTest { + + // ===== Basic splitting tests ===== + @Test - void checkingSplit() { - String txt = "aaa"; - final String msg = "Adjacent separators must create an empty element"; - ArrayList array = new ArrayList<>(4); - array.add(new TextOf("")); - array.add(new TextOf("")); - array.add(new TextOf("")); - array.add(new TextOf("")); - new Assertion<>( - msg, - this.getLength( - new Split( - new TextOf(txt), - new TextOf("a") - ).iterator() - ), - IsNot.not( - Matchers.equalTo( - this.getLength( - new IterableOf( - array.iterator() - ).iterator() - ) - ) - ) + void splitsSimpleCommaSeparatedValues() { + new Assertion<>( + "Must split simple CSV correctly", + this.toList(new SplitPreserveAllTokens("a,b,c", ",")), + new IsEqual<>(java.util.Arrays.asList("a", "b", "c")) ).affirm(); - txt = " how "; - array = new ArrayList<>(3); - array.add(new TextOf("")); - array.add(new TextOf("how")); - array.add(new TextOf("")); - new Assertion<>( - msg, - this.getLength( - new Split( - new TextOf(txt), - new TextOf(" ") - ).iterator() - ), - IsNot.not( - Matchers.equalTo( - this.getLength( - new IterableOf( - array.iterator() - ).iterator() - ) - ) - ) + } + + @Test + void splitsWithSpaceDelimiter() { + new Assertion<>( + "Must split by space correctly", + this.toList(new SplitPreserveAllTokens("hello world", " ")), + new IsEqual<>(java.util.Arrays.asList("hello", "world")) ).affirm(); } - int getLength(final Iterator iter) { - int count = 0; - while (iter.hasNext()) { - iter.next(); - count += 1; - } - return count; + @Test + void splitsWithMultiCharDelimiter() { + new Assertion<>( + "Must split by multi-char delimiter", + this.toList(new SplitPreserveAllTokens("a::b::c", "::")), + new IsEqual<>(java.util.Arrays.asList("a", "b", "c")) + ).affirm(); + } + + // ===== Adjacent delimiter tests (empty tokens in middle) ===== + + @Test + void preservesEmptyTokenBetweenAdjacentDelimiters() { + new Assertion<>( + "Must preserve empty token between adjacent delimiters", + this.toList(new SplitPreserveAllTokens("a,,b", ",")), + new IsEqual<>(java.util.Arrays.asList("a", "", "b")) + ).affirm(); + } + + @Test + void preservesMultipleEmptyTokens() { + new Assertion<>( + "Must preserve multiple empty tokens", + this.toList(new SplitPreserveAllTokens("a,,,b", ",")), + new IsEqual<>(java.util.Arrays.asList("a", "", "", "b")) + ).affirm(); + } + + // ===== Trailing delimiter tests ===== + + @Test + void preservesTrailingEmptyToken() { + new Assertion<>( + "Must preserve trailing empty token after delimiter", + this.toList(new SplitPreserveAllTokens("a,b,", ",")), + new IsEqual<>(java.util.Arrays.asList("a", "b", "")) + ).affirm(); + } + + @Test + void preservesMultipleTrailingEmptyTokens() { + new Assertion<>( + "Must preserve multiple trailing empty tokens", + this.toList(new SplitPreserveAllTokens("a,,", ",")), + new IsEqual<>(java.util.Arrays.asList("a", "", "")) + ).affirm(); + } + + // ===== Leading delimiter tests ===== + + @Test + void preservesLeadingEmptyToken() { + new Assertion<>( + "Must preserve leading empty token before delimiter", + this.toList(new SplitPreserveAllTokens(",a,b", ",")), + new IsEqual<>(java.util.Arrays.asList("", "a", "b")) + ).affirm(); + } + + @Test + void preservesMultipleLeadingEmptyTokens() { + new Assertion<>( + "Must preserve multiple leading empty tokens", + this.toList(new SplitPreserveAllTokens(",,a", ",")), + new IsEqual<>(java.util.Arrays.asList("", "", "a")) + ).affirm(); + } + + // ===== Combined leading and trailing ===== + + @Test + void preservesBothLeadingAndTrailingEmptyTokens() { + new Assertion<>( + "Must preserve both leading and trailing empty tokens", + this.toList(new SplitPreserveAllTokens(",a,", ",")), + new IsEqual<>(java.util.Arrays.asList("", "a", "")) + ).affirm(); + } + + @Test + void preservesWithSpaceDelimiterLeadingTrailing() { + new Assertion<>( + "Must preserve leading/trailing with space delimiter", + this.toList(new SplitPreserveAllTokens(" hello ", " ")), + new IsEqual<>(java.util.Arrays.asList("", "hello", "")) + ).affirm(); + } + + // ===== Only delimiters ===== + + @Test + void handlesStringOfOnlyOneDelimiter() { + new Assertion<>( + "Single delimiter must produce two empty tokens", + this.toList(new SplitPreserveAllTokens(",", ",")), + new IsEqual<>(java.util.Arrays.asList("", "")) + ).affirm(); + } + + @Test + void handlesStringOfOnlyTwoDelimiters() { + new Assertion<>( + "Two delimiters must produce three empty tokens", + this.toList(new SplitPreserveAllTokens(",,", ",")), + new IsEqual<>(java.util.Arrays.asList("", "", "")) + ).affirm(); + } + + @Test + void handlesStringOfOnlyThreeDelimiters() { + new Assertion<>( + "Three delimiters must produce four empty tokens", + this.toList(new SplitPreserveAllTokens(",,,", ",")), + new IsEqual<>(java.util.Arrays.asList("", "", "", "")) + ).affirm(); + } + + @Test + void handlesStringOfOnlySpaces() { + new Assertion<>( + "Two spaces must produce three empty tokens", + this.toList(new SplitPreserveAllTokens(" ", " ")), + new IsEqual<>(java.util.Arrays.asList("", "", "")) + ).affirm(); + } + + // ===== Edge cases ===== + + @Test + void handlesEmptyString() { + new Assertion<>( + "Empty string must produce single empty token", + this.toList(new SplitPreserveAllTokens("", ",")), + new IsEqual<>(java.util.Arrays.asList("")) + ).affirm(); + } + + @Test + void handlesStringWithNoDelimiter() { + new Assertion<>( + "String without delimiter must return single token", + this.toList(new SplitPreserveAllTokens("abc", ",")), + new IsEqual<>(java.util.Arrays.asList("abc")) + ).affirm(); } @Test - void checkingSplitPreserveTokens() { - String txt = "aaa"; - final String msg = "Adjacent separators must create an empty element"; - ArrayList array = new ArrayList<>(4); - array.add(new TextOf("")); - array.add(new TextOf("")); - array.add(new TextOf("")); - array.add(new TextOf("")); + void handlesDelimiterSameAsContent() { new Assertion<>( - msg, - this.getLength( + "Content same as delimiter must produce correct tokens", + this.toList(new SplitPreserveAllTokens("aaa", "a")), + new IsEqual<>(java.util.Arrays.asList("", "", "", "")) + ).affirm(); + } + + @Test + void handlesDelimiterLongerThanSingleChar() { + new Assertion<>( + "Multi-char delimiter at edges must work", + this.toList(new SplitPreserveAllTokens("abab", "ab")), + new IsEqual<>(java.util.Arrays.asList("", "", "")) + ).affirm(); + } + + // ===== Whitespace handling ===== + + @Test + void handlesTabDelimiter() { + new Assertion<>( + "Must split by tab correctly", + this.toList(new SplitPreserveAllTokens("a\t\tb", "\t")), + new IsEqual<>(java.util.Arrays.asList("a", "", "b")) + ).affirm(); + } + + @Test + void handlesMixedContent() { + new Assertion<>( + "Must handle mixed content with spaces", + this.toList(new SplitPreserveAllTokens("lol\\ / dude", " ")), + new IsEqual<>(java.util.Arrays.asList("lol\\", "", "/", "dude")) + ).affirm(); + } + + // ===== Limit functionality ===== + + @Test + void limitsNumberOfTokens() { + new Assertion<>( + "Must limit to specified number of tokens", + this.toList(new SplitPreserveAllTokens("a,b,c,d", ",", 2)), + new IsEqual<>(java.util.Arrays.asList("a", "b")) + ).affirm(); + } + + @Test + void limitWithEmptyTokens() { + new Assertion<>( + "Limit must work with empty tokens", + this.toList(new SplitPreserveAllTokens("a,,b,,c", ",", 3)), + new IsEqual<>(java.util.Arrays.asList("a", "", "b")) + ).affirm(); + } + + @Test + void limitWithOnlyDelimiters() { + new Assertion<>( + "Limit must work with only delimiters", + this.toList(new SplitPreserveAllTokens(",,,", ",", 2)), + new IsEqual<>(java.util.Arrays.asList("", "")) + ).affirm(); + } + + @Test + void limitZeroReturnsAllTokens() { + new Assertion<>( + "Limit 0 must return all tokens", + this.toList(new SplitPreserveAllTokens("a,b,c", ",", 0)), + new IsEqual<>(java.util.Arrays.asList("a", "b", "c")) + ).affirm(); + } + + @Test + void limitGreaterThanTokenCountReturnsAll() { + new Assertion<>( + "Limit greater than token count must return all", + this.toList(new SplitPreserveAllTokens("a,b", ",", 10)), + new IsEqual<>(java.util.Arrays.asList("a", "b")) + ).affirm(); + } + + // ===== Text object input ===== + + @Test + void acceptsTextObjects() { + new Assertion<>( + "Must accept Text objects", + this.toList( new SplitPreserveAllTokens( - new TextOf(txt), - new TextOf("a") - ).iterator() - ), - Matchers.equalTo( - this.getLength( - new IterableOf( - array.iterator() - ).iterator() + new TextOf("x,y,z"), + new TextOf(",") ) - ) + ), + new IsEqual<>(java.util.Arrays.asList("x", "y", "z")) ).affirm(); - txt = "lol\\ / dude"; - array = new ArrayList<>(4); - array.add(new TextOf("lol\\")); - array.add(new TextOf("")); - array.add(new TextOf("/")); - array.add(new TextOf("dude")); + } + + @Test + void acceptsTextObjectsWithLimit() { new Assertion<>( - msg, - this.getLength( + "Must accept Text objects with limit", + this.toList( new SplitPreserveAllTokens( - new TextOf(txt), - new TextOf(" ") - ).iterator() + new TextOf("a,b,c,d"), + new TextOf(","), + 2 + ) ), - Matchers.equalTo( - this.getLength( - new IterableOf( - array.iterator() - ).iterator() + new IsEqual<>(java.util.Arrays.asList("a", "b")) + ).affirm(); + } + + // ===== Comparison with String.split() behavior ===== + + @Test + void differsFromStringSplitOnTrailingDelimiter() { + // String.split(",") would return ["a", "b"] - losing trailing empty + new Assertion<>( + "Must differ from String.split by preserving trailing empty", + this.toList(new SplitPreserveAllTokens("a,b,", ",")), + Matchers.not( + new IsEqual<>( + java.util.Arrays.asList("a,b,".split(",")) ) ) ).affirm(); - txt = " how "; - array = new ArrayList<>(3); - array.add(new TextOf("")); - array.add(new TextOf("how")); - array.add(new TextOf("")); + } + + @Test + void differsFromStringSplitOnOnlyDelimiters() { + // String.split(",") would return [] - empty array new Assertion<>( - msg, - this.getLength( - new SplitPreserveAllTokens( - new TextOf(txt), - new TextOf(" ") - ).iterator() - ), - Matchers.equalTo( - this.getLength( - new IterableOf( - array.iterator() - ).iterator() - ) + "Must differ from String.split on only-delimiter string", + this.toList(new SplitPreserveAllTokens(",,", ",")).size(), + Matchers.greaterThan(0) + ).affirm(); + } + + // ===== Default delimiter (space) ===== + + @Test + void usesSpaceAsDefaultDelimiter() { + new Assertion<>( + "Must use space as default delimiter", + this.toList(new SplitPreserveAllTokens("a b c")), + new IsEqual<>(java.util.Arrays.asList("a", "b", "c")) + ).affirm(); + } + + @Test + void defaultDelimiterPreservesEmptyTokens() { + new Assertion<>( + "Default space delimiter must preserve empty tokens", + this.toList(new SplitPreserveAllTokens(" hello world ")), + new IsEqual<>(java.util.Arrays.asList("", "hello", "", "world", "")) + ).affirm(); + } + + // ===== Real-world scenarios ===== + + @Test + void handlesCsvWithEmptyFields() { + new Assertion<>( + "Must handle CSV with empty fields", + this.toList(new SplitPreserveAllTokens("John,,Smith,,", ",")), + new IsEqual<>( + java.util.Arrays.asList("John", "", "Smith", "", "") ) ).affirm(); } + + @Test + void handlesTsvWithEmptyFields() { + new Assertion<>( + "Must handle TSV with empty fields", + this.toList(new SplitPreserveAllTokens("A\t\tB\tC\t", "\t")), + new IsEqual<>( + java.util.Arrays.asList("A", "", "B", "C", "") + ) + ).affirm(); + } + + /** + * Helper method to convert Iterable of Text to List of String. + * @param iterable The iterable to convert + * @return List of strings + */ + private List toList(final Iterable iterable) { + final List result = new ArrayList<>(); + for (final Text text : iterable) { + try { + result.add(text.asString()); + } catch (final Exception ex) { + throw new IllegalStateException(ex); + } + } + return result; + } }