diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a95243e8..155c5c75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ on: - main env: - VERSION_NUMBER: 'v1.7.4' + VERSION_NUMBER: 'v1.8.0' DOCKERHUB_REGISTRY_NAME: 'digitalghostdev/poke-cli' AWS_REGION: 'us-west-2' diff --git a/.gitignore b/.gitignore index 4e571b13..30e3fbd4 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,6 @@ dbt_packages/ logs/ target/ -dbt_packages/ card_data/infrastructure/supabase/access-token /card_data/infrastructure/supabase/access-token @@ -61,5 +60,6 @@ card_data/.tmp*/** card_data/pipelines/poke_cli_dbt/.user.yml /card_data/supabase/ +/card_data/sample_scripts/ card_data/~/ diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 00000000..46497dcd --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,3 @@ +cmd/card/setslist.go:generic-api-key:116 +cmd/card/cardlist.go:generic-api-key:157 +codecov.yml:generic-api-key:2 \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml index 04c0950f..16995b20 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -14,7 +14,7 @@ builds: - windows - darwin ldflags: - - -s -w -X main.version=v1.7.4 + - -s -w -X main.version=v1.8.0 archives: - formats: [ 'zip' ] diff --git a/Dockerfile b/Dockerfile index 8db3b266..7605a0c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ RUN go mod download COPY . . -RUN go build -ldflags "-X main.version=v1.7.4" -o poke-cli . +RUN go build -ldflags "-X main.version=v1.8.0" -o poke-cli . # build 2 FROM --platform=$BUILDPLATFORM alpine:3.22 diff --git a/README.md b/README.md index a4529df4..fb4e1e77 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,9 @@ pokemon-logo

Pokémon CLI

version-label - docker-image-size + docker-image-size ci-status-badge -
- coderabbit-review-count-badge -
tests-label go-version @@ -18,17 +15,21 @@ `poke-cli` is a hybrid of a classic CLI and a modern TUI tool for viewing data about Pokémon! This is my first Go project. View the [documentation](https://docs.poke-cli.com)! -The architecture behind how the tool works is straight forward: -1. Each command indicates which [API](https://pokeapi.co/) endpoint to use. -2. Flags provide more information and can be stacked together or used individually. -3. Each command has a `-h | --help` flag that is built-in with Golang's `flag` package. - -View future plans in the [Roadmap](#roadmap) section. +* [Demo](#demo) +* [Installation](#installation) +* [Usage](#usage) +* [Roadmap](#roadmap) +* [Tested Terminals](#tested-terminals) --- ## Demo -![demo](https://poke-cli-s3-bucket.s3.us-west-2.amazonaws.com/demo-v1.6.0.gif) +### Video Game Data + +![demo-vg](https://poke-cli-s3-bucket.s3.us-west-2.amazonaws.com/demo-v1.6.0.gif) + +### Trading Card Game Data +![demo-tcg](https://poke-cli-s3-bucket.s3.us-west-2.amazonaws.com/poke-cli-v1.8.0.gif) --- ## Installation @@ -94,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.7.4 [subcommand] flag] + docker run --rm -it digitalghostdev/poke-cli:v1.8.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.7.4 -c "cd /app && exec sh" + docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.8.0 -c "cd /app && exec sh" # placed into the /app directory, run the program with './poke-cli' # example: ./poke-cli ability swift-swim ``` @@ -162,6 +163,7 @@ By running `poke-cli [-h | --help]`, it'll display information on how to use the │ COMMANDS: │ │ ability Get details about an ability │ │ berry Get details about a berry │ +│ card Get details about a TCG card │ │ item Get details about an item │ │ move Get details about a move │ │ natures Get details about all natures │ @@ -187,6 +189,12 @@ Below is a list of the planned/completed commands and flags: - [x] `ability`: get data about an ability. - [x] `-p | --pokemon`: display Pokémon that learn this ability. - [x] `berry`: get data about a berry. +- [ ] `card`: get data about a TCG card. + - [x] add mega evolution data + - [x] add scarlet & violet data + - [ ] add sword & shield data + - [ ] add sun & moon data + - [ ] add x & y data - [x] `item`: get data about an item. - [x] `move`: get data about a move. - [ ] `-p | --pokemon`: display Pokémon that learn this move. @@ -208,15 +216,16 @@ Below is a list of the planned/completed commands and flags: --- ## Tested Terminals -| Terminal | OS | Status | Issues | -|-------------------|:-------------------------:|:------:|---------------------------------------------------------------------------------| -| Alacritty | macOS, Ubuntu,
Windows | ✅ | None | -| Ghostty | macOS | ✅ | None | -| HyperJS | macOS | ✅ | None | -| iTerm2 | macOS | ✅ | None | -| Built-in Terminal | Ubuntu, Debian,
Fedora | ✅ | None | -| Built-in Terminal | Alpine | ⚠️ | Some colors aren't supported.
`pokemon --image=xx` flag pixel issues. | -| Built-in Terminal | macOS | ⚠️ | `pokemon --image=xx` flag pixel issues. | -| Tabby | Ubuntu | ✅ | None | -| WezTerm | macOS, Windows | ✅ | None | -| Built-in Terminal | Windows | ✅ | None | \ No newline at end of file +| Terminal | OS | Status | Issues | +|-------------------|:-------------------------:|:------:|----------------------------------------------------------------------------------------------| +| Alacritty | macOS, Ubuntu,
Windows | 🟡 | - Does not support sixel for TCG images. | +| Ghostty | macOS | 🟡 | - Does not support sixel for TCG images. | +| HyperJS | macOS | 🟡 | - Does not support sixel for TCG images. | +| iTerm2 | macOS | 🟢 | - None | +| Built-in Terminal | Ubuntu, Debian,
Fedora | 🟡 | - Does not support sixel for TCG images. | +| Built-in Terminal | Alpine | 🟡 | - Some colors aren't supported.
- `pokemon --image=xx` flag pixel issues. | +| Built-in Terminal | macOS | 🟠 | - Does not support sixel for TCG images.
- `pokemon --image=xx` flag pixel issues. | +| Foot | Ubuntu | 🟢 | - None | +| Tabby | Ubuntu | 🟢 | - None | +| WezTerm | macOS, Windows | 🟡 | - Windows version has issues with displaying TCG images. | +| Built-in Terminal | Windows | 🟢 | - None | \ No newline at end of file diff --git a/card_data/dagster.yaml b/card_data/dagster.yaml index bad89452..8ddd96d0 100644 --- a/card_data/dagster.yaml +++ b/card_data/dagster.yaml @@ -9,4 +9,7 @@ storage: db_name: postgres port: 5432 params: - sslmode: require \ No newline at end of file + sslmode: require + +telemetry: + enabled: false \ No newline at end of file diff --git a/card_data/pipelines/definitions.py b/card_data/pipelines/definitions.py index dc7a2a64..3c3bf81b 100644 --- a/card_data/pipelines/definitions.py +++ b/card_data/pipelines/definitions.py @@ -5,7 +5,7 @@ import dagster as dg from .defs.extract.extract_pricing_data import build_dataframe -from .defs.load.load_pricing_data import load_pricing_data +from .defs.load.load_pricing_data import load_pricing_data, data_quality_checks_on_pricing @definitions @@ -17,7 +17,7 @@ def defs(): # Define the pricing pipeline job that materializes the assets and downstream dbt model pricing_pipeline_job = dg.define_asset_job( name="pricing_pipeline_job", - selection=dg.AssetSelection.assets(build_dataframe, load_pricing_data).downstream(include_self=True), + selection=dg.AssetSelection.assets(build_dataframe).downstream(include_self=True), ) price_schedule = dg.ScheduleDefinition( @@ -28,7 +28,7 @@ def defs(): ) defs_pricing = dg.Definitions( - assets=[build_dataframe, load_pricing_data], + assets=[build_dataframe, load_pricing_data, data_quality_checks_on_pricing], jobs=[pricing_pipeline_job], schedules=[price_schedule], ) \ No newline at end of file diff --git a/card_data/pipelines/defs/extract/extract_data.py b/card_data/pipelines/defs/extract/extract_data.py index 3aa7c9a4..b523b1cf 100644 --- a/card_data/pipelines/defs/extract/extract_data.py +++ b/card_data/pipelines/defs/extract/extract_data.py @@ -90,10 +90,10 @@ 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/swsh3" + "https://api.tcgdex.net/v2/en/sets/me02" ] - all_card_urls = [] # Initialize empty list to collect all URLs + all_card_urls = [] for url in urls: try: @@ -103,7 +103,7 @@ 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] - all_card_urls.extend(set_card_urls) # Add all URLs from this set + all_card_urls.extend(set_card_urls) time.sleep(0.1) diff --git a/card_data/pipelines/defs/extract/extract_pricing_data.py b/card_data/pipelines/defs/extract/extract_pricing_data.py index 15e68cfe..377af301 100644 --- a/card_data/pipelines/defs/extract/extract_pricing_data.py +++ b/card_data/pipelines/defs/extract/extract_pricing_data.py @@ -10,6 +10,22 @@ 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", + "sv10.5b": "24325", + "sv10.5w": "24326", + "me01": "24380", + "me02": "24448" } @@ -49,16 +65,17 @@ def pull_product_information(set_number: str) -> pl.DataFrame: product_id = SET_PRODUCT_MATCHING[set_number] # Fetch product data - products_url = (f"https://tcgcsv.com/tcgplayer/3/{product_id}/products") + products_url = f"https://tcgcsv.com/tcgplayer/3/{product_id}/products" products_data = requests.get(products_url, timeout=30).json() # Fetch pricing data - prices_url = (f"https://tcgcsv.com/tcgplayer/3/{product_id}/prices") + prices_url = f"https://tcgcsv.com/tcgplayer/3/{product_id}/prices" prices_data = requests.get(prices_url, timeout=30).json() price_dict = { price["productId"]: price.get("marketPrice") for price in prices_data.get("results", []) + if price.get("subTypeName") != "Reverse Holofoil" } cards_data = [] diff --git a/card_data/pipelines/defs/load/load_pricing_data.py b/card_data/pipelines/defs/load/load_pricing_data.py index 5f15f859..cce644f4 100644 --- a/card_data/pipelines/defs/load/load_pricing_data.py +++ b/card_data/pipelines/defs/load/load_pricing_data.py @@ -1,3 +1,6 @@ +import subprocess +from pathlib import Path + import dagster as dg import polars as pl from dagster import RetryPolicy, Backoff @@ -23,3 +26,36 @@ def load_pricing_data(build_pricing_dataframe: pl.DataFrame) -> None: except OperationalError as e: print(colored(" ✖", "red"), "Connection error in load_pricing_data():", e) raise + + +@dg.asset( + deps=[load_pricing_data], + kinds={"Soda"}, + name="data_quality_checks_on_pricing", +) +def data_quality_checks_on_pricing() -> None: + current_file_dir = Path(__file__).parent + print(f"Setting cwd to: {current_file_dir}") + + result = subprocess.run( + [ + "soda", + "scan", + "-d", + "supabase", + "-c", + "../../soda/configuration.yml", + "../../soda/checks_pricing.yml", + ], + capture_output=True, + text=True, + cwd=current_file_dir, + ) + + if result.stdout: + print(result.stdout) + if result.stderr: + print(result.stderr) + + if result.returncode != 0: + raise Exception(f"Soda data quality checks failed with return code {result.returncode}") diff --git a/card_data/pipelines/defs/transformation/transform_data.py b/card_data/pipelines/defs/transformation/transform_data.py index c47c8fb3..dcb85347 100644 --- a/card_data/pipelines/defs/transformation/transform_data.py +++ b/card_data/pipelines/defs/transformation/transform_data.py @@ -16,7 +16,7 @@ def get_asset_key(self, dbt_resource_props): "series": "quality_checks_series", "sets": "load_set_data", "cards": "load_card_data", - "pricing_data": "load_pricing_data", + "pricing_data": "data_quality_checks_on_pricing", } if name in source_mapping: return dg.AssetKey([source_mapping[name]]) diff --git a/card_data/pipelines/poke_cli_dbt/dbt_project.yml b/card_data/pipelines/poke_cli_dbt/dbt_project.yml index 104f295f..06c038e4 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.7.4' +version: '1.8.0' profile: 'poke_cli_dbt' diff --git a/card_data/pipelines/poke_cli_dbt/macros/create_rls.sql b/card_data/pipelines/poke_cli_dbt/macros/create_rls.sql index 89b55a15..0bbdbda7 100644 --- a/card_data/pipelines/poke_cli_dbt/macros/create_rls.sql +++ b/card_data/pipelines/poke_cli_dbt/macros/create_rls.sql @@ -1,4 +1,9 @@ {% macro enable_rls() %} ALTER TABLE {{ this }} ENABLE ROW LEVEL SECURITY; - CREATE POLICY "Enable read access for all users" ON {{ this }} TO PUBLIC USING (true); + CREATE POLICY "Enable Read Access for All Users" + ON {{ this }} + AS PERMISSIVE + FOR SELECT + TO PUBLIC + USING (true); {% endmacro %} \ No newline at end of file diff --git a/card_data/pipelines/poke_cli_dbt/macros/create_view.sql b/card_data/pipelines/poke_cli_dbt/macros/create_view.sql new file mode 100644 index 00000000..c9b29529 --- /dev/null +++ b/card_data/pipelines/poke_cli_dbt/macros/create_view.sql @@ -0,0 +1,40 @@ +{% macro create_view() %} + CREATE OR REPLACE VIEW public.card_pricing_view + WITH (security_invoker = true) AS + WITH cards_cte AS ( + SELECT + set_id, + image, + name, + "localId", + "set_cardCount_official", + CONCAT(name, ' - ', "localId", '/', 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, + card_number + FROM public.pricing_data + ) + + SELECT + c.set_id, + c.name, + CONCAT(p.card_number, ' - ', c.name) AS number_plus_name, + CONCAT(c.image, '/high.png') AS image_url, + c.set_name, + c."localId", + p."market_price", + p."card_number" + FROM + cards_cte AS c + INNER JOIN + cards_pricing_cte AS p + ON c.card_combined_name = p.card_combined_name + ORDER BY c."localId" +{% endmacro %} \ No newline at end of file diff --git a/card_data/pipelines/poke_cli_dbt/models/cards.sql b/card_data/pipelines/poke_cli_dbt/models/cards.sql index ef5be21b..b9087784 100644 --- a/card_data/pipelines/poke_cli_dbt/models/cards.sql +++ b/card_data/pipelines/poke_cli_dbt/models/cards.sql @@ -3,5 +3,5 @@ post_hook="{{ enable_rls() }}" ) }} -SELECT id, set_id, image, name, "localId", category, hp, "set_cardCount_official", set_name +SELECT id, set_id, image, name, "localId", category, hp, "set_cardCount_official", set_name, illustrator FROM {{ source('staging', 'cards') }} \ No newline at end of file diff --git a/card_data/pipelines/poke_cli_dbt/models/pricing_data.sql b/card_data/pipelines/poke_cli_dbt/models/pricing_data.sql index dff35155..abfd7392 100644 --- a/card_data/pipelines/poke_cli_dbt/models/pricing_data.sql +++ b/card_data/pipelines/poke_cli_dbt/models/pricing_data.sql @@ -1,6 +1,9 @@ {{ config( materialized='table', - post_hook="{{ enable_rls() }}" + post_hook=[ + "{{ enable_rls() }}", + "{{ create_view() }}" + ] ) }} SELECT product_id, name, card_number, market_price diff --git a/card_data/pipelines/soda/checks_pricing.yml b/card_data/pipelines/soda/checks_pricing.yml new file mode 100644 index 00000000..cf5e5f07 --- /dev/null +++ b/card_data/pipelines/soda/checks_pricing.yml @@ -0,0 +1,75 @@ +checks for pricing_data: + # Row count validation - currently have 4216 rows + # Expect at least 4000 cards + - row_count > 4000: + name: Minimum row count check + + # Warn if row count drops significantly + - row_count > 4200: + warn: + when fail + name: Row count sanity check (warn if below expected) + + # Schema validation checks + - schema: + fail: + when required column missing: [product_id, name, card_number, market_price] + when wrong column type: + product_id: bigint + name: text + card_number: text + market_price: double precision + + # Completeness checks - product_id, name, card_number should always be present + - missing_count(product_id) = 0: + name: Product ID completeness + + - missing_count(name) = 0: + name: Card name completeness + + - missing_count(card_number) = 0: + name: Card number completeness + + # Data uniqueness checks + - duplicate_count(product_id) = 0: + name: Product ID uniqueness + + # Data format validation + # Card numbers should be alphanumeric with slashes (e.g., "013/198", "4", "005/086") + - invalid_count(card_number) = 0: + valid regex: '^[A-Za-z0-9/]+$' + name: Card number format validation + + # Card names should not be empty and should be reasonable length (<100 chars) + - invalid_count(name) = 0: + valid min length: 1 + valid max length: 100 + name: Card name length validation + + # Data range validation + # Product IDs should be positive 6-digit numbers (observed range: 475k-642k) + - invalid_count(product_id) = 0: + valid min: 100000 + valid max: 999999999 + name: Product ID range validation + + # Market prices (when present) should be positive and reasonable + # Current range: $0.02 to $1119.08 + - invalid_percent(market_price) < 1%: + valid min: 0.01 + valid max: 10000 + name: Market price range validation ($0.01-$10,000) + + # Statistical validation - average price should be reasonable + # Current average is ~$6.01, allow range of $2-$20 for sanity + - avg(market_price): + warn: + when < 2 + when > 20 + name: Average market price sanity check + + # Anomaly detection - check for extreme outliers + - max(market_price) < 5000: + warn: + when fail + name: Maximum price outlier detection (warn if >$5000) \ No newline at end of file diff --git a/card_data/pyproject.toml b/card_data/pyproject.toml index e14f49f5..5f2118a3 100644 --- a/card_data/pyproject.toml +++ b/card_data/pyproject.toml @@ -41,3 +41,9 @@ root_module = "pipelines" registry_modules = [ "pipelines.components.*", ] + +[tool.uv] +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 c4547e07..aeae284c 100644 --- a/card_data/uv.lock +++ b/card_data/uv.lock @@ -2,6 +2,12 @@ version = 1 revision = 2 requires-python = ">=3.12" +[manifest] +overrides = [ + { name = "deepdiff", specifier = "==8.6.1" }, + { name = "starlette", specifier = "==0.49.1" }, +] + [[package]] name = "agate" version = "1.9.1" @@ -703,14 +709,14 @@ wheels = [ [[package]] name = "deepdiff" -version = "7.0.1" +version = "8.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ordered-set" }, + { name = "orderly-set" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/10/6f4b0bd0627d542f63a24f38e29d77095dc63d5f45bc1a7b4a6ca8750fa9/deepdiff-7.0.1.tar.gz", hash = "sha256:260c16f052d4badbf60351b4f77e8390bee03a0b516246f6839bc813fb429ddf", size = 421718, upload-time = "2024-04-08T22:59:24.578Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/76/36c9aab3d5c19a94091f7c6c6e784efca50d87b124bf026c36e94719f33c/deepdiff-8.6.1.tar.gz", hash = "sha256:ec56d7a769ca80891b5200ec7bd41eec300ced91ebcc7797b41eb2b3f3ff643a", size = 634054, upload-time = "2025-09-03T19:40:41.461Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/e6/d27d37dc55dbf40cdbd665aa52844b065ac760c9a02a02265f97ea7a4256/deepdiff-7.0.1-py3-none-any.whl", hash = "sha256:447760081918216aa4fd4ca78a4b6a848b81307b2ea94c810255334b759e1dc3", size = 80825, upload-time = "2024-04-08T22:59:21.885Z" }, + { url = "https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl", hash = "sha256:ee8708a7f7d37fb273a541fa24ad010ed484192cd0c4ffc0fa0ed5e2d4b9e78b", size = 91378, upload-time = "2025-09-03T19:40:39.679Z" }, ] [[package]] @@ -1374,12 +1380,12 @@ wheels = [ ] [[package]] -name = "ordered-set" -version = "4.1.0" +name = "orderly-set" +version = "5.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/bfac8bc689799bcca4157e0e0ced07e70ce125193fc2e166d2e685b7e2fe/ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8", size = 12826, upload-time = "2022-01-26T14:38:56.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/88/39c83c35d5e97cc203e9e77a4f93bf87ec89cf6a22ac4818fdcc65d66584/orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce", size = 27414, upload-time = "2025-07-10T20:10:55.885Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7", size = 13068, upload-time = "2025-07-10T20:10:54.377Z" }, ] [[package]] @@ -2211,15 +2217,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.47.2" +version = "0.49.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/57/d062573f391d062710d4088fa1369428c38d51460ab6fedff920efef932e/starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8", size = 2583948, upload-time = "2025-07-20T17:31:58.522Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/3f/507c21db33b66fb027a332f2cb3abbbe924cc3a79ced12f01ed8645955c9/starlette-0.49.1.tar.gz", hash = "sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb", size = 2654703, upload-time = "2025-10-28T17:34:10.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" }, + { url = "https://files.pythonhosted.org/packages/51/da/545b75d420bb23b5d494b0517757b351963e974e79933f01e05c929f20a6/starlette-0.49.1-py3-none-any.whl", hash = "sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875", size = 74175, upload-time = "2025-10-28T17:34:09.13Z" }, ] [[package]] diff --git a/cli.go b/cli.go index cca5a412..906856bc 100644 --- a/cli.go +++ b/cli.go @@ -9,6 +9,7 @@ import ( "github.com/digitalghost-dev/poke-cli/cmd/ability" "github.com/digitalghost-dev/poke-cli/cmd/berry" + "github.com/digitalghost-dev/poke-cli/cmd/card" "github.com/digitalghost-dev/poke-cli/cmd/item" "github.com/digitalghost-dev/poke-cli/cmd/move" "github.com/digitalghost-dev/poke-cli/cmd/natures" @@ -71,6 +72,7 @@ func runCLI(args []string) int { "\n\n", styling.StyleBold.Render("COMMANDS:"), fmt.Sprintf("\n\t%-15s %s", "ability", "Get details about an ability"), fmt.Sprintf("\n\t%-15s %s", "berry", "Get details about a berry"), + fmt.Sprintf("\n\t%-15s %s", "card", "Get details about a TCG card"), fmt.Sprintf("\n\t%-15s %s", "item", "Get details about an item"), fmt.Sprintf("\n\t%-15s %s", "move", "Get details about a move"), fmt.Sprintf("\n\t%-15s %s", "natures", "Get details about all natures"), @@ -107,6 +109,7 @@ func runCLI(args []string) int { commands := map[string]func() int{ "ability": utils.HandleCommandOutput(ability.AbilityCommand), "berry": utils.HandleCommandOutput(berry.BerryCommand), + "card": utils.HandleCommandOutput(card.CardCommand), "item": utils.HandleCommandOutput(item.ItemCommand), "move": utils.HandleCommandOutput(move.MoveCommand), "natures": utils.HandleCommandOutput(natures.NaturesCommand), @@ -147,6 +150,7 @@ func runCLI(args []string) int { styling.StyleBold.Render("\nCommands:"), fmt.Sprintf("\n\t%-15s %s", "ability", "Get details about an ability"), fmt.Sprintf("\n\t%-15s %s", "berry", "Get details about a berry"), + fmt.Sprintf("\n\t%-15s %s", "card", "Get details about a TCG card"), fmt.Sprintf("\n\t%-15s %s", "item", "Get details about an item"), fmt.Sprintf("\n\t%-15s %s", "move", "Get details about a move"), fmt.Sprintf("\n\t%-15s %s", "natures", "Get details about all natures"), diff --git a/cmd/berry/berryinfo.go b/cmd/berry/berryinfo.go index 534dc4ee..3b284dfa 100644 --- a/cmd/berry/berryinfo.go +++ b/cmd/berry/berryinfo.go @@ -11,7 +11,6 @@ import ( "github.com/disintegration/imaging" ) -// BerryName prints information based on currently selected berry. func BerryName(berryName string) string { return "Berry: " + berryName } diff --git a/cmd/card/card.go b/cmd/card/card.go new file mode 100644 index 00000000..dc3ed96e --- /dev/null +++ b/cmd/card/card.go @@ -0,0 +1,115 @@ +package card + +import ( + "flag" + "fmt" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/digitalghost-dev/poke-cli/cmd/utils" + "github.com/digitalghost-dev/poke-cli/styling" +) + +func CardCommand() (string, error) { + var output strings.Builder + + flag.Usage = func() { + helpMessage := styling.HelpBorder.Render( + "View data about cards from the TCG!\n\n", + styling.StyleBold.Render("USAGE:"), + fmt.Sprintf("\n\t%s %s %s", "poke-cli", styling.StyleBold.Render("card"), "[flag]"), + "\n\n", + styling.StyleBold.Render("FLAGS:"), + fmt.Sprintf("\n\t%-30s %s", "-h, --help", "Prints out the help menu"), + ) + output.WriteString(helpMessage) + } + + flag.Parse() + + // Handle help flag + if len(os.Args) == 3 && (os.Args[2] == "-h" || os.Args[2] == "--help") { + flag.Usage() + return output.String(), nil + } + + // Validate arguments + if err := utils.ValidateCardArgs(os.Args); err != nil { + output.WriteString(err.Error()) + return output.String(), err + } + + seriesModel := SeriesList() + // Program 1: Series selection + finalModel, err := tea.NewProgram(seriesModel).Run() + if err != nil { + return "", fmt.Errorf("error running series selection program: %w", err) + } + + result, ok := finalModel.(SeriesModel) + if !ok { + return "", fmt.Errorf("unexpected model type from series selection: got %T, want SeriesModel", finalModel) + } + + if result.SeriesID != "" { + // Program 2: Sets selection + setsModel, err := SetsList(result.SeriesID) + + if err != nil { + return "", fmt.Errorf("error loading sets: %w", err) + } + + finalSetsModel, err := tea.NewProgram(setsModel).Run() + if err != nil { + return "", fmt.Errorf("error running sets selection program: %w", err) + } + + setsResult, ok := finalSetsModel.(SetsModel) + if !ok { + return "", fmt.Errorf("unexpected model type from sets selection: got %T, want SetsModel", finalSetsModel) + } + + if setsResult.Quitting { + return output.String(), nil + } + + // Program 3: Cards display + if setsResult.SetID != "" { + cardsModel, err := CardsList(setsResult.SetID) + if err != nil { + return "", fmt.Errorf("error loading cards: %w", err) + } + + for { + finalCardsModel, err := tea.NewProgram(cardsModel, tea.WithAltScreen()).Run() + if err != nil { + return "", fmt.Errorf("error running cards program: %w", err) + } + + cardsResult, ok := finalCardsModel.(CardsModel) + if !ok { + return "", fmt.Errorf("unexpected model type from cards display: got %T, want CardsModel", finalCardsModel) + } + + if cardsResult.ViewImage { + // Launch image viewer + imageURL := cardsResult.ImageMap[cardsResult.SelectedOption] + imageModel := ImageRenderer(cardsResult.SelectedOption, imageURL) + _, err := tea.NewProgram(imageModel, tea.WithAltScreen()).Run() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: image viewer error: %v\n", err) + } + + // Re-launch cards with same state + cardsResult.ViewImage = false + cardsModel = cardsResult + } else { + break + } + } + } + } + + return output.String(), nil +} diff --git a/cmd/card/card_test.go b/cmd/card/card_test.go new file mode 100644 index 00000000..209e0df0 --- /dev/null +++ b/cmd/card/card_test.go @@ -0,0 +1,54 @@ +package card + +import ( + "os" + "strings" + "testing" +) + +func TestCardCommand(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + contains string + }{ + { + name: "help flag short", + args: []string{"poke-cli", "card", "-h"}, + wantErr: false, + contains: "USAGE:", + }, + { + name: "help flag long", + args: []string{"poke-cli", "card", "--help"}, + wantErr: false, + contains: "FLAGS:", + }, + { + name: "invalid args", + args: []string{"poke-cli", "card", "invalid-arg"}, + wantErr: true, + contains: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oldArgs := os.Args + os.Args = tt.args + defer func() { os.Args = oldArgs }() + + output, err := CardCommand() + + if (err != nil) != tt.wantErr { + t.Errorf("CardCommand() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.contains != "" && !strings.Contains(output, tt.contains) { + t.Errorf("CardCommand() output should contain %q, got %q", tt.contains, output) + } + }) + } +} diff --git a/cmd/card/cardinfo.go b/cmd/card/cardinfo.go new file mode 100644 index 00000000..18d4705d --- /dev/null +++ b/cmd/card/cardinfo.go @@ -0,0 +1,58 @@ +package card + +import ( + "bytes" + "errors" + "fmt" + "image" + "io" + "net/http" + "net/url" + "time" + + "github.com/charmbracelet/x/ansi/sixel" + "golang.org/x/image/draw" +) + +func resizeImage(img image.Image, width, height int) image.Image { + dst := image.NewRGBA(image.Rect(0, 0, width, height)) + draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil) + return dst +} + +func CardImage(imageURL string) (string, error) { + client := &http.Client{ + Timeout: time.Second * 15, + } + parsedURL, err := url.Parse(imageURL) + if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") { + return "", errors.New("invalid URL scheme") + } + resp, err := client.Get(imageURL) + if err != nil { + return "", fmt.Errorf("failed to fetch image: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("non-200 response: %d", resp.StatusCode) + } + + limitedBody := io.LimitReader(resp.Body, 10*1024*1024) + img, _, err := image.Decode(limitedBody) + if err != nil { + return "", fmt.Errorf("failed to decode image: %w", err) + } + + resized := resizeImage(img, 500, 675) + + // Build Sixel string to return + var buf bytes.Buffer + buf.WriteString("\x1bPq") + if err := new(sixel.Encoder).Encode(&buf, resized); err != nil { + return "", fmt.Errorf("failed to encode sixel: %w", err) + } + buf.WriteString("\x1b\\") + + return buf.String(), nil +} diff --git a/cmd/card/cardinfo_test.go b/cmd/card/cardinfo_test.go new file mode 100644 index 00000000..ba23f7d3 --- /dev/null +++ b/cmd/card/cardinfo_test.go @@ -0,0 +1,161 @@ +package card + +import ( + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestResizeImage(t *testing.T) { + // Create a simple test image (100x100 red square) + testImg := image.NewRGBA(image.Rect(0, 0, 100, 100)) + red := color.RGBA{R: 255, G: 0, B: 0, A: 255} + for y := 0; y < 100; y++ { + for x := 0; x < 100; x++ { + testImg.Set(x, y, red) + } + } + + tests := []struct { + name string + img image.Image + width int + height int + wantWidth int + wantHeight int + }{ + { + name: "resize to smaller dimensions", + img: testImg, + width: 50, + height: 50, + wantWidth: 50, + wantHeight: 50, + }, + { + name: "resize to larger dimensions", + img: testImg, + width: 200, + height: 200, + wantWidth: 200, + wantHeight: 200, + }, + { + name: "resize to card dimensions", + img: testImg, + width: 500, + height: 675, + wantWidth: 500, + wantHeight: 675, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := resizeImage(tt.img, tt.width, tt.height) + bounds := result.Bounds() + + if bounds.Dx() != tt.wantWidth { + t.Errorf("resizeImage() width = %v, want %v", bounds.Dx(), tt.wantWidth) + } + if bounds.Dy() != tt.wantHeight { + t.Errorf("resizeImage() height = %v, want %v", bounds.Dy(), tt.wantHeight) + } + }) + } +} + +func TestCardImage_Success(t *testing.T) { + // Create a test HTTP server that serves a small PNG image + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Create a minimal 10x10 PNG image + img := image.NewRGBA(image.Rect(0, 0, 10, 10)) + blue := color.RGBA{R: 0, G: 0, B: 255, A: 255} + for y := 0; y < 10; y++ { + for x := 0; x < 10; x++ { + img.Set(x, y, blue) + } + } + + w.Header().Set("Content-Type", "image/png") + w.WriteHeader(http.StatusOK) + err := png.Encode(w, img) + if err != nil { + return + } + })) + defer server.Close() + + result, err := CardImage(server.URL) + + if err != nil { + t.Errorf("CardImage() error = %v, want nil", err) + return + } + + // Check that result is a valid Sixel string + if !strings.HasPrefix(result, "\x1bPq") { + t.Error("CardImage() should return string starting with Sixel header") + } + + if !strings.HasSuffix(result, "\x1b\\") { + t.Error("CardImage() should return string ending with Sixel terminator") + } + + if len(result) == 0 { + t.Error("CardImage() should return non-empty string") + } +} + +func TestCardImage_EncodingError(t *testing.T) { + // Create a test HTTP server that serves invalid image data + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte("not a valid PNG")) + if err != nil { + return + } + })) + defer server.Close() + + result, err := CardImage(server.URL) + + if err == nil { + t.Error("CardImage() should return error for invalid image data") + } + + if result != "" { + t.Errorf("CardImage() on error should return empty string, got %v", result) + } + + if !strings.Contains(err.Error(), "failed to decode image") { + t.Errorf("Error message should mention 'failed to decode image', got: %v", err) + } +} + +func TestCardImage_Non200Response(t *testing.T) { + // Create a test HTTP server that returns 404 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + result, err := CardImage(server.URL) + + if err == nil { + t.Error("CardImage() should return error for non-200 response") + } + + if result != "" { + t.Errorf("CardImage() on error should return empty string, got %v", result) + } + + if !strings.Contains(err.Error(), "non-200 response") { + t.Errorf("Error message should mention 'non-200 response', got: %v", err) + } +} diff --git a/cmd/card/cardlist.go b/cmd/card/cardlist.go new file mode 100644 index 00000000..ac86f22c --- /dev/null +++ b/cmd/card/cardlist.go @@ -0,0 +1,179 @@ +package card + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/digitalghost-dev/poke-cli/styling" +) + +type CardsModel struct { + Choice string + IllustratorMap map[string]string + ImageMap map[string]string + PriceMap map[string]string + Quitting bool + SelectedOption string + SeriesName string + Table table.Model + ViewImage bool +} + +func (m CardsModel) Init() tea.Cmd { + return nil +} + +func (m CardsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var bubbleCmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc", "ctrl+c": + m.Quitting = true + return m, tea.Quit + case " ": + m.ViewImage = true + return m, tea.Quit + } + } + + m.Table, bubbleCmd = m.Table.Update(msg) + + // Keep the selected option in sync on every update + if row := m.Table.SelectedRow(); len(row) > 0 { + name := row[0] + if name != m.SelectedOption { + m.SelectedOption = name + } + } + + return m, bubbleCmd +} + +func (m CardsModel) View() string { + if m.Quitting { + return "\n Quitting card search...\n\n" + } + + selectedCard := "" + if row := m.Table.SelectedRow(); len(row) > 0 { + cardName := row[0] + price := m.PriceMap[cardName] + if price == "" { + price = "Price: Not available" + } + illustrator := m.IllustratorMap[cardName] + selectedCard = cardName + "\n---\n" + price + "\n---\n" + illustrator + } + + leftPanel := styling.TypesTableBorder.Render(m.Table.View()) + + rightPanel := lipgloss.NewStyle(). + Width(40). + Height(29). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#FFCC00")). + Padding(1). + Render(selectedCard) + + screen := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel) + + return fmt.Sprintf("Highlight a card!\n%s\n%s", + screen, + styling.KeyMenu.Render("↑ (move up)\n↓ (move down)\nspace (view image)\nctrl+c | esc (quit)")) +} + +type cardData struct { + Illustrator string `json:"illustrator"` + ImageURL string `json:"image_url"` + MarketPrice float64 `json:"market_price"` + Name string `json:"name"` + NumberPlusName string `json:"number_plus_name"` +} + +// 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) + if err != nil { + return CardsModel{}, fmt.Errorf("failed to fetch card data: %w", err) + } + + var allCards []cardData + err = json.Unmarshal(body, &allCards) + if err != nil { + return CardsModel{}, fmt.Errorf("failed to unmarshal card data: %w", err) + } + + // Extract card names and build table rows + price map + rows := make([]table.Row, len(allCards)) + priceMap := make(map[string]string) + imageMap := make(map[string]string) + 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 + imageMap[card.NumberPlusName] = card.ImageURL + } + + t := table.New( + table.WithColumns([]table.Column{{Title: "Card Name", Width: 35}}), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(28), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#FFCC00")). + BorderBottom(true) + s.Selected = s.Selected. + Foreground(lipgloss.Color("#000")). + Background(lipgloss.Color("#FFCC00")) + t.SetStyles(s) + + return CardsModel{ + IllustratorMap: illustratorMap, + ImageMap: imageMap, + PriceMap: priceMap, + Table: t, + }, nil +} + +func CallCardData(url string) ([]byte, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + req.Header.Add("apikey", "sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j") + req.Header.Add("Authorization", "Bearer sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j") + req.Header.Add("Content-Type", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("error making GET request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + return body, nil +} diff --git a/cmd/card/cardlist_test.go b/cmd/card/cardlist_test.go new file mode 100644 index 00000000..7df05944 --- /dev/null +++ b/cmd/card/cardlist_test.go @@ -0,0 +1,256 @@ +package card + +import ( + "strings" + "testing" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" +) + +func TestCardsModel_Init(t *testing.T) { + model := CardsModel{ + SeriesName: "sv", + } + + cmd := model.Init() + if cmd != nil { + t.Error("Init() should return nil") + } +} + +func TestCardsModel_Update_EscKey(t *testing.T) { + rows := []table.Row{ + {"001/198 - Bulbasaur"}, + {"002/198 - Ivysaur"}, + } + columns := []table.Column{ + {Title: "Card Name", Width: 35}, + } + tbl := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + ) + + model := CardsModel{ + Table: tbl, + Quitting: false, + } + + msg := tea.KeyMsg{Type: tea.KeyEsc} + newModel, cmd := model.Update(msg) + + resultModel := newModel.(CardsModel) + + if !resultModel.Quitting { + t.Error("Quitting should be set to true when ESC is pressed") + } + + if cmd == nil { + t.Error("Update with ESC should return tea.Quit command") + } +} + +func TestCardsModel_Update_CtrlC(t *testing.T) { + rows := []table.Row{ + {"001/198 - Bulbasaur"}, + } + columns := []table.Column{ + {Title: "Card Name", Width: 35}, + } + tbl := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + ) + + model := CardsModel{ + Table: tbl, + Quitting: false, + } + + msg := tea.KeyMsg{Type: tea.KeyCtrlC} + newModel, cmd := model.Update(msg) + + resultModel := newModel.(CardsModel) + + if !resultModel.Quitting { + t.Error("Quitting should be set to true when Ctrl+C is pressed") + } + + if cmd == nil { + t.Error("Update with Ctrl+C should return tea.Quit command") + } +} + +func TestCardsModel_Update_SpaceBar(t *testing.T) { + rows := []table.Row{ + {"001/198 - Bulbasaur"}, + } + columns := []table.Column{ + {Title: "Card Name", Width: 35}, + } + tbl := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + ) + + model := CardsModel{ + Table: tbl, + ViewImage: false, + } + + msg := tea.KeyMsg{Type: tea.KeySpace} + newModel, cmd := model.Update(msg) + + resultModel := newModel.(CardsModel) + + if !resultModel.ViewImage { + t.Error("ViewImage should be set to true when SPACE is pressed") + } + + if cmd == nil { + t.Error("Update with SPACE should return tea.Quit command") + } +} + +func TestCardsModel_Update_SelectionSync(t *testing.T) { + rows := []table.Row{ + {"001/198 - Bulbasaur"}, + {"002/198 - Ivysaur"}, + } + columns := []table.Column{ + {Title: "Card Name", Width: 35}, + } + tbl := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + ) + + model := CardsModel{ + Table: tbl, + SelectedOption: "", + } + + // Simulate a key press that won't quit (e.g., arrow down) + msg := tea.KeyMsg{Type: tea.KeyDown} + newModel, _ := model.Update(msg) + + resultModel := newModel.(CardsModel) + + // The selected option should be updated to the current row + if resultModel.SelectedOption == "" { + t.Error("SelectedOption should be synced after table update") + } +} + +func TestCardsModel_View_Quitting(t *testing.T) { + model := CardsModel{ + Quitting: true, + } + + result := model.View() + + if !strings.Contains(result, "Quitting card search") { + t.Errorf("View() when quitting should contain 'Quitting card search', got: %s", result) + } +} + +func TestCardsModel_View_Normal(t *testing.T) { + rows := []table.Row{ + {"001/198 - Bulbasaur"}, + } + columns := []table.Column{ + {Title: "Card Name", Width: 35}, + } + tbl := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + ) + + priceMap := map[string]string{ + "001/198 - Bulbasaur": "Price: $1.50", + } + + model := CardsModel{ + Table: tbl, + PriceMap: priceMap, + Quitting: false, + } + + result := model.View() + + if result == "" { + t.Error("View() should not return empty string in normal state") + } + + // Check that it contains the key menu + if !strings.Contains(result, "move up") { + t.Error("View() should contain key menu instructions") + } +} + +func TestCardsModel_View_PriceDisplay(t *testing.T) { + rows := []table.Row{ + {"001/198 - Bulbasaur"}, + } + columns := []table.Column{ + {Title: "Card Name", Width: 35}, + } + tbl := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + ) + + priceMap := map[string]string{ + "001/198 - Bulbasaur": "Price: $1.50", + } + + model := CardsModel{ + Table: tbl, + PriceMap: priceMap, + Quitting: false, + } + + result := model.View() + + // The view should include the card name + if !strings.Contains(result, "001/198 - Bulbasaur") { + t.Error("View() should display selected card name") + } +} + +func TestCardsModel_View_MissingPrice(t *testing.T) { + rows := []table.Row{ + {"001/198 - Bulbasaur"}, + } + columns := []table.Column{ + {Title: "Card Name", Width: 35}, + } + tbl := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + ) + + // Empty price map - simulates missing price data + priceMap := map[string]string{} + + model := CardsModel{ + Table: tbl, + PriceMap: priceMap, + Quitting: false, + } + + result := model.View() + + // Should show "Price: Not available" when price is missing + if !strings.Contains(result, "Price: Not available") { + t.Error("View() should display 'Price: Not available' for cards without pricing") + } +} diff --git a/cmd/card/design.go b/cmd/card/design.go new file mode 100644 index 00000000..097144c1 --- /dev/null +++ b/cmd/card/design.go @@ -0,0 +1,47 @@ +package card + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + titleStyle = lipgloss.NewStyle().MarginLeft(2) + itemStyle = lipgloss.NewStyle().PaddingLeft(4) + selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.AdaptiveColor{Light: "#E1AD01", Dark: "#FFDE00"}) + paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) + helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) + quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 4) +) + +type item string + +func (i item) FilterValue() string { return "" } + +type itemDelegate struct{} + +func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Spacing() int { return 0 } +func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } +func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(item) + if !ok { + return + } + + str := string(i) + + fn := itemStyle.Render + if index == m.Index() { + fn = func(s ...string) string { + return selectedItemStyle.Render("> " + strings.Join(s, " ")) + } + } + + fmt.Fprint(w, fn(str)) +} diff --git a/cmd/card/design_test.go b/cmd/card/design_test.go new file mode 100644 index 00000000..f938972e --- /dev/null +++ b/cmd/card/design_test.go @@ -0,0 +1,88 @@ +package card + +import ( + "bytes" + "testing" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +func TestItemFilterValue(t *testing.T) { + testItem := item("Test Item") + filterValue := testItem.FilterValue() + + if filterValue != "" { + t.Errorf("Expected FilterValue to return empty string, got '%s'", filterValue) + } +} + +func TestItemDelegateHeight(t *testing.T) { + delegate := itemDelegate{} + height := delegate.Height() + + if height != 1 { + t.Errorf("Expected Height to return 1, got %d", height) + } +} + +func TestItemDelegateSpacing(t *testing.T) { + delegate := itemDelegate{} + spacing := delegate.Spacing() + + if spacing != 0 { + t.Errorf("Expected Spacing to return 0, got %d", spacing) + } +} + +func TestItemDelegateUpdate(t *testing.T) { + delegate := itemDelegate{} + cmd := delegate.Update(tea.KeyMsg{}, &list.Model{}) + + if cmd != nil { + t.Error("Expected Update to return nil, got non-nil value") + } +} + +func TestItemDelegateRender(t *testing.T) { + delegate := itemDelegate{} + + items := []list.Item{ + item("First Item"), + item("Second Item"), + item("Third Item"), + } + + l := list.New(items, delegate, 20, 10) + + var buf bytes.Buffer + delegate.Render(&buf, l, 0, items[0]) + + output := buf.String() + if output == "" { + t.Error("Expected non-empty output from Render") + } +} + +func TestItemDelegateRenderSelected(t *testing.T) { + delegate := itemDelegate{} + + items := []list.Item{ + item("First Item"), + item("Second Item"), + } + + l := list.New(items, delegate, 20, 10) + + var buf bytes.Buffer + delegate.Render(&buf, l, l.Index(), items[l.Index()]) + + output := buf.String() + if output == "" { + t.Error("Expected non-empty output for selected item") + } + + if len(output) == 0 { + t.Error("Selected item should produce rendered output") + } +} diff --git a/cmd/card/imageviewer.go b/cmd/card/imageviewer.go new file mode 100644 index 00000000..e8f00846 --- /dev/null +++ b/cmd/card/imageviewer.go @@ -0,0 +1,40 @@ +package card + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +type ImageModel struct { + CardName string + ImageURL string + Error error +} + +func (m ImageModel) Init() tea.Cmd { + return nil +} + +func (m ImageModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + } + } + return m, nil +} + +func (m ImageModel) View() string { + return m.ImageURL +} + +func ImageRenderer(cardName string, imageURL string) ImageModel { + imageData, err := CardImage(imageURL) + + return ImageModel{ + CardName: cardName, + ImageURL: imageData, + Error: err, + } +} diff --git a/cmd/card/imageviewer_test.go b/cmd/card/imageviewer_test.go new file mode 100644 index 00000000..7b2efa5f --- /dev/null +++ b/cmd/card/imageviewer_test.go @@ -0,0 +1,179 @@ +package card + +import ( + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestImageModel_Init(t *testing.T) { + model := ImageModel{ + CardName: "001/198 - Pineco", + ImageURL: "test-sixel-data", + } + + cmd := model.Init() + if cmd != nil { + t.Error("Init() should return nil") + } +} + +func TestImageModel_Update_EscKey(t *testing.T) { + model := ImageModel{ + CardName: "001/198 - Pineco", + ImageURL: "test-sixel-data", + } + + // Test ESC key + msg := tea.KeyMsg{Type: tea.KeyEsc} + newModel, cmd := model.Update(msg) + + // Should return quit command + if cmd == nil { + t.Error("Update with ESC should return tea.Quit command") + } + + // Model should be returned (even if quitting) + if _, ok := newModel.(ImageModel); !ok { + t.Error("Update should return ImageModel") + } +} + +func TestImageModel_Update_CtrlC(t *testing.T) { + model := ImageModel{ + CardName: "001/198 - Pineco", + ImageURL: "test-sixel-data", + } + + msg := tea.KeyMsg{Type: tea.KeyCtrlC} + _, cmd := model.Update(msg) + + // Should return quit command + if cmd == nil { + t.Error("Update with Ctrl+C should return tea.Quit command") + } +} + +func TestImageModel_Update_DifferentKey(t *testing.T) { + model := ImageModel{ + CardName: "001/198 - Pineco", + ImageURL: "test-sixel-data", + } + + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}} + _, cmd := model.Update(msg) + + if cmd != nil { + t.Error("Update with non-quit key should not return a command") + } +} + +func TestImageModel_View(t *testing.T) { + expectedURL := "test-sixel-data-123" + model := ImageModel{ + CardName: "001/198 - Pineco", + ImageURL: expectedURL, + } + + result := model.View() + + if result != expectedURL { + t.Errorf("View() = %v, want %v", result, expectedURL) + } +} + +func TestImageModel_View_Empty(t *testing.T) { + model := ImageModel{ + CardName: "001/198 - Pineco", + ImageURL: "", + } + + result := model.View() + + if result != "" { + t.Errorf("View() with empty ImageURL should return empty string, got %v", result) + } +} + +func TestImageRenderer_Success(t *testing.T) { + // Create a test HTTP server that serves a valid PNG image + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + img := image.NewRGBA(image.Rect(0, 0, 10, 10)) + blue := color.RGBA{R: 0, G: 0, B: 255, A: 255} + for y := 0; y < 10; y++ { + for x := 0; x < 10; x++ { + img.Set(x, y, blue) + } + } + + w.Header().Set("Content-Type", "image/png") + w.WriteHeader(http.StatusOK) + _ = png.Encode(w, img) + })) + defer server.Close() + + model := ImageRenderer("Pikachu", server.URL) + + if model.CardName != "Pikachu" { + t.Errorf("ImageRenderer() CardName = %v, want %v", model.CardName, "Pikachu") + } + + if model.Error != nil { + t.Errorf("ImageRenderer() Error should be nil on success, got %v", model.Error) + } + + if model.ImageURL == "" { + t.Error("ImageRenderer() ImageURL should not be empty on success") + } +} + +func TestImageRenderer_Error(t *testing.T) { + // Create a test HTTP server that returns an error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + model := ImageRenderer("Charizard", server.URL) + + if model.CardName != "Charizard" { + t.Errorf("ImageRenderer() CardName = %v, want %v", model.CardName, "Charizard") + } + + if model.Error == nil { + t.Error("ImageRenderer() Error should not be nil when image fetch fails") + } + + if model.ImageURL != "" { + t.Errorf("ImageRenderer() ImageURL should be empty on error, got %v", model.ImageURL) + } +} + +func TestImageRenderer_InvalidImage(t *testing.T) { + // Create a test HTTP server that returns invalid image data + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not a valid image")) + })) + defer server.Close() + + model := ImageRenderer("Mewtwo", server.URL) + + if model.CardName != "Mewtwo" { + t.Errorf("ImageRenderer() CardName = %v, want %v", model.CardName, "Mewtwo") + } + + if model.Error == nil { + t.Error("ImageRenderer() Error should not be nil when image decoding fails") + } + + if model.ImageURL != "" { + t.Errorf("ImageRenderer() ImageURL should be empty on error, got %v", model.ImageURL) + } +} diff --git a/cmd/card/serieslist.go b/cmd/card/serieslist.go new file mode 100644 index 00000000..520fc6ed --- /dev/null +++ b/cmd/card/serieslist.go @@ -0,0 +1,80 @@ +package card + +import ( + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +var seriesIDMap = map[string]string{ + "Mega Evolution": "me", + "Scarlet & Violet": "sv", + "Sword & Shield": "swsh", +} + +type SeriesModel struct { + List list.Model + Choice string + SeriesID string + Quitting bool +} + +func (m SeriesModel) Init() tea.Cmd { + return nil +} + +func (m SeriesModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + m.Quitting = true + return m, tea.Quit + case "enter": + i, ok := m.List.SelectedItem().(item) + if ok { + m.Choice = string(i) + m.SeriesID = seriesIDMap[string(i)] + } + return m, tea.Quit + } + + case tea.WindowSizeMsg: + m.List.SetWidth(msg.Width) + return m, nil + } + + var cmd tea.Cmd + m.List, cmd = m.List.Update(msg) + return m, cmd +} + +func (m SeriesModel) View() string { + if m.Quitting { + return "\n Quitting card search...\n\n" + } + if m.Choice != "" { + return quitTextStyle.Render("Series selected:", m.Choice) + } + + return "\n" + m.List.View() +} + +func SeriesList() SeriesModel { + items := []list.Item{ + item("Mega Evolution"), + item("Scarlet & Violet"), + } + + const listWidth = 20 + const listHeight = 12 + + l := list.New(items, itemDelegate{}, listWidth, listHeight) + l.Title = "First, pick a series" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.Styles.Title = titleStyle + l.Styles.PaginationStyle = paginationStyle + l.Styles.HelpStyle = helpStyle + + return SeriesModel{List: l} +} diff --git a/cmd/card/serieslist_test.go b/cmd/card/serieslist_test.go new file mode 100644 index 00000000..e77926b9 --- /dev/null +++ b/cmd/card/serieslist_test.go @@ -0,0 +1,144 @@ +package card + +import ( + "testing" + "time" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" +) + +func TestSeriesModelInit(t *testing.T) { + items := []list.Item{ + item("Mega Evolution"), + item("Scarlet & Violet"), + item("Sword & Shield"), + } + l := list.New(items, itemDelegate{}, 20, 12) + model := SeriesModel{List: l} + + cmd := model.Init() + if cmd != nil { + t.Errorf("Expected Init() to return nil, got %v", cmd) + } +} + +func TestSeriesModelQuit(t *testing.T) { + items := []list.Item{ + item("Mega Evolution"), + item("Scarlet & Violet"), + item("Sword & Shield"), + } + l := list.New(items, itemDelegate{}, 20, 12) + model := SeriesModel{List: l} + + testModel := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(80, 24)) + + // Test ctrl+c quit + testModel.Send(tea.KeyMsg{Type: tea.KeyCtrlC}) + testModel.WaitFinished(t, teatest.WithFinalTimeout(300*time.Millisecond)) + + final := testModel.FinalModel(t).(SeriesModel) + + if !final.Quitting { + t.Errorf("Expected model to be quitting after ctrl+c") + } +} + +func TestSeriesModelEscQuit(t *testing.T) { + items := []list.Item{ + item("Mega Evolution"), + item("Scarlet & Violet"), + item("Sword & Shield"), + } + l := list.New(items, itemDelegate{}, 20, 12) + model := SeriesModel{List: l} + + testModel := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(80, 24)) + + // Test esc quit + testModel.Send(tea.KeyMsg{Type: tea.KeyEsc}) + testModel.WaitFinished(t, teatest.WithFinalTimeout(300*time.Millisecond)) + + final := testModel.FinalModel(t).(SeriesModel) + + if !final.Quitting { + t.Errorf("Expected model to be quitting after esc") + } +} + +func TestSeriesModelSelection(t *testing.T) { + items := []list.Item{ + item("Mega Evolution"), + item("Scarlet & Violet"), + item("Sword & Shield"), + } + l := list.New(items, itemDelegate{}, 20, 12) + model := SeriesModel{List: l} + + testModel := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(80, 24)) + + // Navigate and select + testModel.Send(tea.KeyMsg{Type: tea.KeyDown}) // Move to second item + testModel.Send(tea.KeyMsg{Type: tea.KeyEnter}) // Select it + testModel.WaitFinished(t, teatest.WithFinalTimeout(300*time.Millisecond)) + + final := testModel.FinalModel(t).(SeriesModel) + + if final.Choice == "" { + t.Errorf("Expected a choice to be made, got empty string") + } + if final.Choice != "Scarlet & Violet" { + t.Errorf("Expected choice to be 'Scarlet & Violet', got '%s'", final.Choice) + } +} + +func TestSeriesModelWindowResize(t *testing.T) { + items := []list.Item{ + item("Mega Evolution"), + item("Scarlet & Violet"), + item("Sword & Shield"), + } + l := list.New(items, itemDelegate{}, 20, 12) + model := SeriesModel{List: l} + + // Send window resize message + updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + finalModel := updatedModel.(SeriesModel) + + if finalModel.List.Width() != 100 { + t.Errorf("Expected list width to be 100 after resize, got %d", finalModel.List.Width()) + } +} + +func TestSeriesModelView(t *testing.T) { + items := []list.Item{ + item("Mega Evolution"), + item("Scarlet & Violet"), + item("Sword & Shield"), + } + l := list.New(items, itemDelegate{}, 20, 12) + + // Test normal view + model := SeriesModel{List: l} + view := model.View() + if view == "" { + t.Errorf("Expected non-empty view, got empty string") + } + + // Test quitting view + model.Quitting = true + view = model.View() + if view != "\n Quitting card search...\n\n" { + t.Errorf("Expected quitting message, got '%s'", view) + } + + // Test choice made view + model.Quitting = false + model.Choice = "Scarlet & Violet" + view = model.View() + if view == "" { + t.Errorf("Expected non-empty view for choice, got empty string") + } +} diff --git a/cmd/card/setslist.go b/cmd/card/setslist.go new file mode 100644 index 00000000..4c55e701 --- /dev/null +++ b/cmd/card/setslist.go @@ -0,0 +1,142 @@ +package card + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +type SetsModel struct { + List list.Model + Choice string + SetID string + Quitting bool + SeriesName string + setsIDMap map[string]string // Maps set name -> set_id +} + +func (m SetsModel) Init() tea.Cmd { + return nil +} + +func (m SetsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + m.Quitting = true + return m, tea.Quit + case "enter": + i, ok := m.List.SelectedItem().(item) + if ok { + m.Choice = string(i) + m.SetID = m.setsIDMap[string(i)] // Look up the set_id + } + return m, tea.Quit + } + + case tea.WindowSizeMsg: + m.List.SetWidth(msg.Width) + return m, nil + } + + var cmd tea.Cmd + m.List, cmd = m.List.Update(msg) + return m, cmd +} + +func (m SetsModel) View() string { + if m.Quitting { + return "\n Quitting card search...\n\n" + } + if m.Choice != "" { + return quitTextStyle.Render("Set selected:", m.Choice) + } + + return "\n" + m.List.View() +} + +type setData struct { + SeriesID string `json:"series_id"` + SetID string `json:"set_id"` + SetName string `json:"set_name"` + OfficialCardCount int `json:"official_card_count"` + TotalCardCount int `json:"total_card_count"` + Logo string `json:"logo"` + Symbol string `json:"symbol"` +} + +func SetsList(seriesID string) (SetsModel, error) { + body, err := callSetsData("https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/sets") + if err != nil { + return SetsModel{}, fmt.Errorf("error getting sets data: %v", err) + } + var allSets []setData + + err = json.Unmarshal(body, &allSets) + if err != nil { + return SetsModel{}, fmt.Errorf("error parsing sets data: %v", err) + } + + // Filter sets by series_id and build ID map + var items []list.Item + setsIDMap := make(map[string]string) + for _, set := range allSets { + if set.SeriesID == seriesID { + items = append(items, item(set.SetName)) + setsIDMap[set.SetName] = set.SetID // Map name -> ID + } + } + + const listWidth = 20 + const listHeight = 20 + + l := list.New(items, itemDelegate{}, listWidth, listHeight) + l.Title = fmt.Sprintf("Pick a set from the %s series", seriesID) + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.Styles.Title = titleStyle + l.Styles.PaginationStyle = paginationStyle + l.Styles.HelpStyle = helpStyle + + return SetsModel{ + List: l, + SeriesName: seriesID, + setsIDMap: setsIDMap, + }, + nil +} + +func callSetsData(url string) ([]byte, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + req.Header.Add("apikey", "sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j") + req.Header.Add("Authorization", "Bearer sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j") + req.Header.Add("Content-Type", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("error making GET request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + return body, nil +} diff --git a/cmd/card/setslist_test.go b/cmd/card/setslist_test.go new file mode 100644 index 00000000..e01befdb --- /dev/null +++ b/cmd/card/setslist_test.go @@ -0,0 +1,188 @@ +package card + +import ( + "strings" + "testing" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +func TestSetsModel_Init(t *testing.T) { + model := SetsModel{ + SeriesName: "sv", + Quitting: false, + } + + cmd := model.Init() + if cmd != nil { + t.Error("Init() should return nil") + } +} + +func TestSetsModel_Update_EscKey(t *testing.T) { + items := []list.Item{ + item("Scarlet & Violet"), + item("Paldea Evolved"), + } + l := list.New(items, itemDelegate{}, 20, 20) + + model := SetsModel{ + List: l, + SeriesName: "sv", + Quitting: false, + } + + msg := tea.KeyMsg{Type: tea.KeyEsc} + newModel, cmd := model.Update(msg) + + resultModel, ok := newModel.(SetsModel) + if !ok { + t.Fatalf("expected SetsModel, got %T", newModel) + } + + if !resultModel.Quitting { + t.Error("Quitting should be set to true when ESC is pressed") + } + + if cmd == nil { + t.Error("Update with ESC should return tea.Quit command") + } +} + +func TestSetsModel_Update_CtrlC(t *testing.T) { + items := []list.Item{ + item("Scarlet & Violet"), + } + l := list.New(items, itemDelegate{}, 20, 20) + + model := SetsModel{ + List: l, + SeriesName: "sv", + Quitting: false, + } + + msg := tea.KeyMsg{Type: tea.KeyCtrlC} + newModel, cmd := model.Update(msg) + + resultModel, ok := newModel.(SetsModel) + if !ok { + t.Fatalf("expected SetsModel, got %T", newModel) + } + + if !resultModel.Quitting { + t.Error("Quitting should be set to true when Ctrl+C is pressed") + } + + if cmd == nil { + t.Error("Update with Ctrl+C should return tea.Quit command") + } +} + +func TestSetsModel_Update_WindowSizeMsg(t *testing.T) { + items := []list.Item{ + item("Scarlet & Violet"), + } + l := list.New(items, itemDelegate{}, 20, 20) + + model := SetsModel{ + List: l, + SeriesName: "sv", + } + + msg := tea.WindowSizeMsg{Width: 100, Height: 50} + newModel, cmd := model.Update(msg) + + resultModel, ok := newModel.(SetsModel) + if !ok { + t.Fatalf("expected SetsModel, got %T", newModel) + } + + if cmd != nil { + t.Error("WindowSizeMsg should not return a command") + } + + if resultModel.Quitting { + t.Error("WindowSizeMsg should not set Quitting to true") + } +} + +func TestSetsModel_View_Quitting(t *testing.T) { + items := []list.Item{ + item("Scarlet & Violet"), + } + l := list.New(items, itemDelegate{}, 20, 20) + + model := SetsModel{ + List: l, + Quitting: true, + } + + result := model.View() + + if !strings.Contains(result, "Quitting card search") { + t.Errorf("View() when quitting should contain 'Quitting card search', got: %s", result) + } +} + +func TestSetsModel_View_ChoiceMade(t *testing.T) { + items := []list.Item{ + item("Scarlet & Violet"), + } + l := list.New(items, itemDelegate{}, 20, 20) + + model := SetsModel{ + List: l, + Choice: "Scarlet & Violet", + } + + result := model.View() + + if !strings.Contains(result, "Set selected: Scarlet & Violet") { + t.Errorf("View() with choice should contain 'Set selected: Scarlet & Violet', got: %s", result) + } +} + +func TestSetsModel_View_Normal(t *testing.T) { + items := []list.Item{ + item("Scarlet & Violet"), + } + l := list.New(items, itemDelegate{}, 20, 20) + + model := SetsModel{ + List: l, + Quitting: false, + Choice: "", + } + + result := model.View() + + if result == "" { + t.Error("View() should not return empty string in normal state") + } +} + +func TestSetsModel_Update_EnterKey(t *testing.T) { + items := []list.Item{ + item("Scarlet & Violet"), + item("Paldea Evolved"), + } + l := list.New(items, itemDelegate{}, 20, 20) + + setsIDMap := map[string]string{ + "Scarlet & Violet": "sv01", + "Paldea Evolved": "sv02", + } + + model := SetsModel{ + List: l, + setsIDMap: setsIDMap, + } + + msg := tea.KeyMsg{Type: tea.KeyEnter} + _, cmd := model.Update(msg) + + if cmd == nil { + t.Error("Update with Enter should return tea.Quit command") + } +} diff --git a/cmd/search/model_input.go b/cmd/search/model_input.go index 82457e32..2eaf57bb 100644 --- a/cmd/search/model_input.go +++ b/cmd/search/model_input.go @@ -2,11 +2,12 @@ package search import ( "fmt" + "strings" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/digitalghost-dev/poke-cli/styling" - "strings" ) // UpdateInput handles text input updates. diff --git a/cmd/search/model_input_test.go b/cmd/search/model_input_test.go index 4b3be154..3d71a251 100644 --- a/cmd/search/model_input_test.go +++ b/cmd/search/model_input_test.go @@ -1,9 +1,10 @@ package search import ( + "testing" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "testing" ) func TestUpdateInput(t *testing.T) { diff --git a/cmd/search/model_selection.go b/cmd/search/model_selection.go index 02f4a65e..02f5d573 100644 --- a/cmd/search/model_selection.go +++ b/cmd/search/model_selection.go @@ -2,6 +2,7 @@ package search import ( "fmt" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/digitalghost-dev/poke-cli/styling" diff --git a/cmd/search/parse.go b/cmd/search/parse.go index 284d3416..50b7e7b9 100644 --- a/cmd/search/parse.go +++ b/cmd/search/parse.go @@ -1,8 +1,9 @@ package search import ( - "github.com/digitalghost-dev/poke-cli/connections" "strings" + + "github.com/digitalghost-dev/poke-cli/connections" ) type Resource struct { diff --git a/cmd/search/search.go b/cmd/search/search.go index b99370a0..c103fc83 100644 --- a/cmd/search/search.go +++ b/cmd/search/search.go @@ -3,11 +3,12 @@ package search import ( "flag" "fmt" + "os" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/digitalghost-dev/poke-cli/cmd/utils" "github.com/digitalghost-dev/poke-cli/styling" - "os" ) func SearchCommand() { @@ -72,9 +73,10 @@ func (m Model) Init() tea.Cmd { // Update handles keypresses and updates the state. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if msg, ok := msg.(tea.KeyMsg); ok { - k := msg.String() - if k == "esc" || k == "ctrl+c" { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": m.Quitting = true return m, tea.Quit } diff --git a/cmd/search/search_test.go b/cmd/search/search_test.go index 9033437b..93807740 100644 --- a/cmd/search/search_test.go +++ b/cmd/search/search_test.go @@ -3,12 +3,13 @@ package search import ( "bytes" "fmt" - tea "github.com/charmbracelet/bubbletea" - "github.com/digitalghost-dev/poke-cli/styling" "os" "strings" "testing" + tea "github.com/charmbracelet/bubbletea" + "github.com/digitalghost-dev/poke-cli/styling" + "github.com/stretchr/testify/assert" ) diff --git a/cmd/utils/validateargs.go b/cmd/utils/validateargs.go index c76f194e..ab2d80c6 100644 --- a/cmd/utils/validateargs.go +++ b/cmd/utils/validateargs.go @@ -42,6 +42,7 @@ func ValidateAbilityArgs(args []string) error { return nil } +// ValidateBerryArgs validates the command line arguments func ValidateBerryArgs(args []string) error { if err := checkLength(args, 3); err != nil { return err @@ -54,6 +55,19 @@ func ValidateBerryArgs(args []string) error { return nil } +// ValidateCardArgs validates the command line arguments +func ValidateCardArgs(args []string) error { + if err := checkLength(args, 3); err != nil { + return err + } + + if err := checkNoOtherOptions(args, 3, ""); err != nil { + return err + } + + return nil +} + // ValidateItemArgs validates the command line arguments func ValidateItemArgs(args []string) error { if err := checkLength(args, 3); err != nil { diff --git a/cmd/utils/validateargs_test.go b/cmd/utils/validateargs_test.go index 99c60dc0..c2617162 100644 --- a/cmd/utils/validateargs_test.go +++ b/cmd/utils/validateargs_test.go @@ -1,10 +1,11 @@ package utils import ( + "testing" + "github.com/digitalghost-dev/poke-cli/styling" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "testing" ) func TestCheckLength(t *testing.T) { @@ -221,6 +222,45 @@ func TestValidateBerryArgs(t *testing.T) { } } +// TestValidateCardArgs tests the ValidateCardArgs function +func TestValidateCardArgs(t *testing.T) { + validInputs := [][]string{ + {"poke-cli", "card"}, + {"poke-cli", "card", "--help"}, + } + + for _, input := range validInputs { + err := ValidateCardArgs(input) + require.NoError(t, err, "Expected no error for valid input") + } + + invalidInputs := [][]string{ + {"poke-cli", "card", "scarlet"}, + } + + for _, input := range invalidInputs { + err := ValidateCardArgs(input) + require.Error(t, err, "Expected error for invalid input") + } + + tooManyArgs := [][]string{ + {"poke-cli", "card", "scarlet", "violet"}, + } + + expectedError := styling.StripANSI("╭──────────────────╮\n│✖ Error! │\n│Too many arguments│\n╰──────────────────╯") + + for _, input := range tooManyArgs { + err := ValidateCardArgs(input) + + if err == nil { + t.Fatalf("Expected an error for input %v, but got nil", input) + } + + strippedErr := styling.StripANSI(err.Error()) + assert.Equal(t, expectedError, strippedErr, "Unexpected error message for invalid input") + } +} + // TestValidateItemArgs tests the ValidateItemArgs function func TestValidateItemArgs(t *testing.T) { validInputs := [][]string{ diff --git a/docs/Infrastructure_Guide/index.md b/docs/Infrastructure_Guide/index.md index 115afb55..67189a63 100644 --- a/docs/Infrastructure_Guide/index.md +++ b/docs/Infrastructure_Guide/index.md @@ -4,7 +4,7 @@ weight: 1 # 1 // Overview -This section serves as a knowledge base for the project’s backend infrastructure. It was created for a few purposes: +This section serves as a knowledge base for the project’s backend data infrastructure. It was created for a few purposes: 1. To document how I built everything, so I can easily reference it later. 2. To help others learn how to build something similar. @@ -21,6 +21,19 @@ The VGC data simply calls one API. If building on a different operating system, please find the equivalent command. Links will be provided for install guides for all operating systems when possible. +## Data Infrastructure Diagram +![data_infrastructure_diagram](../assets/data_infrastructure_diagram.svg) + +1. TCGPlayer pricing data and TCGDex card data are called and processed through a data pipeline orchestrated by Dagster +and hosted on AWS. +2. When the pipeline starts, Pydantic validates the incoming API data against a pre-defined schema, ensuring the data +types match the expected structure. +3. Polars is used to create DataFrames. +4. The data is loaded into a Supabase staging schema. +5. Soda data quality checks are performed. +6. `dbt` runs tests and builds the final tables in a Supabase production schema. +7. Users are then able to query the `pokeapi.co` or `supabase` APIs for either video game or trading card data, respectively. + ## Tools & Services Below is a list of all the tools and services used in this project's infrastructure: diff --git a/docs/assets/data_infrastructure_diagram.svg b/docs/assets/data_infrastructure_diagram.svg new file mode 100644 index 00000000..933e228b --- /dev/null +++ b/docs/assets/data_infrastructure_diagram.svg @@ -0,0 +1,5 @@ + + +dagsterelastic compute cloud (virtual machine)Dagster connected to RDSinstancecreate dataframespolarssupabaseload into stagingdatabaseverify data typesfrom APIspydanticdata quality checksperformtransformationsus-west-2read data through APIsUservirtual private cloudstore dagstermetadataRDSIaCterraformstatemanangementhashicorp cloudsupabaseload into proddatabaseus-east-2EventBridgeschedule instancedowntimeTrack RDS, EC2,and VPC \ No newline at end of file diff --git a/go.mod b/go.mod index 6dfd4ea1..920a8a2f 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,18 @@ module github.com/digitalghost-dev/poke-cli go 1.24.9 require ( - github.com/charmbracelet/bubbles v0.21.0 - github.com/charmbracelet/bubbletea v1.3.6 - github.com/charmbracelet/huh v0.7.0 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 - github.com/charmbracelet/x/exp/strings v0.0.0-20250708181618-a60a724ba6c3 - github.com/charmbracelet/x/exp/teatest v0.0.0-20250702191427-5bdfc8f2e4ff - github.com/charmbracelet/x/term v0.2.1 + github.com/charmbracelet/x/ansi v0.11.0 + github.com/charmbracelet/x/exp/strings v0.0.0-20251110210702-903592506081 + github.com/charmbracelet/x/exp/teatest v0.0.0-20251110210702-903592506081 + github.com/charmbracelet/x/term v0.2.2 github.com/disintegration/imaging v1.6.2 - github.com/stretchr/testify v1.10.0 - golang.org/x/text v0.27.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/image v0.33.0 + golang.org/x/text v0.31.0 modernc.org/sqlite v1.39.1 ) @@ -20,32 +22,34 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-udiff v0.3.1 // indirect + github.com/bits-and-blooms/bitset v1.24.3 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/colorprofile v0.3.1 // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/exp/golden v0.0.0-20250702191427-5bdfc8f2e4ff // indirect + github.com/charmbracelet/colorprofile v0.3.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.14 // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20251110210702-903592506081 // indirect + github.com/clipperhouse/displaywidth v0.5.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/uuid v1.6.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/image v0.28.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.36.0 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sys v0.38.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 1cb34172..a31baba8 100644 --- a/go.sum +++ b/go.sum @@ -6,38 +6,46 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/bits-and-blooms/bitset v1.24.3 h1:Bte86SlO3lwPQqww+7BE9ZuUCKIjfqnG5jtEyqA9y9Y= +github.com/bits-and-blooms/bitset v1.24.3/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= -github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= -github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= -github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= -github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= -github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc= -github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= +github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/ansi v0.11.0 h1:uuIVK7GIplwX6UBIz8S2TF8nkr7xRlygSsBRjSJqIvA= +github.com/charmbracelet/x/ansi v0.11.0/go.mod h1:uQt8bOrq/xgXjlGcFMc8U2WYbnxyjrKhnvTQluvfCaE= +github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= +github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= -github.com/charmbracelet/x/exp/golden v0.0.0-20250702191427-5bdfc8f2e4ff h1:mEllIwjDs9aKqnzckYmNZqxoULwp4afFLVgH9x9QAGA= -github.com/charmbracelet/x/exp/golden v0.0.0-20250702191427-5bdfc8f2e4ff/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= -github.com/charmbracelet/x/exp/strings v0.0.0-20250708181618-a60a724ba6c3 h1:QTf5vyE6CO+zZiF83Je/r3zWol31h3sKRJ7Vt2PwJVg= -github.com/charmbracelet/x/exp/strings v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:Rgw3/F+xlcUc5XygUtimVSxAqCOsqyvJjqF5UHRvc5k= -github.com/charmbracelet/x/exp/teatest v0.0.0-20250702191427-5bdfc8f2e4ff h1:DKxPeQDSnQPDxD27Bq8nUDwiSecy2Yf+nT3U/TlPXs8= -github.com/charmbracelet/x/exp/teatest v0.0.0-20250702191427-5bdfc8f2e4ff/go.mod h1:RXbDhep1qKL/SEz2IuOhOUrsNHDKGqRmGks1nZStKyU= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/exp/golden v0.0.0-20251110210702-903592506081 h1:0pHMO3V29SZJuasznHm3s3XkQZUgoBXQM+VILYoVj50= +github.com/charmbracelet/x/exp/golden v0.0.0-20251110210702-903592506081/go.mod h1:V8n/g3qVKNxr2FR37Y+otCsMySvZr601T0C7coEP0bw= +github.com/charmbracelet/x/exp/strings v0.0.0-20251110210702-903592506081 h1:pTHy/fb1lG8MTw0FizbBQV9HHXEO2+MtPXkcE0S44nM= +github.com/charmbracelet/x/exp/strings v0.0.0-20251110210702-903592506081/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= +github.com/charmbracelet/x/exp/teatest v0.0.0-20251110210702-903592506081 h1:4V7nggB2MvTMnI03immNNETBuRZHZE9N/awjP77IooY= +github.com/charmbracelet/x/exp/teatest v0.0.0-20251110210702-903592506081/go.mod h1:aPVjFrBwbJgj5Qz1F0IXsnbcOVJcMKgu1ySUfTAxh7k= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/clipperhouse/displaywidth v0.5.0 h1:AIG5vQaSL2EKqzt0M9JMnvNxOCRTKUc4vUnLWGgP89I= +github.com/clipperhouse/displaywidth v0.5.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -52,14 +60,16 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -68,37 +78,38 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= -golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= +golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/nfpm.yaml b/nfpm.yaml index d5ff4ad5..dca2fa6d 100644 --- a/nfpm.yaml +++ b/nfpm.yaml @@ -1,7 +1,7 @@ name: "poke-cli" arch: "arm64" platform: "linux" -version: "v1.7.4" +version: "v1.8.0" section: "default" version_schema: semver maintainer: "Christian S" diff --git a/testdata/cli_help.golden b/testdata/cli_help.golden index f2840b8d..a86fca94 100644 --- a/testdata/cli_help.golden +++ b/testdata/cli_help.golden @@ -14,6 +14,7 @@ │ COMMANDS: │ │ ability Get details about an ability │ │ berry Get details about a berry │ +│ card Get details about a TCG card │ │ item Get details about an item │ │ move Get details about a move │ │ natures Get details about all natures │ diff --git a/testdata/cli_incorrect_command.golden b/testdata/cli_incorrect_command.golden index e57f2eb9..67df9721 100644 --- a/testdata/cli_incorrect_command.golden +++ b/testdata/cli_incorrect_command.golden @@ -5,6 +5,7 @@ │Commands: │ │ ability Get details about an ability │ │ berry Get details about a berry │ +│ card Get details about a TCG card │ │ item Get details about an item │ │ move Get details about a move │ │ natures Get details about all natures │ diff --git a/testdata/main_latest_flag.golden b/testdata/main_latest_flag.golden index 262d4786..4166dd4b 100644 --- a/testdata/main_latest_flag.golden +++ b/testdata/main_latest_flag.golden @@ -2,6 +2,6 @@ ┃ ┃ ┃ Latest available release ┃ ┃ on GitHub: ┃ -┃ • v1.7.3 ┃ +┃ • v1.7.4 ┃ ┃ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛