diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8988a2b..13e130a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ on: - main env: - VERSION_NUMBER: 'v1.8.1' + VERSION_NUMBER: 'v1.8.2' DOCKERHUB_REGISTRY_NAME: 'digitalghostdev/poke-cli' AWS_REGION: 'us-west-2' diff --git a/.gitignore b/.gitignore index 30e3fbd..1b8277f 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ card_data/pipelines/poke_cli_dbt/.user.yml /card_data/sample_scripts/ card_data/~/ +card_data/storage/ +/.claude/ +CLAUDE.md diff --git a/.goreleaser.yml b/.goreleaser.yml index 88a2427..8a53370 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -14,7 +14,7 @@ builds: - windows - darwin ldflags: - - -s -w -X main.version=v1.8.1 + - -s -w -X main.version=v1.8.2 archives: - formats: [ 'zip' ] diff --git a/Dockerfile b/Dockerfile index 327d079..f97a844 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # build 1 -FROM golang:1.24.9-alpine3.22 AS build +FROM golang:1.24.11-alpine3.23 AS build WORKDIR /app @@ -8,13 +8,13 @@ RUN go mod download COPY . . -RUN go build -ldflags "-X main.version=v1.8.1" -o poke-cli . +RUN go build -ldflags "-X main.version=v1.8.2" -o poke-cli . # build 2 -FROM --platform=$BUILDPLATFORM alpine:3.22 +FROM --platform=$BUILDPLATFORM alpine:3.23 # Installing only necessary packages and remove them after use -RUN apk add --no-cache shadow=4.17.3-r0 && \ +RUN apk add --no-cache shadow=4.18.0-r0 && \ addgroup -S poke_group && adduser -S poke_user -G poke_group && \ sed -i 's/^root:.*/root:!*:0:0:root:\/root:\/sbin\/nologin/' /etc/passwd && \ apk del shadow diff --git a/README.md b/README.md index 5eef998..43444b9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ pokemon-logo

Pokémon CLI

version-label - docker-image-size + docker-image-size ci-status-badge
@@ -95,11 +95,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.8.1 [subcommand] [flag] + docker run --rm -it digitalghostdev/poke-cli:v1.8.2 [subcommand] [flag] ``` * Enter the container and use its shell: ```bash - docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.8.1 -c "cd /app && exec sh" + docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.8.2 -c "cd /app && exec sh" # placed into the /app directory, run the program with './poke-cli' # example: ./poke-cli ability swift-swim ``` @@ -192,7 +192,7 @@ Below is a list of the planned/completed commands and flags: - [ ] `card`: get data about a TCG card. - [x] add mega evolution data - [x] add scarlet & violet data - - [ ] add sword & shield data + - [x] add sword & shield data - [ ] add sun & moon data - [ ] add x & y data - [x] `item`: get data about an item. diff --git a/card_data/pipelines/defs/extract/extract_data.py b/card_data/pipelines/defs/extract/extract_data.py index b523b1c..2e95051 100644 --- a/card_data/pipelines/defs/extract/extract_data.py +++ b/card_data/pipelines/defs/extract/extract_data.py @@ -10,6 +10,7 @@ import requests + class Series(BaseModel): id: str name: str @@ -42,16 +43,18 @@ def extract_series_data() -> pl.DataFrame: print(e) raise - filtered = [s.model_dump(mode="json") for s in validated if s.id in ["swsh", "sv", "me"]] + filtered = [ + s.model_dump(mode="json") for s in validated if s.id in ["swsh", "sv", "me"] + ] return pl.DataFrame(filtered) @dg.asset(kinds={"API", "Polars", "Pydantic"}, name="extract_set_data") def extract_set_data() -> pl.DataFrame: url_list = [ - "https://api.tcgdex.net/v2/en/series/swsh", - "https://api.tcgdex.net/v2/en/series/sv", "https://api.tcgdex.net/v2/en/series/me", + "https://api.tcgdex.net/v2/en/series/sv", + "https://api.tcgdex.net/v2/en/series/swsh", ] flat: list[dict] = [] @@ -68,17 +71,14 @@ def extract_set_data() -> pl.DataFrame: "official_card_count": s.get("cardCount", {}).get("official"), "total_card_count": s.get("cardCount", {}).get("total"), "logo": s.get("logo"), - "symbol": s.get("symbol") + "symbol": s.get("symbol"), } flat.append(entry) # Pydantic validation try: validated: list[Set] = [Set(**item) for item in flat] - print( - colored(" ✓", "green"), - "Pydantic validation passed for all set entries." - ) + print(colored(" ✓", "green"), "Pydantic validation passed for all set entries.") except ValidationError as e: print(colored(" ✖", "red"), "Pydantic validation failed.") print(e) @@ -89,9 +89,7 @@ def extract_set_data() -> pl.DataFrame: @dg.asset(kinds={"API"}, name="extract_card_url_from_set_data") def extract_card_url_from_set() -> list: - urls = [ - "https://api.tcgdex.net/v2/en/sets/me02" - ] + urls = ["https://api.tcgdex.net/v2/en/sets/me02"] all_card_urls = [] @@ -102,7 +100,11 @@ def extract_card_url_from_set() -> list: data = r.json()["cards"] - set_card_urls = [f"https://api.tcgdex.net/v2/en/cards/{card['id']}" for card in data] + set_card_urls = [ + f"https://api.tcgdex.net/v2/en/cards/{card['id']}" + for card in data + if "-TG" not in card["id"] + ] all_card_urls.extend(set_card_urls) time.sleep(0.1) @@ -113,9 +115,9 @@ def extract_card_url_from_set() -> list: return all_card_urls -@dg.asset(deps=[extract_card_url_from_set], kinds={"API"}, name="extract_card_info") -def extract_card_info() -> list: - card_url_list = extract_card_url_from_set() +@dg.asset(kinds={"API"}, name="extract_card_info") +def extract_card_info(extract_card_url_from_set_data: list) -> list: + card_url_list = extract_card_url_from_set_data cards_list = [] for url in card_url_list: @@ -124,7 +126,7 @@ def extract_card_info() -> list: r.raise_for_status() data = r.json() cards_list.append(data) - # print(f"Retrieved card: {data['id']} - {data.get('name', 'Unknown')}") + print(f"Retrieved card: {data['id']} - {data.get('name', 'Unknown')}") time.sleep(0.1) except requests.RequestException as e: print(f"Failed to fetch {url}: {e}") @@ -132,9 +134,9 @@ def extract_card_info() -> list: return cards_list -@dg.asset(deps=[extract_card_info], kinds={"Polars"}, name="create_card_dataframe") -def create_card_dataframe() -> pl.DataFrame: - cards_list = extract_card_info() +@dg.asset(kinds={"Polars"}, name="create_card_dataframe") +def create_card_dataframe(extract_card_info: list) -> pl.DataFrame: + cards_list = extract_card_info all_flat_cards = [] @@ -142,8 +144,19 @@ def create_card_dataframe() -> pl.DataFrame: flat = {} # Copy top-level scalar values - scalar_keys = ['category', 'hp', 'id', 'illustrator', 'image', 'localId', - 'name', 'rarity', 'regulationMark', 'retreat', 'stage'] + scalar_keys = [ + "category", + "hp", + "id", + "illustrator", + "image", + "localId", + "name", + "rarity", + "regulationMark", + "retreat", + "stage", + ] for key in scalar_keys: flat[key] = card.get(key) @@ -165,7 +178,7 @@ def create_card_dataframe() -> pl.DataFrame: attacks = card.get("attacks", []) for i, atk in enumerate(attacks): - prefix = f"attack_{i+1}" + prefix = f"attack_{i + 1}" flat[f"{prefix}_name"] = atk.get("name") flat[f"{prefix}_damage"] = atk.get("damage") flat[f"{prefix}_effect"] = atk.get("effect") diff --git a/card_data/pipelines/defs/extract/extract_pricing_data.py b/card_data/pipelines/defs/extract/extract_pricing_data.py index 377af30..101b6b3 100644 --- a/card_data/pipelines/defs/extract/extract_pricing_data.py +++ b/card_data/pipelines/defs/extract/extract_pricing_data.py @@ -1,4 +1,6 @@ from typing import Optional +import re +import unicodedata import dagster as dg import polars as pl @@ -8,24 +10,42 @@ SET_PRODUCT_MATCHING = { - "sv01": "22873", - "sv02": "23120", - "sv03": "23228", - "sv03.5": "23237", - "sv04": "23286", - "sv04.5": "23353", - "sv05": "23381", - "sv06": "23473", - "sv06.5": "23529", - "sv07": "23537", - "sv08": "23651", - "sv08.5": "23821", - "sv09": "24073", - "sv10": "24269", + "me02": "24448", + "me01": "24380", + # Scarlet & Violet "sv10.5b": "24325", "sv10.5w": "24326", - "me01": "24380", - "me02": "24448" + "sv10": "24269", + "sv09": "24073", + "sv08.5": "23821", + "sv08": "23651", + "sv07": "23537", + "sv06.5": "23529", + "sv06": "23473", + "sv05": "23381", + "sv04.5": "23353", + "sv04": "23286", + "sv03.5": "23237", + "sv03": "23228", + "sv02": "23120", + "sv01": "22873", + # Sword & Shield + "swsh12.5": "17688", + "swsh12": "3170", + "swsh11": "3118", + "swsh10.5": "3064", + "swsh10": "3040", + "swsh9": "2948", + "swsh8": "2906", + "swsh7": "2848", + "swsh6": "2807", + "swsh5": "2765", + "swsh4.5": "2754", + "swsh4": "2701", + "swsh3.5": "2685", + "swsh3": "2675", + "swsh2": "2626", + "swsh1": "2585", } @@ -53,8 +73,31 @@ def get_card_number(card: dict) -> Optional[str]: def extract_card_name(full_name: str) -> str: - """Extract clean card name, removing variant information after dash""" - return full_name.partition("-")[0].strip() if "-" in full_name else full_name + """Extract clean card name, removing variant information after dash and parenthetical suffixes""" + + name = full_name.partition("-")[0].strip() if "-" in full_name else full_name + + # Remove parenthetical card numbers like "(010)" or "(1)" + # Pattern: space followed by parentheses containing only digits + name = re.sub(r"\s+\(\d+\)$", "", name) + + # Remove known variant types in parentheses + # e.g., "(Secret)", "(Full Art)", "(Reverse Holofoil)", etc. + variant_types = [ + "Full Art", + "Secret", + "Reverse Holofoil", + "Rainbow Rare", + "Gold", + ] + for variant in variant_types: + name = name.replace(f" ({variant})", "") + + # Normalize accented characters (é → e, ñ → n, etc.) + name = unicodedata.normalize("NFD", name) + name = "".join(char for char in name if unicodedata.category(char) != "Mn") + + return name.strip() def pull_product_information(set_number: str) -> pl.DataFrame: @@ -83,9 +126,14 @@ def pull_product_information(set_number: str) -> pl.DataFrame: if not is_card(card): continue + # Skip ball pattern variants (unique to Prismatic Evolutions) + card_name = card.get("name", "") + if "(Poke Ball Pattern)" in card_name or "(Master Ball Pattern)" in card_name: + continue + card_info = { "product_id": card["productId"], - "name": extract_card_name(card["name"]), + "name": extract_card_name(card_name), "card_number": get_card_number(card), "market_price": price_dict.get(card["productId"]), } @@ -115,8 +163,10 @@ def build_dataframe() -> pl.DataFrame: # Raise error if any DataFrame is empty if df is None or df.shape[1] == 0 or df.is_empty(): - error_msg = f"Empty DataFrame returned for set '{set_number}'. " \ - f"Cannot proceed with drop+replace operation to avoid data loss." + error_msg = ( + f"Empty DataFrame returned for set '{set_number}'. " + f"Cannot proceed with drop+replace operation to avoid data loss." + ) print(colored(" ✖", "red"), error_msg) raise ValueError(error_msg) diff --git a/card_data/pipelines/defs/load/load_data.py b/card_data/pipelines/defs/load/load_data.py index 8ec9a9e..092dff1 100644 --- a/card_data/pipelines/defs/load/load_data.py +++ b/card_data/pipelines/defs/load/load_data.py @@ -10,19 +10,19 @@ from termcolor import colored import subprocess from pathlib import Path +import polars as pl @dg.asset( - deps=[extract_series_data], kinds={"Supabase", "Postgres"}, name="load_series_data", retry_policy=RetryPolicy(max_retries=3, delay=2, backoff=Backoff.EXPONENTIAL) ) -def load_series_data() -> None: +def load_series_data(extract_series_data: pl.DataFrame) -> None: database_url: str = fetch_secret() table_name: str = "staging.series" - df = extract_series_data() + df = extract_series_data try: df.write_database( table_name=table_name, connection=database_url, if_table_exists="replace" @@ -68,16 +68,15 @@ def data_quality_check_on_series() -> None: @dg.asset( - deps=[extract_set_data], kinds={"Supabase", "Postgres"}, name="load_set_data", retry_policy=RetryPolicy(max_retries=3, delay=2, backoff=Backoff.EXPONENTIAL) ) -def load_set_data() -> None: +def load_set_data(extract_set_data: pl.DataFrame) -> None: database_url: str = fetch_secret() table_name: str = "staging.sets" - df = extract_set_data() + df = extract_set_data try: df.write_database( table_name=table_name, connection=database_url, if_table_exists="replace" @@ -89,16 +88,15 @@ def load_set_data() -> None: @dg.asset( - deps=[create_card_dataframe], kinds={"Supabase", "Postgres"}, name="load_card_data", retry_policy=RetryPolicy(max_retries=3, delay=2, backoff=Backoff.EXPONENTIAL) ) -def load_card_data() -> None: +def load_card_data(create_card_dataframe: pl.DataFrame) -> None: database_url: str = fetch_secret() table_name: str = "staging.cards" - df = create_card_dataframe() + df = create_card_dataframe try: df.write_database( table_name=table_name, connection=database_url, if_table_exists="append" diff --git a/card_data/pipelines/poke_cli_dbt/dbt_project.yml b/card_data/pipelines/poke_cli_dbt/dbt_project.yml index 333cfe3..2f56743 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.8.1' +version: '1.8.2' profile: 'poke_cli_dbt' diff --git a/card_data/pipelines/poke_cli_dbt/macros/create_view.sql b/card_data/pipelines/poke_cli_dbt/macros/create_view.sql index c9b2952..5e940f1 100644 --- a/card_data/pipelines/poke_cli_dbt/macros/create_view.sql +++ b/card_data/pipelines/poke_cli_dbt/macros/create_view.sql @@ -4,37 +4,37 @@ WITH cards_cte AS ( SELECT set_id, - image, name, + image, + illustrator, "localId", "set_cardCount_official", - CONCAT(name, ' - ', "localId", '/', LPAD("set_cardCount_official"::text, 3, '0')) AS card_combined_name, + CONCAT(name, ' - ', LPAD("localId", 3, '0'), '/', LPAD("set_cardCount_official"::text, 3, '0')) AS card_combined_name, set_name FROM public.cards ), - cards_pricing_cte AS ( SELECT product_id, market_price, - CONCAT(name, ' - ', card_number) AS card_combined_name, + CONCAT(REGEXP_REPLACE(name, '\s*\(.*\)$', ''), ' - ', card_number) AS card_combined_name, card_number FROM public.pricing_data ) - SELECT c.set_id, c.name, - CONCAT(p.card_number, ' - ', c.name) AS number_plus_name, + CONCAT(COALESCE(p.card_number, LPAD(c."localId", 3, '0')), ' - ', c.name) AS number_plus_name, CONCAT(c.image, '/high.png') AS image_url, c.set_name, - c."localId", + LPAD(c."localId", 3, '0') AS "localId", p."market_price", - p."card_number" + COALESCE(p."card_number", LPAD(c."localId", 3, '0')) AS card_number, + c.illustrator FROM cards_cte AS c - INNER JOIN + LEFT JOIN cards_pricing_cte AS p ON c.card_combined_name = p.card_combined_name - ORDER BY c."localId" + ORDER BY c."localId"::integer {% endmacro %} \ No newline at end of file diff --git a/card_data/pipelines/soda/checks.yml b/card_data/pipelines/soda/checks.yml index 95d4b1a..ef3cf7d 100644 --- a/card_data/pipelines/soda/checks.yml +++ b/card_data/pipelines/soda/checks.yml @@ -1,6 +1,6 @@ checks for series: # Row count validation - - row_count = 3 + - row_count = 4 # Schema validation checks - schema: diff --git a/card_data/pipelines/tests/extract_series_test.py b/card_data/pipelines/tests/extract_series_test.py new file mode 100644 index 0000000..c89303c --- /dev/null +++ b/card_data/pipelines/tests/extract_series_test.py @@ -0,0 +1,40 @@ +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 pipelines.defs.extract.extract_data import extract_series_data + +@pytest.fixture +def mock_api_response(): + """Sample API response matching tcgdex format""" + return [ + {"id": "sv", "name": "Scarlet & Violet", "logo": "https://example.com/sv.png"}, + {"id": "swsh", "name": "Sword & Shield", "logo": "https://example.com/swsh.png"}, + {"id": "xy", "name": "XY", "logo": "https://example.com/xy.png"}, + {"id": "me", "name": "McDonald's Collection", "logo": "https://example.com/me.png"}, + {"id": "sm", "name": "Sun & Moon", "logo": None}, + ] + +@responses.activate +def test_extract_series_data_success(mock_api_response): + """Test successful extraction and filtering""" + # Mock the API call + responses.add( + responses.GET, + "https://api.tcgdex.net/v2/en/series", + json=mock_api_response, + status=200 + ) + + result = extract_series_data() + + # Assertions + assert isinstance(result, pl.DataFrame) + assert len(result) == 3 # Only swsh, sv, me + assert set(result["id"].to_list()) == {"swsh", "sv", "me"} + assert "name" in result.columns + assert "logo" in result.columns \ No newline at end of file diff --git a/card_data/pyproject.toml b/card_data/pyproject.toml index 5f2118a..bb4b679 100644 --- a/card_data/pyproject.toml +++ b/card_data/pyproject.toml @@ -31,6 +31,8 @@ dev = [ "dagster-dg-cli", "dagster-dbt>=0.27.3", "dagster-postgres>=0.27.3", + "pytest>=9.0.2", + "responses>=0.25.8", ] [tool.dg] @@ -46,4 +48,4 @@ registry_modules = [ override-dependencies = [ "deepdiff==8.6.1", "starlette==0.49.1", -] \ No newline at end of file +] diff --git a/card_data/uv.lock b/card_data/uv.lock index aeae284..ce60f02 100644 --- a/card_data/uv.lock +++ b/card_data/uv.lock @@ -157,7 +157,9 @@ dependencies = [ { name = "psycopg2-binary" }, { name = "pyarrow" }, { name = "pydantic" }, + { name = "pytest" }, { name = "requests" }, + { name = "responses" }, { name = "soda-core-postgres" }, { name = "sqlalchemy" }, { name = "termcolor" }, @@ -187,7 +189,9 @@ requires-dist = [ { name = "psycopg2-binary", specifier = ">=2.9.10" }, { name = "pyarrow", specifier = ">=20.0.0" }, { name = "pydantic", specifier = ">=2.11.7" }, + { name = "pytest", specifier = ">=9.0.2" }, { name = "requests", specifier = ">=2.32.4" }, + { name = "responses", specifier = ">=0.25.8" }, { name = "soda-core-postgres", specifier = ">=3.5.5" }, { name = "sqlalchemy", specifier = ">=2.0.41" }, { name = "termcolor", specifier = ">=3.1.0" }, @@ -981,6 +985,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/eb/427ed2b20a38a4ee29f24dbe4ae2dafab198674fe9a85e3d6adf9e5f5f41/inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344", size = 35197, upload-time = "2024-12-28T17:11:15.931Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "isodate" version = "0.6.1" @@ -1498,6 +1511,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "polars" version = "1.31.0" @@ -1769,6 +1791,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1915,6 +1953,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] +[[package]] +name = "responses" +version = "0.25.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/95/89c054ad70bfef6da605338b009b2e283485835351a9935c7bfbfaca7ffc/responses-0.25.8.tar.gz", hash = "sha256:9374d047a575c8f781b94454db5cab590b6029505f488d12899ddb10a4af1cf4", size = 79320, upload-time = "2025-08-08T19:01:46.709Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/4c/cc276ce57e572c102d9542d383b2cfd551276581dc60004cb94fe8774c11/responses-0.25.8-py3-none-any.whl", hash = "sha256:0c710af92def29c8352ceadff0c3fe340ace27cf5af1bbe46fb71275bcd2831c", size = 34769, upload-time = "2025-08-08T19:01:45.018Z" }, +] + [[package]] name = "rich" version = "14.1.0" diff --git a/cmd/card/cardlist.go b/cmd/card/cardlist.go index ac86f22..7fc38ff 100644 --- a/cmd/card/cardlist.go +++ b/cmd/card/cardlist.go @@ -101,7 +101,7 @@ type cardData struct { // CardsList creates and returns a new CardsModel with cards from a specific set func CardsList(setID string) (CardsModel, error) { url := fmt.Sprintf("https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/card_pricing_view?set_id=eq.%s&select=number_plus_name,market_price,image_url,illustrator&order=localId", setID) - body, err := CallCardData(url) + body, err := getCardData(url) if err != nil { return CardsModel{}, fmt.Errorf("failed to fetch card data: %w", err) } @@ -119,8 +119,18 @@ func CardsList(setID string) (CardsModel, error) { illustratorMap := make(map[string]string) for i, card := range allCards { rows[i] = []string{card.NumberPlusName} - priceMap[card.NumberPlusName] = fmt.Sprintf("Price: $%.2f", card.MarketPrice) - illustratorMap[card.NumberPlusName] = "Illustrator: " + card.Illustrator + if card.MarketPrice != 0 { + priceMap[card.NumberPlusName] = fmt.Sprintf("Price: $%.2f", card.MarketPrice) + } else { + priceMap[card.NumberPlusName] = "Pricing not available" + } + + if card.Illustrator != "" { + illustratorMap[card.NumberPlusName] = "Illustrator: " + card.Illustrator + } else { + illustratorMap[card.NumberPlusName] = "Illustrator not available" + } + imageMap[card.NumberPlusName] = card.ImageURL } @@ -149,6 +159,9 @@ func CardsList(setID string) (CardsModel, error) { }, nil } +// creating a function variable to swap the implementation in tests +var getCardData = CallCardData + func CallCardData(url string) ([]byte, error) { req, err := http.NewRequest("GET", url, nil) if err != nil { diff --git a/cmd/card/cardlist_test.go b/cmd/card/cardlist_test.go index 7df0594..322bd0b 100644 --- a/cmd/card/cardlist_test.go +++ b/cmd/card/cardlist_test.go @@ -1,6 +1,7 @@ package card import ( + "errors" "strings" "testing" @@ -254,3 +255,113 @@ func TestCardsModel_View_MissingPrice(t *testing.T) { t.Error("View() should display 'Price: Not available' for cards without pricing") } } + +func TestCardsList_SuccessAndFallbacks(t *testing.T) { + // Save and restore getCardData stub + original := getCardData + defer func() { getCardData = original }() + + var capturedURL string + getCardData = func(url string) ([]byte, error) { + capturedURL = url + // Return two cards: one with price + illustrator, one with fallbacks + json := `[ + {"number_plus_name":"001/198 - Bulbasaur","market_price":1.5,"image_url":"https://example.com/bulba.png","illustrator":"Ken Sugimori"}, + {"number_plus_name":"002/198 - Ivysaur","market_price":0,"image_url":"https://example.com/ivy.png","illustrator":""} + ]` + return []byte(json), nil + } + + model, err := CardsList("set123") + if err != nil { + t.Fatalf("CardsList returned error: %v", err) + } + + // URL should target the correct set id and select fields + if !strings.Contains(capturedURL, "set_id=eq.set123") { + t.Errorf("expected URL to contain set_id filter, got %s", capturedURL) + } + if !strings.Contains(capturedURL, "select=number_plus_name,market_price,image_url,illustrator") { + t.Errorf("expected URL to contain select fields, got %s", capturedURL) + } + + // PriceMap expectations + if got := model.PriceMap["001/198 - Bulbasaur"]; got != "Price: $1.50" { + t.Errorf("unexpected price for Bulbasaur: %s", got) + } + if got := model.PriceMap["002/198 - Ivysaur"]; got != "Pricing not available" { + t.Errorf("unexpected price for Ivysaur: %s", got) + } + + // IllustratorMap expectations + if got := model.IllustratorMap["001/198 - Bulbasaur"]; got != "Illustrator: Ken Sugimori" { + t.Errorf("unexpected illustrator for Bulbasaur: %s", got) + } + if got := model.IllustratorMap["002/198 - Ivysaur"]; got != "Illustrator not available" { + t.Errorf("unexpected illustrator for Ivysaur: %s", got) + } + + // Image map + if model.ImageMap["001/198 - Bulbasaur"] != "https://example.com/bulba.png" { + t.Errorf("unexpected image url for Bulbasaur: %s", model.ImageMap["001/198 - Bulbasaur"]) + } + if model.ImageMap["002/198 - Ivysaur"] != "https://example.com/ivy.png" { + t.Errorf("unexpected image url for Ivysaur: %s", model.ImageMap["002/198 - Ivysaur"]) + } + + if row := model.Table.SelectedRow(); len(row) == 0 { + if model.View() == "" { + t.Error("model view should render even if no row is selected") + } + } +} + +func TestCardsList_FetchError(t *testing.T) { + original := getCardData + defer func() { getCardData = original }() + + getCardData = func(url string) ([]byte, error) { + return nil, errors.New("network error") + } + + _, err := CardsList("set123") + if err == nil { + t.Fatal("expected error when fetch fails") + } +} + +func TestCardsList_BadJSON(t *testing.T) { + original := getCardData + defer func() { getCardData = original }() + + getCardData = func(url string) ([]byte, error) { + return []byte("not-json"), nil + } + + _, err := CardsList("set123") + if err == nil { + t.Fatal("expected error for bad JSON payload") + } +} + +func TestCardsList_EmptyResult(t *testing.T) { + original := getCardData + defer func() { getCardData = original }() + + getCardData = func(url string) ([]byte, error) { + return []byte("[]"), nil + } + + model, err := CardsList("set123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(model.PriceMap) != 0 || len(model.IllustratorMap) != 0 || len(model.ImageMap) != 0 { + t.Errorf("expected empty maps, got price:%d illus:%d image:%d", len(model.PriceMap), len(model.IllustratorMap), len(model.ImageMap)) + } + + if model.View() == "" { + t.Error("expected view to render with empty data") + } +} diff --git a/cmd/card/serieslist.go b/cmd/card/serieslist.go index 520fc6e..ef6e1c3 100644 --- a/cmd/card/serieslist.go +++ b/cmd/card/serieslist.go @@ -63,6 +63,7 @@ func SeriesList() SeriesModel { items := []list.Item{ item("Mega Evolution"), item("Scarlet & Violet"), + item("Sword & Shield"), } const listWidth = 20 diff --git a/cmd/card/serieslist_test.go b/cmd/card/serieslist_test.go index e77926b..d339ed8 100644 --- a/cmd/card/serieslist_test.go +++ b/cmd/card/serieslist_test.go @@ -142,3 +142,26 @@ func TestSeriesModelView(t *testing.T) { t.Errorf("Expected non-empty view for choice, got empty string") } } + +func TestSeriesList(t *testing.T) { + model := SeriesList() + items := model.List.Items() + + // Check that list has 3 items + if items == nil { + t.Error("SeriesList() should create a list with items") + } + + if len(items) != 3 { + t.Errorf("Expected 3 items, got %d", len(items)) + } + + // Verify all three series are present + expectedSeries := []string{"Mega Evolution", "Scarlet & Violet", "Sword & Shield"} + for i, expected := range expectedSeries { + itemStr := string(items[i].(item)) + if itemStr != expected { + t.Errorf("Expected item %d to be '%s', got '%s'", i, expected, itemStr) + } + } +} diff --git a/go.mod b/go.mod index 920a8a2..a4c89cc 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/digitalghost-dev/poke-cli -go 1.24.9 +go 1.24.11 require ( github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 diff --git a/nfpm.yaml b/nfpm.yaml index dfea760..84aabaa 100644 --- a/nfpm.yaml +++ b/nfpm.yaml @@ -1,7 +1,7 @@ name: "poke-cli" arch: "arm64" platform: "linux" -version: "v1.8.1" +version: "v1.8.2" section: "default" version_schema: semver maintainer: "Christian S" diff --git a/testdata/main_latest_flag.golden b/testdata/main_latest_flag.golden index ebd0a2a..bfac272 100644 --- a/testdata/main_latest_flag.golden +++ b/testdata/main_latest_flag.golden @@ -2,6 +2,6 @@ ┃ ┃ ┃ Latest available release ┃ ┃ on GitHub: ┃ -┃ • v1.8.0 ┃ +┃ • v1.8.1 ┃ ┃ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛