diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 41a9742..2fe9a04 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,7 +31,7 @@ on:
- main
env:
- VERSION_NUMBER: 'v1.9.4'
+ VERSION_NUMBER: 'v1.10.0'
DOCKERHUB_REGISTRY_NAME: 'digitalghostdev/poke-cli'
AWS_REGION: 'us-west-2'
@@ -96,7 +96,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
- go-version: '1.25.8'
+ go-version: '1.25.9'
- name: Build Go Binary
env:
diff --git a/.gitignore b/.gitignore
index 5d79543..7633e31 100644
--- a/.gitignore
+++ b/.gitignore
@@ -73,3 +73,11 @@ CLAUDE.md
REFACTORING.md
/card_data/.codspeed/
/.ai/
+
+# Version management
+VERSION
+version-bump.sh
+
+# Testing libraries
+.codspeed/
+.pytest_cache/
\ No newline at end of file
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 9228c2d..53c71ab 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -14,7 +14,7 @@ builds:
- windows
- darwin
ldflags:
- - -s -w -X main.version=v1.9.4
+ - -s -w -X main.version=v1.10.0
archives:
- formats: [ 'zip' ]
diff --git a/Dockerfile b/Dockerfile
index 1ddfbd5..01050a4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# build 1
-FROM golang:1.25.8-alpine3.23 AS build
+FROM golang:1.25.9-alpine3.23 AS build
WORKDIR /app
@@ -8,7 +8,7 @@ RUN go mod download
COPY . .
-RUN go build -ldflags "-X main.version=v1.9.4" -o poke-cli .
+RUN go build -ldflags "-X main.version=v1.10.0" -o poke-cli .
# build 2
FROM --platform=$BUILDPLATFORM alpine:3.23
diff --git a/README.md b/README.md
index c3fa3ea..fbc84d8 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-
+
@@ -99,11 +99,11 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an
3. Choose how to interact with the container:
* Run a single command and exit:
```bash
- docker run --rm -it digitalghostdev/poke-cli:v1.9.4 [subcommand] [flag]
+ docker run --rm -it digitalghostdev/poke-cli:v1.10.0 [subcommand] [flag]
```
* Enter the container and use its shell:
```bash
- docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.9.4 -c "cd /app && exec sh"
+ docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.10.0 -c "cd /app && exec sh"
# placed into the /app directory, run the program with './poke-cli'
# example: ./poke-cli ability swift-swim
```
@@ -112,13 +112,13 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an
> The `card` command renders TCG card images using your terminal's graphics protocol. When running inside Docker, pass your terminal's environment variables so image rendering works correctly:
> ```bash
> # Kitty
-> docker run --rm -it -e TERM -e KITTY_WINDOW_ID digitalghostdev/poke-cli:v1.9.4 card
+> docker run --rm -it -e TERM -e KITTY_WINDOW_ID digitalghostdev/poke-cli:v1.10.0 card
>
> # WezTerm, iTerm2, Ghostty, Konsole, Rio, Tabby
-> docker run --rm -it -e TERM -e TERM_PROGRAM digitalghostdev/poke-cli:v1.9.4 card
+> docker run --rm -it -e TERM -e TERM_PROGRAM digitalghostdev/poke-cli:v1.10.0 card
>
> # Windows Terminal (Sixel)
-> docker run --rm -it -e WT_SESSION digitalghostdev/poke-cli:v1.9.4 card
+> docker run --rm -it -e WT_SESSION digitalghostdev/poke-cli:v1.10.0 card
> ```
> If your terminal is not listed above, image rendering is not supported inside Docker.
diff --git a/card_data/pipelines/poke_cli_dbt/dbt_project.yml b/card_data/pipelines/poke_cli_dbt/dbt_project.yml
index dc9e11d..e5544fa 100644
--- a/card_data/pipelines/poke_cli_dbt/dbt_project.yml
+++ b/card_data/pipelines/poke_cli_dbt/dbt_project.yml
@@ -1,5 +1,5 @@
name: 'poke_cli_dbt'
-version: '1.9.4'
+version: 'v1.10.0'
profile: 'poke_cli_dbt'
diff --git a/card_data/pipelines/tests/extract_pricing_test.py b/card_data/pipelines/tests/extract_pricing_test.py
new file mode 100644
index 0000000..161ce40
--- /dev/null
+++ b/card_data/pipelines/tests/extract_pricing_test.py
@@ -0,0 +1,353 @@
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+import pytest
+import polars as pl
+import responses
+from pydantic import ValidationError
+from unittest.mock import patch
+
+from pipelines.defs.extract.tcgcsv.extract_pricing import (
+ build_dataframe,
+ extract_card_name,
+ get_card_number,
+ is_card,
+ normalize_card_number,
+ pull_product_information,
+ SET_PRODUCT_MATCHING,
+)
+
+
+# ---------------------------------------------------------------------------
+# is_card()
+# ---------------------------------------------------------------------------
+
+
+def test_is_card_with_number_field(benchmark):
+ item = {"extendedData": [{"name": "Number", "value": "025/195"}]}
+ assert benchmark(is_card, item) is True # nosec
+
+
+def test_is_card_no_extended_data(benchmark):
+ assert benchmark(is_card, {}) is False # nosec
+
+
+def test_is_card_empty_extended_data(benchmark):
+ assert benchmark(is_card, {"extendedData": []}) is False # nosec
+
+
+def test_is_card_no_number_field(benchmark):
+ item = {"extendedData": [{"name": "Color", "value": "Yellow"}]}
+ assert benchmark(is_card, item) is False # nosec
+
+
+# ---------------------------------------------------------------------------
+# get_card_number()
+# ---------------------------------------------------------------------------
+
+
+def test_get_card_number_found(benchmark):
+ card = {"extendedData": [{"name": "Number", "value": "025/195"}]}
+ assert benchmark(get_card_number, card) == "025/195" # nosec
+
+
+def test_get_card_number_no_extended_data(benchmark):
+ assert benchmark(get_card_number, {}) is None # nosec
+
+
+def test_get_card_number_no_number_field(benchmark):
+ card = {"extendedData": [{"name": "HP", "value": "60"}]}
+ assert benchmark(get_card_number, card) is None # nosec
+
+
+def test_get_card_number_no_value_key(benchmark):
+ card = {"extendedData": [{"name": "Number"}]}
+ assert benchmark(get_card_number, card) is None # nosec
+
+
+# ---------------------------------------------------------------------------
+# normalize_card_number()
+# ---------------------------------------------------------------------------
+
+
+def test_normalize_card_number_single_digit(benchmark):
+ assert benchmark(normalize_card_number, "1/149") == "001/149" # nosec
+
+
+def test_normalize_card_number_double_digit(benchmark):
+ assert benchmark(normalize_card_number, "10/149") == "010/149" # nosec
+
+
+def test_normalize_card_number_triple_digit(benchmark):
+ assert benchmark(normalize_card_number, "100/149") == "100/149" # nosec
+
+
+def test_normalize_card_number_non_numeric_parts(benchmark):
+ assert benchmark(normalize_card_number, "GG01/GG70") == "GG01/GG70" # nosec
+
+
+def test_normalize_card_number_no_slash_passthrough(benchmark):
+ assert benchmark(normalize_card_number, "SWSH001") == "SWSH001" # nosec
+
+
+def test_normalize_card_number_already_padded(benchmark):
+ assert benchmark(normalize_card_number, "001/149") == "001/149" # nosec
+
+
+# ---------------------------------------------------------------------------
+# extract_card_name()
+# ---------------------------------------------------------------------------
+
+
+def test_extract_card_name_plain(benchmark):
+ assert benchmark(extract_card_name, "Pikachu") == "Pikachu" # nosec
+
+
+def test_extract_card_name_strip_dash_variant(benchmark):
+ assert benchmark(extract_card_name, "Pikachu - 045/195") == "Pikachu" # nosec
+
+
+def test_extract_card_name_strip_parenthetical_number(benchmark):
+ assert benchmark(extract_card_name, "Pikachu (010)") == "Pikachu" # nosec
+
+
+def test_extract_card_name_strip_full_art(benchmark):
+ assert benchmark(extract_card_name, "Charizard (Full Art)") == "Charizard" # nosec
+
+
+def test_extract_card_name_strip_secret(benchmark):
+ assert benchmark(extract_card_name, "Charizard (Secret)") == "Charizard" # nosec
+
+
+def test_extract_card_name_strip_reverse_holofoil(benchmark):
+ assert benchmark(extract_card_name, "Pikachu (Reverse Holofoil)") == "Pikachu" # nosec
+
+
+def test_extract_card_name_strip_gold(benchmark):
+ assert benchmark(extract_card_name, "Pikachu (Gold)") == "Pikachu" # nosec
+
+
+def test_extract_card_name_accented_characters(benchmark):
+ assert benchmark(extract_card_name, "Flabébé - 088/195") == "Flabebe" # nosec
+
+
+def test_extract_card_name_dash_and_variant_suffix(benchmark):
+ assert benchmark(extract_card_name, "Pikachu - 045/195 (Full Art)") == "Pikachu" # nosec
+
+
+def test_extract_card_name_unknown_variant_not_stripped(benchmark):
+ # variants not in the hardcoded list are left in place
+ assert benchmark(extract_card_name, "Pikachu (Shiny)") == "Pikachu (Shiny)" # nosec
+
+
+# ---------------------------------------------------------------------------
+# pull_product_information()
+# ---------------------------------------------------------------------------
+
+
+def _make_product(product_id: int, name: str, card_number: str) -> dict:
+ return {
+ "productId": product_id,
+ "name": name,
+ "extendedData": [{"name": "Number", "value": card_number}],
+ }
+
+
+def _make_price(product_id: int, market_price: float | None, sub_type: str = "Normal") -> dict:
+ return {"productId": product_id, "marketPrice": market_price, "subTypeName": sub_type}
+
+
+@responses.activate
+def test_pull_product_information_success(benchmark):
+ product_id = "22873" # sv01
+ responses.add(
+ responses.GET,
+ f"https://tcgcsv.com/tcgplayer/3/{product_id}/products",
+ json={"results": [
+ _make_product(1001, "Pikachu", "025/198"),
+ _make_product(1002, "Charizard", "006/198"),
+ ]},
+ status=200,
+ )
+ responses.add(
+ responses.GET,
+ f"https://tcgcsv.com/tcgplayer/3/{product_id}/prices",
+ json={"results": [
+ _make_price(1001, 1.50),
+ _make_price(1002, None),
+ ]},
+ status=200,
+ )
+
+ df = benchmark(pull_product_information, "sv01")
+
+ assert isinstance(df, pl.DataFrame) # nosec
+ assert len(df) == 2 # nosec
+ assert df.filter(pl.col("name") == "Pikachu")["market_price"].to_list() == [1.50] # nosec
+ assert df.filter(pl.col("name") == "Charizard")["market_price"].to_list() == [None] # nosec
+
+
+@responses.activate
+def test_pull_product_information_skips_variants(benchmark):
+ product_id = "22873" # sv01
+ responses.add(
+ responses.GET,
+ f"https://tcgcsv.com/tcgplayer/3/{product_id}/products",
+ json={"results": [
+ _make_product(1001, "Pikachu", "025/198"),
+ _make_product(1002, "Pikachu (Poke Ball Pattern)", "025/198"),
+ _make_product(1003, "Pikachu (Master Ball Pattern)", "025/198"),
+ ]},
+ status=200,
+ )
+ responses.add(
+ responses.GET,
+ f"https://tcgcsv.com/tcgplayer/3/{product_id}/prices",
+ json={"results": [
+ _make_price(1001, 2.00),
+ _make_price(1002, 3.00),
+ _make_price(1003, 4.00),
+ ]},
+ status=200,
+ )
+
+ df = benchmark(pull_product_information, "sv01")
+
+ assert len(df) == 1 # nosec
+ assert df["name"].to_list() == ["Pikachu"] # nosec
+
+
+@responses.activate
+def test_pull_product_information_skips_non_cards(benchmark):
+ product_id = "22873" # sv01
+ responses.add(
+ responses.GET,
+ f"https://tcgcsv.com/tcgplayer/3/{product_id}/products",
+ json={"results": [
+ _make_product(1001, "Pikachu", "025/198"),
+ {"productId": 1002, "name": "Booster Pack", "extendedData": []},
+ ]},
+ status=200,
+ )
+ responses.add(
+ responses.GET,
+ f"https://tcgcsv.com/tcgplayer/3/{product_id}/prices",
+ json={"results": [_make_price(1001, 1.00)]},
+ status=200,
+ )
+
+ df = benchmark(pull_product_information, "sv01")
+
+ assert len(df) == 1 # nosec
+ assert df["name"].to_list() == ["Pikachu"] # nosec
+
+
+@responses.activate
+def test_pull_product_information_sm_normalizes_card_number(benchmark):
+ product_id = "1863" # sm1
+ responses.add(
+ responses.GET,
+ f"https://tcgcsv.com/tcgplayer/3/{product_id}/products",
+ json={"results": [_make_product(2001, "Rowlet", "9/149")]},
+ status=200,
+ )
+ responses.add(
+ responses.GET,
+ f"https://tcgcsv.com/tcgplayer/3/{product_id}/prices",
+ json={"results": [_make_price(2001, 0.50)]},
+ status=200,
+ )
+
+ df = benchmark(pull_product_information, "sm1")
+
+ assert df["card_number"].to_list() == ["009/149"] # nosec
+
+
+@responses.activate
+def test_pull_product_information_excludes_reverse_holofoil_prices(benchmark):
+ product_id = "22873" # sv01 — both Normal and Reverse Holofoil entries for same card
+ responses.add(
+ responses.GET,
+ f"https://tcgcsv.com/tcgplayer/3/{product_id}/products",
+ json={"results": [_make_product(1001, "Pikachu", "025/198")]},
+ status=200,
+ )
+ responses.add(
+ responses.GET,
+ f"https://tcgcsv.com/tcgplayer/3/{product_id}/prices",
+ json={"results": [
+ _make_price(1001, 5.00, "Reverse Holofoil"),
+ _make_price(1001, 1.50, "Normal"),
+ ]},
+ status=200,
+ )
+
+ df = benchmark(pull_product_information, "sv01")
+
+ # Reverse Holofoil price entry is skipped; Normal price is used
+ assert df["market_price"].to_list() == [1.50] # nosec
+
+
+@responses.activate
+def test_pull_product_information_validation_error_raises(benchmark):
+ product_id = "22873" # sv01
+ responses.add(
+ responses.GET,
+ f"https://tcgcsv.com/tcgplayer/3/{product_id}/products",
+ json={"results": [
+ {
+ "productId": "not-an-integer",
+ "name": "Bad Card",
+ "extendedData": [{"name": "Number", "value": "999/198"}],
+ }
+ ]},
+ status=200,
+ )
+ responses.add(
+ responses.GET,
+ f"https://tcgcsv.com/tcgplayer/3/{product_id}/prices",
+ json={"results": []},
+ status=200,
+ )
+
+ def run():
+ with pytest.raises(ValidationError):
+ pull_product_information("sv01")
+
+ benchmark(run)
+
+
+# ---------------------------------------------------------------------------
+# build_dataframe()
+# ---------------------------------------------------------------------------
+
+
+@patch("pipelines.defs.extract.tcgcsv.extract_pricing.pull_product_information")
+def test_build_dataframe_concatenates_all_sets(mock_pull, benchmark):
+ sample_df = pl.DataFrame({
+ "product_id": [1001],
+ "name": ["Pikachu"],
+ "card_number": ["025/198"],
+ "market_price": [1.50],
+ })
+ mock_pull.return_value = sample_df
+
+ result = benchmark(build_dataframe)
+
+ assert isinstance(result, pl.DataFrame) # nosec
+ assert len(result) == len(SET_PRODUCT_MATCHING) # one row per set # nosec
+ assert result.columns == ["product_id", "name", "card_number", "market_price"] # nosec
+ assert result.dtypes == sample_df.dtypes # nosec
+
+
+@patch("pipelines.defs.extract.tcgcsv.extract_pricing.pull_product_information")
+def test_build_dataframe_raises_on_empty_dataframe(mock_pull, benchmark):
+ mock_pull.return_value = pl.DataFrame()
+
+ def run():
+ with pytest.raises(ValueError, match="Empty DataFrame"):
+ build_dataframe()
+
+ benchmark(run)
diff --git a/card_data/pipelines/tests/extract_series_test.py b/card_data/pipelines/tests/extract_series_test.py
index 0dbc230..999a5b8 100644
--- a/card_data/pipelines/tests/extract_series_test.py
+++ b/card_data/pipelines/tests/extract_series_test.py
@@ -5,7 +5,9 @@
import pytest
import polars as pl
+import requests
import responses
+from pydantic import ValidationError
from pipelines.defs.extract.tcgdex.extract_series import extract_series_data
@@ -38,3 +40,53 @@ def test_extract_series_data_success(benchmark, mock_api_response):
assert set(result["id"].to_list()) == {"swsh", "sv", "me", "sm"} # nosec
assert "name" in result.columns # nosec
assert "logo" in result.columns # nosec
+
+
+@responses.activate
+def test_extract_series_data_validation_error(benchmark):
+ """Test that Pydantic ValidationError propagates when a required field is missing."""
+ responses.add(
+ responses.GET,
+ "https://api.tcgdex.net/v2/en/series",
+ json=[{"logo": "https://example.com/test.png"}], # missing required 'id' and 'name'
+ status=200,
+ )
+
+ def run():
+ with pytest.raises(ValidationError):
+ extract_series_data()
+
+ benchmark(run)
+
+
+@responses.activate
+def test_extract_series_data_http_error(benchmark):
+ """Test that an HTTP 500 from the API propagates as HTTPError."""
+ responses.add(
+ responses.GET,
+ "https://api.tcgdex.net/v2/en/series",
+ json={"error": "internal server error"},
+ status=500,
+ )
+
+ def run():
+ with pytest.raises(requests.exceptions.HTTPError):
+ extract_series_data()
+
+ benchmark(run)
+
+
+@responses.activate
+def test_extract_series_data_all_filtered_out(benchmark):
+ """Test that an empty DataFrame is returned when no series match the allowed IDs."""
+ responses.add(
+ responses.GET,
+ "https://api.tcgdex.net/v2/en/series",
+ json=[{"id": "bw", "name": "Black & White", "logo": None}],
+ status=200,
+ )
+
+ result = benchmark(extract_series_data)
+
+ assert isinstance(result, pl.DataFrame) # nosec
+ assert result.is_empty() # nosec
diff --git a/card_data/pipelines/tests/sensors_test.py b/card_data/pipelines/tests/sensors_test.py
new file mode 100644
index 0000000..0f501b9
--- /dev/null
+++ b/card_data/pipelines/tests/sensors_test.py
@@ -0,0 +1,104 @@
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+import requests
+from unittest.mock import patch, MagicMock
+
+from pipelines.sensors import discord_success_sensor, discord_failure_sensor
+
+# Access raw Python functions — the Dagster decorator wraps them in a SensorDefinition
+_success_fn = discord_success_sensor._run_status_sensor_fn
+_failure_fn = discord_failure_sensor._run_status_sensor_fn
+
+
+def _make_context(run_id: str = "test-run-id", job_name: str = "test-job") -> MagicMock:
+ ctx = MagicMock()
+ ctx.dagster_run.run_id = run_id
+ ctx.dagster_run.job_name = job_name
+ return ctx
+
+
+# ---------------------------------------------------------------------------
+# discord_success_sensor()
+# ---------------------------------------------------------------------------
+
+
+@patch("pipelines.sensors.fetch_n8n_webhook_secret", return_value="https://n8n.example.com/hook")
+@patch("pipelines.sensors.requests.post")
+def test_discord_success_sensor_posts_webhook(mock_post, mock_secret, benchmark):
+ mock_post.return_value.status_code = 200
+ ctx = _make_context()
+
+ benchmark(_success_fn, ctx)
+
+ mock_post.assert_called_with(
+ "https://n8n.example.com/hook",
+ json={"job_name": "test-job", "status": "SUCCESS", "run_id": "test-run-id"},
+ timeout=10,
+ )
+
+
+@patch("pipelines.sensors.fetch_n8n_webhook_secret", return_value="https://n8n.example.com/hook")
+@patch("pipelines.sensors.requests.post", side_effect=requests.RequestException("connection refused"))
+def test_discord_success_sensor_handles_request_exception(mock_post, mock_secret, benchmark):
+ ctx = _make_context()
+
+ benchmark(_success_fn, ctx) # must not raise
+
+ assert ctx.log.error.called # nosec
+ assert "connection refused" in ctx.log.error.call_args[0][0] # nosec
+
+
+@patch("pipelines.sensors.fetch_n8n_webhook_secret", return_value="https://n8n.example.com/hook")
+@patch("pipelines.sensors.requests.post", side_effect=Exception("unexpected error"))
+def test_discord_success_sensor_handles_generic_exception(mock_post, mock_secret, benchmark):
+ ctx = _make_context()
+
+ benchmark(_success_fn, ctx) # must not raise
+
+ assert ctx.log.error.called # nosec
+ assert "unexpected error" in ctx.log.error.call_args[0][0] # nosec
+
+
+# ---------------------------------------------------------------------------
+# discord_failure_sensor()
+# ---------------------------------------------------------------------------
+
+
+@patch("pipelines.sensors.fetch_n8n_webhook_secret", return_value="https://n8n.example.com/hook")
+@patch("pipelines.sensors.requests.post")
+def test_discord_failure_sensor_posts_webhook(mock_post, mock_secret, benchmark):
+ mock_post.return_value.status_code = 200
+ ctx = _make_context()
+
+ benchmark(_failure_fn, ctx)
+
+ mock_post.assert_called_with(
+ "https://n8n.example.com/hook",
+ json={"job_name": "test-job", "status": "FAILURE", "run_id": "test-run-id"},
+ timeout=10,
+ )
+
+
+@patch("pipelines.sensors.fetch_n8n_webhook_secret", return_value="https://n8n.example.com/hook")
+@patch("pipelines.sensors.requests.post", side_effect=requests.RequestException("timeout"))
+def test_discord_failure_sensor_handles_request_exception(mock_post, mock_secret, benchmark):
+ ctx = _make_context()
+
+ benchmark(_failure_fn, ctx) # must not raise
+
+ assert ctx.log.error.called # nosec
+ assert "timeout" in ctx.log.error.call_args[0][0] # nosec
+
+
+@patch("pipelines.sensors.fetch_n8n_webhook_secret", return_value="https://n8n.example.com/hook")
+@patch("pipelines.sensors.requests.post", side_effect=Exception("service unavailable"))
+def test_discord_failure_sensor_handles_generic_exception(mock_post, mock_secret, benchmark):
+ ctx = _make_context()
+
+ benchmark(_failure_fn, ctx) # must not raise
+
+ assert ctx.log.error.called # nosec
+ assert "service unavailable" in ctx.log.error.call_args[0][0] # nosec
diff --git a/card_data/pyproject.toml b/card_data/pyproject.toml
index 63ee660..db27956 100644
--- a/card_data/pyproject.toml
+++ b/card_data/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "card-data"
-version = "1.9.0"
+version = "v1.10.0"
description = "File directory to store all data related processes for the Pokémon TCG."
readme = "README.md"
requires-python = ">=3.12"
@@ -19,6 +19,7 @@ dependencies = [
"psycopg2-binary==2.9.10",
"pyarrow==20.0.0",
"pydantic==2.11.7",
+ "pytest-cov==7.1.0",
"requests==2.32.4",
"soda-core-postgres==3.5.5",
"sqlalchemy==2.0.41",
diff --git a/card_data/uv.lock b/card_data/uv.lock
index 9865d7c..eec09d4 100644
--- a/card_data/uv.lock
+++ b/card_data/uv.lock
@@ -139,7 +139,7 @@ wheels = [
[[package]]
name = "card-data"
-version = "1.9.0"
+version = "1.10.0"
source = { virtual = "." }
dependencies = [
{ name = "aws-secretsmanager-caching" },
@@ -156,6 +156,7 @@ dependencies = [
{ name = "psycopg2-binary" },
{ name = "pyarrow" },
{ name = "pydantic" },
+ { name = "pytest-cov" },
{ name = "requests" },
{ name = "soda-core-postgres" },
{ name = "sqlalchemy" },
@@ -189,6 +190,7 @@ requires-dist = [
{ name = "psycopg2-binary", specifier = "==2.9.10" },
{ name = "pyarrow", specifier = "==20.0.0" },
{ name = "pydantic", specifier = "==2.11.7" },
+ { name = "pytest-cov", specifier = "==7.1.0" },
{ name = "requests", specifier = "==2.32.4" },
{ name = "soda-core-postgres", specifier = "==3.5.5" },
{ name = "sqlalchemy", specifier = "==2.0.41" },
@@ -374,6 +376,90 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/2f/12747be360d6dea432e7b5dfae3419132cb008535cfe614af73b9ce2643b/coloredlogs-14.0-py2.py3-none-any.whl", hash = "sha256:346f58aad6afd48444c2468618623638dadab76e4e70d5e10822676f2d32226a", size = 43888, upload-time = "2020-02-16T20:51:09.712Z" },
]
+[[package]]
+name = "coverage"
+version = "7.13.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
+ { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
+ { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
+ { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
+ { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
+ { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
+ { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
+ { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
+ { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
+ { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
+ { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
+ { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
+ { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
+ { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
+ { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
+ { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
+ { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
+ { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
+ { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
+ { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
+ { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
+ { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
+ { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
+ { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
+ { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
+ { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
+]
+
[[package]]
name = "cryptography"
version = "46.0.3"
@@ -2098,6 +2184,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/25/0e/8cb71fd3ed4ed08c07aec1245aea7bc1b661ba55fd9c392db76f1978d453/pytest_codspeed-4.2.0-py3-none-any.whl", hash = "sha256:e81bbb45c130874ef99aca97929d72682733527a49f84239ba575b5cb843bab0", size = 113726, upload-time = "2025-10-24T09:02:54.785Z" },
]
+[[package]]
+name = "pytest-cov"
+version = "7.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage" },
+ { name = "pluggy" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
+]
+
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
diff --git a/cmd/berry/berry.go b/cmd/berry/berry.go
index 5b84a68..b0edb7d 100644
--- a/cmd/berry/berry.go
+++ b/cmd/berry/berry.go
@@ -36,11 +36,29 @@ func BerryCommand() (string, error) {
flag.Parse()
// Validate arguments
- if err := utils.ValidateArgs(os.Args, utils.Validator{MaxArgs: 3, CmdName: "berry", RequireName: false, HasFlags: false}); err != nil {
+ if err := utils.ValidateArgs(os.Args, utils.Validator{MaxArgs: 4, CmdName: "berry", RequireName: false, HasFlags: false}); err != nil {
output.WriteString(err.Error())
return output.String(), err
}
+ if len(os.Args) > 2 {
+ berryName := styling.CapitalizeResourceName(os.Args[2])
+ exists, err := berryExists(berryName)
+ if err != nil {
+ output.WriteString(utils.FormatError(err.Error()))
+ return output.String(), err
+ }
+ if !exists {
+ err := fmt.Errorf("berry %q not found", os.Args[2])
+ output.WriteString(utils.FormatError(err.Error()))
+ return output.String(), err
+ }
+ containers := berryContainers(berryName)
+ output.WriteString(containers)
+ output.WriteString("\n")
+ return output.String(), nil
+ }
+
if err := tableGeneration(); err != nil {
output.WriteString(err.Error())
return output.String(), err
@@ -94,7 +112,7 @@ func (m model) View() tea.View {
selectedBerry := ""
if row := m.table.SelectedRow(); len(row) > 0 {
- selectedBerry = BerryName(row[0]) + "\n---\n" + BerryEffect(row[0]) + "\n---\n" + BerryInfo(row[0]) + "\n---\nImage\n" + BerryImage(row[0])
+ selectedBerry = berryName(row[0]) + "\n---\n" + berryEffect(row[0]) + "\n---\n" + berryInfo(row[0]) + "\n---\nImage\n" + berryImage(row[0])
}
leftPanel := styling.TypesTableBorder.Render(m.table.View())
@@ -158,3 +176,30 @@ func tableGeneration() error {
return nil
}
+
+func berryContainers(name string) string {
+ header := lipgloss.NewStyle().Bold(true).PaddingBottom(1).Render(
+ styling.StyleBold.Render(styling.CapitalizeResourceName(name)),
+ )
+ infoContent := lipgloss.JoinVertical(lipgloss.Top, header, berryInfo(name), "\n"+berryEffect(name))
+ imageContent := berryImage(name)
+
+ boxStyle := lipgloss.NewStyle().
+ Padding(1, 2).
+ BorderStyle(lipgloss.ThickBorder()).
+ BorderForeground(styling.YellowColor).
+ Width(34)
+
+ // Render both without height constraints to measure natural heights.
+ infoH := lipgloss.Height(boxStyle.Render(infoContent))
+ imageH := lipgloss.Height(boxStyle.Render(imageContent))
+
+ // Pad the shorter content with blank lines before final render so both boxes match.
+ if infoH < imageH {
+ infoContent += strings.Repeat("\n", imageH-infoH)
+ } else if imageH < infoH {
+ imageContent += strings.Repeat("\n", infoH-imageH)
+ }
+
+ return lipgloss.JoinHorizontal(lipgloss.Top, boxStyle.Render(infoContent), boxStyle.Render(imageContent))
+}
diff --git a/cmd/berry/berry_test.go b/cmd/berry/berry_test.go
index 105e014..1176b26 100644
--- a/cmd/berry/berry_test.go
+++ b/cmd/berry/berry_test.go
@@ -10,6 +10,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/exp/teatest/v2"
+ "github.com/digitalghost-dev/poke-cli/cmd/utils"
"github.com/digitalghost-dev/poke-cli/styling"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -34,6 +35,12 @@ func TestBerryCommand(t *testing.T) {
wantErr: false,
contains: "FLAGS:",
},
+ {
+ name: "invalid berry name",
+ args: []string{"poke-cli", "berry", "fakemon"},
+ wantErr: true,
+ contains: "not found",
+ },
}
for _, tt := range tests {
@@ -258,6 +265,66 @@ func TestTableInitialSelection(t *testing.T) {
}
}
+func TestBerryCommandOutput(t *testing.T) {
+ err := os.Setenv("GO_TESTING", "1")
+ if err != nil {
+ t.Fatalf("Failed to set GO_TESTING env var: %v", err)
+ }
+
+ defer func() {
+ err := os.Unsetenv("GO_TESTING")
+ if err != nil {
+ t.Logf("Warning: failed to unset GO_TESTING: %v", err)
+ }
+ }()
+
+ tests := []struct {
+ name string
+ args []string
+ expectedOutput string
+ }{
+ {
+ name: "Select 'Cheri' berry",
+ args: []string{"berry", "Cheri"},
+ expectedOutput: utils.LoadGolden(t, "berry_cheri.golden"),
+ },
+ {
+ name: "Select 'Oran' berry",
+ args: []string{"berry", "Oran"},
+ expectedOutput: utils.LoadGolden(t, "berry_oran.golden"),
+ },
+ {
+ name: "Select 'Sitrus' berry",
+ args: []string{"berry", "Sitrus"},
+ expectedOutput: utils.LoadGolden(t, "berry_sitrus.golden"),
+ },
+ {
+ name: "Select 'Aguav' berry",
+ args: []string{"berry", "Aguav"},
+ expectedOutput: utils.LoadGolden(t, "berry_aguav.golden"),
+ },
+ {
+ name: "Select 'Chople' berry",
+ args: []string{"berry", "Chople"},
+ expectedOutput: utils.LoadGolden(t, "berry_chople.golden"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ originalArgs := os.Args
+ os.Args = append([]string{"poke-cli"}, tt.args...)
+ defer func() { os.Args = originalArgs }()
+
+ output, err := BerryCommand()
+ require.NoError(t, err, "BerryCommand failed: %v", err)
+ cleanOutput := styling.StripANSI(output)
+
+ assert.Equal(t, tt.expectedOutput, cleanOutput, "Output should match expected")
+ })
+ }
+}
+
func TestBerryCommandValidationError(t *testing.T) {
originalArgs := os.Args
defer func() { os.Args = originalArgs }()
diff --git a/cmd/berry/berryinfo.go b/cmd/berry/berryinfo.go
index 4aef543..a1ca79e 100644
--- a/cmd/berry/berryinfo.go
+++ b/cmd/berry/berryinfo.go
@@ -11,11 +11,21 @@ import (
"github.com/disintegration/imaging"
)
-func BerryName(berryName string) string {
+func berryExists(name string) (bool, error) {
+ results, err := connections.QueryBerryData(`
+ SELECT 1 FROM berries
+ WHERE UPPER(SUBSTR(name, 1, 1)) || SUBSTR(name, 2) = ?
+ LIMIT 1`,
+ name,
+ )
+ return len(results) > 0, err
+}
+
+func berryName(berryName string) string {
return "Berry: " + berryName
}
-func BerryEffect(berryName string) string {
+func berryEffect(berryName string) string {
berryEffect, err := connections.QueryBerryData(`
SELECT
effect
@@ -33,7 +43,7 @@ func BerryEffect(berryName string) string {
return berryEffect[0]
}
-func BerryInfo(berryName string) string {
+func berryInfo(berryName string) string {
berryInfo, err := connections.QueryBerryData(`
SELECT
'Firmness: ' || firmness || char(10) ||
@@ -54,7 +64,7 @@ func BerryInfo(berryName string) string {
return berryInfo[0]
}
-func BerryImage(berryName string) string {
+func berryImage(berryName string) string {
berryImage, err := connections.QueryBerryData(`
SELECT
sprite_url
diff --git a/cmd/berry/berryinfo_test.go b/cmd/berry/berryinfo_test.go
index 1e901ad..753d355 100644
--- a/cmd/berry/berryinfo_test.go
+++ b/cmd/berry/berryinfo_test.go
@@ -34,7 +34,7 @@ func TestBerryName(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := BerryName(tt.input)
+ result := berryName(tt.input)
if result != tt.expected {
t.Errorf("BerryName(%q) = %q, want %q", tt.input, result, tt.expected)
}
@@ -62,10 +62,10 @@ func TestBerryEffect(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := BerryEffect(tt.input)
+ result := berryEffect(tt.input)
if tt.input == "NonExistentBerry" || tt.input == "" {
if result != tt.expected {
- t.Errorf("BerryEffect(%q) = %q, want %q", tt.input, result, tt.expected)
+ t.Errorf("berryEffect(%q) = %q, want %q", tt.input, result, tt.expected)
}
}
})
@@ -92,10 +92,10 @@ func TestBerryInfo(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := BerryInfo(tt.input)
+ result := berryInfo(tt.input)
if tt.input == "NonExistentBerry" || tt.input == "" {
if result != tt.expected {
- t.Errorf("BerryInfo(%q) = %q, want %q", tt.input, result, tt.expected)
+ t.Errorf("berryInfo(%q) = %q, want %q", tt.input, result, tt.expected)
}
}
})
@@ -117,15 +117,15 @@ func TestBerryImageWithMockServer(t *testing.T) {
}))
defer server.Close()
- result := BerryImage("NonExistentBerry")
+ result := berryImage("NonExistentBerry")
expected := "Image information not available"
if result != expected {
- t.Errorf("BerryImage('NonExistentBerry') = %q, want %q", result, expected)
+ t.Errorf("berryImage('NonExistentBerry') = %q, want %q", result, expected)
}
- result = BerryImage("")
+ result = berryImage("")
if result != expected {
- t.Errorf("BerryImage('') = %q, want %q", result, expected)
+ t.Errorf("berryImage('') = %q, want %q", result, expected)
}
}
@@ -149,9 +149,9 @@ func TestBerryImageErrorHandling(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := BerryImage(tt.input)
+ result := berryImage(tt.input)
if result != tt.expected {
- t.Errorf("BerryImage(%q) = %q, want %q", tt.input, result, tt.expected)
+ t.Errorf("berryImage(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
@@ -170,9 +170,9 @@ func TestToStringStructure(t *testing.T) {
}
}
- // Test the ToString function indirectly by checking that BerryImage
+ // Test the ToString function indirectly by checking that berryImage
// with invalid input returns the expected error message
- result := BerryImage("InvalidBerry")
+ result := berryImage("InvalidBerry")
if !strings.Contains(result, "information not available") {
t.Errorf("Expected error message for invalid berry, got: %q", result)
}
@@ -187,20 +187,20 @@ func TestBerryFunctionsErrorHandling(t *testing.T) {
contains string
}{
{
- name: "BerryEffect with invalid input",
- function: BerryEffect,
+ name: "berryEffect with invalid input",
+ function: berryEffect,
input: "InvalidBerry123",
contains: "not available",
},
{
- name: "BerryInfo with invalid input",
- function: BerryInfo,
+ name: "berryInfo with invalid input",
+ function: berryInfo,
input: "InvalidBerry123",
contains: "not available",
},
{
- name: "BerryImage with invalid input",
- function: BerryImage,
+ name: "berryImage with invalid input",
+ function: berryImage,
input: "InvalidBerry123",
contains: "not available",
},
diff --git a/cmd/card/cardinfo.go b/cmd/card/cardinfo.go
index 190430b..43bbdb3 100644
--- a/cmd/card/cardinfo.go
+++ b/cmd/card/cardinfo.go
@@ -83,7 +83,7 @@ func CardImage(imageURL string) (imageData string, protocol string, err error) {
}
parsedURL, err := url.Parse(imageURL)
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
- return "", "", errors.New("invalid URL scheme")
+ return "", "", errors.New("image is not available from the API")
}
resp, err := client.Get(imageURL)
if err != nil {
diff --git a/cmd/pokemon/render_test.go b/cmd/pokemon/render_test.go
index 1d4c338..98635f1 100644
--- a/cmd/pokemon/render_test.go
+++ b/cmd/pokemon/render_test.go
@@ -165,8 +165,12 @@ func TestRenderEffortValues(t *testing.T) {
Name string `json:"name"`
} `json:"stat"`
}{
- {Effort: 2, Stat: struct{ Name string `json:"name"` }{Name: "speed"}},
- {Effort: 0, Stat: struct{ Name string `json:"name"` }{Name: "attack"}},
+ {Effort: 2, Stat: struct {
+ Name string `json:"name"`
+ }{Name: "speed"}},
+ {Effort: 0, Stat: struct {
+ Name string `json:"name"`
+ }{Name: "attack"}},
},
},
contains: "2 Spd",
@@ -182,7 +186,9 @@ func TestRenderEffortValues(t *testing.T) {
Name string `json:"name"`
} `json:"stat"`
}{
- {Effort: 1, Stat: struct{ Name string `json:"name"` }{Name: "mystery-stat"}},
+ {Effort: 1, Stat: struct {
+ Name string `json:"name"`
+ }{Name: "mystery-stat"}},
},
},
contains: "Missing from API",
@@ -197,7 +203,9 @@ func TestRenderEffortValues(t *testing.T) {
Name string `json:"name"`
} `json:"stat"`
}{
- {Effort: 0, Stat: struct{ Name string `json:"name"` }{Name: "hp"}},
+ {Effort: 0, Stat: struct {
+ Name string `json:"name"`
+ }{Name: "hp"}},
},
},
contains: "Effort Values:",
diff --git a/cmd/types/damage_table.go b/cmd/types/damage_table.go
index 572b3da..1e931e9 100644
--- a/cmd/types/damage_table.go
+++ b/cmd/types/damage_table.go
@@ -14,7 +14,12 @@ import (
)
// DamageTable Function to display type details after a type is selected
-func DamageTable(typesName string, endpoint string) {
+func DamageTable(typesName string, endpoint string) error {
+ typesStruct, typeName, err := connections.TypesApiCall(endpoint, typesName, connections.APIURL)
+ if err != nil {
+ return err
+ }
+
// Setting up variables to style the list
var columnWidth = 11
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
@@ -25,8 +30,6 @@ func DamageTable(typesName string, endpoint string) {
var listItem = lipgloss.NewStyle().Render
var docStyle = lipgloss.NewStyle().Padding(1, 1, 1, 1)
- typesStruct, typeName, _ := connections.TypesApiCall(endpoint, typesName, connections.APIURL)
-
// Format selected type
selectedType := cases.Title(language.English).String(typeName)
coloredType := lipgloss.NewStyle().Foreground(lipgloss.Color(styling.GetTypeColor(typeName))).Render(selectedType)
@@ -99,4 +102,6 @@ func DamageTable(typesName string, endpoint string) {
// Print the rendered document
fmt.Println(docStyle.Render(doc.String()))
+
+ return nil
}
diff --git a/cmd/types/damage_table_test.go b/cmd/types/damage_table_test.go
index e14a7db..ae1b9cc 100644
--- a/cmd/types/damage_table_test.go
+++ b/cmd/types/damage_table_test.go
@@ -19,7 +19,9 @@ func TestDamageTable(t *testing.T) {
os.Stdout = w
- DamageTable("fire", "type")
+ if err := DamageTable("fire", "type"); err != nil {
+ t.Fatalf("DamageTable returned an error: %v", err)
+ }
err = w.Close()
if err != nil {
@@ -43,3 +45,17 @@ func TestDamageTable(t *testing.T) {
t.Errorf("Expected output to contain 'Damage Chart:', got:\n%s", output)
}
}
+
+func TestDamageTable_TypeNotFound(t *testing.T) {
+ err := DamageTable("notatype", "type")
+ if err == nil {
+ t.Fatal("expected an error for unknown type, got nil")
+ }
+ actual := styling.StripANSI(err.Error())
+ if !strings.Contains(actual, "Type not found") {
+ t.Errorf("expected error to contain 'Type not found', got: %s", actual)
+ }
+ if !strings.Contains(actual, "Perhaps a typo?") {
+ t.Errorf("expected error to contain 'Perhaps a typo?', got: %s", actual)
+ }
+}
diff --git a/cmd/types/types.go b/cmd/types/types.go
index e69b093..7ae0128 100644
--- a/cmd/types/types.go
+++ b/cmd/types/types.go
@@ -138,7 +138,9 @@ func runTypeSelectionTable(endpoint string) error {
}
if finalModel, ok := programModel.(model); ok && finalModel.selectedOption != "" {
- DamageTable(strings.ToLower(finalModel.selectedOption), endpoint)
+ if err := DamageTable(strings.ToLower(finalModel.selectedOption), endpoint); err != nil {
+ return err
+ }
}
return nil
diff --git a/flags/abilityflagset.go b/flags/abilityflagset.go
index 8adfa15..d82cfba 100644
--- a/flags/abilityflagset.go
+++ b/flags/abilityflagset.go
@@ -37,7 +37,10 @@ func SetupAbilityFlagSet() *AbilityFlags {
}
func PokemonAbilitiesFlag(w io.Writer, endpoint string, abilityName string) error {
- abilitiesStruct, _, _ := connections.AbilityApiCall(endpoint, abilityName, connections.APIURL)
+ abilitiesStruct, _, err := connections.AbilityApiCall(endpoint, abilityName, connections.APIURL)
+ if err != nil {
+ return err
+ }
capitalizedEffect := cases.Title(language.English).String(strings.ReplaceAll(abilityName, "-", " "))
diff --git a/flags/abilityflagset_test.go b/flags/abilityflagset_test.go
index eb92de5..f1a9cd6 100644
--- a/flags/abilityflagset_test.go
+++ b/flags/abilityflagset_test.go
@@ -34,6 +34,16 @@ func TestSetupAbilityFlagSet(t *testing.T) {
}
}
+func TestPokemonAbilitiesFlag_AbilityNotFound(t *testing.T) {
+ var buf bytes.Buffer
+ err := PokemonAbilitiesFlag(&buf, "ability", "notarealability")
+ require.Error(t, err)
+
+ actual := styling.StripANSI(err.Error())
+ assert.Contains(t, actual, "Ability not found")
+ assert.Contains(t, actual, "Perhaps a typo?")
+}
+
func TestPokemonFlag(t *testing.T) {
var output bytes.Buffer
stdout := os.Stdout
diff --git a/flags/pokemonflagset.go b/flags/pokemonflagset.go
index 27cfda0..1f2047d 100644
--- a/flags/pokemonflagset.go
+++ b/flags/pokemonflagset.go
@@ -101,10 +101,13 @@ func SetupPokemonFlagSet() *PokemonFlags {
}
func AbilitiesFlag(w io.Writer, endpoint string, pokemonName string) error {
- pokemonStruct, _, _ := connections.PokemonApiCall(endpoint, pokemonName, connections.APIURL)
+ pokemonStruct, _, err := connections.PokemonApiCall(endpoint, pokemonName, connections.APIURL)
+ if err != nil {
+ return err
+ }
// Print the header from header func
- _, err := fmt.Fprintln(w, header("Abilities"))
+ _, err = fmt.Fprintln(w, header("Abilities"))
if err != nil {
return err
}
@@ -130,10 +133,13 @@ func AbilitiesFlag(w io.Writer, endpoint string, pokemonName string) error {
}
func DefenseFlag(w io.Writer, endpoint string, pokemonName string) error {
- pokemonStruct, _, _ := connections.PokemonApiCall(endpoint, pokemonName, connections.APIURL)
+ pokemonStruct, _, err := connections.PokemonApiCall(endpoint, pokemonName, connections.APIURL)
+ if err != nil {
+ return err
+ }
// Print the header from header func
- _, err := fmt.Fprintln(w, header("Type Defenses"))
+ _, err = fmt.Fprintln(w, header("Type Defenses"))
if err != nil {
return err
}
@@ -144,7 +150,10 @@ func DefenseFlag(w io.Writer, endpoint string, pokemonName string) error {
typeData := make(map[string]structs.TypesJSONStruct)
for _, pokeType := range pokemonStruct.Types {
- typeStruct, _, _ := connections.TypesApiCall("type", pokeType.Type.Name, connections.APIURL)
+ typeStruct, _, err := connections.TypesApiCall("type", pokeType.Type.Name, connections.APIURL)
+ if err != nil {
+ return err
+ }
typeData[pokeType.Type.Name] = typeStruct
}
@@ -314,10 +323,13 @@ func DefenseFlag(w io.Writer, endpoint string, pokemonName string) error {
}
func ImageFlag(w io.Writer, endpoint string, pokemonName string, size string) error {
- pokemonStruct, _, _ := connections.PokemonApiCall(endpoint, pokemonName, connections.APIURL)
+ pokemonStruct, _, err := connections.PokemonApiCall(endpoint, pokemonName, connections.APIURL)
+ if err != nil {
+ return err
+ }
// Print the header from header func
- _, err := fmt.Fprintln(w, header("Image"))
+ _, err = fmt.Fprintln(w, header("Image"))
if err != nil {
return err
}
@@ -402,9 +414,12 @@ func ImageFlag(w io.Writer, endpoint string, pokemonName string, size string) er
}
func MovesFlag(w io.Writer, endpoint string, pokemonName string) error {
- pokemonStruct, _, _ := connections.PokemonApiCall(endpoint, pokemonName, connections.APIURL)
+ pokemonStruct, _, err := connections.PokemonApiCall(endpoint, pokemonName, connections.APIURL)
+ if err != nil {
+ return err
+ }
- _, err := fmt.Fprintln(w, header("Learnable Moves"))
+ _, err = fmt.Fprintln(w, header("Learnable Moves"))
if err != nil {
return err
}
@@ -549,10 +564,13 @@ func MovesFlag(w io.Writer, endpoint string, pokemonName string) error {
}
func StatsFlag(w io.Writer, endpoint string, pokemonName string) error {
- pokemonStruct, _, _ := connections.PokemonApiCall(endpoint, pokemonName, connections.APIURL)
+ pokemonStruct, _, err := connections.PokemonApiCall(endpoint, pokemonName, connections.APIURL)
+ if err != nil {
+ return err
+ }
// Print the header from header func
- _, err := fmt.Fprintln(w, header("Base Stats"))
+ _, err = fmt.Fprintln(w, header("Base Stats"))
if err != nil {
return err
}
@@ -644,10 +662,13 @@ func StatsFlag(w io.Writer, endpoint string, pokemonName string) error {
}
func TypesFlag(w io.Writer, endpoint string, pokemonName string) error {
- pokemonStruct, _, _ := connections.PokemonApiCall(endpoint, pokemonName, connections.APIURL)
+ pokemonStruct, _, err := connections.PokemonApiCall(endpoint, pokemonName, connections.APIURL)
+ if err != nil {
+ return err
+ }
// Print the header from header func
- _, err := fmt.Fprintln(w, header("Typing"))
+ _, err = fmt.Fprintln(w, header("Typing"))
if err != nil {
return err
}
diff --git a/go.mod b/go.mod
index 8d0334e..d88d963 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/digitalghost-dev/poke-cli
-go 1.25.8
+go 1.25.9
require (
charm.land/bubbles/v2 v2.1.0
diff --git a/nfpm.yaml b/nfpm.yaml
index 86c4449..a8715ad 100644
--- a/nfpm.yaml
+++ b/nfpm.yaml
@@ -1,7 +1,7 @@
name: "poke-cli"
arch: "arm64"
platform: "linux"
-version: "v1.9.4"
+version: "v1.10.0"
section: "default"
version_schema: semver
maintainer: "Christian S"
diff --git a/structs/structs.go b/structs/structs.go
index 9b6e495..c3d8683 100644
--- a/structs/structs.go
+++ b/structs/structs.go
@@ -142,6 +142,10 @@ type PokemonJSONStruct struct {
Hidden bool `json:"hidden"`
Slot int `json:"slot"`
} `json:"abilities"`
+ Cries struct {
+ Latest string `json:"latest"`
+ Legacy string `json:"legacy"`
+ } `json:"cries"`
Moves []struct {
Move struct {
Name string `json:"name"`
diff --git a/testdata/berry_aguav.golden b/testdata/berry_aguav.golden
new file mode 100644
index 0000000..3dd0733
--- /dev/null
+++ b/testdata/berry_aguav.golden
@@ -0,0 +1,19 @@
+┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+┃ ┃┃ ┃
+┃ Aguav ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Firmness: super-hard ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Smoothness: 25 ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Growth Time: 5 hours ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Max Harvest: 5 ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ When holder has less than ¼ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ of their max HP, restores ⅓ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ of max HP, but confuses the ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ holder if it dislikes the ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ bitter flavor ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ┃
+┃ ┃┃ ┃
+┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
diff --git a/testdata/berry_cheri.golden b/testdata/berry_cheri.golden
new file mode 100644
index 0000000..156f783
--- /dev/null
+++ b/testdata/berry_cheri.golden
@@ -0,0 +1,19 @@
+┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+┃ ┃┃ ┃
+┃ Cheri ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Firmness: soft ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Smoothness: 25 ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Growth Time: 3 hours ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Max Harvest: 5 ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ When paralyzed, cures ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ paralysis ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ┃
+┃ ┃┃ ┃
+┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
diff --git a/testdata/berry_chople.golden b/testdata/berry_chople.golden
new file mode 100644
index 0000000..8b13682
--- /dev/null
+++ b/testdata/berry_chople.golden
@@ -0,0 +1,19 @@
+┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+┃ ┃┃ ┃
+┃ Chople ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Firmness: soft ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Smoothness: 30 ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Growth Time: 18 hours ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Max Harvest: 5 ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ When holder is hit by a ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ supereffective Fighting-type ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ move, halves the damage ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ┃
+┃ ┃┃ ┃
+┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
diff --git a/testdata/berry_oran.golden b/testdata/berry_oran.golden
new file mode 100644
index 0000000..23d1dfd
--- /dev/null
+++ b/testdata/berry_oran.golden
@@ -0,0 +1,19 @@
+┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+┃ ┃┃ ┃
+┃ Oran ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Firmness: super-hard ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Smoothness: 20 ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Growth Time: 4 hours ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Max Harvest: 5 ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ When holder has less than ½ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ of their max HP, restores 10 ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ HP ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ┃
+┃ ┃┃ ┃
+┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
diff --git a/testdata/berry_sitrus.golden b/testdata/berry_sitrus.golden
new file mode 100644
index 0000000..925c1fc
--- /dev/null
+++ b/testdata/berry_sitrus.golden
@@ -0,0 +1,19 @@
+┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+┃ ┃┃ ┃
+┃ Sitrus ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Firmness: very-hard ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Smoothness: 20 ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Growth Time: 8 hours ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ Max Harvest: 5 ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ When holder has less than ½ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ of their max HP, restores ¼ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ of max HP ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ┃
+┃ ┃┃ ┃
+┃ ┃┃ ┃
+┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
diff --git a/testdata/main_latest_flag.golden b/testdata/main_latest_flag.golden
index 2848c86..adb69f9 100644
--- a/testdata/main_latest_flag.golden
+++ b/testdata/main_latest_flag.golden
@@ -2,6 +2,6 @@
┃ ┃
┃ Latest available release ┃
┃ on GitHub: ┃
-┃ • v1.9.3 ┃
+┃ • v1.9.4 ┃
┃ ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
diff --git a/web/pyproject.toml b/web/pyproject.toml
index b717b61..5f4b931 100644
--- a/web/pyproject.toml
+++ b/web/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "web"
-version = "1.9.4"
+version = "v1.10.0"
description = "Streamlit dashboard for browsing and visualizing Pokémon TCG tournament standings and results."
readme = "README.md"
requires-python = ">=3.12"