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 @@ pokemon-logo

version-label - docker-image-size + docker-image-size ci-status-badge
@@ -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"