From 221faa9c97a17a99efc39ec8c561231a81058854 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Wed, 13 May 2026 13:06:34 +0200 Subject: [PATCH] v1.0.0 - Initial release (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial release 🥳 Reviewed-on: http://git.hadak.org/dart/list_or/pulls/1 --- .gitea/workflows/ci.yml | 287 +++++++++++++++++++++++++++++++++++ .githooks/pre-commit | 9 ++ .githooks/pre-push | 9 ++ CHANGELOG.md | 3 + LICENSE | 28 ++++ README.md | 89 +++++++++++ analysis_options.yaml | 30 ++++ example/list_or_example.dart | 56 +++++++ lib/list_or.dart | 118 ++++++++++++++ pubspec.yaml | 12 ++ test/list_or_test.dart | 138 +++++++++++++++++ 11 files changed, 779 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100755 .githooks/pre-commit create mode 100755 .githooks/pre-push create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 example/list_or_example.dart create mode 100644 lib/list_or.dart create mode 100644 pubspec.yaml create mode 100644 test/list_or_test.dart diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..e5e02a5 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,287 @@ +name: CI and Release + +on: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + format: + name: format + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Dart + uses: dart-lang/setup-dart@v1 + - name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + + analyze: + name: analyze + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Dart + uses: dart-lang/setup-dart@v1 + - name: Install dependencies + run: dart pub get + - name: Run analyzer + run: dart analyze --fatal-infos + + test: + name: test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Dart + uses: dart-lang/setup-dart@v1 + - name: Install dependencies + run: dart pub get + - name: Run tests + run: dart test + + pana: + name: pana + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Dart + uses: dart-lang/setup-dart@v1 + - name: Install dependencies + run: dart pub get + - name: Run pana and enforce full score + run: | + set -euo pipefail + dart pub global activate pana + export PATH="$PATH:$HOME/.pub-cache/bin" + + pana . | tee /tmp/pana.log + + SCORE_LINE="$(grep -Eo 'Points: [0-9]+/[0-9]+' /tmp/pana.log | tail -n1 || true)" + if [ -z "$SCORE_LINE" ]; then + echo "Could not parse pana score output." + exit 1 + fi + + SCORE="${SCORE_LINE#Points: }" + GOT="${SCORE%/*}" + MAX="${SCORE#*/}" + + if [ "$GOT" -ne "$MAX" ]; then + echo "Pana score must be full. Got $SCORE." + exit 1 + fi + + echo "Pana full score confirmed: $SCORE" + + version_and_changelog: + name: version-and-changelog + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Validate release metadata for PRs into main + run: | + set -euo pipefail + + if [ "${GITHUB_EVENT_NAME}" != "pull_request" ]; then + echo "Not a pull_request event; skipping version/changelog gate." + exit 0 + fi + + if [ -z "${GITHUB_BASE_REF:-}" ]; then + echo "GITHUB_BASE_REF is not set for pull_request event." + exit 1 + fi + + git fetch origin "${GITHUB_BASE_REF}" --depth=1 + + PR_VERSION="$(sed -nE 's/^version:\s*([^[:space:]]+)\s*$/\1/p' pubspec.yaml | head -n1)" + BASE_HAS_PUBSPEC="false" + BASE_VERSION="" + + if git cat-file -e "origin/${GITHUB_BASE_REF}:pubspec.yaml" 2>/dev/null; then + BASE_HAS_PUBSPEC="true" + BASE_VERSION="$(git show "origin/${GITHUB_BASE_REF}:pubspec.yaml" | sed -nE 's/^version:\s*([^[:space:]]+)\s*$/\1/p' | head -n1)" + fi + + if [ -z "$PR_VERSION" ]; then + echo "Unable to read version from PR pubspec.yaml." + exit 1 + fi + + if [ "$BASE_HAS_PUBSPEC" = "true" ]; then + if [ -z "$BASE_VERSION" ]; then + echo "Unable to read version from base branch pubspec.yaml." + exit 1 + fi + + parse_semver_core() { + printf '%s' "$1" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/' + } + + BASE_CORE="$(parse_semver_core "$BASE_VERSION")" + PR_CORE="$(parse_semver_core "$PR_VERSION")" + + if ! printf '%s' "$BASE_CORE" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Base version is not valid semver: $BASE_VERSION" + exit 1 + fi + + if ! printf '%s' "$PR_CORE" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "PR version is not valid semver: $PR_VERSION" + exit 1 + fi + + BASE_MAJOR="${BASE_CORE%%.*}" + BASE_REST="${BASE_CORE#*.}" + BASE_MINOR="${BASE_REST%%.*}" + BASE_PATCH="${BASE_REST#*.}" + + PR_MAJOR="${PR_CORE%%.*}" + PR_REST="${PR_CORE#*.}" + PR_MINOR="${PR_REST%%.*}" + PR_PATCH="${PR_REST#*.}" + + if [ "$PR_MAJOR" -lt "$BASE_MAJOR" ]; then + echo "pubspec.yaml version must be greater than base version ($BASE_VERSION -> $PR_VERSION)." + exit 1 + fi + + if [ "$PR_MAJOR" -eq "$BASE_MAJOR" ] && [ "$PR_MINOR" -lt "$BASE_MINOR" ]; then + echo "pubspec.yaml version must be greater than base version ($BASE_VERSION -> $PR_VERSION)." + exit 1 + fi + + if [ "$PR_MAJOR" -eq "$BASE_MAJOR" ] && [ "$PR_MINOR" -eq "$BASE_MINOR" ] && [ "$PR_PATCH" -le "$BASE_PATCH" ]; then + echo "pubspec.yaml version must be at least 0.0.1 greater than base version ($BASE_VERSION -> $PR_VERSION)." + exit 1 + fi + else + echo "Base branch has no pubspec.yaml; treating this as first release." + fi + + if git diff --quiet "origin/${GITHUB_BASE_REF}...HEAD" -- CHANGELOG.md; then + echo "CHANGELOG.md must be updated in the PR." + exit 1 + fi + + if ! grep -Fq "## $PR_VERSION" CHANGELOG.md && ! grep -Fq "## [$PR_VERSION]" CHANGELOG.md; then + echo "CHANGELOG.md must include a heading for version $PR_VERSION." + exit 1 + fi + + if [ "$BASE_HAS_PUBSPEC" = "true" ]; then + echo "Version/changelog gate passed: $BASE_VERSION -> $PR_VERSION" + else + echo "Version/changelog gate passed for first release: $PR_VERSION" + fi + + publish: + name: publish + runs-on: ubuntu-latest + needs: + - format + - analyze + - test + - pana + - version_and_changelog + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Dart + uses: dart-lang/setup-dart@v1 + - name: Publish package and create Gitea release on push to main + env: + PUB_CREDENTIALS_JSON: ${{ secrets.PUB_CREDENTIALS_JSON }} + RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FALLBACK_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + set -euo pipefail + + if [ "${GITHUB_EVENT_NAME}" != "push" ] || [ "${GITHUB_REF}" != "refs/heads/main" ]; then + echo "Not a push to main; skipping publish." + exit 0 + fi + + PACKAGE_NAME="$(sed -nE 's/^name:\s*([^[:space:]]+)\s*$/\1/p' pubspec.yaml | head -n1)" + PACKAGE_VERSION="$(sed -nE 's/^version:\s*([^[:space:]]+)\s*$/\1/p' pubspec.yaml | head -n1)" + + if [ -z "$PACKAGE_NAME" ] || [ -z "$PACKAGE_VERSION" ]; then + echo "Failed to parse package name/version from pubspec.yaml." + exit 1 + fi + + if curl -fsSL "https://pub.dev/api/packages/${PACKAGE_NAME}" | grep -q "\"version\":\"${PACKAGE_VERSION}\""; then + echo "${PACKAGE_NAME} ${PACKAGE_VERSION} is already published; skipping." + exit 0 + fi + + if [ -z "${PUB_CREDENTIALS_JSON:-}" ]; then + echo "Missing required secret PUB_CREDENTIALS_JSON." + exit 1 + fi + + mkdir -p "$HOME/.pub-cache" + printf '%s' "$PUB_CREDENTIALS_JSON" > "$HOME/.pub-cache/credentials.json" + chmod 600 "$HOME/.pub-cache/credentials.json" + + dart pub get + dart pub publish --dry-run + dart pub publish --force + + TOKEN="${RELEASE_TOKEN:-}" + if [ -z "$TOKEN" ]; then + TOKEN="${FALLBACK_RELEASE_TOKEN:-}" + fi + + if [ -z "$TOKEN" ]; then + echo "Missing release token. Provide default workflow token or RELEASE_TOKEN." + exit 1 + fi + + SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}" + REPO_PATH="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}" + + if [ -z "$SERVER_URL" ] || [ -z "$REPO_PATH" ]; then + echo "Missing repository context required for Gitea API release creation." + exit 1 + fi + + TAG="v${PACKAGE_VERSION}" + RELEASE_BY_TAG_URL="${SERVER_URL}/api/v1/repos/${REPO_PATH}/releases/tags/${TAG}" + + if curl -fsS -H "Authorization: token ${TOKEN}" "$RELEASE_BY_TAG_URL" >/dev/null; then + echo "Release ${TAG} already exists; skipping release creation." + exit 0 + fi + + CREATE_RELEASE_URL="${SERVER_URL}/api/v1/repos/${REPO_PATH}/releases" + RELEASE_PAYLOAD="{\"tag_name\":\"${TAG}\",\"target\":\"${GITHUB_SHA}\",\"target_commitish\":\"${GITHUB_SHA}\",\"title\":\"${TAG}\",\"name\":\"${TAG}\",\"note\":\"Release ${TAG}\",\"body\":\"Release ${TAG}\",\"draft\":false,\"prerelease\":false}" + + HTTP_CODE="$(curl -sS -o /tmp/gitea-release-response.json -w '%{http_code}' \ + -X POST "$CREATE_RELEASE_URL" \ + -H "Authorization: token ${TOKEN}" \ + -H 'Content-Type: application/json' \ + -d "$RELEASE_PAYLOAD")" + + if [ "$HTTP_CODE" = "201" ]; then + echo "Created Gitea release for ${TAG}." + elif [ "$HTTP_CODE" = "409" ]; then + echo "Release or tag ${TAG} already exists; treating as success." + else + echo "Failed to create Gitea release. HTTP ${HTTP_CODE}." + cat /tmp/gitea-release-response.json + exit 1 + fi diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..52ef566 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +set -eu + +printf '%s\n' 'Running pre-commit checks: dart format + dart analyze' + +dart format --output=none --set-exit-if-changed . +dart analyze --fatal-infos + +printf '%s\n' 'Pre-commit checks passed.' \ No newline at end of file diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..c882c03 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +set -eu + +printf '%s\n' 'Running pre-push checks: dart format + dart test' + +dart format --output=none --set-exit-if-changed . +dart test + +printf '%s\n' 'Pre-push checks passed.' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..684a19a --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, Hans Kokx + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..80aee76 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# list_or + +Ever worked with an API that usually returns a list of items, but sometimes decides to return a single item just to keep you on your toes? + +`list_or` is a lightweight Dart utility that fixes this exact headache. It takes an arbitrary dynamic value, whether it's a single object, an array, a `Set`, or even `null`, and normalizes it into a standard, predictable `List`. + +No more writing boilerplate type-checks for every flexible JSON payload. + +## Features + +* **Normalizes inputs:** Wraps single values into a list, or passes through existing iterables. +* **Fully mutable:** Implements `ListMixin`, meaning you can `.add()`, `.map()`, `.remove()`, and iterate over it just like any other Dart list. +* **Type-safe:** Strictly enforces your generic type `T`. If you expect a `String` and get an `int`, it throws immediately. +* **Smart null handling:** Explicitly supports `null` values only if you define the type as nullable (e.g., `ListOr`). +* **Clean stringification:** If the list only has one item, `toString()` prints just that item instead of adding `[ ]` brackets. + +## Getting Started + +Add the package to your `pubspec.yaml`: + +```bash +dart pub add list_or + +``` + +Or, if you're using Flutter: + +```bash +flutter pub add list_or + +``` + +Import it in your file: + +```dart +import 'package:list_or/list_or.dart'; + +``` + +## Usage + +The most common use case is parsing unpredictable JSON. + +Imagine an API endpoint where the `tags` field might be a single string or an array of strings depending on how many tags the user added. + +```dart +import 'package:list_or/list_or.dart'; + +void main() { + // Scenario A: The API returns a single string + final Map json1 = {'id': 1, 'tags': 'flutter'}; + + // Scenario B: The API returns an array of strings + final Map json2 = {'id': 2, 'tags': ['dart', 'backend']}; + + // ListOr normalizes both seamlessly. + final tags1 = ListOr(json1['tags']); + final tags2 = ListOr(json2['tags']); + + print(tags1); // Output: flutter + print(tags2); // Output: [dart, backend] + + // You can interact with it exactly like a normal list + tags1.add('mobile'); + print(tags1.length); // Output: 2 + print(tags1.contains('mobile')); // Output: true +} + +``` + +### Handling Nulls + +If your payload might intentionally contain nulls, just make your generic type +nullable: + +```dart +// This works perfectly +final nullableList = ListOr(null); +print(nullableList.length); // Output: 1 + +// This will throw an ArgumentError to keep your types safe +final strictList = ListOr(null); + +``` + +## Additional Information + +* **Found a bug?** Open an issue on the issue tracker. +* **Want to contribute?** Pull requests are welcome. Please ensure that all existing tests pass and add new tests for any new functionality. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/example/list_or_example.dart b/example/list_or_example.dart new file mode 100644 index 0000000..8f8040d --- /dev/null +++ b/example/list_or_example.dart @@ -0,0 +1,56 @@ +import 'package:list_or/list_or.dart'; + +void main() { + print('--- Example 1: Wrapping a Single Value ---'); + // A single value is converted into a list containing just that value. + final singleItem = ListOr('Hello World'); + + print('Length: ${singleItem.length}'); // Output: 1 + print('First item: ${singleItem[0]}'); // Output: Hello World + + // Notice the custom toString():it prints just the string, not ['Hello World'] + print('String representation: $singleItem'); // Output: Hello World + + print('\n--- Example 2: Wrapping an Iterable ---'); + // A standard List or Set is normalized into a modifiable ListOr. + final multipleItems = ListOr(['Apple', 'Banana']); + + // Because it mixes in ListMixin, you can use all standard list methods. + multipleItems.add('Cherry'); + + print('Length: ${multipleItems.length}'); // Output: 3 + + // When the length is not exactly 1, toString() acts like a normal List. + print( + 'String representation: $multipleItems', + ); // Output: [Apple, Banana, Cherry] + + print('\n--- Example 3: Parsing Flexible JSON Data ---'); + // Imagine an API that irregularly returns 'tags' as either a String or a List. + final Map jsonResponse1 = {'id': 1, 'tags': 'flutter'}; + final Map jsonResponse2 = { + 'id': 2, + 'tags': ['dart', 'backend'], + }; + + // ListOr cleanly normalizes both scenarios without complex type checking. + final tags1 = ListOr(jsonResponse1['tags']); + final tags2 = ListOr(jsonResponse2['tags']); + + print( + 'Tags 1 contains "flutter": ${tags1.contains('flutter')}', + ); // Output: true + print('Tags 2 length: ${tags2.length}'); // Output: 2 + + print('\n--- Example 4: Handling Nulls with Nullable Types ---'); + // If your data might explicitly be null and you want to keep that null + // as a valid single element, you must use a nullable generic type. + final nullableList = ListOr(null); + + print('Nullable list length: ${nullableList.length}'); // Output: 1 + print('First item: ${nullableList.first}'); // Output: null + + // Note: Trying to do the following would throw an ArgumentError, + // keeping your types safe: + // final strictList = ListOr(null); // THROWS! +} diff --git a/lib/list_or.dart b/lib/list_or.dart new file mode 100644 index 0000000..3edafd5 --- /dev/null +++ b/lib/list_or.dart @@ -0,0 +1,118 @@ +/// A utility library for handling flexible data structures. +/// +/// This library provides the `ListOr` class, which normalizes single values or +/// iterables into a unified `List` interface. It is particularly useful for +/// parsing JSON or dynamic data where a field might interchangeably be provided +/// as a single element or a collection of elements. +library; + +import 'dart:collection'; + +/// A list implementation that normalizes a single value or an iterable into a +/// [List]. +/// +/// This class mixes in [ListMixin] to provide a complete list API backed by an +/// internal list. It is particularly useful when parsing flexible data +/// structures (such as JSON) where a field might be provided as either a single +/// element or a collection of elements. +class ListOr extends Object with ListMixin { + /// The underlying list storing the elements. + final List _items; + + /// Creates a [ListOr] instance from an arbitrary [value]. + /// + /// The factory normalizes the [value] based on its type: + /// * If [value] is an [Iterable] (e.g., `List` or `Set`), it creates a new + /// modifiable list and casts the elements to [T]. + /// * If [value] is exactly of type [T] (including `null` if [T] is nullable), + /// it wraps the value in a single-element list. + /// + /// Throws an [ArgumentError] if [value] is `null` but the generic type [T] is + /// non-nullable. + /// Throws an [ArgumentError] if the [value] is neither of type [T] nor an + /// [Iterable] compatible with [T]. + factory ListOr(Object? value) { + if (value is Iterable) { + return ListOr._(List.from(value)); + } + + if (value is T) { + return ListOr._([value]); + } + + if (value == null) { + throw ArgumentError( + 'The type $T does not accept null values. ' + 'Pass a value of type $T or use ListOr<$T?> instead.', + ); + } + + throw ArgumentError( + 'Value must be of type $T or Iterable containing $T. Got ${value.runtimeType}', + ); + } + + /// Internal constructor to initialize the backing list. + ListOr._(this._items); + + /// Returns the number of elements in the list. + @override + int get length => _items.length; + + /// Updates the length of the list. + @override + set length(int newLength) { + _items.length = newLength; + } + + /// Returns the element at the given [index]. + @override + T operator [](int index) => _items[index]; + + /// Sets the element at the given [index] to [value]. + @override + void operator []=(int index, T value) { + _items[index] = value; + } + + /// Adds [element] to the end of this list. + @override + void add(T element) => _items.add(element); + + /// Appends all objects of [iterable] to the end of this list. + @override + void addAll(Iterable iterable) => _items.addAll(iterable); + + /// Inserts [element] at position [index] in this list. + @override + void insert(int index, T element) => _items.insert(index, element); + + /// Removes the object at position [index] from this list. + /// + /// Returns the removed element. + @override + T removeAt(int index) => _items.removeAt(index); + + /// Removes the first occurrence of [element] from this list. + /// + /// Returns `true` if [element] was in the list, `false` otherwise. + @override + bool remove(Object? element) => _items.remove(element); + + /// Removes all objects from this list; the length of the list becomes zero. + @override + void clear() => _items.clear(); + + /// Returns a string representation of this object. + /// + /// If the list contains exactly one element, this returns the string + /// representation of that single element. Otherwise, it returns the standard + /// bracketed list representation (e.g., `[a, b]` or `[]`). + @override + String toString() { + if (_items.length == 1) { + return _items.firstOrNull.toString(); + } + return _items.toString(); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..13b47d5 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,12 @@ +name: list_or +description: A Dart utility that seamlessly normalizes single values and iterables into a unified, type-safe List. +version: 1.0.0 +repository: https://git.hadak.org/dart/list_or +issue_tracker: https://git.hadak.org/dart/list_or/issues + +environment: + sdk: ^3.0.0 + +dev_dependencies: + lints: ^6.0.0 + test: ^1.25.6 diff --git a/test/list_or_test.dart b/test/list_or_test.dart new file mode 100644 index 0000000..9d43683 --- /dev/null +++ b/test/list_or_test.dart @@ -0,0 +1,138 @@ +import 'package:list_or/list_or.dart'; +import 'package:test/test.dart'; + +void main() { + group('ListOr Initialization', () { + test('creates from a single value', () { + final list = ListOr('hello'); + + expect(list.length, equals(1)); + expect(list.first, equals('hello')); + expect(list, equals(['hello'])); + }); + + test('creates from a List', () { + final list = ListOr([1, 2, 3]); + + expect(list.length, equals(3)); + expect(list, equals([1, 2, 3])); + }); + + test('creates from a Set (Iterable)', () { + final list = ListOr({4, 5, 6}); + + expect(list.length, equals(3)); + expect(list, equals([4, 5, 6])); + }); + + test('handles null correctly when T is nullable', () { + final list = ListOr(null); + + expect(list.length, equals(1)); + expect(list.first, isNull); + expect(list, equals([null])); + }); + + test('throws ArgumentError when null is passed but T is non-nullable', () { + expect( + () => ListOr(null), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('does not accept null values'), + ), + ), + ); + }); + + test('throws ArgumentError on incompatible types', () { + expect( + () => ListOr(123), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Value must be of type'), + ), + ), + ); + }); + }); + + group('ListOr Mutations (Bug Fix Verification)', () { + test('can add to a single-initialized list without throwing', () { + final list = ListOr('Apple'); + list.add('Banana'); // This would throw prior to the explicit override fix + + expect(list, equals(['Apple', 'Banana'])); + }); + + test('can addAll from another iterable', () { + final list = ListOr(1); + list.addAll([2, 3, 4]); + + expect(list, equals([1, 2, 3, 4])); + }); + + test('can insert at a specific index', () { + final list = ListOr(['A', 'C']); + list.insert(1, 'B'); + + expect(list, equals(['A', 'B', 'C'])); + }); + + test('can update elements using index operator', () { + final list = ListOr([10, 20, 30]); + list[1] = 99; + + expect(list, equals([10, 99, 30])); + }); + + test('can remove a specific element', () { + final list = ListOr(['A', 'B', 'C']); + final result = list.remove('B'); + + expect(result, isTrue); + expect(list, equals(['A', 'C'])); + }); + + test('can remove at a specific index', () { + final list = ListOr([1, 2, 3]); + final removed = list.removeAt(0); + + expect(removed, equals(1)); + expect(list, equals([2, 3])); + }); + + test('can clear the list entirely', () { + final list = ListOr(['A', 'B']); + list.clear(); + + expect(list, isEmpty); + expect(list.length, equals(0)); + }); + }); + + group('ListOr toString formatting', () { + test('formats a single element as just the element string', () { + final list = ListOr('hello'); + expect(list.toString(), equals('hello')); + }); + + test('formats multiple elements with standard list brackets', () { + final list = ListOr([1, 2, 3]); + expect(list.toString(), equals('[1, 2, 3]')); + }); + + test('formats empty list with standard empty brackets', () { + final list = ListOr([]); + expect(list.toString(), equals('[]')); + }); + + test('formats single null element correctly', () { + final list = ListOr(null); + expect(list.toString(), equals('null')); + }); + }); +}