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 @@
Pokémon CLI
-
+
@@ -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 ┃
┃ ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛