mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 17:01:02 +00:00
major: initial release
* initial development * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * ci/cd * ci/cd * ci/cd * ci/cd * ci/cd * ci/cd * ci/cd * pokemon * pokemon * pokemon * pokemon * pokemon * pokemon * improvements * improvements * ci/cd * ci/cd * improvements * improvements * improvements * improvements * improvements * improvements * improvements * improvements * improvements --------- Co-authored-by: sdine <sdine@sdine.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
# AGENTS.md
|
||||
|
||||
GitHub Actions workflows for CI, release automation, and policy checks.
|
||||
|
||||
## Scope
|
||||
|
||||
- This file governs edits in `.github/workflows/*.yml`.
|
||||
- Keep one top-level triggered workflow per CI intent:
|
||||
- `feature-ci.yml` for non-`master` push CI
|
||||
- `master-ci.yml` for merged-PR-to-`master` release CI
|
||||
- Keep OS/platform splits in reusable workflows invoked via `workflow_call`.
|
||||
|
||||
## Current workflow map
|
||||
|
||||
- `feature-ci.yml` -> orchestrator for feature branch CI.
|
||||
- `feature-linux.yml` -> reusable Linux build/test/package workflow.
|
||||
- `feature-windows.yml` -> reusable Windows build/test/package workflow.
|
||||
- `master-pr-title-guard.yml` -> validates semantic-prefix policy for PRs targeting `master`.
|
||||
- `master-ci.yml` -> orchestrator for merged PRs into `master`.
|
||||
- `master-windows.yml` -> reusable Windows build/test/package workflow for master release flow.
|
||||
|
||||
## CI invariants (do not break)
|
||||
|
||||
1. Keep workflow intent stable:
|
||||
- feature workflows produce branch+sha artifacts
|
||||
- master workflows produce semver-tagged release artifacts
|
||||
2. Do not duplicate top-level triggers for the same intent (avoid split runs in Actions UI).
|
||||
3. Preserve artifact naming conventions unless docs are updated in the same change:
|
||||
- `ccm3-linux-<version>.zip`
|
||||
- `ccm3-windows-<version>.zip`
|
||||
- `ccm3-windows-installer-<version>` (feature) and `.exe` (master release asset)
|
||||
4. Preserve embedded app version wiring via `-DCCM_APP_VERSION=...` in both feature and master build flows.
|
||||
5. Keep `master` release flow gated to merged PRs only.
|
||||
6. Keep PR title prefix policy aligned with `scripts/compute_master_semver.sh`.
|
||||
|
||||
## Editing rules
|
||||
|
||||
- Prefer minimal, surgical edits; avoid large workflow rewrites unless requested.
|
||||
- Reusable workflows should declare explicit `workflow_call` inputs for required context (e.g., version, merge SHA).
|
||||
- Keep `permissions` least-privilege:
|
||||
- reusable build workflows: `contents: read`
|
||||
- release/tag orchestrator: `contents: write`
|
||||
- Keep shells consistent with runner setup:
|
||||
- Linux steps use standard bash-compatible shell.
|
||||
- Windows MSYS2 jobs keep `shell: msys2 {0}` and run `msys2/setup-msys2` before MSYS2 commands.
|
||||
|
||||
## Required follow-ups
|
||||
|
||||
- After changing workflow topology, update `docs/ci-cd-guide.md`.
|
||||
- If artifact names or release behavior change, update `docs/ci-cd-guide.md` and `docs/versioning.md` if needed.
|
||||
- If semver prefix policy changes, update both `master-pr-title-guard.yml` and docs.
|
||||
- If adding/removing workflow files, update this file's "Current workflow map".
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Do not add a second top-level feature or master trigger file.
|
||||
- Do not move release creation into a reusable workflow that lacks `contents: write`.
|
||||
- Do not introduce MSVC-specific build commands; project CI targets Clang/MinGW toolchains.
|
||||
- Do not silently change artifact names; downstream release/download steps depend on exact names.
|
||||
@@ -0,0 +1,18 @@
|
||||
name: Feature CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
name: Linux build + tests
|
||||
uses: ./.github/workflows/feature-linux.yml
|
||||
|
||||
windows:
|
||||
name: Windows build + tests
|
||||
uses: ./.github/workflows/feature-windows.yml
|
||||
@@ -0,0 +1,87 @@
|
||||
name: Feature Linux Build, Test, and Package
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
name: Linux build + tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CCACHE_DIR: ${{ github.workspace }}/.ccache
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compute feature version
|
||||
run: |
|
||||
VERSION="$(bash scripts/compute_feature_version.sh "${GITHUB_REF_NAME}" "${GITHUB_SHA}")"
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install build tools
|
||||
run: >
|
||||
sudo apt-get update &&
|
||||
sudo apt-get install -y
|
||||
ninja-build
|
||||
ccache
|
||||
pkg-config
|
||||
libgtk-3-dev
|
||||
libwxgtk3.2-dev
|
||||
|
||||
- name: Restore Linux compiler cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.CCACHE_DIR }}
|
||||
key: ccache-linux-${{ github.ref_name }}-v1
|
||||
restore-keys: |
|
||||
ccache-linux-${{ github.ref_name }}-
|
||||
ccache-linux-
|
||||
|
||||
- name: Configure
|
||||
run: >
|
||||
cmake -S . -B build -G Ninja
|
||||
-DCCM_BUILD_TESTS=ON
|
||||
-DCCM_USE_SYSTEM_WX=ON
|
||||
-DCCM_APP_VERSION="${VERSION}"
|
||||
-DCMAKE_C_COMPILER_LAUNCHER=ccache
|
||||
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build --parallel 3
|
||||
|
||||
- name: Run unit tests
|
||||
run: ctest --test-dir build --output-on-failure
|
||||
|
||||
- name: Remove test executable from bundle
|
||||
run: rm -f build/bin/ccm_core_tests
|
||||
|
||||
- name: Verify runtime dependencies are resolved
|
||||
run: |
|
||||
for f in build/bin/*; do
|
||||
[ -f "$f" ] || continue
|
||||
if file "$f" | grep -q "ELF"; then
|
||||
echo "Checking $f"
|
||||
ldd "$f"
|
||||
if ldd "$f" | grep -q "not found"; then
|
||||
echo "Missing runtime dependency detected in $f"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Stage Linux artifact folder
|
||||
run: |
|
||||
rm -rf dist/ccm3-linux
|
||||
mkdir -p dist/ccm3-linux
|
||||
cp -a build/bin/. dist/ccm3-linux/
|
||||
|
||||
- name: Upload Linux artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ccm3-linux-${{ env.VERSION }}.zip
|
||||
path: dist/ccm3-linux
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,122 @@
|
||||
name: Feature Windows Build, Test, and Package
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
name: Windows build + tests
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
CCACHE_DIR_WIN: ${{ github.workspace }}\.ccache
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: msys2 {0}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup MSYS2 (MinGW-w64 UCRT64)
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
cache: true
|
||||
install: >-
|
||||
mingw-w64-ucrt-x86_64-gcc
|
||||
mingw-w64-ucrt-x86_64-cmake
|
||||
mingw-w64-ucrt-x86_64-ninja
|
||||
mingw-w64-ucrt-x86_64-make
|
||||
mingw-w64-ucrt-x86_64-ccache
|
||||
mingw-w64-ucrt-x86_64-wxwidgets3.2-msw
|
||||
mingw-w64-ucrt-x86_64-nsis
|
||||
|
||||
- name: Compute feature version
|
||||
run: |
|
||||
VERSION="$(bash scripts/compute_feature_version.sh "${GITHUB_REF_NAME}" "${GITHUB_SHA}")"
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Restore Windows compiler cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.CCACHE_DIR_WIN }}
|
||||
key: ccache-windows-${{ github.ref_name }}-v2
|
||||
restore-keys: |
|
||||
ccache-windows-${{ github.ref_name }}-
|
||||
ccache-windows-
|
||||
|
||||
- name: Configure MSYS2 ccache directory
|
||||
run: |
|
||||
CCACHE_DIR_POSIX="$(cygpath -u "$CCACHE_DIR_WIN")"
|
||||
echo "CCACHE_DIR=${CCACHE_DIR_POSIX}" >> "$GITHUB_ENV"
|
||||
mkdir -p "$CCACHE_DIR_POSIX"
|
||||
|
||||
- name: Configure
|
||||
run: >
|
||||
cmake -S . -B build -G Ninja
|
||||
-DCMAKE_BUILD_TYPE=Release
|
||||
-DCCM_BUILD_TESTS=ON
|
||||
-DCCM_USE_SYSTEM_WX=ON
|
||||
-DCCM_APP_VERSION="${VERSION}"
|
||||
-DCMAKE_C_COMPILER_LAUNCHER=ccache
|
||||
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build --parallel 4
|
||||
|
||||
- name: Run unit tests
|
||||
run: ctest --test-dir build --output-on-failure
|
||||
|
||||
- name: Collect runtime DLL dependencies
|
||||
run: |
|
||||
rm -f deps.txt
|
||||
for f in build/bin/*.exe build/bin/*.dll; do
|
||||
[ -e "$f" ] || continue
|
||||
ldd "$f" | awk '/\/ucrt64\/bin\// { print $3 }' >> deps.txt
|
||||
done
|
||||
sort -u deps.txt | while read -r dep; do
|
||||
[ -f "$dep" ] && cp -n "$dep" build/bin/
|
||||
done
|
||||
|
||||
- name: Verify runtime dependencies are resolved
|
||||
run: |
|
||||
for f in build/bin/*.exe build/bin/*.dll; do
|
||||
[ -e "$f" ] || continue
|
||||
echo "Checking $f"
|
||||
ldd "$f"
|
||||
if ldd "$f" | grep -q "not found"; then
|
||||
echo "Missing runtime dependency detected in $f"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Remove test executable from bundle
|
||||
run: rm -f build/bin/ccm_core_tests.exe build/bin/ccm_core_tests
|
||||
|
||||
- name: Build Windows installer
|
||||
run: makensis -DAPP_VERSION="${VERSION}" scripts/installer.nsi
|
||||
|
||||
- name: Stage Windows artifact folder
|
||||
run: |
|
||||
rm -rf dist/ccm3-windows
|
||||
mkdir -p dist/ccm3-windows
|
||||
cp -a build/bin/. dist/ccm3-windows/
|
||||
|
||||
- name: Upload Windows artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ccm3-windows-${{ env.VERSION }}.zip
|
||||
path: dist/ccm3-windows
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Windows installer artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ccm3-windows-installer-${{ env.VERSION }}
|
||||
path: ccm3-windows-installer.exe
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Master CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
types:
|
||||
- closed
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
compute-version:
|
||||
name: Determine semantic version
|
||||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
release_tag: ${{ steps.version.outputs.release_tag }}
|
||||
steps:
|
||||
- name: Checkout tags
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve semantic version
|
||||
id: version
|
||||
run: bash scripts/compute_master_semver.sh "${{ github.event.pull_request.title }}"
|
||||
|
||||
build-windows:
|
||||
name: Windows build + tests
|
||||
needs: compute-version
|
||||
uses: ./.github/workflows/master-windows.yml
|
||||
with:
|
||||
version: ${{ needs.compute-version.outputs.version }}
|
||||
merge_commit_sha: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
|
||||
release-master:
|
||||
name: Tag and release on master
|
||||
needs:
|
||||
- compute-version
|
||||
- build-windows
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download Windows artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ccm3-windows-${{ needs.compute-version.outputs.version }}.zip
|
||||
path: release/windows
|
||||
|
||||
- name: Download Windows installer artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ccm3-windows-installer-${{ needs.compute-version.outputs.version }}
|
||||
path: release/installer
|
||||
|
||||
- name: Package release assets with semantic version
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
VERSION="${{ needs.compute-version.outputs.version }}"
|
||||
zip -r "release-assets/ccm3-windows-${VERSION}.zip" release/windows
|
||||
cp release/installer/ccm3-windows-installer.exe "release-assets/ccm3-windows-installer-${VERSION}.exe"
|
||||
|
||||
- name: Create tag and GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.compute-version.outputs.release_tag }}
|
||||
target_commitish: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
generate_release_notes: true
|
||||
files: release-assets/*
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Master PR Title Guard
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate-title:
|
||||
name: Require semver prefix in PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Validate pull request title prefix
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
run: |
|
||||
title_lower="$(echo "${PR_TITLE}" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "${title_lower}" == major* || "${title_lower}" == minor* || "${title_lower}" == fix* || "${title_lower}" == patch* || "${title_lower}" == path* ]]; then
|
||||
echo "PR title is valid: ${PR_TITLE}"
|
||||
exit 0
|
||||
fi
|
||||
echo "Invalid PR title: '${PR_TITLE}'"
|
||||
echo "Title must start with one of: major, minor, fix, patch, or path."
|
||||
exit 1
|
||||
@@ -0,0 +1,128 @@
|
||||
name: Master Windows Build, Test, and Package
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
merge_commit_sha:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
name: Windows build + tests
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
CCACHE_DIR_WIN: ${{ github.workspace }}\.ccache
|
||||
VERSION: ${{ inputs.version }}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: msys2 {0}
|
||||
|
||||
steps:
|
||||
- name: Checkout merge commit
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.merge_commit_sha }}
|
||||
|
||||
- name: Setup MSYS2 (MinGW-w64 UCRT64)
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
cache: true
|
||||
install: >-
|
||||
mingw-w64-ucrt-x86_64-gcc
|
||||
mingw-w64-ucrt-x86_64-cmake
|
||||
mingw-w64-ucrt-x86_64-ninja
|
||||
mingw-w64-ucrt-x86_64-make
|
||||
mingw-w64-ucrt-x86_64-ccache
|
||||
mingw-w64-ucrt-x86_64-wxwidgets3.2-msw
|
||||
mingw-w64-ucrt-x86_64-nsis
|
||||
|
||||
- name: Restore Windows compiler cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.CCACHE_DIR_WIN }}
|
||||
key: ccache-windows-master-v2
|
||||
restore-keys: |
|
||||
ccache-windows-master-
|
||||
ccache-windows-
|
||||
|
||||
- name: Configure MSYS2 ccache directory
|
||||
run: |
|
||||
CCACHE_DIR_POSIX="$(cygpath -u "$CCACHE_DIR_WIN")"
|
||||
echo "CCACHE_DIR=${CCACHE_DIR_POSIX}" >> "$GITHUB_ENV"
|
||||
mkdir -p "$CCACHE_DIR_POSIX"
|
||||
|
||||
- name: Configure
|
||||
run: >
|
||||
cmake -S . -B build -G Ninja
|
||||
-DCMAKE_BUILD_TYPE=Release
|
||||
-DCCM_BUILD_TESTS=ON
|
||||
-DCCM_USE_SYSTEM_WX=ON
|
||||
-DCCM_APP_VERSION="${VERSION}"
|
||||
-DCMAKE_C_COMPILER_LAUNCHER=ccache
|
||||
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build --parallel 4
|
||||
|
||||
- name: Run unit tests
|
||||
run: ctest --test-dir build --output-on-failure
|
||||
|
||||
- name: Collect runtime DLL dependencies
|
||||
run: |
|
||||
rm -f deps.txt
|
||||
for f in build/bin/*.exe build/bin/*.dll; do
|
||||
[ -e "$f" ] || continue
|
||||
ldd "$f" | awk '/\/ucrt64\/bin\// { print $3 }' >> deps.txt
|
||||
done
|
||||
sort -u deps.txt | while read -r dep; do
|
||||
[ -f "$dep" ] && cp -n "$dep" build/bin/
|
||||
done
|
||||
|
||||
- name: Verify runtime dependencies are resolved
|
||||
run: |
|
||||
for f in build/bin/*.exe build/bin/*.dll; do
|
||||
[ -e "$f" ] || continue
|
||||
echo "Checking $f"
|
||||
ldd "$f"
|
||||
if ldd "$f" | grep -q "not found"; then
|
||||
echo "Missing runtime dependency detected in $f"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Remove test executable from bundle
|
||||
run: rm -f build/bin/ccm_core_tests.exe build/bin/ccm_core_tests
|
||||
|
||||
- name: Build Windows installer
|
||||
run: makensis -DAPP_VERSION="${VERSION}" scripts/installer.nsi
|
||||
|
||||
- name: Stage Windows artifact folder
|
||||
run: |
|
||||
rm -rf dist/ccm3-windows
|
||||
mkdir -p dist/ccm3-windows
|
||||
cp -a build/bin/. dist/ccm3-windows/
|
||||
|
||||
- name: Upload Windows artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ccm3-windows-${{ env.VERSION }}.zip
|
||||
path: dist/ccm3-windows
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Windows installer artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ccm3-windows-installer-${{ env.VERSION }}
|
||||
path: ccm3-windows-installer.exe
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# AGENTS.md
|
||||
|
||||
C++ desktop implementation (originally based on a Tauri Rust+TS version) — single wxWidgets binary built with CMake + FetchContent.
|
||||
|
||||
## Project structure
|
||||
|
||||
- `core/` — `ccm_core` static library. UI-agnostic domain, ports, services, infra adapters. **Never** depends on wxWidgets. See `core/AGENTS.md`.
|
||||
- `ui_wx/` — `ccm_ui_wx` static library. The only place that touches wxWidgets. See `ui_wx/AGENTS.md`.
|
||||
- `app/` — `ccm` executable (composition root). Wires concrete adapters into services. See `app/AGENTS.md`.
|
||||
- `tests/` — `ccm_core_tests` doctest binary. Pure-logic tests against in-memory fakes. See `tests/AGENTS.md`.
|
||||
- `docs/` — long-form developer documentation. Start with `docs/adding-a-new-game.md` for the canonical end-to-end procedure for extending the app with a new TCG. See `docs/AGENTS.md`.
|
||||
- `.github/workflows/` — GitHub Actions CI/release workflows. See `.github/workflows/AGENTS.md` for orchestrator/reusable workflow rules and CI invariants.
|
||||
- `cmake/` — `Toolchain.cmake` (Clang first, MinGW-w64 fallback), `Dependencies.cmake` (FetchContent pins), `CompilerWarnings.cmake` (`ccm_warnings` interface target).
|
||||
- `CMakeLists.txt` — top-level. Defines options `CCM_USE_SYSTEM_WX` (default OFF) and `CCM_BUILD_TESTS` (default ON).
|
||||
- Build metadata option: `CCM_APP_VERSION` (defaults to `${PROJECT_VERSION} (localbuild)` for local/manual builds, overridden by CI).
|
||||
|
||||
## Architecture rules (do not break)
|
||||
|
||||
1. Dependencies point inward only: `app` -> `ui_wx` -> `core`. `core` depends on no other first-party target.
|
||||
2. `core` must not include any wx header. CI-equivalent: `rg "wx/" core/` must return zero hits.
|
||||
3. Cross-boundary types are domain types and `ccm::ui::AppContext`. UI code consumes services via the references in `AppContext` — **never** by including a concrete adapter header.
|
||||
4. Errors cross port boundaries as `ccm::Result<T, E=std::string>` (see `core/include/ccm/util/Result.hpp`). Do not throw across ports; reserve exceptions for genuinely unrecoverable bugs.
|
||||
5. JSON layout must stay byte-for-byte stable: aliases `releaseDate`, `setNo`, `firstEdition`, `dataStorage`, `defaultGame`, and the `signed` JSON key (mapped to C++ field `signed_`). If you touch a domain type, update the round-trip test in `tests/domain_json_tests.cpp`.
|
||||
|
||||
## Toolchain
|
||||
|
||||
- **Compilers**: Clang 14+ preferred, MinGW-w64 GCC 11+ fallback on Windows. **Do not** add MSVC support.
|
||||
- **C++ standard**: C++20 (`CMAKE_CXX_STANDARD 20`, `CXX_EXTENSIONS OFF`).
|
||||
- **Build system**: CMake 3.22+ with `FetchContent`. Pin every dep by tag in `cmake/Dependencies.cmake`; never use `master`.
|
||||
|
||||
## Key dependencies
|
||||
|
||||
| Library | Version | Purpose |
|
||||
|---|---|---|
|
||||
| nlohmann/json | v3.11.3 | All JSON serde |
|
||||
| libcpr/cpr | 1.10.5 | HTTPS (libcurl built in-tree, Schannel on Windows) |
|
||||
| wxWidgets | v3.2.5 | UI toolkit (only `ui_wx/` may use it) |
|
||||
| doctest | v2.4.11 | Tests (only when `CCM_BUILD_TESTS=ON`) |
|
||||
|
||||
## Commands
|
||||
|
||||
Run from the **workspace root**.
|
||||
|
||||
- Configure (Clang/Ninja, FetchContent wx):
|
||||
`cmake -S . -B build -G Ninja`
|
||||
- Configure (Windows MinGW-w64 fallback — verified working with MSYS2 UCRT64 GCC 15.2 + CMake 4.x):
|
||||
`cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release`
|
||||
- Configure with system wx for fast iteration:
|
||||
`cmake -S . -B build -G Ninja -DCCM_USE_SYSTEM_WX=ON`
|
||||
- Build everything:
|
||||
`cmake --build build --parallel`
|
||||
- Run the app:
|
||||
`./build/bin/ccm3` (`.\build\bin\ccm3.exe` on Windows)
|
||||
- Run tests (CCM_BUILD_TESTS defaults to ON):
|
||||
`ctest --test-dir build --output-on-failure` — current baseline: **86 cases / 211 assertions, all green**.
|
||||
- Build tests only:
|
||||
`cmake --build build --target ccm_core_tests`
|
||||
|
||||
> **Windows runtime note**: `cpr` is built as a shared library, so `build/bin/` ends up with `libcpr.dll`, `libcurl.dll`, `libzlib.dll` next to `ccm.exe`. With MinGW-w64 you also need `libgcc_s_seh-1.dll` and `libstdc++-6.dll` from your MSYS2 UCRT64 `bin/` on `PATH` (or copied alongside the exe) to launch from Explorer.
|
||||
>
|
||||
> **Windows rebuild note**: linking `ccm.exe` fails with `Permission denied` if the app is still running/locked. Close `ccm.exe` before rebuilding app targets.
|
||||
>
|
||||
> **Windows cold-start note**: first launch right after a fresh build is often slower than subsequent launches due to cold file cache and Windows security scanning (Defender/SmartScreen) on the new exe/dll set. Warm launches are the meaningful baseline for app-side perf changes.
|
||||
|
||||
## UI performance guardrails
|
||||
|
||||
- Keep first paint responsive: avoid heavy synchronous work in constructors of top-level windows/dialogs.
|
||||
- For startup, defer non-critical work with `CallAfter(...)` so the frame appears before data loading.
|
||||
- Preserve "select first row on startup" behavior without blocking first paint by scheduling the initial selection with `CallAfter(...)` instead of selecting synchronously during row rebuild.
|
||||
- Avoid repeated set-list loads when opening Add/Edit: cache Magic sets in `MainFrame` and reuse them in `CardEditDialog`.
|
||||
- Pass preloaded set data to dialogs by pointer/reference, not by value, to avoid copying large vectors on every open.
|
||||
- While constructing/populating dialogs with many controls/choices, wrap with `Freeze()`/`Thaw()` and append choice items in bulk (`wxArrayString`) to reduce layout/repaint churn.
|
||||
- Keep selected-card preview usable when remote lookup fails: show a per-game card-back fallback image (CCM2 parity), not a blank preview panel.
|
||||
|
||||
## Windows UI theming guardrails
|
||||
|
||||
- `wxWidgets` native dark-mode behavior on Windows is inconsistent across controls and OS builds; prefer explicit app theming in `ui_wx/src/Theme.cpp` plus targeted native hints only where needed.
|
||||
- Treat UI text from domain/services as UTF-8 and convert explicitly at wx boundaries (`wxString::FromUTF8(...)` for display, `ToStdString(wxConvUTF8)` for write-back); do not rely on implicit `std::string` conversions on Windows.
|
||||
- For dialogs (`wxDialog`) and frames (`wxFrame`), apply title-bar dark mode through top-level-window handling (not frame-only handling), otherwise modal window headers stay light.
|
||||
- The `wxListCtrl` native header can ignore dark hints; if native theming is unreliable, use a custom themed header row and preserve key UX parity (single-click sort, edge-drag resize, divider double-click autosize).
|
||||
- Do **not** apply `Explorer` class theming to `wxTextCtrl` in dark mode; some Windows builds force black typed text. Keep edit controls palette-driven, and for critical fields (for example the top-right filter box) enforce colors through `WM_CTLCOLOREDIT` handling in `MainFrame` when needed.
|
||||
- Theme modal dialogs explicitly before `ShowModal()` (Settings, Create/Edit, image viewer, etc.) so they don't inherit mismatched defaults from Windows.
|
||||
- For button hover/pressed contrast fixes in dark theme, prefer explicit state handling in `Theme.cpp`; native Windows button states can override wx colors and produce unreadable white-on-white combinations.
|
||||
- Keep button theming state dynamic across theme switches (Dark <-> Light). Avoid lambdas that permanently capture old theme colors or behavior; stale handlers can make light-mode buttons look wrong.
|
||||
- After changing `ui_wx` theming behavior, rebuild the final app target (`cmake --build build --target ccm --parallel`), not just `ccm_ui_wx`, before validating runtime behavior.
|
||||
- If linker fails with `Permission denied` on `build/bin/ccm.exe`, the app is still running; close it before rebuilding.
|
||||
|
||||
## Required follow-ups
|
||||
|
||||
- After modifying a domain type's fields or JSON layout you **must** update the matching round-trip test in `tests/domain_json_tests.cpp` and re-run tests.
|
||||
- After adding a new `.cpp` to `core/` or `ui_wx/` you **must** add it to that package's `CMakeLists.txt`. There is no glob.
|
||||
- After adding a new dependency you **must** verify its license is compatible with this repository's MIT license before merging.
|
||||
- After adding a new game module you **must**: (1) extend `Game` enum + string mappings in `core/include/ccm/domain/Enums.hpp`, (2) register the module in `app/main.cpp`, (3) add a directory mapping in `app/main.cpp::dirNameForGame`, (4) implement an `IGameView` derived class (or `<Name>GameView`) and add it to `AppContext::gameViews` in the composition root.
|
||||
- After changing the per-game seams (`IGameModule`, `IGameView`, the `BaseCard*Panel` template hooks) you **must** update `docs/adding-a-new-game.md` so the canonical "add a new game" walkthrough stays in sync with the code.
|
||||
- After changing `formatTextForFs` or `parseIndexFromFilename` you **must** update `tests/fs_names_tests.cpp` — these functions exist to stay byte-compatible with the original Rust `util/fs.rs`.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Don't include `wx/...` headers from `core/` (breaks layering and tests will refuse to build).
|
||||
- Don't add tests that hit real network or real disk; use the fake `ccm::testing::InMemoryFileSystem` and the existing http/source fakes.
|
||||
- Don't enable `-Wconversion` / `-Wsign-conversion`; they fight wxWidgets's `int` IDs. They were intentionally removed from `cmake/CompilerWarnings.cmake`.
|
||||
- Don't use `master` for FetchContent tags. Bump deliberately.
|
||||
- Don't bump `cpr` past `1.10.5` without re-doing the curl install/export plumbing: cpr 1.11.x adds `install(EXPORT cprTargets)` rules that reference `libcurl_shared`, which isn't in any export set when curl is built as a sub-project, breaking configure. The 1.10.5 + `HAVE_IOCTLSOCKET_FIONBIO=ON` workaround in `cmake/Dependencies.cmake` is the verified MinGW-w64 path — do not remove it without an end-to-end Windows build first.
|
||||
- Don't create multiple top-level triggers for the same CI intent (feature or master). Keep one triggered orchestrator workflow (`feature-ci.yml`, `master-ci.yml`) and use `workflow_call` reusable workflows for OS-specific splits so GitHub Actions stays a single run per intent.
|
||||
@@ -0,0 +1,58 @@
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
|
||||
# Detect the toolchain BEFORE project() so we can pick clang or fall back to
|
||||
# MinGW-w64 g++ on Windows. This file is no-op on non-Windows hosts.
|
||||
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/Toolchain.cmake)
|
||||
|
||||
project(ccm
|
||||
VERSION 0.1.0
|
||||
DESCRIPTION "Card Collection Manager 3 - C++/wxWidgets desktop app"
|
||||
LANGUAGES CXX
|
||||
)
|
||||
|
||||
if(WIN32)
|
||||
enable_language(RC)
|
||||
endif()
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
||||
endif()
|
||||
|
||||
# Generate compile_commands.json for tooling (clangd, ccls, etc.).
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
|
||||
# All built artifacts land in a predictable place.
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
|
||||
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
|
||||
|
||||
# Project-wide options.
|
||||
option(CCM_USE_SYSTEM_WX "Use a system-installed wxWidgets via find_package instead of FetchContent" OFF)
|
||||
option(CCM_BUILD_TESTS "Build unit tests for ccm_core" ON)
|
||||
set(CCM_APP_VERSION "${PROJECT_VERSION} (localbuild)" CACHE STRING "Application version string embedded in UI")
|
||||
|
||||
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/CompilerWarnings.cmake)
|
||||
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/Dependencies.cmake)
|
||||
|
||||
add_subdirectory(core)
|
||||
add_subdirectory(ui_wx)
|
||||
add_subdirectory(app)
|
||||
|
||||
if(CCM_BUILD_TESTS)
|
||||
enable_testing()
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
|
||||
message(STATUS "")
|
||||
message(STATUS "============================================================")
|
||||
message(STATUS " ${PROJECT_NAME} ${PROJECT_VERSION}")
|
||||
message(STATUS " Compiler: ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}")
|
||||
message(STATUS " Build type: ${CMAKE_BUILD_TYPE}")
|
||||
message(STATUS " System wx: ${CCM_USE_SYSTEM_WX}")
|
||||
message(STATUS " Build tests: ${CCM_BUILD_TESTS}")
|
||||
message(STATUS "============================================================")
|
||||
message(STATUS "")
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Card Collection Manager 3 contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Card Collection Manager 3
|
||||
|
||||
Card Collection Manager 3 is an extensible desktop application for managing trading card game collections. It is designed as a practical way to track cards and manage per-card images for large collections, with local per-game data, set synchronization workflows, and a desktop-first UX. The app preserves the established JSON layout from earlier CCM versions so existing collections stay compatible.
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Magic The Gathering
|
||||
|
||||

|
||||
|
||||
### Pokemon TCG
|
||||
|
||||

|
||||
|
||||
## Migrating From CCM1 And CCM2
|
||||
|
||||
CCM3 reads the established collection layout, so data from both CCM1 and CCM2 can be copied into the configured CCM3 data directory.
|
||||
|
||||
Basic migration flow:
|
||||
|
||||
1. Close any running CCM app instance that points to the same data folder.
|
||||
2. Copy your existing game data into the matching CCM3 game folder:
|
||||
- from CCM1: export/copy your collection JSON and images into the corresponding CCM3 game directory.
|
||||
- from CCM2: copy per-game `collection.json` and `images/` directories into the same per-game folders in CCM3.
|
||||
3. Start CCM3 and confirm the data directory in `File > Settings`.
|
||||
|
||||
If your files are in the expected layout, collections should load without conversion.
|
||||
|
||||
## Technical Overview
|
||||
|
||||
This is a C++20 project with a wxWidgets UI:
|
||||
|
||||
- `core/`: domain logic, services, and infrastructure adapters
|
||||
- `ui_wx/`: wxWidgets presentation layer
|
||||
- `app/`: executable composition root
|
||||
|
||||
Key libraries used by the project:
|
||||
|
||||
- `cpr` (libcurl-based): REST/HTTP calls
|
||||
- `nlohmann/json`: JSON serialization/deserialization
|
||||
- `doctest`: unit testing
|
||||
|
||||
Dependency direction is strict: `app -> ui_wx -> core`.
|
||||
|
||||
## Documentation
|
||||
|
||||
Implementation details now live in `docs/`. Start here:
|
||||
|
||||
- [Documentation Index](docs/README.md)
|
||||
- [Build Locally Guide](docs/dow-doc-build-locally.md)
|
||||
- [Intro For New Developers](docs/intro-to-new-developers.md)
|
||||
- [CI/CD Guide](docs/ci-cd-guide.md)
|
||||
- [Versioning Guide](docs/versioning.md)
|
||||
- [Testing Guide And Test Code Of Conduct](docs/testing-and-test-code-of-conduct.md)
|
||||
- [Adding A New Game To Card Collection Manager](docs/adding-a-new-game.md)
|
||||
|
||||
## Previous Implementations
|
||||
|
||||
This project continues earlier versions of Card Collection Manager:
|
||||
|
||||
- [Card Collection Manager (CCM1)](https://github.com/sebastiandine/Card-Collection-Manager): original Java/Swing implementation.
|
||||
- [Card Collection Manager 2 (CCM2)](https://github.com/sebastiandine/Card-Collection-Manager-2): Rust/TypeScript (Tauri) rewrite with multi-game support.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
|
||||
Third-party dependencies and assets remain under their respective licenses.
|
||||
@@ -0,0 +1,35 @@
|
||||
# app/AGENTS.md
|
||||
|
||||
The `ccm` executable — composition root only. The single place where concrete adapter types are mentioned. Read the root `AGENTS.md` first.
|
||||
|
||||
## File pointers
|
||||
|
||||
- `main.cpp` — the entire app. Defines `CcmApp : public wxApp`, builds the dependency graph in `OnInit()`, then hands an `AppContext` to `MainFrame`.
|
||||
- `CMakeLists.txt` — declares the `ccm` target. Sets `WIN32_EXECUTABLE TRUE` on Windows so no console window appears. Links `ccm_core`, `ccm_ui_wx`, `ccm_warnings`.
|
||||
|
||||
## Conventions
|
||||
|
||||
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonSetRepository`, `LocalImageStore`, `MagicGameModule`, `PokemonGameModule`, `MagicGameView`, `PokemonGameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
|
||||
2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually.
|
||||
3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set).
|
||||
4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`).
|
||||
5. **`config.json` location** is the executable's parent directory, resolved via `wxStandardPaths::Get().GetExecutablePath()`. Do not change this — existing installations rely on that location.
|
||||
6. **Image format handlers** must be registered via `wxImage::AddHandler(new wxPNGHandler)` and `new wxJPEGHandler` before any image is loaded. They are added in `OnInit()` first thing — keep it that way.
|
||||
7. **Card preview source ownership** lives inside the `IGameModule`. The composition root never constructs an `<Name>CardPreviewSource` directly; it calls `previewSvc_->registerModule(*<name>Mod_)` and the service pulls the module's preview source via `IGameModule::cardPreviewSource()` (returning `nullptr` is silently skipped).
|
||||
|
||||
## Required follow-ups
|
||||
|
||||
- After adding a new game module you **must**: (1) add a `unique_ptr<<Name>GameModule>` member in declaration-order-correct position, (2) construct it in `OnInit()`, (3) call `setSvc_->registerModule(<name>Mod_.get())`, (4) call `previewSvc_->registerModule(*<name>Mod_)` (no-op when the module has no preview source), (5) extend `dirNameForGame`, (6) add a typed `JsonCollectionRepository<<Name>Card>` + `CollectionService<<Name>Card>` if the game has a custom card type, (7) construct a `<Name>GameView` and append its raw pointer to the `AppContext::gameViews` vector, (8) make sure the view's `unique_ptr<>` member sits **after** all its deps (typed services + `IGameModule`).
|
||||
- After adding a new core service you **must** add a `unique_ptr<...>` member, construct it in `OnInit()` after its deps, and add a reference field to `AppContext`.
|
||||
- After adding a new dependency edge you **must** verify destruction order is still correct: deps **before** dependents in the member list.
|
||||
- After changing the IGameView contract or the AppContext shape, update `docs/adding-a-new-game.md` so the canonical procedure stays in sync.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Don't add business logic here. If something is more than `std::make_unique` and a `register/Bind` call, it belongs in `core/`.
|
||||
- Don't construct services on the stack inside `OnInit()` — they must outlive the `MainFrame`, so they live as `CcmApp` members.
|
||||
|
||||
## Commands
|
||||
|
||||
- Build the binary: `cmake --build build --target ccm`
|
||||
- Run on Windows / MinGW-w64: `.\build\bin\ccm.exe`. The cpr/curl/zlib DLLs are placed next to the exe automatically; the MSYS2 UCRT64 runtime (`libgcc_s_seh-1.dll`, `libstdc++-6.dll`) needs to be on `PATH` (e.g. `P:\msys2\msys64\ucrt64\bin`). On verified runs the exe loads under window title "Card Collection Manager 3".
|
||||
@@ -0,0 +1,20 @@
|
||||
# ccm: thin executable / composition root.
|
||||
# Wires concrete adapters into the services and hands them to the UI.
|
||||
|
||||
add_executable(ccm
|
||||
main.cpp
|
||||
)
|
||||
set_target_properties(ccm PROPERTIES OUTPUT_NAME ccm3)
|
||||
|
||||
# Subsystem WINDOWS on Windows so we don't get a stray console.
|
||||
if(WIN32)
|
||||
target_sources(ccm PRIVATE ccm.rc)
|
||||
set_target_properties(ccm PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||
endif()
|
||||
|
||||
target_link_libraries(ccm
|
||||
PRIVATE
|
||||
ccm_core
|
||||
ccm_ui_wx
|
||||
ccm_warnings
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
ccm_main_icon ICON "resources/ccm.ico"
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
// Composition root: builds the dependency graph from the bottom up and hands
|
||||
// it to the wxWidgets UI layer. This is the only place where concrete adapter
|
||||
// types are mentioned - everything downstream depends on interfaces.
|
||||
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/games/magic/MagicGameModule.hpp"
|
||||
#include "ccm/games/pokemon/PokemonGameModule.hpp"
|
||||
#include "ccm/infra/CprHttpClient.hpp"
|
||||
#include "ccm/infra/JsonCollectionRepository.hpp"
|
||||
#include "ccm/infra/JsonSetRepository.hpp"
|
||||
#include "ccm/infra/LocalImageStore.hpp"
|
||||
#include "ccm/infra/StdFileSystem.hpp"
|
||||
#include "ccm/services/CardPreviewService.hpp"
|
||||
#include "ccm/services/CollectionService.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/services/ImageService.hpp"
|
||||
#include "ccm/services/SetService.hpp"
|
||||
#include "ccm/ui/AppContext.hpp"
|
||||
#include "ccm/ui/MagicGameView.hpp"
|
||||
#include "ccm/ui/MainFrame.hpp"
|
||||
#include "ccm/ui/PokemonGameView.hpp"
|
||||
|
||||
#include <wx/app.h>
|
||||
#include <wx/icon.h>
|
||||
#include <wx/image.h>
|
||||
#include <wx/msgdlg.h>
|
||||
#include <wx/stdpaths.h>
|
||||
#include <wx/utils.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
// All Game::X -> directory string mappings live in one place. Eliminates the
|
||||
// need for the repositories to know about concrete game module classes.
|
||||
std::string dirNameForGame(ccm::Game g) {
|
||||
switch (g) {
|
||||
case ccm::Game::Magic: return "magic";
|
||||
case ccm::Game::Pokemon: return "pokemon";
|
||||
}
|
||||
return "magic";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class CcmApp : public wxApp {
|
||||
public:
|
||||
bool OnInit() override {
|
||||
// wxImage knows about PNG/JPEG once these handlers are registered.
|
||||
wxImage::AddHandler(new wxPNGHandler);
|
||||
wxImage::AddHandler(new wxJPEGHandler);
|
||||
|
||||
// --- locate config next to exe, data in user home ----------------
|
||||
const std::filesystem::path exeDir =
|
||||
std::filesystem::path(wxStandardPaths::Get().GetExecutablePath().ToStdString())
|
||||
.parent_path();
|
||||
const auto configPath = exeDir / "config.json";
|
||||
const auto defaultDataDir =
|
||||
std::filesystem::path(wxGetHomeDir().ToStdString()) / "ccm3-data";
|
||||
|
||||
// --- build the dependency graph -----------------------------------
|
||||
fs_ = std::make_unique<ccm::StdFileSystem>();
|
||||
config_ = std::make_unique<ccm::ConfigService>(*fs_, configPath, defaultDataDir);
|
||||
|
||||
if (auto init = config_->initialize(); !init) {
|
||||
wxMessageBox("Failed to load configuration: " + init.error(),
|
||||
"Startup error", wxOK | wxICON_ERROR);
|
||||
return false;
|
||||
}
|
||||
|
||||
http_ = std::make_unique<ccm::CprHttpClient>();
|
||||
magicMod_ = std::make_unique<ccm::MagicGameModule>(*http_);
|
||||
pokeMod_ = std::make_unique<ccm::PokemonGameModule>(*http_);
|
||||
|
||||
magicRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::MagicCard>>(
|
||||
*fs_, *config_, &dirNameForGame);
|
||||
pokeRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::PokemonCard>>(
|
||||
*fs_, *config_, &dirNameForGame);
|
||||
setRepo_ = std::make_unique<ccm::JsonSetRepository>(*fs_, *config_, &dirNameForGame);
|
||||
imgStore_ = std::make_unique<ccm::LocalImageStore>(*fs_, *config_, &dirNameForGame);
|
||||
|
||||
imgSvc_ = std::make_unique<ccm::ImageService>(*imgStore_);
|
||||
magicCollSvc_ = std::make_unique<ccm::CollectionService<ccm::MagicCard>>(
|
||||
*magicRepo_, *imgStore_);
|
||||
pokeCollSvc_ = std::make_unique<ccm::CollectionService<ccm::PokemonCard>>(
|
||||
*pokeRepo_, *imgStore_);
|
||||
setSvc_ = std::make_unique<ccm::SetService>(*setRepo_);
|
||||
setSvc_->registerModule(magicMod_.get());
|
||||
setSvc_->registerModule(pokeMod_.get());
|
||||
|
||||
previewSvc_ = std::make_unique<ccm::CardPreviewService>(*http_);
|
||||
previewSvc_->registerModule(*magicMod_);
|
||||
previewSvc_->registerModule(*pokeMod_);
|
||||
|
||||
// Per-game UI bundles. Order here is the order shown in the Game menu.
|
||||
magicView_ = std::make_unique<ccm::ui::MagicGameView>(
|
||||
*config_, *magicCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *magicMod_);
|
||||
pokeView_ = std::make_unique<ccm::ui::PokemonGameView>(
|
||||
*config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_);
|
||||
|
||||
ctx_ = std::make_unique<ccm::ui::AppContext>(ccm::ui::AppContext{
|
||||
*config_,
|
||||
*setSvc_,
|
||||
*imgSvc_,
|
||||
*previewSvc_,
|
||||
*magicMod_,
|
||||
*pokeMod_,
|
||||
{ magicView_.get(), pokeView_.get() },
|
||||
});
|
||||
|
||||
auto* frame = new ccm::ui::MainFrame(*ctx_);
|
||||
#ifdef __WXMSW__
|
||||
frame->SetIcon(wxICON(ccm_main_icon));
|
||||
#endif
|
||||
frame->Show(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
// Order matters - destruction is reverse, so put services that depend on
|
||||
// others *after* their deps in the member list. Game views are torn down
|
||||
// first so their panels release any references to the typed services.
|
||||
std::unique_ptr<ccm::StdFileSystem> fs_;
|
||||
std::unique_ptr<ccm::ConfigService> config_;
|
||||
std::unique_ptr<ccm::CprHttpClient> http_;
|
||||
std::unique_ptr<ccm::MagicGameModule> magicMod_;
|
||||
std::unique_ptr<ccm::PokemonGameModule> pokeMod_;
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::MagicCard>> magicRepo_;
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::PokemonCard>> pokeRepo_;
|
||||
std::unique_ptr<ccm::JsonSetRepository> setRepo_;
|
||||
std::unique_ptr<ccm::LocalImageStore> imgStore_;
|
||||
std::unique_ptr<ccm::ImageService> imgSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::MagicCard>> magicCollSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::PokemonCard>> pokeCollSvc_;
|
||||
std::unique_ptr<ccm::SetService> setSvc_;
|
||||
std::unique_ptr<ccm::CardPreviewService> previewSvc_;
|
||||
std::unique_ptr<ccm::ui::MagicGameView> magicView_;
|
||||
std::unique_ptr<ccm::ui::PokemonGameView> pokeView_;
|
||||
std::unique_ptr<ccm::ui::AppContext> ctx_;
|
||||
};
|
||||
|
||||
wxIMPLEMENT_APP(CcmApp);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,33 @@
|
||||
# Shared warning configuration as an INTERFACE library so each first-party
|
||||
# target can opt in with `target_link_libraries(<tgt> PRIVATE ccm_warnings)`
|
||||
# without duplicating flags.
|
||||
|
||||
add_library(ccm_warnings INTERFACE)
|
||||
|
||||
set(_CCM_GCC_CLANG_FLAGS
|
||||
-Wall
|
||||
-Wextra
|
||||
-Wpedantic
|
||||
-Wshadow
|
||||
-Wnon-virtual-dtor
|
||||
-Wcast-align
|
||||
-Wunused
|
||||
-Woverloaded-virtual
|
||||
-Wnull-dereference
|
||||
-Wdouble-promotion
|
||||
-Wformat=2
|
||||
# -Wconversion / -Wsign-conversion are intentionally NOT enabled - they
|
||||
# fight badly with wxWidgets's wxWindowID = int API surface and the
|
||||
# noise-to-signal ratio doesn't justify it for a single-binary app.
|
||||
)
|
||||
|
||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang")
|
||||
target_compile_options(ccm_warnings INTERFACE ${_CCM_GCC_CLANG_FLAGS})
|
||||
elseif(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
|
||||
target_compile_options(ccm_warnings INTERFACE /W4 /permissive-)
|
||||
endif()
|
||||
|
||||
# UTF-8 source/exec encoding everywhere.
|
||||
if(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
|
||||
target_compile_options(ccm_warnings INTERFACE /utf-8)
|
||||
endif()
|
||||
@@ -0,0 +1,145 @@
|
||||
# Third-party dependencies pulled via FetchContent. Pinning to known-good tags
|
||||
# is intentional - bump deliberately, never use `master`.
|
||||
|
||||
include(FetchContent)
|
||||
|
||||
# Avoid hammering the network on every reconfigure once content is downloaded.
|
||||
set(FETCHCONTENT_UPDATES_DISCONNECTED ON CACHE BOOL "" FORCE)
|
||||
|
||||
# CMake 4 dropped compatibility with cmake_minimum_required(VERSION < 3.5).
|
||||
# Some pinned tags below (doctest 2.4.11, nlohmann/json 3.11.3, wxWidgets 3.2.5)
|
||||
# still ship a `cmake_minimum_required(VERSION 3.0/3.1)` and would otherwise
|
||||
# fail to configure. Override the floor for all FetchContent'd subprojects.
|
||||
# See: https://cmake.org/cmake/help/latest/variable/CMAKE_POLICY_VERSION_MINIMUM.html
|
||||
if(NOT DEFINED CMAKE_POLICY_VERSION_MINIMUM)
|
||||
set(CMAKE_POLICY_VERSION_MINIMUM 3.5)
|
||||
endif()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# nlohmann/json - JSON (de)serialization. Header-only.
|
||||
# ---------------------------------------------------------------------------
|
||||
message(STATUS "[ccm] Fetching nlohmann/json ...")
|
||||
FetchContent_Declare(
|
||||
nlohmann_json
|
||||
GIT_REPOSITORY https://github.com/nlohmann/json.git
|
||||
GIT_TAG v3.11.3
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
set(JSON_BuildTests OFF CACHE INTERNAL "")
|
||||
set(JSON_Install OFF CACHE INTERNAL "")
|
||||
FetchContent_MakeAvailable(nlohmann_json)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cpr - C++ Requests (libcurl wrapper). Builds curl in-tree so we don't need
|
||||
# a system libcurl. Used for Scryfall + pokemontcg.io REST calls.
|
||||
#
|
||||
# Pinned at 1.10.5 deliberately. 1.11.x adds an `install(EXPORT cprTargets)`
|
||||
# rule that references `libcurl_shared`, which isn't in any export set when
|
||||
# curl is built as a sub-project here - configure fails with:
|
||||
# "install(EXPORT \"cprTargets\" ...) includes target \"cpr\" which requires
|
||||
# target \"libcurl_shared\" that is not in any export set."
|
||||
# Bumping past 1.10.5 needs additional plumbing (either disable cpr's install
|
||||
# rules or move curl to its own export set). Verified end-to-end on MSYS2
|
||||
# UCRT64 GCC 15.2 + CMake 4.3 with this pin.
|
||||
# ---------------------------------------------------------------------------
|
||||
message(STATUS "[ccm] Fetching libcpr/cpr (this also fetches a curl source tree) ...")
|
||||
FetchContent_Declare(
|
||||
cpr
|
||||
GIT_REPOSITORY https://github.com/libcpr/cpr.git
|
||||
GIT_TAG 1.10.5
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
set(CPR_USE_SYSTEM_CURL OFF CACHE INTERNAL "")
|
||||
set(CPR_BUILD_TESTS OFF CACHE INTERNAL "")
|
||||
set(CPR_ENABLE_SSL ON CACHE INTERNAL "")
|
||||
# Let cpr fetch & build a matching curl, with a Windows-friendly SSL backend.
|
||||
set(CPR_FORCE_USE_SYSTEM_CURL OFF CACHE INTERNAL "")
|
||||
|
||||
# Trim the curl build aggressively. We only need HTTPS GET, so anything else
|
||||
# is dead weight at best and a MinGW-w64 link-failure source at worst.
|
||||
set(BUILD_CURL_EXE OFF CACHE INTERNAL "")
|
||||
set(BUILD_TESTING OFF CACHE INTERNAL "")
|
||||
set(CURL_DISABLE_TESTS ON CACHE INTERNAL "")
|
||||
set(CURL_DISABLE_DOCS ON CACHE INTERNAL "")
|
||||
set(CURL_DISABLE_INSTALL ON CACHE INTERNAL "")
|
||||
set(CURL_DISABLE_LDAP ON CACHE INTERNAL "")
|
||||
set(CURL_DISABLE_LDAPS ON CACHE INTERNAL "")
|
||||
set(CURL_DISABLE_FTP ON CACHE INTERNAL "")
|
||||
set(CURL_DISABLE_TELNET ON CACHE INTERNAL "")
|
||||
set(CURL_DISABLE_DICT ON CACHE INTERNAL "")
|
||||
set(CURL_DISABLE_FILE ON CACHE INTERNAL "")
|
||||
set(CURL_DISABLE_TFTP ON CACHE INTERNAL "")
|
||||
set(CURL_DISABLE_GOPHER ON CACHE INTERNAL "")
|
||||
set(CURL_DISABLE_IMAP ON CACHE INTERNAL "")
|
||||
set(CURL_DISABLE_POP3 ON CACHE INTERNAL "")
|
||||
set(CURL_DISABLE_SMB ON CACHE INTERNAL "")
|
||||
set(CURL_DISABLE_SMTP ON CACHE INTERNAL "")
|
||||
set(CURL_DISABLE_RTSP ON CACHE INTERNAL "")
|
||||
set(CURL_DISABLE_MQTT ON CACHE INTERNAL "")
|
||||
|
||||
if(WIN32)
|
||||
# Use the OS-provided TLS stack so we don't need OpenSSL on the build host.
|
||||
set(CURL_USE_SCHANNEL ON CACHE INTERNAL "")
|
||||
set(CMAKE_USE_SCHANNEL ON CACHE INTERNAL "")
|
||||
set(CURL_USE_OPENSSL OFF CACHE INTERNAL "")
|
||||
|
||||
# Workaround: curl's CMake compile-test for ioctlsocket(FIONBIO) sometimes
|
||||
# fails on MinGW-w64 UCRT64 due to winsock2.h header ordering, which then
|
||||
# makes nonblock.c #error out with "no non-blocking method was found".
|
||||
# Force-set the macro so the build proceeds; ioctlsocket+FIONBIO is the
|
||||
# canonical Win32 API for non-blocking sockets and is always available.
|
||||
set(HAVE_IOCTLSOCKET_FIONBIO ON CACHE INTERNAL "")
|
||||
endif()
|
||||
FetchContent_MakeAvailable(cpr)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wxWidgets - cross-platform UI toolkit.
|
||||
# ---------------------------------------------------------------------------
|
||||
# ---------------------------------------------------------------------------
|
||||
# doctest - lightweight, header-only unit testing framework. Only fetched when
|
||||
# tests are enabled, so a default release build doesn't pull it.
|
||||
# ---------------------------------------------------------------------------
|
||||
if(CCM_BUILD_TESTS)
|
||||
message(STATUS "[ccm] Fetching doctest ...")
|
||||
FetchContent_Declare(
|
||||
doctest
|
||||
GIT_REPOSITORY https://github.com/doctest/doctest.git
|
||||
GIT_TAG v2.4.11
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
set(DOCTEST_WITH_TESTS OFF CACHE INTERNAL "")
|
||||
set(DOCTEST_WITH_MAIN_IN_STATIC_LIB OFF CACHE INTERNAL "")
|
||||
FetchContent_MakeAvailable(doctest)
|
||||
endif()
|
||||
|
||||
if(CCM_USE_SYSTEM_WX)
|
||||
message(STATUS "[ccm] Using system wxWidgets via find_package")
|
||||
find_package(wxWidgets REQUIRED COMPONENTS core base)
|
||||
include(${wxWidgets_USE_FILE})
|
||||
# Re-export as a cleaner imported target so the rest of the build can
|
||||
# depend on `wx::wx` regardless of how it was provided.
|
||||
add_library(wx::wx INTERFACE IMPORTED)
|
||||
target_include_directories(wx::wx INTERFACE ${wxWidgets_INCLUDE_DIRS})
|
||||
target_compile_definitions(wx::wx INTERFACE ${wxWidgets_DEFINITIONS})
|
||||
target_link_libraries(wx::wx INTERFACE ${wxWidgets_LIBRARIES})
|
||||
else()
|
||||
message(STATUS "[ccm] Fetching wxWidgets (slow on first configure) ...")
|
||||
set(wxBUILD_SHARED OFF CACHE INTERNAL "")
|
||||
set(wxBUILD_PRECOMP OFF CACHE INTERNAL "")
|
||||
set(wxBUILD_INSTALL OFF CACHE INTERNAL "")
|
||||
set(wxUSE_STL ON CACHE INTERNAL "")
|
||||
set(wxUSE_GUI ON CACHE INTERNAL "")
|
||||
FetchContent_Declare(
|
||||
wxWidgets
|
||||
GIT_REPOSITORY https://github.com/wxWidgets/wxWidgets.git
|
||||
GIT_TAG v3.2.5
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
FetchContent_MakeAvailable(wxWidgets)
|
||||
# wxWidgets provides `wx::core`, `wx::base` etc. as targets when used as
|
||||
# a sub-project. Aggregate the ones we need into `wx::wx`.
|
||||
if(NOT TARGET wx::wx)
|
||||
add_library(wx::wx INTERFACE IMPORTED)
|
||||
target_link_libraries(wx::wx INTERFACE wx::core wx::base)
|
||||
endif()
|
||||
endif()
|
||||
@@ -0,0 +1,49 @@
|
||||
# Toolchain detection.
|
||||
#
|
||||
# Preferred compiler is Clang. On Windows hosts we additionally fall back to
|
||||
# MinGW-w64 g++ when Clang is not present. MSVC is intentionally not supported.
|
||||
#
|
||||
# Run this BEFORE project() so the resulting CMAKE_C(XX)_COMPILER takes effect.
|
||||
|
||||
if(DEFINED CMAKE_CXX_COMPILER)
|
||||
# Respect explicit user override.
|
||||
return()
|
||||
endif()
|
||||
|
||||
# Look for clang / clang++ first.
|
||||
find_program(_CCM_CLANG NAMES clang clang-cl)
|
||||
find_program(_CCM_CLANGXX NAMES clang++ clang-cl)
|
||||
|
||||
if(_CCM_CLANG AND _CCM_CLANGXX)
|
||||
set(CMAKE_C_COMPILER "${_CCM_CLANG}" CACHE FILEPATH "C compiler" FORCE)
|
||||
set(CMAKE_CXX_COMPILER "${_CCM_CLANGXX}" CACHE FILEPATH "C++ compiler" FORCE)
|
||||
message(STATUS "[ccm] Toolchain: Clang at ${_CCM_CLANGXX}")
|
||||
return()
|
||||
endif()
|
||||
|
||||
if(WIN32)
|
||||
# MinGW-w64 GCC fallback.
|
||||
find_program(_CCM_GCC NAMES x86_64-w64-mingw32-gcc gcc)
|
||||
find_program(_CCM_GXX NAMES x86_64-w64-mingw32-g++ g++)
|
||||
if(_CCM_GCC AND _CCM_GXX)
|
||||
set(CMAKE_C_COMPILER "${_CCM_GCC}" CACHE FILEPATH "C compiler" FORCE)
|
||||
set(CMAKE_CXX_COMPILER "${_CCM_GXX}" CACHE FILEPATH "C++ compiler" FORCE)
|
||||
message(STATUS "[ccm] Toolchain: MinGW-w64 GCC at ${_CCM_GXX}")
|
||||
return()
|
||||
endif()
|
||||
message(WARNING
|
||||
"[ccm] Neither Clang nor MinGW-w64 GCC was found in PATH. "
|
||||
"Install LLVM (preferred) or MSYS2's mingw-w64-x86_64-gcc and re-run."
|
||||
)
|
||||
else()
|
||||
# Unix-likes: GCC fallback.
|
||||
find_program(_CCM_GCC NAMES gcc cc)
|
||||
find_program(_CCM_GXX NAMES g++ c++)
|
||||
if(_CCM_GCC AND _CCM_GXX)
|
||||
set(CMAKE_C_COMPILER "${_CCM_GCC}" CACHE FILEPATH "C compiler" FORCE)
|
||||
set(CMAKE_CXX_COMPILER "${_CCM_GXX}" CACHE FILEPATH "C++ compiler" FORCE)
|
||||
message(STATUS "[ccm] Toolchain: GCC at ${_CCM_GXX}")
|
||||
return()
|
||||
endif()
|
||||
message(WARNING "[ccm] Neither Clang nor GCC was found in PATH.")
|
||||
endif()
|
||||
@@ -0,0 +1,59 @@
|
||||
# core/AGENTS.md
|
||||
|
||||
`ccm_core` static library — domain types, ports, services, infra adapters. Hard rule: **no UI dependencies, ever**. Read the root `AGENTS.md` first.
|
||||
|
||||
## Layer pointers
|
||||
|
||||
- `include/ccm/domain/` — POD value types: `Enums`, `Set`, `MagicCard`, `PokemonCard`, `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
|
||||
- `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository<T>`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`). All seams the services depend on. Add new ports here when adding new external concerns.
|
||||
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list). They depend only on ports.
|
||||
- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`.
|
||||
- `include/ccm/games/` — `IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/` and `pokemon/` are the reference implementations — both expose a fully working set source + card preview source.
|
||||
- `include/ccm/util/` — `Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`).
|
||||
- `src/` mirrors `include/ccm/` for non-template implementations.
|
||||
|
||||
## Conventions
|
||||
|
||||
1. **No throw across ports.** Return `ccm::Result<T>::ok(...)` / `Result<T>::err("msg")`. The caller propagates with `if (!r) return Result<T>::err(r.error());`.
|
||||
2. **JSON serde stays byte-for-byte stable.** When the C++ field name differs from the JSON key (`signed_` vs `"signed"`, `releaseDate`, `setNo`, `firstEdition`, `dataStorage`, `defaultGame`), write hand-rolled `to_json` / `from_json` instead of `NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE` so the alias is explicit. Round-trip tests in `tests/domain_json_tests.cpp` enforce this — extend them whenever you touch a domain type.
|
||||
3. **Filename rule for images** lives in `services/ImageService.hpp` and matches the Rust source exactly:
|
||||
- new entry -> `"{set}+{name}+{idx}.{ext}"`
|
||||
- existing -> `"{id}+{set}+{name}+{idx}.{ext}"`
|
||||
`ImageService::buildTargetName` is the single source of truth. Don't duplicate the rule elsewhere.
|
||||
4. **Templates stay header-only** (`CollectionService<T>`, `JsonCollectionRepository<T>`). Don't add `.cpp` files for them; explicit instantiation is not used.
|
||||
5. **Path strings** that get persisted (e.g. `Configuration::dataStorage`) use `std::filesystem::path::generic_string()`, never `string()` — keeps `/` separators on Windows so JSON round-trips and tests stay portable.
|
||||
6. **Compiler warnings**: every target in this package links `ccm_warnings` `PRIVATE`. Treat warnings as errors locally during dev (`-Werror` is opt-in but encouraged).
|
||||
7. **No `wx/...` includes** in headers or sources here. Verify with `rg "wx/" core/` — must be empty.
|
||||
8. **HTTP query strings must be percent-encoded** before they reach `IHttpClient::get`. `cpr::Url` does **not** encode the URL string we hand it. See `MagicCardPreviewSource::buildSearchUrl` for the canonical pattern (RFC 3986 unreserved-set encoder). `IHttpClient::get` accepts arbitrary bytes back — `Result<std::string>` is a binary buffer, not text, so callers can use it for image payloads directly.
|
||||
|
||||
## Adding a new game
|
||||
|
||||
The end-to-end procedure (core + UI + composition root + docs) lives in `docs/adding-a-new-game.md`. The core-side checklist is:
|
||||
|
||||
1. Add `Game::<Name>` plus `to_string` / `<Name>FromString` / `allGames()` entries in `include/ccm/domain/Enums.hpp` and `src/domain/Enums.cpp`.
|
||||
2. Create `include/ccm/games/<name>/<Name>SetSource.hpp` + `.cpp` implementing `ISetSource`. Mirror `MagicSetSource` / `PokemonSetSource`: expose a static `parseResponse(std::string)` helper so it's unit-testable without HTTP.
|
||||
3. (Optional) Create `include/ccm/games/<name>/<Name>CardPreviewSource.hpp` + `.cpp` implementing `ICardPreviewSource`. Mirror `MagicCardPreviewSource` / `PokemonCardPreviewSource`: expose static `buildSearchUrl` + `parseResponse` helpers for unit testing without HTTP.
|
||||
4. Create `include/ccm/games/<name>/<Name>GameModule.hpp` + `.cpp` implementing `IGameModule`. Pick a stable lowercase `dirName()` — it becomes the on-disk subdirectory and must never change. The module **owns** its set source and (optionally) its card preview source: override `cardPreviewSource()` to return `&previewSource_` when present (default returns `nullptr`).
|
||||
5. If the game has a card type with different fields, add a `<Name>Card` domain type with hand-rolled JSON aliases. Otherwise reuse an existing one.
|
||||
6. Add the new `.cpp` files to `core/CMakeLists.txt` (no glob).
|
||||
7. Add tests under `tests/<name>_set_source_tests.cpp` and `tests/<name>_card_preview_source_tests.cpp` modeled on the Magic / Pokemon versions.
|
||||
8. The composition root in `app/main.cpp` and the directory mapping in `app/main.cpp::dirNameForGame` must be updated too — see `app/AGENTS.md`. `CardPreviewService::registerModule(*module)` is the single registration call; modules whose `cardPreviewSource()` returns `nullptr` are silently skipped.
|
||||
|
||||
## Adding / changing a card-table column
|
||||
|
||||
When you add or rename a `tableFields` entry on a list panel (Magic or Pokemon), keep `core/`'s sort/filter helpers and their tests in lockstep:
|
||||
|
||||
1. Extend `MagicSortColumn` / `PokemonSortColumn` and add a `case` branch in `sortMagicCards` / `sortPokemonCards` (`core/include/ccm/services/CardSorter.hpp` + `.cpp`).
|
||||
2. Add the new value-key column to the matching `matchesMagicFilter` / `matchesPokemonFilter` (`core/include/ccm/services/CardFilter.hpp` + `.cpp`) — boolean-flag columns are *excluded* (the filtering rule only checks values equivalent to JS `typeof === "string" | "number"`).
|
||||
3. Add tests under `tests/card_sorter_tests.cpp` and `tests/card_filter_tests.cpp`.
|
||||
|
||||
## Adding a new port
|
||||
|
||||
1. Add the interface header under `include/ccm/ports/` with `virtual ~IFoo() = default;`.
|
||||
2. Implement the adapter under `include/ccm/infra/` + `src/infra/`. Mark it `final`.
|
||||
3. Update `core/CMakeLists.txt`. Wire it into the relevant service's constructor.
|
||||
4. Add a fake under `tests/fakes/` modeled on `InMemoryFileSystem` and write service-level tests against it.
|
||||
|
||||
## Commands
|
||||
|
||||
Build core only: `cmake --build build --target ccm_core`
|
||||
@@ -0,0 +1,49 @@
|
||||
# ccm_core: UI-agnostic domain, services, ports, and infra adapters.
|
||||
# This target MUST NOT depend on wxWidgets or any UI toolkit.
|
||||
|
||||
add_library(ccm_core STATIC
|
||||
src/domain/Enums.cpp
|
||||
src/domain/Set.cpp
|
||||
src/domain/MagicCard.cpp
|
||||
src/domain/PokemonCard.cpp
|
||||
src/domain/Configuration.cpp
|
||||
|
||||
src/services/ConfigService.cpp
|
||||
src/services/ImageService.cpp
|
||||
src/services/SetService.cpp
|
||||
src/services/CardPreviewService.cpp
|
||||
src/services/CardSorter.cpp
|
||||
src/services/CardFilter.cpp
|
||||
|
||||
src/infra/CprHttpClient.cpp
|
||||
src/infra/StdFileSystem.cpp
|
||||
src/infra/JsonSetRepository.cpp
|
||||
src/infra/LocalImageStore.cpp
|
||||
|
||||
src/games/magic/MagicSetSource.cpp
|
||||
src/games/magic/MagicCardPreviewSource.cpp
|
||||
src/games/magic/MagicGameModule.cpp
|
||||
src/games/pokemon/PokemonSetSource.cpp
|
||||
src/games/pokemon/PokemonCardPreviewSource.cpp
|
||||
src/games/pokemon/PokemonGameModule.cpp
|
||||
|
||||
src/util/FsNames.cpp
|
||||
)
|
||||
|
||||
target_include_directories(ccm_core
|
||||
PUBLIC
|
||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||
)
|
||||
|
||||
target_link_libraries(ccm_core
|
||||
PUBLIC
|
||||
nlohmann_json::nlohmann_json
|
||||
PRIVATE
|
||||
cpr::cpr
|
||||
ccm_warnings
|
||||
)
|
||||
|
||||
target_compile_features(ccm_core PUBLIC cxx_std_20)
|
||||
|
||||
# JsonCollectionRepository<T> and CollectionService<T> are header-only templates
|
||||
# and live entirely under include/ccm/ - nothing to compile here for them.
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
// Configuration: matches the persisted `config.json` schema used by this app.
|
||||
//
|
||||
// { "dataStorage": "/abs/path", "defaultGame": "Magic" }
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct Configuration {
|
||||
std::string dataStorage;
|
||||
Game defaultGame{Game::Magic};
|
||||
Theme theme{Theme::Light};
|
||||
|
||||
friend bool operator==(const Configuration&, const Configuration&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const Configuration& c);
|
||||
void from_json(const nlohmann::json& j, Configuration& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,77 @@
|
||||
#pragma once
|
||||
|
||||
// Game / Language / Condition enums.
|
||||
//
|
||||
// String spellings are kept identical to the established Rust serde defaults
|
||||
// (e.g. `NearMint`, `LightPlayed`, `Magic`, `Pokemon`) so existing JSON files
|
||||
// remain interchangeable.
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
enum class Game {
|
||||
Magic,
|
||||
Pokemon,
|
||||
};
|
||||
|
||||
enum class Language {
|
||||
English,
|
||||
German,
|
||||
French,
|
||||
Spanish,
|
||||
Italian,
|
||||
Chinese,
|
||||
Japanese,
|
||||
Russian,
|
||||
};
|
||||
|
||||
enum class Condition {
|
||||
Mint,
|
||||
NearMint,
|
||||
Excellent,
|
||||
Good,
|
||||
LightPlayed,
|
||||
Played,
|
||||
Poor,
|
||||
};
|
||||
|
||||
enum class Theme {
|
||||
Light,
|
||||
Dark,
|
||||
};
|
||||
|
||||
std::string_view to_string(Game g) noexcept;
|
||||
std::string_view to_string(Language l) noexcept;
|
||||
std::string_view to_string(Condition c) noexcept;
|
||||
std::string_view to_string(Theme t) noexcept;
|
||||
|
||||
std::optional<Game> gameFromString(std::string_view s) noexcept;
|
||||
std::optional<Language> languageFromString(std::string_view s) noexcept;
|
||||
std::optional<Condition> conditionFromString(std::string_view s) noexcept;
|
||||
std::optional<Theme> themeFromString(std::string_view s) noexcept;
|
||||
|
||||
const std::array<Game, 2>& allGames() noexcept;
|
||||
const std::array<Language, 8>& allLanguages() noexcept;
|
||||
const std::array<Condition, 7>& allConditions() noexcept;
|
||||
const std::array<Theme, 2>& allThemes() noexcept;
|
||||
|
||||
// nlohmann/json hooks - serialize as plain strings, matching Rust serde.
|
||||
void to_json(nlohmann::json& j, Game v);
|
||||
void from_json(const nlohmann::json& j, Game& v);
|
||||
|
||||
void to_json(nlohmann::json& j, Language v);
|
||||
void from_json(const nlohmann::json& j, Language& v);
|
||||
|
||||
void to_json(nlohmann::json& j, Condition v);
|
||||
void from_json(const nlohmann::json& j, Condition& v);
|
||||
|
||||
void to_json(nlohmann::json& j, Theme v);
|
||||
void from_json(const nlohmann::json& j, Theme& v);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
// MagicCard - faithful port of magic/card_services.rs::Card.
|
||||
// JSON layout remains stable so collection.json files stay interchangeable.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct MagicCard {
|
||||
std::uint32_t id{0};
|
||||
std::uint8_t amount{1};
|
||||
std::string name;
|
||||
Set set;
|
||||
std::string note;
|
||||
std::vector<std::string> images;
|
||||
Language language{Language::English};
|
||||
Condition condition{Condition::NearMint};
|
||||
bool foil{false};
|
||||
bool signed_{false}; // `signed` is a reserved keyword
|
||||
bool altered{false};
|
||||
|
||||
friend bool operator==(const MagicCard&, const MagicCard&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const MagicCard& c);
|
||||
void from_json(const nlohmann::json& j, MagicCard& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonCard - faithful port of pokemon/card_services.rs::Card.
|
||||
// Same established JSON shape (with `setNo` and `firstEdition` aliases).
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct PokemonCard {
|
||||
std::uint32_t id{0};
|
||||
std::uint8_t amount{1};
|
||||
std::string name;
|
||||
Set set;
|
||||
std::string setNo;
|
||||
std::string note;
|
||||
std::vector<std::string> images;
|
||||
Language language{Language::English};
|
||||
Condition condition{Condition::NearMint};
|
||||
bool firstEdition{false};
|
||||
bool holo{false};
|
||||
bool signed_{false};
|
||||
bool altered{false};
|
||||
|
||||
friend bool operator==(const PokemonCard&, const PokemonCard&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const PokemonCard& c);
|
||||
void from_json(const nlohmann::json& j, PokemonCard& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
// Set: identifier of a printing run shared by both supported games.
|
||||
// JSON shape matches the original Rust Set struct exactly:
|
||||
// { "id": "...", "name": "...", "releaseDate": "YYYY/MM/DD" }
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct Set {
|
||||
std::string id;
|
||||
std::string name;
|
||||
std::string releaseDate; // formatted as "YYYY/MM/DD"
|
||||
|
||||
friend bool operator==(const Set&, const Set&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const Set& s);
|
||||
void from_json(const nlohmann::json& j, Set& s);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
// IGameModule + ISetSource: the seam that lets the UI handle Magic and Pokemon
|
||||
// uniformly. New game support is "implement these interfaces and register the
|
||||
// module in the composition root" - see the Rust `templates/` module for the
|
||||
// pattern this is modeled after.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class ISetSource {
|
||||
public:
|
||||
virtual ~ISetSource() = default;
|
||||
|
||||
// Fetch the canonical set list from the game's external API.
|
||||
// Implementations return a vector that has already been filtered
|
||||
// (e.g. no digital-only sets) and sorted by release date ascending.
|
||||
virtual Result<std::vector<Set>> fetchAll() = 0;
|
||||
};
|
||||
|
||||
class IGameModule {
|
||||
public:
|
||||
virtual ~IGameModule() = default;
|
||||
|
||||
[[nodiscard]] virtual Game id() const noexcept = 0;
|
||||
// Subdirectory name used inside the data storage root. Matches the Rust
|
||||
// string literals "magic" / "pokemon".
|
||||
[[nodiscard]] virtual std::string dirName() const = 0;
|
||||
[[nodiscard]] virtual std::string displayName() const = 0;
|
||||
|
||||
virtual ISetSource& setSource() = 0;
|
||||
|
||||
// Optional preview source. Returning nullptr signals the game has no
|
||||
// remote preview API (the UI then renders only locally stored images).
|
||||
// The default keeps existing modules compiling without forcing every
|
||||
// game to provide one.
|
||||
virtual ICardPreviewSource* cardPreviewSource() noexcept { return nullptr; }
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
// MagicCardPreviewSource: ICardPreviewSource implementation for Magic the
|
||||
// Gathering. Calls Scryfall's search endpoint at
|
||||
// https://api.scryfall.com/cards/search?q=name:"<name>" AND set:<setCode>
|
||||
// and returns `data[0].image_uris.normal`. Mirrors the established
|
||||
// `src/components/magic/SelectedMtgPanel.tsx::getImage`, including the
|
||||
// `&` -> `and` substitution in the card name.
|
||||
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class MagicCardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
explicit MagicCardPreviewSource(IHttpClient& http);
|
||||
|
||||
Result<std::string> fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
|
||||
// Build the fully URL-encoded Scryfall search URL for the given card.
|
||||
// Exposed for unit testing and to keep encoding rules in one place.
|
||||
static std::string buildSearchUrl(std::string_view name,
|
||||
std::string_view setId);
|
||||
|
||||
// Parse a Scryfall /cards/search response body and pull out the
|
||||
// `data[0].image_uris.normal` URL. Returns an error result when no
|
||||
// matching printing is found, when the JSON is malformed, or when the
|
||||
// entry has no top-level `image_uris` (double-faced cards expose them
|
||||
// on a face object - no fallback in this compatibility behavior either).
|
||||
static Result<std::string> parseResponse(const std::string& body);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
// MagicGameModule: IGameModule for Magic the Gathering. Owns its set source
|
||||
// and card preview source, reports the canonical "magic" subdirectory name
|
||||
// used on disk.
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/magic/MagicCardPreviewSource.hpp"
|
||||
#include "ccm/games/magic/MagicSetSource.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class MagicGameModule final : public IGameModule {
|
||||
public:
|
||||
explicit MagicGameModule(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] Game id() const noexcept override { return Game::Magic; }
|
||||
[[nodiscard]] std::string dirName() const override { return "magic"; }
|
||||
[[nodiscard]] std::string displayName() const override { return "Magic"; }
|
||||
|
||||
ISetSource& setSource() override { return setSource_; }
|
||||
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
|
||||
|
||||
private:
|
||||
MagicSetSource setSource_;
|
||||
MagicCardPreviewSource previewSource_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
// MagicSetSource: ISetSource implementation for Magic the Gathering.
|
||||
// Calls the Scryfall API at https://api.scryfall.com/sets, drops digital-only
|
||||
// sets, maps the response into our `Set` domain type, and sorts by release date
|
||||
// ascending. Behavior matches `magic/set_services.rs::update_sets`.
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class MagicSetSource final : public ISetSource {
|
||||
public:
|
||||
static constexpr const char* kEndpoint = "https://api.scryfall.com/sets";
|
||||
|
||||
explicit MagicSetSource(IHttpClient& http);
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
|
||||
// Pure parser exposed for unit testing without a network round-trip.
|
||||
static Result<std::vector<Set>> parseResponse(const std::string& body);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonCardPreviewSource: ICardPreviewSource implementation for the Pokemon
|
||||
// TCG. Calls the Pokemon TCG search endpoint at
|
||||
// https://api.pokemontcg.io/v2/cards?q=name:"<name>" set.id:<setId> number:<setNo>
|
||||
// and returns `data[0].images.large` (with `images.small` as a graceful
|
||||
// fallback). Mirrors the established `getImage` flow in
|
||||
// `src/components/pokemon/SelectedPokemonPanel.tsx`.
|
||||
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class PokemonCardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
explicit PokemonCardPreviewSource(IHttpClient& http);
|
||||
|
||||
Result<std::string> fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
|
||||
// Build the fully URL-encoded Pokemon TCG search URL for the given card.
|
||||
// Exposed for unit testing and to keep encoding rules in one place.
|
||||
static std::string buildSearchUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo);
|
||||
|
||||
// Parse a Pokemon TCG /v2/cards response body and pull out the image URL
|
||||
// for the first matching card. Prefers `images.large`, falls back to
|
||||
// `images.small`, and returns an error result if neither is present, the
|
||||
// data array is empty, or the JSON is malformed.
|
||||
static Result<std::string> parseResponse(const std::string& body);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonGameModule: IGameModule for the Pokemon TCG. Owns its set source
|
||||
// and card preview source, both backed by api.pokemontcg.io/v2.
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
|
||||
#include "ccm/games/pokemon/PokemonSetSource.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class PokemonGameModule final : public IGameModule {
|
||||
public:
|
||||
explicit PokemonGameModule(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] Game id() const noexcept override { return Game::Pokemon; }
|
||||
[[nodiscard]] std::string dirName() const override { return "pokemon"; }
|
||||
[[nodiscard]] std::string displayName() const override { return "Pokemon"; }
|
||||
|
||||
ISetSource& setSource() override { return setSource_; }
|
||||
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
|
||||
|
||||
private:
|
||||
PokemonSetSource setSource_;
|
||||
PokemonCardPreviewSource previewSource_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonSetSource: ISetSource implementation for the Pokemon TCG.
|
||||
// Calls the Pokemon TCG API at https://api.pokemontcg.io/v2/sets, maps the
|
||||
// response into our `Set` domain type, and sorts by release date ascending.
|
||||
// The Pokemon TCG API already returns `releaseDate` in `YYYY/MM/DD` format,
|
||||
// so no rewriting is needed (unlike Scryfall's `released_at`).
|
||||
// Behavior matches `pokemon/set_services.rs::update_sets`.
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class PokemonSetSource final : public ISetSource {
|
||||
public:
|
||||
static constexpr const char* kEndpoint = "https://api.pokemontcg.io/v2/sets";
|
||||
|
||||
explicit PokemonSetSource(IHttpClient& http);
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
|
||||
// Pure parser exposed for unit testing without a network round-trip.
|
||||
static Result<std::vector<Set>> parseResponse(const std::string& body);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
// CprHttpClient: cpr-based implementation of IHttpClient.
|
||||
// All cpr/libcurl symbols stay confined to the .cpp file - any TU that just
|
||||
// needs to make HTTP calls only depends on the IHttpClient port.
|
||||
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <chrono>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class CprHttpClient final : public IHttpClient {
|
||||
public:
|
||||
explicit CprHttpClient(std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
|
||||
|
||||
Result<std::string> get(std::string_view url) override;
|
||||
|
||||
private:
|
||||
std::chrono::milliseconds timeout_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,85 @@
|
||||
#pragma once
|
||||
|
||||
// JsonCollectionRepository<T>: persists std::map<id, TCard> as a JSON object
|
||||
// keyed by stringified id. Layout matches the established collection file:
|
||||
//
|
||||
// <dataStorage>/<gameDir>/collection.json -> { "0": {...}, "1": {...} }
|
||||
//
|
||||
// Header-only because it's a template over the card type.
|
||||
|
||||
#include "ccm/domain/Configuration.hpp"
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/ICollectionRepository.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
template <typename TCard>
|
||||
class JsonCollectionRepository final : public ICollectionRepository<TCard> {
|
||||
public:
|
||||
using Map = typename ICollectionRepository<TCard>::Map;
|
||||
// The repository needs a way to translate a Game enum into the per-game
|
||||
// subdirectory name ("magic" / "pokemon"); a small lookup function keeps
|
||||
// this layer free of concrete game module types.
|
||||
using DirNameFn = std::function<std::string(Game)>;
|
||||
|
||||
JsonCollectionRepository(IFileSystem& fs, ConfigService& config, DirNameFn dirName)
|
||||
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
|
||||
|
||||
Result<Map> load(Game game) override {
|
||||
const auto p = collectionPath(game);
|
||||
if (!fs_.exists(p)) {
|
||||
// Mirror the Rust "create on first read" semantics so the UI
|
||||
// never sees a missing-file error on a fresh install.
|
||||
Map empty;
|
||||
auto saved = save(game, empty);
|
||||
if (!saved) return Result<Map>::err(saved.error());
|
||||
return Result<Map>::ok(std::move(empty));
|
||||
}
|
||||
auto text = fs_.readText(p);
|
||||
if (!text) return Result<Map>::err(text.error());
|
||||
try {
|
||||
auto j = nlohmann::json::parse(text.value());
|
||||
Map out;
|
||||
for (auto it = j.begin(); it != j.end(); ++it) {
|
||||
std::uint32_t key = static_cast<std::uint32_t>(std::stoul(it.key()));
|
||||
out.emplace(key, it.value().template get<TCard>());
|
||||
}
|
||||
return Result<Map>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<Map>::err(std::string("JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<void> save(Game game, const Map& collection) override {
|
||||
nlohmann::json j = nlohmann::json::object();
|
||||
for (const auto& [id, card] : collection) {
|
||||
j[std::to_string(id)] = card;
|
||||
}
|
||||
const auto p = collectionPath(game);
|
||||
auto dirRes = fs_.ensureDirectory(p.parent_path());
|
||||
if (!dirRes) return dirRes;
|
||||
return fs_.writeText(p, j.dump(2));
|
||||
}
|
||||
|
||||
private:
|
||||
std::filesystem::path collectionPath(Game game) const {
|
||||
return std::filesystem::path(config_.current().dataStorage) /
|
||||
dirName_(game) / "collection.json";
|
||||
}
|
||||
|
||||
IFileSystem& fs_;
|
||||
ConfigService& config_;
|
||||
DirNameFn dirName_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
// JsonSetRepository: persists vector<Set> to `<dataStorage>/<game>/sets.json`.
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/ports/ISetRepository.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class JsonSetRepository final : public ISetRepository {
|
||||
public:
|
||||
using DirNameFn = std::function<std::string(Game)>;
|
||||
|
||||
JsonSetRepository(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
|
||||
|
||||
Result<std::vector<Set>> load(Game game) override;
|
||||
Result<void> save(Game game, const std::vector<Set>& sets) override;
|
||||
|
||||
private:
|
||||
IFileSystem& fs_;
|
||||
ConfigService& config_;
|
||||
DirNameFn dirName_;
|
||||
|
||||
[[nodiscard]] std::filesystem::path setsPath(Game game) const;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
// LocalImageStore: stores card images under
|
||||
// `<dataStorage>/<game>/images/<filename>`.
|
||||
// Implements IImageStore, used by ImageService.
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/ports/IImageStore.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class LocalImageStore final : public IImageStore {
|
||||
public:
|
||||
using DirNameFn = std::function<std::string(Game)>;
|
||||
|
||||
LocalImageStore(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
|
||||
|
||||
Result<std::string> copyIn(Game game,
|
||||
const std::filesystem::path& srcPath,
|
||||
const std::string& targetName) override;
|
||||
Result<void> remove(Game game, const std::string& imageName) override;
|
||||
std::filesystem::path resolvePath(Game game, const std::string& imageName) const override;
|
||||
|
||||
private:
|
||||
IFileSystem& fs_;
|
||||
ConfigService& config_;
|
||||
DirNameFn dirName_;
|
||||
|
||||
[[nodiscard]] std::filesystem::path gameImageDir(Game game) const;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
// std::filesystem-backed implementation of IFileSystem.
|
||||
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class StdFileSystem final : public IFileSystem {
|
||||
public:
|
||||
[[nodiscard]] bool exists(const std::filesystem::path& p) const override;
|
||||
[[nodiscard]] bool isDirectory(const std::filesystem::path& p) const override;
|
||||
|
||||
Result<void> ensureDirectory(const std::filesystem::path& p) override;
|
||||
Result<std::string> readText(const std::filesystem::path& p) override;
|
||||
Result<void> writeText(const std::filesystem::path& p, std::string_view contents) override;
|
||||
Result<void> copyFile(const std::filesystem::path& from,
|
||||
const std::filesystem::path& to,
|
||||
bool overwrite) override;
|
||||
Result<void> remove(const std::filesystem::path& p) override;
|
||||
Result<std::vector<std::filesystem::path>> listDirectory(
|
||||
const std::filesystem::path& p) override;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
// ICardPreviewSource - resolves the preview image URL for a single card via
|
||||
// the active game's external API. Implementations stay HTTP-bound, no UI deps.
|
||||
//
|
||||
// Modeled after per-game `getImage` helpers in
|
||||
// `src/components/{magic,pokemon}/Selected*Panel.tsx`. The resulting URL is
|
||||
// then fetched as raw image bytes by `CardPreviewService` and decoded by the
|
||||
// UI layer (wxImage in our wx adapter).
|
||||
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class ICardPreviewSource {
|
||||
public:
|
||||
virtual ~ICardPreviewSource() = default;
|
||||
|
||||
// Resolve the preview image URL for a single card. `setNo` is optional
|
||||
// (empty string is fine); some game APIs (e.g. Pokemon TCG) can use it as
|
||||
// a more precise lookup key, others (Magic/Scryfall) ignore it.
|
||||
virtual Result<std::string> fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) = 0;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
// ICollectionRepository<T> - persistence port for a per-game card collection,
|
||||
// keyed by uint32_t id. Mirrors the HashMap<u32, T> in the original Rust code.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
template <typename TCard>
|
||||
class ICollectionRepository {
|
||||
public:
|
||||
using Map = std::map<std::uint32_t, TCard>;
|
||||
|
||||
virtual ~ICollectionRepository() = default;
|
||||
|
||||
virtual Result<Map> load(Game game) = 0;
|
||||
virtual Result<void> save(Game game, const Map& collection) = 0;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
// IFileSystem - filesystem operations the services need, expressed as a
|
||||
// narrow port. Real implementation is `StdFileSystem` (over <filesystem>).
|
||||
// In-memory implementation can be plugged in for tests.
|
||||
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class IFileSystem {
|
||||
public:
|
||||
virtual ~IFileSystem() = default;
|
||||
|
||||
[[nodiscard]] virtual bool exists(const std::filesystem::path& p) const = 0;
|
||||
[[nodiscard]] virtual bool isDirectory(const std::filesystem::path& p) const = 0;
|
||||
|
||||
virtual Result<void> ensureDirectory(const std::filesystem::path& p) = 0;
|
||||
virtual Result<std::string> readText(const std::filesystem::path& p) = 0;
|
||||
virtual Result<void> writeText(const std::filesystem::path& p, std::string_view contents) = 0;
|
||||
virtual Result<void> copyFile(const std::filesystem::path& from,
|
||||
const std::filesystem::path& to,
|
||||
bool overwrite) = 0;
|
||||
virtual Result<void> remove(const std::filesystem::path& p) = 0;
|
||||
virtual Result<std::vector<std::filesystem::path>> listDirectory(
|
||||
const std::filesystem::path& p) = 0;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
// IHttpClient - tiny abstraction over HTTP GET so the rest of the codebase
|
||||
// never sees libcurl/cpr directly. Tests can substitute a fake client.
|
||||
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class IHttpClient {
|
||||
public:
|
||||
virtual ~IHttpClient() = default;
|
||||
|
||||
// Issue a blocking HTTPS GET. Returns the response body on success, an
|
||||
// error string on failure (no exceptions across the port boundary).
|
||||
//
|
||||
// The returned `std::string` is a raw byte buffer - it is NOT decoded as
|
||||
// text. Binary payloads (e.g. PNG/JPEG image bytes for the card preview
|
||||
// feature) round-trip through this method intact.
|
||||
virtual Result<std::string> get(std::string_view url) = 0;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
// IImageStore - manages card image files on disk inside the per-game
|
||||
// `images/` subdirectory. Returns absolute paths so the UI can decode with
|
||||
// whichever image library it likes (we use wxImage in the wx adapter); core
|
||||
// stays free of any image decoding dependency.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class IImageStore {
|
||||
public:
|
||||
virtual ~IImageStore() = default;
|
||||
|
||||
// Copy `srcPath` into the per-game image dir under filename `targetName`
|
||||
// (extension preserved from `srcPath`). Returns the final filename
|
||||
// (basename only, no path) on success.
|
||||
virtual Result<std::string> copyIn(Game game,
|
||||
const std::filesystem::path& srcPath,
|
||||
const std::string& targetName) = 0;
|
||||
|
||||
// Delete `imageName` from the per-game image dir.
|
||||
virtual Result<void> remove(Game game, const std::string& imageName) = 0;
|
||||
|
||||
// Resolve `imageName` to an absolute path inside the per-game image dir.
|
||||
virtual std::filesystem::path resolvePath(Game game, const std::string& imageName) const = 0;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
// ISetRepository - persistence port for the cached `sets.json` of a game.
|
||||
// Stored as a flat list to mirror the original Rust file layout.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class ISetRepository {
|
||||
public:
|
||||
virtual ~ISetRepository() = default;
|
||||
|
||||
virtual Result<std::vector<Set>> load(Game game) = 0;
|
||||
virtual Result<void> save(Game game, const std::vector<Set>& sets) = 0;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
|
||||
// CardFilter - row matcher aligned with TableTemplate.tsx::applyFilter semantics.
|
||||
// kept a single `filter` string in the table component and, before rendering,
|
||||
// kept only the rows where *any* of the `tableFields` value-key columns
|
||||
// (string- or number-typed) contained the filter as a substring. Boolean flag
|
||||
// columns (foil / signed / altered / holo / firstEdition) were skipped because
|
||||
// `typeof` was neither "string" nor "number".
|
||||
//
|
||||
// We replicate that logic here as free functions so the UI can stay dumb (it
|
||||
// just owns a wxTextCtrl and re-asks core which rows match).
|
||||
//
|
||||
// Differences worth knowing:
|
||||
// * The match is case-insensitive on **both** sides — the old JS path only lowercased
|
||||
// the cell value, leaving the filter as-typed, which made uppercase input
|
||||
// never match. Lowercasing the filter too is the obvious-intent fix and
|
||||
// keeps round-trip semantics for any filter the original UI would accept
|
||||
// (lowercase filters behave identically).
|
||||
// * An empty filter matches every row, exactly as in JS where every string
|
||||
// `.includes("")` returns true.
|
||||
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// Magic value-key columns from the MtgTable.tsx tableFields list:
|
||||
// name, set.name, language, condition, amount, note.
|
||||
// Foil/Signed/Altered are bool-typed and intentionally excluded.
|
||||
[[nodiscard]] bool matchesMagicFilter(const MagicCard& card,
|
||||
std::string_view filter);
|
||||
|
||||
// Pokemon value-key columns from PokemonTable.tsx tableFields list:
|
||||
// name, set.name, setNo, language, condition, amount, note.
|
||||
// Holo/FirstEdition/Signed/Altered are bool-typed and excluded.
|
||||
[[nodiscard]] bool matchesPokemonFilter(const PokemonCard& card,
|
||||
std::string_view filter);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
// CardPreviewService: high-level operation for resolving and downloading a
|
||||
// card's preview image from the active game's external API. Mirrors the
|
||||
// `EntryPanelTemplate.tsx::useEffect([entry])` flow:
|
||||
// 1. Per-game `ICardPreviewSource` resolves the card -> an image URL.
|
||||
// 2. Service issues a GET against that URL through `IHttpClient`.
|
||||
// 3. Raw bytes are returned to the caller (UI decodes them with whichever
|
||||
// image lib it prefers - we use wxImage in the wx adapter).
|
||||
//
|
||||
// Sources are registered through `IGameModule::cardPreviewSource()`. Modules
|
||||
// that return nullptr are silently skipped; games without a registered source
|
||||
// produce an explicit error result on lookup rather than silently doing nothing.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class CardPreviewService {
|
||||
public:
|
||||
explicit CardPreviewService(IHttpClient& http);
|
||||
|
||||
// Register a game module's preview source. Calling this with a module
|
||||
// whose `cardPreviewSource()` returns nullptr is a no-op (the game has
|
||||
// no remote preview API). The module reference must remain valid for
|
||||
// the lifetime of the service.
|
||||
void registerModule(IGameModule& module);
|
||||
|
||||
// Resolve and download the preview image bytes for a single card.
|
||||
// The returned `std::string` is a raw byte buffer (PNG/JPEG payload) -
|
||||
// it is NOT decoded text. Use std::string::data()/size() with whatever
|
||||
// image-decoding facility your UI provides.
|
||||
Result<std::string> fetchPreviewBytes(Game game,
|
||||
std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo);
|
||||
|
||||
// Download image bytes from a fully-qualified URL without going through
|
||||
// per-game preview-source resolution.
|
||||
Result<std::string> fetchImageBytesByUrl(std::string_view url);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
std::unordered_map<Game, ICardPreviewSource*> sources_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
|
||||
// CardSorter - per-column sorting behavior matching the established table model
|
||||
// `tableFields` configuration. The original TS code stored each column's
|
||||
// (valueKey, sortKey) pair on a config object so that, e.g., the "Set" column
|
||||
// could *display* `set.name` while sorting by `set.releaseDate`. We replicate
|
||||
// that mapping here as two enums (one per supported game), each variant naming
|
||||
// a sort key. The free functions below run a stable sort over a vector using
|
||||
// the same per-type rules as the original `byField`:
|
||||
//
|
||||
// * strings -> case-insensitive (lowercased) `<` / `>`
|
||||
// * numbers -> direct `<` / `>`
|
||||
// * booleans -> direct `<` / `>` (so false < true; unset flags first when asc)
|
||||
//
|
||||
// Stable sort matches the JS `Array.prototype.sort` guarantee from ES2019; the
|
||||
// UI relies on it so successive clicks on different columns compose predictably
|
||||
// (e.g. sort by name, then by set => grouped by set, name-sorted within each).
|
||||
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// One value per *real* (non-spacer) column of the Magic table, in the same
|
||||
// order as the Magic tableFields list.
|
||||
enum class MagicSortColumn {
|
||||
Name,
|
||||
SetReleaseDate, // value column shows set.name, sort key is set.releaseDate
|
||||
Language,
|
||||
Condition,
|
||||
Amount,
|
||||
Foil,
|
||||
Signed,
|
||||
Altered,
|
||||
Note,
|
||||
};
|
||||
|
||||
// Pokemon equivalent (PokemonTable.tsx tableFields). Adds Holo + FirstEdition,
|
||||
// drops Foil.
|
||||
enum class PokemonSortColumn {
|
||||
Name,
|
||||
SetReleaseDate,
|
||||
Language,
|
||||
Condition,
|
||||
Amount,
|
||||
Holo,
|
||||
FirstEdition,
|
||||
Signed,
|
||||
Altered,
|
||||
Note,
|
||||
};
|
||||
|
||||
// Stable in-place sort. `ascending=false` runs the same comparator with
|
||||
// inverted sign, matching `byField(field, asc)` semantics.
|
||||
void sortMagicCards(std::vector<MagicCard>& cards, MagicSortColumn column,
|
||||
bool ascending);
|
||||
void sortPokemonCards(std::vector<PokemonCard>& cards, PokemonSortColumn column,
|
||||
bool ascending);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,126 @@
|
||||
#pragma once
|
||||
|
||||
// CollectionService<TCard>: high-level CRUD over the per-game collection.
|
||||
// Header-only template that operates on the ICollectionRepository<T> port and
|
||||
// delegates image cleanup to an IImageStore on remove.
|
||||
//
|
||||
// This is the C++ equivalent of the Rust generic helpers in
|
||||
// `templates/card_service_templates.rs` (add_entry_to_collection,
|
||||
// update_entry_in_collection, get_entry_by_id, delete_entry_by_id, ...).
|
||||
//
|
||||
// Each TCard must expose `std::uint32_t id` and `std::vector<std::string> images`.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/ports/ICollectionRepository.hpp"
|
||||
#include "ccm/ports/IImageStore.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
template <typename TCard>
|
||||
class CollectionService {
|
||||
public:
|
||||
using Map = std::map<std::uint32_t, TCard>;
|
||||
|
||||
CollectionService(ICollectionRepository<TCard>& repo, IImageStore& imageStore)
|
||||
: repo_(repo), imageStore_(imageStore) {}
|
||||
|
||||
Result<std::vector<TCard>> list(Game game) {
|
||||
auto loaded = repo_.load(game);
|
||||
if (!loaded) return Result<std::vector<TCard>>::err(loaded.error());
|
||||
Map map = std::move(loaded).value();
|
||||
std::vector<TCard> out;
|
||||
out.reserve(map.size());
|
||||
for (auto& [id, card] : map) {
|
||||
(void)id;
|
||||
out.push_back(std::move(card));
|
||||
}
|
||||
return Result<std::vector<TCard>>::ok(std::move(out));
|
||||
}
|
||||
|
||||
[[nodiscard]] static std::uint32_t nextId(const Map& map) noexcept {
|
||||
if (map.empty()) return 0;
|
||||
return map.rbegin()->first + 1;
|
||||
}
|
||||
|
||||
// Add a card. The `id` field on the input is overwritten with the next
|
||||
// free id (matching the Rust HashMap-keyed behavior).
|
||||
Result<std::uint32_t> add(Game game, TCard card) {
|
||||
auto loaded = repo_.load(game);
|
||||
if (!loaded) return Result<std::uint32_t>::err(loaded.error());
|
||||
Map map = std::move(loaded).value();
|
||||
const std::uint32_t newId = nextId(map);
|
||||
card.id = newId;
|
||||
map.emplace(newId, std::move(card));
|
||||
auto saved = repo_.save(game, map);
|
||||
if (!saved) return Result<std::uint32_t>::err(saved.error());
|
||||
return Result<std::uint32_t>::ok(newId);
|
||||
}
|
||||
|
||||
// Update an existing entry, identified by `card.id`. If the id is not
|
||||
// present, an error is returned.
|
||||
Result<void> update(Game game, TCard card) {
|
||||
auto loaded = repo_.load(game);
|
||||
if (!loaded) return Result<void>::err(loaded.error());
|
||||
Map map = std::move(loaded).value();
|
||||
auto it = map.find(card.id);
|
||||
if (it == map.end()) {
|
||||
return Result<void>::err("Card with id " + std::to_string(card.id) +
|
||||
" not found in collection.");
|
||||
}
|
||||
it->second = std::move(card);
|
||||
return repo_.save(game, map);
|
||||
}
|
||||
|
||||
// Remove the card with the given id. Also deletes any associated images
|
||||
// via the IImageStore (best-effort - image removal failures are logged in
|
||||
// the error string but the card itself is still purged from the JSON).
|
||||
Result<void> remove(Game game, std::uint32_t id) {
|
||||
auto loaded = repo_.load(game);
|
||||
if (!loaded) return Result<void>::err(loaded.error());
|
||||
Map map = std::move(loaded).value();
|
||||
auto it = map.find(id);
|
||||
if (it == map.end()) {
|
||||
return Result<void>::err("Card with id " + std::to_string(id) + " not found.");
|
||||
}
|
||||
std::string imgErr;
|
||||
for (const auto& image : it->second.images) {
|
||||
auto rm = imageStore_.remove(game, image);
|
||||
if (!rm) {
|
||||
if (!imgErr.empty()) imgErr += "; ";
|
||||
imgErr += rm.error();
|
||||
}
|
||||
}
|
||||
map.erase(it);
|
||||
auto saved = repo_.save(game, map);
|
||||
if (!saved) return saved;
|
||||
if (!imgErr.empty()) {
|
||||
return Result<void>::err("Card removed but image cleanup had issues: " + imgErr);
|
||||
}
|
||||
return Result<void>::ok();
|
||||
}
|
||||
|
||||
// Look up a card by id without mutating storage.
|
||||
Result<std::optional<TCard>> findById(Game game, std::uint32_t id) {
|
||||
auto loaded = repo_.load(game);
|
||||
if (!loaded) return Result<std::optional<TCard>>::err(loaded.error());
|
||||
const auto& m = loaded.value();
|
||||
auto it = m.find(id);
|
||||
if (it == m.end()) return Result<std::optional<TCard>>::ok(std::nullopt);
|
||||
return Result<std::optional<TCard>>::ok(it->second);
|
||||
}
|
||||
|
||||
private:
|
||||
ICollectionRepository<TCard>& repo_;
|
||||
IImageStore& imageStore_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
|
||||
// ConfigService: owns the live `Configuration`, persists it to `config.json`
|
||||
// next to the executable. Mirrors `util/config.rs` behavior - a missing file
|
||||
// is auto-created with sensible defaults on first launch.
|
||||
|
||||
#include "ccm/domain/Configuration.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class ConfigService {
|
||||
public:
|
||||
// `configFilePath` is the absolute path to `config.json`. The defaults
|
||||
// applied to a freshly created config use `defaultDataStorage` for the
|
||||
// dataStorage field.
|
||||
ConfigService(IFileSystem& fs,
|
||||
std::filesystem::path configFilePath,
|
||||
std::filesystem::path defaultDataStorage);
|
||||
|
||||
// Load (or create) the configuration. Must be called once at startup.
|
||||
Result<void> initialize();
|
||||
|
||||
[[nodiscard]] const Configuration& current() const noexcept { return current_; }
|
||||
|
||||
// Replace the live configuration and persist immediately.
|
||||
Result<void> store(Configuration cfg);
|
||||
|
||||
private:
|
||||
IFileSystem& fs_;
|
||||
std::filesystem::path path_;
|
||||
std::filesystem::path defaultDataStorage_;
|
||||
Configuration current_{};
|
||||
|
||||
[[nodiscard]] Configuration makeDefault() const;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
// ImageService: applies the established image filename rules and delegates the actual
|
||||
// disk operations to an IImageStore.
|
||||
//
|
||||
// Filename rule (mirrors Rust `magic/card_services.rs` and
|
||||
// `pokemon/card_services.rs`):
|
||||
// - new entry -> "{set}+{name}+{idx}.{ext}"
|
||||
// - existing -> "{id}+{set}+{name}+{idx}.{ext}"
|
||||
//
|
||||
// The "+name+set+name+" convention is preserved byte-for-byte so legacy
|
||||
// collections continue to display correctly when imported.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/ports/IImageStore.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class ImageService {
|
||||
public:
|
||||
explicit ImageService(IImageStore& store);
|
||||
|
||||
// Compute the next image index from the previously stored image filenames
|
||||
// for a card. Implements the same logic as the Rust card_services modules.
|
||||
static std::uint8_t nextImageIndex(const std::vector<std::string>& existingImages);
|
||||
|
||||
// Build the target filename (without extension) for a new image attached
|
||||
// to a card. Pass the card's existing id (0 if not yet stored) and a
|
||||
// `newEntry` flag matching the established semantic.
|
||||
static std::string buildTargetName(bool newEntry,
|
||||
std::uint32_t cardId,
|
||||
const std::string& setName,
|
||||
const std::string& cardName,
|
||||
std::uint8_t index);
|
||||
|
||||
// Convenience: build target name + delegate copy to the IImageStore.
|
||||
Result<std::string> addImage(Game game,
|
||||
const std::filesystem::path& srcPath,
|
||||
bool newEntry,
|
||||
std::uint32_t cardId,
|
||||
const std::string& setName,
|
||||
const std::string& cardName,
|
||||
const std::vector<std::string>& existingImages);
|
||||
|
||||
Result<void> removeImage(Game game, const std::string& imageName);
|
||||
|
||||
// Ensure image filenames for a persisted card include the card id prefix.
|
||||
// This upgrades "create-mode" names (`set+name+idx.ext`) to
|
||||
// `id+set+name+idx.ext` to match the ccm2-compatible naming scheme.
|
||||
Result<std::vector<std::string>> normalizeNamesForPersistedCard(
|
||||
Game game,
|
||||
std::uint32_t cardId,
|
||||
const std::string& setName,
|
||||
const std::string& cardName,
|
||||
const std::vector<std::string>& imageNames);
|
||||
|
||||
[[nodiscard]] std::filesystem::path resolveImagePath(Game game,
|
||||
const std::string& imageName) const;
|
||||
|
||||
private:
|
||||
IImageStore& store_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
// SetService: high-level operations for fetching and caching set data.
|
||||
// Wraps an ISetRepository (cache) and dispatches to the correct ISetSource
|
||||
// based on the active IGameModule.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/ISetRepository.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class SetService {
|
||||
public:
|
||||
explicit SetService(ISetRepository& repo);
|
||||
|
||||
// Register a game module so this service can route fetch requests.
|
||||
// Pointer must remain valid for the lifetime of the SetService.
|
||||
void registerModule(IGameModule* module);
|
||||
|
||||
// Force a fresh fetch from the API for `game`, persist it via the
|
||||
// repository, and return the new list.
|
||||
Result<std::vector<Set>> updateSets(Game game);
|
||||
|
||||
// Cached read; returns an error if no local data exists yet.
|
||||
Result<std::vector<Set>> getSets(Game game);
|
||||
|
||||
private:
|
||||
ISetRepository& repo_;
|
||||
std::unordered_map<Game, IGameModule*> modules_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
// Pure functions for filename munging. Ported from the original `util/fs.rs` so
|
||||
// that file naming on disk stays identical between the two codebases.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// Replace problematic characters so the result is safe to use as a filename
|
||||
// component. Mirrors `format_text_for_fs` in Rust (drops apostrophes/commas,
|
||||
// strips spaces, replaces `:` with `-`, `&` with `And`, `|` with `Or`,
|
||||
// flattens accented vowels). Pure; safe at any call site.
|
||||
std::string formatTextForFs(std::string_view text);
|
||||
|
||||
// Parse the trailing 1- or 2-digit numeric index from a filename. Examples:
|
||||
// "Image1.png" -> 1
|
||||
// "Image22.jpeg" -> 22
|
||||
// Returns 0 on parse failure, matching the optimistic Rust behavior.
|
||||
std::uint8_t parseIndexFromFilename(std::string_view filename) noexcept;
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,150 @@
|
||||
#pragma once
|
||||
|
||||
// A minimal Result<T, E> type used as a sum-type for fallible operations.
|
||||
// Mirrors the Rust `Result<T, &str>` style used in the original codebase.
|
||||
//
|
||||
// We intentionally do not depend on tl::expected or std::expected so this
|
||||
// header stays buildable on every toolchain in our matrix (clang 14+, GCC 11+).
|
||||
|
||||
#include <new>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
template <typename T, typename E = std::string>
|
||||
class Result {
|
||||
public:
|
||||
using value_type = T;
|
||||
using error_type = E;
|
||||
|
||||
static Result ok(T value) { return Result(OkTag{}, std::move(value)); }
|
||||
static Result err(E error) { return Result(ErrTag{}, std::move(error)); }
|
||||
|
||||
Result(const Result& other) : has_value_(other.has_value_) {
|
||||
if (has_value_) {
|
||||
::new (static_cast<void*>(&value_)) T(other.value_);
|
||||
} else {
|
||||
::new (static_cast<void*>(&error_)) E(other.error_);
|
||||
}
|
||||
}
|
||||
|
||||
Result(Result&& other) noexcept : has_value_(other.has_value_) {
|
||||
if (has_value_) {
|
||||
::new (static_cast<void*>(&value_)) T(std::move(other.value_));
|
||||
} else {
|
||||
::new (static_cast<void*>(&error_)) E(std::move(other.error_));
|
||||
}
|
||||
}
|
||||
|
||||
Result& operator=(const Result& other) {
|
||||
if (this == &other) return *this;
|
||||
destroy();
|
||||
has_value_ = other.has_value_;
|
||||
if (has_value_) {
|
||||
::new (static_cast<void*>(&value_)) T(other.value_);
|
||||
} else {
|
||||
::new (static_cast<void*>(&error_)) E(other.error_);
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
Result& operator=(Result&& other) noexcept {
|
||||
if (this == &other) return *this;
|
||||
destroy();
|
||||
has_value_ = other.has_value_;
|
||||
if (has_value_) {
|
||||
::new (static_cast<void*>(&value_)) T(std::move(other.value_));
|
||||
} else {
|
||||
::new (static_cast<void*>(&error_)) E(std::move(other.error_));
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
~Result() { destroy(); }
|
||||
|
||||
[[nodiscard]] bool isOk() const noexcept { return has_value_; }
|
||||
[[nodiscard]] bool isErr() const noexcept { return !has_value_; }
|
||||
explicit operator bool() const noexcept { return has_value_; }
|
||||
|
||||
const T& value() const& {
|
||||
if (!has_value_) throw std::logic_error("Result::value() on err");
|
||||
return value_;
|
||||
}
|
||||
T& value() & {
|
||||
if (!has_value_) throw std::logic_error("Result::value() on err");
|
||||
return value_;
|
||||
}
|
||||
T&& value() && {
|
||||
if (!has_value_) throw std::logic_error("Result::value() on err");
|
||||
return std::move(value_);
|
||||
}
|
||||
|
||||
const E& error() const& {
|
||||
if (has_value_) throw std::logic_error("Result::error() on ok");
|
||||
return error_;
|
||||
}
|
||||
E&& error() && {
|
||||
if (has_value_) throw std::logic_error("Result::error() on ok");
|
||||
return std::move(error_);
|
||||
}
|
||||
|
||||
template <typename U>
|
||||
T valueOr(U&& fallback) const& {
|
||||
return has_value_ ? value_ : static_cast<T>(std::forward<U>(fallback));
|
||||
}
|
||||
|
||||
private:
|
||||
struct OkTag {};
|
||||
struct ErrTag {};
|
||||
|
||||
Result(OkTag, T value) : has_value_(true) {
|
||||
::new (static_cast<void*>(&value_)) T(std::move(value));
|
||||
}
|
||||
Result(ErrTag, E error) : has_value_(false) {
|
||||
::new (static_cast<void*>(&error_)) E(std::move(error));
|
||||
}
|
||||
|
||||
void destroy() noexcept {
|
||||
if (has_value_) {
|
||||
value_.~T();
|
||||
} else {
|
||||
error_.~E();
|
||||
}
|
||||
}
|
||||
|
||||
bool has_value_;
|
||||
union {
|
||||
T value_;
|
||||
E error_;
|
||||
};
|
||||
};
|
||||
|
||||
// Specialization for void-returning fallible operations.
|
||||
template <typename E>
|
||||
class Result<void, E> {
|
||||
public:
|
||||
using value_type = void;
|
||||
using error_type = E;
|
||||
|
||||
static Result ok() { return Result(true, E{}); }
|
||||
static Result err(E error) { return Result(false, std::move(error)); }
|
||||
|
||||
[[nodiscard]] bool isOk() const noexcept { return has_value_; }
|
||||
[[nodiscard]] bool isErr() const noexcept { return !has_value_; }
|
||||
explicit operator bool() const noexcept { return has_value_; }
|
||||
|
||||
const E& error() const& {
|
||||
if (has_value_) throw std::logic_error("Result::error() on ok");
|
||||
return error_;
|
||||
}
|
||||
|
||||
private:
|
||||
Result(bool ok, E error) : has_value_(ok), error_(std::move(error)) {}
|
||||
bool has_value_;
|
||||
E error_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,19 @@
|
||||
#include "ccm/domain/Configuration.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const Configuration& c) {
|
||||
j = nlohmann::json{
|
||||
{"dataStorage", c.dataStorage},
|
||||
{"defaultGame", c.defaultGame},
|
||||
{"theme", c.theme},
|
||||
};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, Configuration& c) {
|
||||
j.at("dataStorage").get_to(c.dataStorage);
|
||||
j.at("defaultGame").get_to(c.defaultGame);
|
||||
c.theme = j.value("theme", Theme::Light);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,139 @@
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
std::string_view to_string(Game g) noexcept {
|
||||
switch (g) {
|
||||
case Game::Magic: return "Magic";
|
||||
case Game::Pokemon: return "Pokemon";
|
||||
}
|
||||
return "Magic";
|
||||
}
|
||||
|
||||
std::string_view to_string(Language l) noexcept {
|
||||
switch (l) {
|
||||
case Language::English: return "English";
|
||||
case Language::German: return "German";
|
||||
case Language::French: return "French";
|
||||
case Language::Spanish: return "Spanish";
|
||||
case Language::Italian: return "Italian";
|
||||
case Language::Chinese: return "Chinese";
|
||||
case Language::Japanese: return "Japanese";
|
||||
case Language::Russian: return "Russian";
|
||||
}
|
||||
return "English";
|
||||
}
|
||||
|
||||
std::string_view to_string(Condition c) noexcept {
|
||||
switch (c) {
|
||||
case Condition::Mint: return "Mint";
|
||||
case Condition::NearMint: return "NearMint";
|
||||
case Condition::Excellent: return "Excellent";
|
||||
case Condition::Good: return "Good";
|
||||
case Condition::LightPlayed: return "LightPlayed";
|
||||
case Condition::Played: return "Played";
|
||||
case Condition::Poor: return "Poor";
|
||||
}
|
||||
return "Mint";
|
||||
}
|
||||
|
||||
std::string_view to_string(Theme t) noexcept {
|
||||
switch (t) {
|
||||
case Theme::Light: return "Light";
|
||||
case Theme::Dark: return "Dark";
|
||||
}
|
||||
return "Light";
|
||||
}
|
||||
|
||||
std::optional<Game> gameFromString(std::string_view s) noexcept {
|
||||
if (s == "Magic") return Game::Magic;
|
||||
if (s == "Pokemon") return Game::Pokemon;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Language> languageFromString(std::string_view s) noexcept {
|
||||
if (s == "English") return Language::English;
|
||||
if (s == "German") return Language::German;
|
||||
if (s == "French") return Language::French;
|
||||
if (s == "Spanish") return Language::Spanish;
|
||||
if (s == "Italian") return Language::Italian;
|
||||
if (s == "Chinese") return Language::Chinese;
|
||||
if (s == "Japanese") return Language::Japanese;
|
||||
if (s == "Russian") return Language::Russian;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Condition> conditionFromString(std::string_view s) noexcept {
|
||||
if (s == "Mint") return Condition::Mint;
|
||||
if (s == "NearMint") return Condition::NearMint;
|
||||
if (s == "Excellent") return Condition::Excellent;
|
||||
if (s == "Good") return Condition::Good;
|
||||
if (s == "LightPlayed") return Condition::LightPlayed;
|
||||
if (s == "Played") return Condition::Played;
|
||||
if (s == "Poor") return Condition::Poor;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Theme> themeFromString(std::string_view s) noexcept {
|
||||
if (s == "Light") return Theme::Light;
|
||||
if (s == "Dark") return Theme::Dark;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::array<Game, 2>& allGames() noexcept {
|
||||
static constexpr std::array<Game, 2> v{Game::Magic, Game::Pokemon};
|
||||
return v;
|
||||
}
|
||||
|
||||
const std::array<Language, 8>& allLanguages() noexcept {
|
||||
static constexpr std::array<Language, 8> v{
|
||||
Language::English, Language::German, Language::French, Language::Spanish,
|
||||
Language::Italian, Language::Chinese, Language::Japanese, Language::Russian
|
||||
};
|
||||
return v;
|
||||
}
|
||||
|
||||
const std::array<Condition, 7>& allConditions() noexcept {
|
||||
static constexpr std::array<Condition, 7> v{
|
||||
Condition::Mint, Condition::NearMint, Condition::Excellent,
|
||||
Condition::Good, Condition::LightPlayed, Condition::Played, Condition::Poor
|
||||
};
|
||||
return v;
|
||||
}
|
||||
|
||||
const std::array<Theme, 2>& allThemes() noexcept {
|
||||
static constexpr std::array<Theme, 2> v{Theme::Light, Theme::Dark};
|
||||
return v;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, Game v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Language v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Condition v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Theme v) { j = std::string(to_string(v)); }
|
||||
|
||||
void from_json(const nlohmann::json& j, Game& v) {
|
||||
auto parsed = gameFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown Game value: " + j.get<std::string>());
|
||||
v = *parsed;
|
||||
}
|
||||
void from_json(const nlohmann::json& j, Language& v) {
|
||||
auto parsed = languageFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown Language value: " + j.get<std::string>());
|
||||
v = *parsed;
|
||||
}
|
||||
void from_json(const nlohmann::json& j, Condition& v) {
|
||||
auto parsed = conditionFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown Condition value: " + j.get<std::string>());
|
||||
v = *parsed;
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, Theme& v) {
|
||||
auto parsed = themeFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown Theme value: " + j.get<std::string>());
|
||||
v = *parsed;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,35 @@
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const MagicCard& c) {
|
||||
j = nlohmann::json{
|
||||
{"id", c.id},
|
||||
{"amount", c.amount},
|
||||
{"name", c.name},
|
||||
{"set", c.set},
|
||||
{"note", c.note},
|
||||
{"images", c.images},
|
||||
{"language", c.language},
|
||||
{"condition", c.condition},
|
||||
{"foil", c.foil},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, MagicCard& c) {
|
||||
j.at("id").get_to(c.id);
|
||||
j.at("amount").get_to(c.amount);
|
||||
j.at("name").get_to(c.name);
|
||||
j.at("set").get_to(c.set);
|
||||
j.at("note").get_to(c.note);
|
||||
j.at("images").get_to(c.images);
|
||||
j.at("language").get_to(c.language);
|
||||
j.at("condition").get_to(c.condition);
|
||||
j.at("foil").get_to(c.foil);
|
||||
j.at("signed").get_to(c.signed_);
|
||||
j.at("altered").get_to(c.altered);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const PokemonCard& c) {
|
||||
j = nlohmann::json{
|
||||
{"id", c.id},
|
||||
{"amount", c.amount},
|
||||
{"name", c.name},
|
||||
{"set", c.set},
|
||||
{"setNo", c.setNo},
|
||||
{"note", c.note},
|
||||
{"images", c.images},
|
||||
{"language", c.language},
|
||||
{"condition", c.condition},
|
||||
{"firstEdition", c.firstEdition},
|
||||
{"holo", c.holo},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, PokemonCard& c) {
|
||||
j.at("id").get_to(c.id);
|
||||
j.at("amount").get_to(c.amount);
|
||||
j.at("name").get_to(c.name);
|
||||
j.at("set").get_to(c.set);
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("note").get_to(c.note);
|
||||
j.at("images").get_to(c.images);
|
||||
j.at("language").get_to(c.language);
|
||||
j.at("condition").get_to(c.condition);
|
||||
j.at("firstEdition").get_to(c.firstEdition);
|
||||
j.at("holo").get_to(c.holo);
|
||||
j.at("signed").get_to(c.signed_);
|
||||
j.at("altered").get_to(c.altered);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,19 @@
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const Set& s) {
|
||||
j = nlohmann::json{
|
||||
{"id", s.id},
|
||||
{"name", s.name},
|
||||
{"releaseDate", s.releaseDate},
|
||||
};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, Set& s) {
|
||||
j.at("id").get_to(s.id);
|
||||
j.at("name").get_to(s.name);
|
||||
j.at("releaseDate").get_to(s.releaseDate);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,100 @@
|
||||
#include "ccm/games/magic/MagicCardPreviewSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// Percent-encode all bytes that are not unreserved per RFC 3986
|
||||
// (A-Z / a-z / 0-9 / - . _ ~). Spaces become %20, quotes become %22, etc.
|
||||
// Used to keep Scryfall's `q=...` parameter syntactically valid through cpr,
|
||||
// which does not URL-encode the URL string we hand it.
|
||||
std::string urlEncode(std::string_view in) {
|
||||
std::ostringstream out;
|
||||
out.fill('0');
|
||||
out << std::hex << std::uppercase;
|
||||
for (unsigned char c : in) {
|
||||
const bool unreserved =
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '.' || c == '_' || c == '~';
|
||||
if (unreserved) {
|
||||
out << static_cast<char>(c);
|
||||
} else {
|
||||
out << '%';
|
||||
out.width(2);
|
||||
out << static_cast<unsigned int>(c);
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
// Apply the same name massaging as the legacy query path before sending.
|
||||
std::string sanitizeName(std::string_view name) {
|
||||
std::string s(name);
|
||||
std::string::size_type pos = 0;
|
||||
while ((pos = s.find('&', pos)) != std::string::npos) {
|
||||
s.replace(pos, 1, "and");
|
||||
pos += 3;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MagicCardPreviewSource::MagicCardPreviewSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
std::string MagicCardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
std::string_view setId) {
|
||||
// Build the unencoded query first so the output matches what Scryfall
|
||||
// would parse: name:"<sanitized>" AND set:<setId>
|
||||
const std::string sanitized = sanitizeName(name);
|
||||
std::string query = "name:\"";
|
||||
query += sanitized;
|
||||
query += "\" AND set:";
|
||||
query += std::string(setId);
|
||||
return std::string("https://api.scryfall.com/cards/search?q=") + urlEncode(query);
|
||||
}
|
||||
|
||||
Result<std::string> MagicCardPreviewSource::parseResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
return Result<std::string>::err("Scryfall response missing 'data' array.");
|
||||
}
|
||||
const auto& data = j.at("data");
|
||||
if (data.empty()) {
|
||||
return Result<std::string>::err("Scryfall returned no matching cards.");
|
||||
}
|
||||
const auto& first = data.at(0);
|
||||
if (!first.contains("image_uris") || !first.at("image_uris").is_object()) {
|
||||
// Double-faced cards expose image_uris on each face; there is no
|
||||
// fallback for this and surfaces it as "no preview".
|
||||
return Result<std::string>::err("Card has no top-level image_uris.");
|
||||
}
|
||||
const auto& uris = first.at("image_uris");
|
||||
if (!uris.contains("normal") || !uris.at("normal").is_string()) {
|
||||
return Result<std::string>::err("Card has no 'normal' image variant.");
|
||||
}
|
||||
return Result<std::string>::ok(uris.at("normal").get<std::string>());
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::string>::err(std::string("Scryfall JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string> MagicCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view /*setNo*/) {
|
||||
const std::string url = buildSearchUrl(name, setId);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return Result<std::string>::err(resp.error());
|
||||
return parseResponse(resp.value());
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "ccm/games/magic/MagicGameModule.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
MagicGameModule::MagicGameModule(IHttpClient& http)
|
||||
: setSource_(http), previewSource_(http) {}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,48 @@
|
||||
#include "ccm/games/magic/MagicSetSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
MagicSetSource::MagicSetSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
Result<std::vector<Set>> MagicSetSource::parseResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
return Result<std::vector<Set>>::err("Scryfall response missing 'data' array.");
|
||||
}
|
||||
std::vector<Set> out;
|
||||
out.reserve(j.at("data").size());
|
||||
for (const auto& entry : j.at("data")) {
|
||||
// Filter out digital-only sets exactly like the Rust code.
|
||||
const bool digital = entry.value("digital", false);
|
||||
if (digital) continue;
|
||||
|
||||
Set s;
|
||||
s.id = entry.value("code", "");
|
||||
s.name = entry.value("name", "");
|
||||
// Scryfall returns "released_at" as YYYY-MM-DD; persisted data stores YYYY/MM/DD.
|
||||
std::string releasedAt = entry.value("released_at", "");
|
||||
std::replace(releasedAt.begin(), releasedAt.end(), '-', '/');
|
||||
s.releaseDate = std::move(releasedAt);
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(std::string("Scryfall JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> MagicSetSource::fetchAll() {
|
||||
auto resp = http_.get(kEndpoint);
|
||||
if (!resp) return Result<std::vector<Set>>::err(resp.error());
|
||||
return parseResponse(resp.value());
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,110 @@
|
||||
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// RFC 3986 percent-encoder for the search-query payload. Same rules as the
|
||||
// Magic implementation; kept private so the two can drift independently if a
|
||||
// future API requires it.
|
||||
std::string urlEncode(std::string_view in) {
|
||||
std::ostringstream out;
|
||||
out.fill('0');
|
||||
out << std::hex << std::uppercase;
|
||||
for (unsigned char c : in) {
|
||||
const bool unreserved =
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '.' || c == '_' || c == '~';
|
||||
if (unreserved) {
|
||||
out << static_cast<char>(c);
|
||||
} else {
|
||||
out << '%';
|
||||
out.width(2);
|
||||
out << static_cast<unsigned int>(c);
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
// Strip everything after the first '/' in a Pokemon collector number.
|
||||
// The Pokemon TCG API expects `number:"4"`, but cards are commonly stored as
|
||||
// `4/102`. Without this, no API match is found.
|
||||
std::string normalizeNumber(std::string_view setNo) {
|
||||
std::string s(setNo);
|
||||
const auto slash = s.find('/');
|
||||
if (slash != std::string::npos) {
|
||||
s = s.substr(0, slash);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
// Build the unencoded query first so the output matches what the Pokemon
|
||||
// TCG search syntax expects: name:"<name>" set.id:<setId> number:<num>.
|
||||
std::string query = "name:\"";
|
||||
query += std::string(name);
|
||||
query += "\"";
|
||||
if (!setId.empty()) {
|
||||
query += " set.id:";
|
||||
query += std::string(setId);
|
||||
}
|
||||
const std::string num = normalizeNumber(setNo);
|
||||
if (!num.empty()) {
|
||||
query += " number:";
|
||||
query += num;
|
||||
}
|
||||
return std::string("https://api.pokemontcg.io/v2/cards?q=") + urlEncode(query);
|
||||
}
|
||||
|
||||
Result<std::string> PokemonCardPreviewSource::parseResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
return Result<std::string>::err("Pokemon TCG response missing 'data' array.");
|
||||
}
|
||||
const auto& data = j.at("data");
|
||||
if (data.empty()) {
|
||||
return Result<std::string>::err("Pokemon TCG returned no matching cards.");
|
||||
}
|
||||
const auto& first = data.at(0);
|
||||
if (!first.contains("images") || !first.at("images").is_object()) {
|
||||
return Result<std::string>::err("Card has no 'images' object.");
|
||||
}
|
||||
const auto& images = first.at("images");
|
||||
if (images.contains("large") && images.at("large").is_string()) {
|
||||
return Result<std::string>::ok(images.at("large").get<std::string>());
|
||||
}
|
||||
if (images.contains("small") && images.at("small").is_string()) {
|
||||
return Result<std::string>::ok(images.at("small").get<std::string>());
|
||||
}
|
||||
return Result<std::string>::err("Card has no 'large' or 'small' image variant.");
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::string>::err(
|
||||
std::string("Pokemon TCG JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string> PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
const std::string url = buildSearchUrl(name, setId, setNo);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return Result<std::string>::err(resp.error());
|
||||
return parseResponse(resp.value());
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "ccm/games/pokemon/PokemonGameModule.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
PokemonGameModule::PokemonGameModule(IHttpClient& http)
|
||||
: setSource_(http), previewSource_(http) {}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,45 @@
|
||||
#include "ccm/games/pokemon/PokemonSetSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
Result<std::vector<Set>> PokemonSetSource::parseResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
"Pokemon TCG API response missing 'data' array.");
|
||||
}
|
||||
std::vector<Set> out;
|
||||
out.reserve(j.at("data").size());
|
||||
for (const auto& entry : j.at("data")) {
|
||||
Set s;
|
||||
s.id = entry.value("id", "");
|
||||
s.name = entry.value("name", "");
|
||||
// Pokemon TCG API already returns "releaseDate" in YYYY/MM/DD;
|
||||
// no separator rewrite needed (cf. Scryfall's "released_at").
|
||||
s.releaseDate = entry.value("releaseDate", "");
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
std::string("Pokemon TCG JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> PokemonSetSource::fetchAll() {
|
||||
auto resp = http_.get(kEndpoint);
|
||||
if (!resp) return Result<std::vector<Set>>::err(resp.error());
|
||||
return parseResponse(resp.value());
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,30 @@
|
||||
#include "ccm/infra/CprHttpClient.hpp"
|
||||
|
||||
#include <cpr/cpr.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout) : timeout_(timeout) {}
|
||||
|
||||
Result<std::string> CprHttpClient::get(std::string_view url) {
|
||||
cpr::Response r = cpr::Get(
|
||||
cpr::Url{std::string(url)},
|
||||
cpr::Timeout{timeout_},
|
||||
// Identify ourselves; some APIs rate-limit unknown agents harshly.
|
||||
cpr::Header{{"User-Agent", "card-collection-manager-3/0.1"},
|
||||
{"Accept", "application/json"}}
|
||||
);
|
||||
|
||||
if (r.error) {
|
||||
return Result<std::string>::err("HTTP error: " + r.error.message);
|
||||
}
|
||||
if (r.status_code < 200 || r.status_code >= 300) {
|
||||
return Result<std::string>::err(
|
||||
"HTTP " + std::to_string(r.status_code) + " from " + std::string(url));
|
||||
}
|
||||
return Result<std::string>::ok(std::move(r.text));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,41 @@
|
||||
#include "ccm/infra/JsonSetRepository.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
JsonSetRepository::JsonSetRepository(IFileSystem& fs, ConfigService& config, DirNameFn dirName)
|
||||
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
|
||||
|
||||
fs::path JsonSetRepository::setsPath(Game game) const {
|
||||
return fs::path(config_.current().dataStorage) / dirName_(game) / "sets.json";
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> JsonSetRepository::load(Game game) {
|
||||
const auto p = setsPath(game);
|
||||
if (!fs_.exists(p)) {
|
||||
return Result<std::vector<Set>>::err("Set list not yet downloaded for this game.");
|
||||
}
|
||||
auto text = fs_.readText(p);
|
||||
if (!text) return Result<std::vector<Set>>::err(text.error());
|
||||
try {
|
||||
auto j = nlohmann::json::parse(text.value());
|
||||
return Result<std::vector<Set>>::ok(j.get<std::vector<Set>>());
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(std::string("sets.json parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<void> JsonSetRepository::save(Game game, const std::vector<Set>& sets) {
|
||||
const auto p = setsPath(game);
|
||||
auto dir = fs_.ensureDirectory(p.parent_path());
|
||||
if (!dir) return dir;
|
||||
const nlohmann::json j = sets;
|
||||
return fs_.writeText(p, j.dump(2));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,43 @@
|
||||
#include "ccm/infra/LocalImageStore.hpp"
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
LocalImageStore::LocalImageStore(IFileSystem& fs, ConfigService& config, DirNameFn dirName)
|
||||
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
|
||||
|
||||
fs::path LocalImageStore::gameImageDir(Game game) const {
|
||||
return fs::path(config_.current().dataStorage) / dirName_(game) / "images";
|
||||
}
|
||||
|
||||
Result<std::string> LocalImageStore::copyIn(Game game,
|
||||
const fs::path& srcPath,
|
||||
const std::string& targetName) {
|
||||
const auto dir = gameImageDir(game);
|
||||
auto ensure = fs_.ensureDirectory(dir);
|
||||
if (!ensure) return Result<std::string>::err(ensure.error());
|
||||
|
||||
// Preserve the source's extension - the original Rust code does the same.
|
||||
std::string ext = srcPath.extension().string();
|
||||
std::string finalName = targetName + ext;
|
||||
const auto dest = dir / finalName;
|
||||
|
||||
auto cp = fs_.copyFile(srcPath, dest, /*overwrite=*/true);
|
||||
if (!cp) return Result<std::string>::err(cp.error());
|
||||
return Result<std::string>::ok(std::move(finalName));
|
||||
}
|
||||
|
||||
Result<void> LocalImageStore::remove(Game game, const std::string& imageName) {
|
||||
const auto p = gameImageDir(game) / imageName;
|
||||
if (!fs_.exists(p)) return Result<void>::ok(); // be forgiving on stale entries
|
||||
return fs_.remove(p);
|
||||
}
|
||||
|
||||
fs::path LocalImageStore::resolvePath(Game game, const std::string& imageName) const {
|
||||
return gameImageDir(game) / imageName;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,88 @@
|
||||
#include "ccm/infra/StdFileSystem.hpp"
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <system_error>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
bool StdFileSystem::exists(const fs::path& p) const {
|
||||
std::error_code ec;
|
||||
return fs::exists(p, ec);
|
||||
}
|
||||
|
||||
bool StdFileSystem::isDirectory(const fs::path& p) const {
|
||||
std::error_code ec;
|
||||
return fs::is_directory(p, ec);
|
||||
}
|
||||
|
||||
Result<void> StdFileSystem::ensureDirectory(const fs::path& p) {
|
||||
std::error_code ec;
|
||||
if (fs::exists(p, ec)) {
|
||||
if (fs::is_directory(p, ec)) return Result<void>::ok();
|
||||
return Result<void>::err("Path exists but is not a directory: " + p.string());
|
||||
}
|
||||
fs::create_directories(p, ec);
|
||||
if (ec) return Result<void>::err("create_directories failed: " + ec.message());
|
||||
return Result<void>::ok();
|
||||
}
|
||||
|
||||
Result<std::string> StdFileSystem::readText(const fs::path& p) {
|
||||
std::ifstream in(p, std::ios::binary);
|
||||
if (!in) return Result<std::string>::err("Unable to open file: " + p.string());
|
||||
std::ostringstream ss;
|
||||
ss << in.rdbuf();
|
||||
if (!in && !in.eof()) return Result<std::string>::err("Read error on: " + p.string());
|
||||
return Result<std::string>::ok(ss.str());
|
||||
}
|
||||
|
||||
Result<void> StdFileSystem::writeText(const fs::path& p, std::string_view contents) {
|
||||
std::error_code ec;
|
||||
if (p.has_parent_path()) {
|
||||
fs::create_directories(p.parent_path(), ec);
|
||||
if (ec) return Result<void>::err("create_directories failed: " + ec.message());
|
||||
}
|
||||
std::ofstream out(p, std::ios::binary | std::ios::trunc);
|
||||
if (!out) return Result<void>::err("Unable to create file: " + p.string());
|
||||
out.write(contents.data(), static_cast<std::streamsize>(contents.size()));
|
||||
if (!out) return Result<void>::err("Write error on: " + p.string());
|
||||
return Result<void>::ok();
|
||||
}
|
||||
|
||||
Result<void> StdFileSystem::copyFile(const fs::path& from, const fs::path& to, bool overwrite) {
|
||||
std::error_code ec;
|
||||
if (to.has_parent_path()) {
|
||||
fs::create_directories(to.parent_path(), ec);
|
||||
if (ec) return Result<void>::err("create_directories failed: " + ec.message());
|
||||
ec.clear();
|
||||
}
|
||||
const auto opt = overwrite ? fs::copy_options::overwrite_existing
|
||||
: fs::copy_options::none;
|
||||
fs::copy_file(from, to, opt, ec);
|
||||
if (ec) return Result<void>::err("copy_file failed: " + ec.message());
|
||||
return Result<void>::ok();
|
||||
}
|
||||
|
||||
Result<void> StdFileSystem::remove(const fs::path& p) {
|
||||
std::error_code ec;
|
||||
fs::remove(p, ec);
|
||||
if (ec) return Result<void>::err("remove failed: " + ec.message());
|
||||
return Result<void>::ok();
|
||||
}
|
||||
|
||||
Result<std::vector<fs::path>> StdFileSystem::listDirectory(const fs::path& p) {
|
||||
std::error_code ec;
|
||||
if (!fs::is_directory(p, ec)) {
|
||||
return Result<std::vector<fs::path>>::err("Not a directory: " + p.string());
|
||||
}
|
||||
std::vector<fs::path> out;
|
||||
for (const auto& entry : fs::directory_iterator(p, ec)) {
|
||||
out.push_back(entry.path());
|
||||
}
|
||||
if (ec) return Result<std::vector<fs::path>>::err("directory_iterator: " + ec.message());
|
||||
return Result<std::vector<fs::path>>::ok(std::move(out));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,66 @@
|
||||
#include "ccm/services/CardFilter.hpp"
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
namespace {
|
||||
|
||||
// Plain ASCII tolower, same approach as CardSorter::asciiLower. The old JS path used
|
||||
// String.prototype.toLowerCase() which on the realistic ASCII-only data set
|
||||
// (English/German set names, Scryfall-fed labels, integer amounts) behaves
|
||||
// identically.
|
||||
std::string asciiLower(std::string_view s) {
|
||||
std::string out;
|
||||
out.reserve(s.size());
|
||||
for (char c : s) {
|
||||
out.push_back(static_cast<char>(
|
||||
std::tolower(static_cast<unsigned char>(c))));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool containsLower(std::string_view haystack, std::string_view needleLower) {
|
||||
return asciiLower(haystack).find(needleLower) != std::string::npos;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool matchesMagicFilter(const MagicCard& card, std::string_view filter) {
|
||||
// `""`.includes(filter) is true for filter == "" in JS; mirror that so the
|
||||
// panel does not need a separate "no filter" branch.
|
||||
if (filter.empty()) return true;
|
||||
|
||||
const std::string needle = asciiLower(filter);
|
||||
|
||||
// Order mirrors the MtgTable.tsx tableFields list (minus the boolean
|
||||
// flag columns, which `applyFilter` skips). Stops on first match for the
|
||||
// same short-circuit behavior as the JS for-loop with `break`.
|
||||
if (containsLower(card.name, needle)) return true;
|
||||
if (containsLower(card.set.name, needle)) return true;
|
||||
if (containsLower(to_string(card.language), needle)) return true;
|
||||
if (containsLower(to_string(card.condition), needle)) return true;
|
||||
if (containsLower(std::to_string(card.amount), needle)) return true;
|
||||
if (containsLower(card.note, needle)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool matchesPokemonFilter(const PokemonCard& card, std::string_view filter) {
|
||||
if (filter.empty()) return true;
|
||||
|
||||
const std::string needle = asciiLower(filter);
|
||||
|
||||
if (containsLower(card.name, needle)) return true;
|
||||
if (containsLower(card.set.name, needle)) return true;
|
||||
if (containsLower(card.setNo, needle)) return true;
|
||||
if (containsLower(to_string(card.language), needle)) return true;
|
||||
if (containsLower(to_string(card.condition), needle)) return true;
|
||||
if (containsLower(std::to_string(card.amount), needle)) return true;
|
||||
if (containsLower(card.note, needle)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,34 @@
|
||||
#include "ccm/services/CardPreviewService.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
CardPreviewService::CardPreviewService(IHttpClient& http) : http_(http) {}
|
||||
|
||||
void CardPreviewService::registerModule(IGameModule& module) {
|
||||
if (auto* src = module.cardPreviewSource(); src != nullptr) {
|
||||
sources_[module.id()] = src;
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
|
||||
std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
auto it = sources_.find(game);
|
||||
if (it == sources_.end() || it->second == nullptr) {
|
||||
return Result<std::string>::err("No preview source registered for this game.");
|
||||
}
|
||||
auto url = it->second->fetchImageUrl(name, setId, setNo);
|
||||
if (!url) return Result<std::string>::err(url.error());
|
||||
auto bytes = http_.get(url.value());
|
||||
if (!bytes) return Result<std::string>::err(bytes.error());
|
||||
return Result<std::string>::ok(std::move(bytes).value());
|
||||
}
|
||||
|
||||
Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view url) {
|
||||
auto bytes = http_.get(url);
|
||||
if (!bytes) return Result<std::string>::err(bytes.error());
|
||||
return Result<std::string>::ok(std::move(bytes).value());
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,173 @@
|
||||
#include "ccm/services/CardSorter.hpp"
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
namespace {
|
||||
|
||||
// The comparator lowercases strings before compare via String.toLowerCase()-style behavior.
|
||||
// We use ASCII-only tolower; the original TS app processed the same fields and
|
||||
// never special-cased Unicode either, so this stays byte-compatible for the
|
||||
// realistic data set (English/German/etc. names already lowercase identically).
|
||||
std::string asciiLower(std::string_view s) {
|
||||
std::string out;
|
||||
out.reserve(s.size());
|
||||
for (char c : s) {
|
||||
out.push_back(static_cast<char>(
|
||||
std::tolower(static_cast<unsigned char>(c))));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Wrap a less-than predicate so that ascending=false flips its meaning,
|
||||
// mirroring `byField(field, asc)` in TableTemplate.tsx.
|
||||
template <typename Less>
|
||||
auto directional(Less less, bool ascending) {
|
||||
return [less, ascending](const auto& a, const auto& b) {
|
||||
return ascending ? less(a, b) : less(b, a);
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void sortMagicCards(std::vector<MagicCard>& cards, MagicSortColumn column,
|
||||
bool ascending) {
|
||||
switch (column) {
|
||||
case MagicSortColumn::Name:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const MagicCard& a, const MagicCard& b) {
|
||||
return asciiLower(a.name) < asciiLower(b.name);
|
||||
}, ascending));
|
||||
break;
|
||||
case MagicSortColumn::SetReleaseDate:
|
||||
// Release dates are stored as "YYYY/MM/DD" so plain lexicographic
|
||||
// compare is chronological. The legacy JS path lowercased strings anyway; we
|
||||
// do the same for parity even though digits/'/' are unaffected.
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const MagicCard& a, const MagicCard& b) {
|
||||
return asciiLower(a.set.releaseDate) <
|
||||
asciiLower(b.set.releaseDate);
|
||||
}, ascending));
|
||||
break;
|
||||
case MagicSortColumn::Language:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const MagicCard& a, const MagicCard& b) {
|
||||
return asciiLower(to_string(a.language)) <
|
||||
asciiLower(to_string(b.language));
|
||||
}, ascending));
|
||||
break;
|
||||
case MagicSortColumn::Condition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const MagicCard& a, const MagicCard& b) {
|
||||
return asciiLower(to_string(a.condition)) <
|
||||
asciiLower(to_string(b.condition));
|
||||
}, ascending));
|
||||
break;
|
||||
case MagicSortColumn::Amount:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const MagicCard& a, const MagicCard& b) {
|
||||
return a.amount < b.amount;
|
||||
}, ascending));
|
||||
break;
|
||||
case MagicSortColumn::Foil:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const MagicCard& a, const MagicCard& b) {
|
||||
return a.foil < b.foil; // false < true (asc puts unset first)
|
||||
}, ascending));
|
||||
break;
|
||||
case MagicSortColumn::Signed:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const MagicCard& a, const MagicCard& b) {
|
||||
return a.signed_ < b.signed_;
|
||||
}, ascending));
|
||||
break;
|
||||
case MagicSortColumn::Altered:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const MagicCard& a, const MagicCard& b) {
|
||||
return a.altered < b.altered;
|
||||
}, ascending));
|
||||
break;
|
||||
case MagicSortColumn::Note:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const MagicCard& a, const MagicCard& b) {
|
||||
return asciiLower(a.note) < asciiLower(b.note);
|
||||
}, ascending));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void sortPokemonCards(std::vector<PokemonCard>& cards, PokemonSortColumn column,
|
||||
bool ascending) {
|
||||
switch (column) {
|
||||
case PokemonSortColumn::Name:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return asciiLower(a.name) < asciiLower(b.name);
|
||||
}, ascending));
|
||||
break;
|
||||
case PokemonSortColumn::SetReleaseDate:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return asciiLower(a.set.releaseDate) <
|
||||
asciiLower(b.set.releaseDate);
|
||||
}, ascending));
|
||||
break;
|
||||
case PokemonSortColumn::Language:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return asciiLower(to_string(a.language)) <
|
||||
asciiLower(to_string(b.language));
|
||||
}, ascending));
|
||||
break;
|
||||
case PokemonSortColumn::Condition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return asciiLower(to_string(a.condition)) <
|
||||
asciiLower(to_string(b.condition));
|
||||
}, ascending));
|
||||
break;
|
||||
case PokemonSortColumn::Amount:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return a.amount < b.amount;
|
||||
}, ascending));
|
||||
break;
|
||||
case PokemonSortColumn::Holo:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return a.holo < b.holo;
|
||||
}, ascending));
|
||||
break;
|
||||
case PokemonSortColumn::FirstEdition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return a.firstEdition < b.firstEdition;
|
||||
}, ascending));
|
||||
break;
|
||||
case PokemonSortColumn::Signed:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return a.signed_ < b.signed_;
|
||||
}, ascending));
|
||||
break;
|
||||
case PokemonSortColumn::Altered:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return a.altered < b.altered;
|
||||
}, ascending));
|
||||
break;
|
||||
case PokemonSortColumn::Note:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return asciiLower(a.note) < asciiLower(b.note);
|
||||
}, ascending));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,47 @@
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
ConfigService::ConfigService(IFileSystem& fs,
|
||||
std::filesystem::path configFilePath,
|
||||
std::filesystem::path defaultDataStorage)
|
||||
: fs_(fs),
|
||||
path_(std::move(configFilePath)),
|
||||
defaultDataStorage_(std::move(defaultDataStorage)) {}
|
||||
|
||||
Configuration ConfigService::makeDefault() const {
|
||||
Configuration c;
|
||||
// generic_string() always uses '/' separators - keeps the value portable
|
||||
// across Windows / Unix and round-trip-friendly for tests and JSON.
|
||||
c.dataStorage = defaultDataStorage_.generic_string();
|
||||
c.defaultGame = Game::Magic;
|
||||
return c;
|
||||
}
|
||||
|
||||
Result<void> ConfigService::initialize() {
|
||||
if (!fs_.exists(path_)) {
|
||||
current_ = makeDefault();
|
||||
return store(current_);
|
||||
}
|
||||
auto text = fs_.readText(path_);
|
||||
if (!text) return Result<void>::err(text.error());
|
||||
try {
|
||||
auto j = nlohmann::json::parse(text.value());
|
||||
current_ = j.get<Configuration>();
|
||||
} catch (const std::exception& e) {
|
||||
return Result<void>::err(std::string("config.json parse error: ") + e.what());
|
||||
}
|
||||
return Result<void>::ok();
|
||||
}
|
||||
|
||||
Result<void> ConfigService::store(Configuration cfg) {
|
||||
current_ = std::move(cfg);
|
||||
const nlohmann::json j = current_;
|
||||
return fs_.writeText(path_, j.dump(2));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,120 @@
|
||||
#include "ccm/services/ImageService.hpp"
|
||||
|
||||
#include "ccm/util/FsNames.hpp"
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
ImageService::ImageService(IImageStore& store) : store_(store) {}
|
||||
|
||||
std::uint8_t ImageService::nextImageIndex(const std::vector<std::string>& existingImages) {
|
||||
if (existingImages.empty()) return 0;
|
||||
const std::string& last = existingImages.back();
|
||||
// Preserve the compatibility shim for legacy image filenames that
|
||||
// contain "IMG_FRONT"/"IMG_BACK" markers - those start a fresh index.
|
||||
if (last.find("IMG_FRONT") != std::string::npos ||
|
||||
last.find("IMG_BACK") != std::string::npos) {
|
||||
return 0;
|
||||
}
|
||||
const std::uint8_t parsed = parseIndexFromFilename(last);
|
||||
// Saturating +1 since we hand back uint8 just like the Rust version.
|
||||
return parsed == 255 ? 255 : static_cast<std::uint8_t>(parsed + 1);
|
||||
}
|
||||
|
||||
std::string ImageService::buildTargetName(bool newEntry,
|
||||
std::uint32_t cardId,
|
||||
const std::string& setName,
|
||||
const std::string& cardName,
|
||||
std::uint8_t index) {
|
||||
const std::string set = formatTextForFs(setName);
|
||||
const std::string card = formatTextForFs(cardName);
|
||||
if (newEntry) {
|
||||
return set + "+" + card + "+" + std::to_string(static_cast<int>(index));
|
||||
}
|
||||
return std::to_string(cardId) + "+" + set + "+" + card + "+" +
|
||||
std::to_string(static_cast<int>(index));
|
||||
}
|
||||
|
||||
Result<std::string> ImageService::addImage(Game game,
|
||||
const std::filesystem::path& srcPath,
|
||||
bool newEntry,
|
||||
std::uint32_t cardId,
|
||||
const std::string& setName,
|
||||
const std::string& cardName,
|
||||
const std::vector<std::string>& existingImages) {
|
||||
const auto idx = nextImageIndex(existingImages);
|
||||
const auto target = buildTargetName(newEntry, cardId, setName, cardName, idx);
|
||||
return store_.copyIn(game, srcPath, target);
|
||||
}
|
||||
|
||||
Result<void> ImageService::removeImage(Game game, const std::string& imageName) {
|
||||
return store_.remove(game, imageName);
|
||||
}
|
||||
|
||||
Result<std::vector<std::string>> ImageService::normalizeNamesForPersistedCard(
|
||||
Game game,
|
||||
std::uint32_t cardId,
|
||||
const std::string& setName,
|
||||
const std::string& cardName,
|
||||
const std::vector<std::string>& imageNames) {
|
||||
const std::string idPrefix = std::to_string(cardId) + "+";
|
||||
std::vector<std::string> normalized = imageNames;
|
||||
struct RenameOp {
|
||||
std::string oldName;
|
||||
std::string newName;
|
||||
};
|
||||
std::vector<RenameOp> ops;
|
||||
ops.reserve(imageNames.size());
|
||||
|
||||
for (std::size_t i = 0; i < imageNames.size(); ++i) {
|
||||
const std::string& oldName = imageNames[i];
|
||||
if (oldName.starts_with(idPrefix)) {
|
||||
continue;
|
||||
}
|
||||
const std::uint8_t idx = parseIndexFromFilename(oldName);
|
||||
const std::filesystem::path oldPath(oldName);
|
||||
const std::string ext = oldPath.extension().string();
|
||||
const std::string newBase = buildTargetName(false, cardId, setName, cardName, idx);
|
||||
const std::string newName = newBase + ext;
|
||||
if (newName == oldName) {
|
||||
continue;
|
||||
}
|
||||
ops.push_back({oldName, newName});
|
||||
normalized[i] = newName;
|
||||
}
|
||||
|
||||
if (ops.empty()) {
|
||||
return Result<std::vector<std::string>>::ok(std::move(normalized));
|
||||
}
|
||||
|
||||
std::vector<std::string> created;
|
||||
created.reserve(ops.size());
|
||||
for (const auto& op : ops) {
|
||||
auto copied = store_.copyIn(game, store_.resolvePath(game, op.oldName),
|
||||
std::filesystem::path(op.newName).stem().string());
|
||||
if (!copied) {
|
||||
for (const auto& createdName : created) {
|
||||
(void)store_.remove(game, createdName);
|
||||
}
|
||||
return Result<std::vector<std::string>>::err(copied.error());
|
||||
}
|
||||
created.push_back(copied.value());
|
||||
}
|
||||
|
||||
for (const auto& op : ops) {
|
||||
auto removed = store_.remove(game, op.oldName);
|
||||
if (!removed) {
|
||||
return Result<std::vector<std::string>>::err(removed.error());
|
||||
}
|
||||
}
|
||||
|
||||
return Result<std::vector<std::string>>::ok(std::move(normalized));
|
||||
}
|
||||
|
||||
std::filesystem::path ImageService::resolveImagePath(Game game,
|
||||
const std::string& imageName) const {
|
||||
return store_.resolvePath(game, imageName);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,27 @@
|
||||
#include "ccm/services/SetService.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
SetService::SetService(ISetRepository& repo) : repo_(repo) {}
|
||||
|
||||
void SetService::registerModule(IGameModule* module) {
|
||||
if (module) modules_[module->id()] = module;
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> SetService::updateSets(Game game) {
|
||||
auto it = modules_.find(game);
|
||||
if (it == modules_.end() || it->second == nullptr) {
|
||||
return Result<std::vector<Set>>::err("No game module registered for this game.");
|
||||
}
|
||||
auto fetched = it->second->setSource().fetchAll();
|
||||
if (!fetched) return fetched;
|
||||
auto saved = repo_.save(game, fetched.value());
|
||||
if (!saved) return Result<std::vector<Set>>::err(saved.error());
|
||||
return fetched;
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> SetService::getSets(Game game) {
|
||||
return repo_.load(game);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,85 @@
|
||||
#include "ccm/util/FsNames.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// Replacements ordered exactly like the Rust source's chained .replace(...) so
|
||||
// behavior is bit-identical for inputs that contain multiple of these chars.
|
||||
struct Replacement {
|
||||
std::string_view from;
|
||||
std::string_view to;
|
||||
};
|
||||
|
||||
constexpr std::array<Replacement, 14> kReplacements{{
|
||||
{"'", ""},
|
||||
{"`", ""},
|
||||
{",", ""},
|
||||
{" ", ""},
|
||||
{":", "-"},
|
||||
{"&", "And"},
|
||||
{"|", "Or"},
|
||||
{"\xC3\xA1", "a"}, // a-acute (UTF-8)
|
||||
{"\xC3\xA9", "e"}, // e-acute
|
||||
{"\xC3\xAD", "i"}, // i-acute
|
||||
{"\xC3\xB3", "o"}, // o-acute
|
||||
{"\xC3\xBA", "u"}, // u-acute
|
||||
{"\xC3\xBB", "u"}, // u-circumflex
|
||||
// Remaining accented vowels appear in modern Scryfall data but were not
|
||||
// listed in the Rust source. Keeping behavior 1:1 deliberately.
|
||||
}};
|
||||
|
||||
void replaceAllInPlace(std::string& s, std::string_view from, std::string_view to) {
|
||||
if (from.empty()) return;
|
||||
std::string::size_type pos = 0;
|
||||
while ((pos = s.find(from, pos)) != std::string::npos) {
|
||||
s.replace(pos, from.size(), to);
|
||||
pos += to.size();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string formatTextForFs(std::string_view text) {
|
||||
std::string out(text);
|
||||
for (const auto& r : kReplacements) {
|
||||
replaceAllInPlace(out, r.from, r.to);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::uint8_t parseIndexFromFilename(std::string_view filename) noexcept {
|
||||
const auto dot = filename.find_last_of('.');
|
||||
if (dot == std::string_view::npos || dot == 0) return 0;
|
||||
|
||||
// Walk backwards from the position before the dot, collecting digits.
|
||||
std::size_t end = dot;
|
||||
std::size_t begin = end;
|
||||
while (begin > 0) {
|
||||
unsigned char ch = static_cast<unsigned char>(filename[begin - 1]);
|
||||
if (std::isdigit(ch)) {
|
||||
--begin;
|
||||
// Rust source intentionally caps at 2-digit indices.
|
||||
if (end - begin >= 2) break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (begin == end) return 0;
|
||||
|
||||
unsigned int value = 0;
|
||||
for (std::size_t i = begin; i < end; ++i) {
|
||||
value = value * 10 + static_cast<unsigned int>(filename[i] - '0');
|
||||
}
|
||||
if (value > 255) value = 255;
|
||||
return static_cast<std::uint8_t>(value);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,39 @@
|
||||
# AGENTS.md
|
||||
|
||||
Long-form contributor documentation that lives outside the source tree.
|
||||
|
||||
## Files
|
||||
|
||||
- `adding-a-new-game.md` — the **canonical, end-to-end procedure** for adding a brand-new game module to the project. Covers required external resources (sets API, optional card-preview API, icons), domain type with stable JSON, set source + tests, optional card preview source + tests, `IGameModule` glue, `Game` enum + `dirNameForGame` wiring, deriving the three UI panel templates (`BaseCardListPanel<TCard, TSortColumn>`, `BaseCardEditDialog<TCard>`, `BaseSelectedCardPanel<TCard>`), the `IGameView` adapter, composition-root wiring in `app/main.cpp`, CMake additions, AGENTS.md updates, and the test checklist.
|
||||
- `ci-cd-guide.md` — user-facing CI/CD and release procedure: feature/master workflow split, orchestrator + reusable platform workflow structure, versioning conventions, PR title guard for `master`, artifact naming, and a concrete "create a new release" checklist.
|
||||
- `versioning.md` — dedicated versioning reference: branch+sha scheme for feature builds, semantic version bump rules for `master`, tag format, and embedded app version behavior.
|
||||
- `windows-installer.md` — how the NSIS-based Windows installer (`scripts/installer.nsi`) is built and configured: the `APP_VERSION` define, where the version is surfaced (window title, branding, Programs and Features), installed sections, registry layout, and CI vs. local invocation.
|
||||
- `dow-doc-build-locally.md` — complete local build/setup reference for Windows and Linux, including dependency management and troubleshooting.
|
||||
- `intro-to-new-developers.md` — onboarding map for new contributors: architecture, folder responsibilities, guardrails, anti-patterns, and links to deeper docs.
|
||||
- `testing-and-test-code-of-conduct.md` — testing workflow plus expected standards for writing and maintaining deterministic, hermetic, behavior-focused tests.
|
||||
- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic and Pokemon modules, plus the runtime flow through `SetService` / `CardPreviewService` and the error-surface conventions.
|
||||
- `README.md` — index page that clusters docs by area and links to all documents in this directory.
|
||||
|
||||
## Subdirectories
|
||||
|
||||
- `assets/images/` — static screenshots and other binary assets referenced from the documentation (currently `demo-mtg.png`, `demo-pkm.png`). Keep filenames stable so cross-doc links don't break, and prefer compressed PNG/JPEG over uncompressed formats.
|
||||
|
||||
## Conventions
|
||||
|
||||
- These docs are reference material for contributors, not user-facing release notes. Keep them precise and dated implicitly by the source state they describe.
|
||||
- Code examples should be C++20 and quote real file paths from the repo.
|
||||
- Do **not** introduce dependencies on a specific upcoming feature, hypothetical game, or unmerged branch. The procedure must always describe the current code as it is on `main`.
|
||||
|
||||
## Required follow-ups
|
||||
|
||||
- After changing per-game seams in `core/` (e.g. `IGameModule`, `ISetSource`, `ICardPreviewSource`, `CollectionService`, `SetService`, `CardPreviewService`, `ImageService`) you **must** update `adding-a-new-game.md` to keep the canonical procedure in sync. The same applies to the UI seams (`IGameView`, `BaseCardListPanel`, `BaseCardEditDialog`, `BaseSelectedCardPanel`) and the composition-root wiring in `app/main.cpp`.
|
||||
- After changing the Magic or Pokemon set/preview adapters (`MagicSetSource`, `MagicCardPreviewSource`, `PokemonSetSource`, `PokemonCardPreviewSource`) — endpoints, response parsing, name/number normalization, or the info-vs-asset split — you **must** update `assets-and-info-apis.md` so the API reference matches the live behavior.
|
||||
- After bumping a key dependency (`nlohmann/json`, `cpr`, `wxWidgets`, `doctest`) in a way that changes a public API used in the guide's examples, update those examples.
|
||||
- After adding a new file under `docs/` (or a new entry under `docs/assets/images/`) you **must** add it to the file list above **and** to `README.md` so the index stays complete.
|
||||
- Do **not** rename, move, or split this file without first updating every other `AGENTS.md` that points at it (root, `core/`, `ui_wx/`, `app/`, `tests/`).
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Don't sneak hypothetical or in-progress games into the doc as concrete examples; the guide is meant to be game-agnostic and must read as such.
|
||||
- Don't link to the original Rust/Tauri repo as the source of truth — it is a historical reference, not the spec. The C++ code under `core/`, `ui_wx/`, and `app/` is the spec.
|
||||
- Don't duplicate the per-package `AGENTS.md` content here; cross-link instead.
|
||||
@@ -0,0 +1,26 @@
|
||||
#documentation #contributors #ccm3
|
||||
|
||||
# Documentation Index
|
||||
|
||||
This folder contains contributor documentation for Card Collection Manager 3. Start with [Intro for New Developers](intro-to-new-developers.md) if you are new to the repository, then use the category sections below to navigate to specific tasks.
|
||||
|
||||
## CI/CD And Release
|
||||
|
||||
- [ci-cd-guide.md](ci-cd-guide.md): CI workflows, merge guards, artifacts, and release execution.
|
||||
- [versioning.md](versioning.md): feature-build version format, semantic release rules on `master`, and tag/app-version behavior.
|
||||
- [windows-installer.md](windows-installer.md): how the NSIS-based Windows installer is built, configured, and versioned.
|
||||
|
||||
## Build And Local Setup
|
||||
|
||||
- [dow-doc-build-locally.md](dow-doc-build-locally.md): local build setup for Windows/Linux, dependency model, build options, and troubleshooting.
|
||||
|
||||
## Onboarding & Development Workflows
|
||||
|
||||
- [intro-to-new-developers.md](intro-to-new-developers.md): architecture orientation, boundaries, common pitfalls, and first-week workflow guidance.
|
||||
|
||||
- [testing-and-test-code-of-conduct.md](testing-and-test-code-of-conduct.md): test workflow plus repository rules for deterministic, behavior-focused tests.
|
||||
|
||||
- [adding-a-new-game.md](adding-a-new-game.md): canonical end-to-end procedure for adding a new game module across `core/`, `ui_wx/`, and `app/`.
|
||||
|
||||
- [assets-and-info-apis.md](assets-and-info-apis.md): external info and asset APIs used by Magic/Pokemon modules and their runtime purpose.
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
#documentation #architecture #game-modules
|
||||
|
||||
# Adding A New Game To Card Collection Manager
|
||||
|
||||
This is the canonical end-to-end walkthrough for adding support for a new TCG to Card Collection Manager. The guide covers external API selection, core and UI integration, composition-root wiring, and required tests so a full implementation can land cleanly on `main`.
|
||||
|
||||
**Quick Setup:** gather required external resources first, then follow sections in order and run the full verification checklist before opening a PR.
|
||||
|
||||
The guide is prescriptive about file locations and seam shapes but game-agnostic in naming. Replace `<Name>` with your game type name and `<name>` with the lowercase key used for on-disk directories and `dirName()`. Both Magic and Pokemon follow this structure; use their implementations as references when needed.
|
||||
|
||||
Read the root `AGENTS.md`, `core/AGENTS.md`, `ui_wx/AGENTS.md`, `app/AGENTS.md`, and `tests/AGENTS.md` before starting. They define the architecture rules this guide is built on top of.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites: pick your external resources
|
||||
|
||||
Before you write any C++, gather the following. The further along you discover that something is missing, the more work you throw away.
|
||||
|
||||
### 1.1 Set list API (required)
|
||||
|
||||
A public HTTP endpoint that returns the canonical set / expansion list for the game, with at least:
|
||||
|
||||
- a stable identifier (`id`) — used as the on-disk and JSON key. Must be stable across API revisions.
|
||||
- a human-readable name.
|
||||
- a release date — used to sort the set picker chronologically.
|
||||
|
||||
The endpoint must be callable without authentication, or you must accept a hard-coded API key (we do not currently expose a way to ask the user for one). It must support HTTPS. Plan for the response body to be JSON; we do not have an XML or CSV path.
|
||||
|
||||
The release date may be in any format **as long as you can rewrite it to `YYYY/MM/DD` during parsing**, because the `Set` domain type stores it that way (see `core/include/ccm/domain/Set.hpp`) and the rest of the code assumes lexicographic comparison sorts chronologically.
|
||||
|
||||
### 1.2 Card preview API (optional)
|
||||
|
||||
A public HTTP endpoint that returns the URL of a card's preview image given some lookup key (typically `name`, `set id`, and possibly a printed collector number). If the game does not expose one, the UI will simply skip the remote preview and only show locally-stored images — the implementation is allowed to omit this seam entirely.
|
||||
|
||||
A few traps to plan around now, before you write code:
|
||||
|
||||
- **Lookup precision.** Some APIs return many ambiguous matches when you query by name only and require the set id (and sometimes the collector number) to disambiguate. Decide up front which fields make a search reliable enough to take the first result.
|
||||
- **URL encoding.** All query strings must be RFC 3986 percent-encoded before they reach `IHttpClient::get` (`cpr::Url` does **not** re-encode). The Magic/Pokemon implementations have a private `urlEncode` helper you can copy.
|
||||
- **Collector-number normalization.** Pokemon stores `4/102` but the API only accepts `4`. Whichever convention your domain type uses, normalize it inside `buildSearchUrl` so the wire format is whatever the API actually expects. Mismatches here produce empty result sets, which then look identical to "no preview available" and are very tedious to debug.
|
||||
|
||||
### 1.3 Flag icons
|
||||
|
||||
Identify any boolean flag columns the game needs (Magic: `Foil`, `Signed`, `Altered`; Pokemon: `Holo`, `1. Edition`, `Signed`, `Altered`). For each one that doesn't already exist, plan an SVG glyph. SVG art with a single fillable path works best — see `ui_wx/src/SvgIcons.cpp` for the established style. Re-use existing glyphs across games where the meaning is identical (`Signed` and `Altered` are shared between Magic and Pokemon).
|
||||
|
||||
### 1.4 Domain shape decision
|
||||
|
||||
Decide whether the new game can re-use an existing card type or needs its own. Re-use is allowed when **every** field has identical semantics; in practice, every game we have shipped has needed its own type because at least one flag or extra column differs (e.g. Pokemon adds `setNo`, `holo`, `firstEdition`).
|
||||
|
||||
If you create a new type, freeze the JSON layout now. The root `AGENTS.md` rule is unambiguous: **JSON layout must stay byte-for-byte stable** once you ship. Pick names that match any pre-existing on-disk format (this app may inherit data from a previous tool), and decide which C++ field names need a JSON alias (the canonical example: the C++ field `signed_` maps to the JSON key `"signed"` because `signed` is a C++ keyword).
|
||||
|
||||
---
|
||||
|
||||
## 2. Core: domain types and the `Game` enum
|
||||
|
||||
Everything below this point assumes you have already gathered the resources from §1.
|
||||
|
||||
### 2.1 Extend the `Game` enum
|
||||
|
||||
Edit `core/include/ccm/domain/Enums.hpp`:
|
||||
|
||||
- Add a new enumerator to `enum class Game`.
|
||||
- Update the size of `allGames()` (`std::array<Game, N>`).
|
||||
|
||||
Edit `core/src/domain/Enums.cpp`:
|
||||
|
||||
- Add a `case` to `to_string(Game)`.
|
||||
- Add a branch to `gameFromString(std::string_view)`.
|
||||
- Add the new enumerator to the `allGames()` constexpr array.
|
||||
|
||||
The `Game` enum is the only place in `core/` that hardcodes which games exist. Adding a new entry here is what makes the rest of the registries (`SetService`, `CardPreviewService`, `AppContext::gameViews`, `dirNameForGame`) accept it.
|
||||
|
||||
### 2.2 Add the card domain type (only if needed)
|
||||
|
||||
If you decided in §1.4 that an existing card type fits, skip this section.
|
||||
|
||||
Otherwise, create `core/include/ccm/domain/<Name>Card.hpp`:
|
||||
|
||||
- A `struct <Name>Card` with `id`, `amount`, `name`, `set`, `note`, `images`, `language`, `condition` (these seven fields are required — the UI templates assume them) and any game-specific extras.
|
||||
- Forward-declare `to_json` and `from_json` for `nlohmann::json`.
|
||||
- Add a defaulted `friend bool operator==(const <Name>Card&, const <Name>Card&) = default;` so the JSON round-trip test can compare values.
|
||||
|
||||
Then create `core/src/domain/<Name>Card.cpp`:
|
||||
|
||||
- Hand-roll `to_json` and `from_json` using `nlohmann::json`. **Do not** use `NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE` — the explicit form keeps JSON aliases visible and makes future stability bugs easier to catch in code review.
|
||||
|
||||
A real example to cargo-cult from is `core/src/domain/PokemonCard.cpp`. Note how `signed` (the JSON key) maps to `signed_` (the C++ field), and how every JSON key is spelled out. If your game uses a printed collector number, follow Pokemon's lead and store it as `setNo` (string) so the format `"4/102"` can survive a round-trip even when the API only consumes `"4"`.
|
||||
|
||||
### 2.3 Update the JSON round-trip test
|
||||
|
||||
Open `tests/domain_json_tests.cpp`. Add a `TEST_CASE` that:
|
||||
|
||||
1. Constructs a `<Name>Card` with **every** field non-default (including `set.id`, `set.name`, `set.releaseDate`).
|
||||
2. Serializes it to `nlohmann::json`.
|
||||
3. Asserts the JSON contains the expected keys with the expected literal spellings (in particular, any C++/JSON aliases like `"signed"`).
|
||||
4. Round-trips it back into a `<Name>Card` and `CHECK(roundTripped == original)`.
|
||||
|
||||
This is the **byte-for-byte stability gate**. If you skip it, the alias bugs only surface in production after you ship and someone's collection.json fails to parse.
|
||||
|
||||
---
|
||||
|
||||
## 3. Core: set source and (optional) card preview source
|
||||
|
||||
### 3.1 `<Name>SetSource`
|
||||
|
||||
Mirrors `core/include/ccm/games/pokemon/PokemonSetSource.hpp` and the matching `.cpp`. Put your files at:
|
||||
|
||||
- `core/include/ccm/games/<name>/<Name>SetSource.hpp`
|
||||
- `core/src/games/<name>/<Name>SetSource.cpp`
|
||||
|
||||
The header should declare:
|
||||
|
||||
- `class <Name>SetSource final : public ISetSource`
|
||||
- `static constexpr const char* kEndpoint = "<your full HTTPS URL>";`
|
||||
- `explicit <Name>SetSource(IHttpClient& http);`
|
||||
- `Result<std::vector<Set>> fetchAll() override;`
|
||||
- `static Result<std::vector<Set>> parseResponse(const std::string& body);`
|
||||
|
||||
The static `parseResponse` is mandatory. It is the seam you unit-test (no HTTP, no fakes — just a string in, a `Result` out). `fetchAll()` is a thin wrapper that calls `http_.get(kEndpoint)` and forwards to `parseResponse` on success.
|
||||
|
||||
In `parseResponse`:
|
||||
|
||||
1. Parse the body with `nlohmann::json::parse(body)` inside a `try`/`catch (const std::exception&)` block. Throwing across the port boundary is forbidden; catch and return `Result<std::vector<Set>>::err(...)` with a useful message.
|
||||
2. Walk the response, mapping each entry to a `Set { id, name, releaseDate }`. Rewrite the release date to `YYYY/MM/DD` if the API uses a different format.
|
||||
3. `std::sort` ascending by release date.
|
||||
4. Return `Result<std::vector<Set>>::ok(std::move(out))`.
|
||||
|
||||
A note on quirky APIs: some endpoints return a top-level array, some wrap it in `{ "data": [...] }`, and some put it under a different key. The two reference implementations diverge on exactly this: Magic walks the Scryfall-shaped response, Pokemon walks `data[]`. Do whatever your API requires; it is fine for `parseResponse` to be game-specific.
|
||||
|
||||
### 3.2 `<Name>CardPreviewSource` (optional)
|
||||
|
||||
Skip this section if the game has no remote preview API.
|
||||
|
||||
Mirror `core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp`. The header should declare:
|
||||
|
||||
- `class <Name>CardPreviewSource final : public ICardPreviewSource`
|
||||
- `explicit <Name>CardPreviewSource(IHttpClient& http);`
|
||||
- `Result<std::string> fetchImageUrl(std::string_view name, std::string_view setId, std::string_view setNo) override;`
|
||||
- `static std::string buildSearchUrl(std::string_view name, std::string_view setId, std::string_view setNo);`
|
||||
- `static Result<std::string> parseResponse(const std::string& body);`
|
||||
|
||||
Both `buildSearchUrl` and `parseResponse` are static and pure on purpose: every URL-encoding and JSON-shape rule is testable without HTTP. Common edge cases your tests must cover:
|
||||
|
||||
- Names with spaces, punctuation, or non-ASCII characters (percent-encoding correctness).
|
||||
- An empty `setId` (don't append the `set.id:` clause).
|
||||
- An empty `setNo`, and a `setNo` that needs normalization (strip everything after `/`, strip leading zeros, etc.).
|
||||
- Response with the preferred image variant present.
|
||||
- Response with only the fallback image variant present.
|
||||
- Empty `data[]` array.
|
||||
- Malformed JSON (parse error path).
|
||||
|
||||
`fetchImageUrl` is a thin wrapper: build URL → `http_.get(url)` → `parseResponse(body)`.
|
||||
|
||||
### 3.3 `<Name>GameModule`
|
||||
|
||||
Wire the two sources together. Create:
|
||||
|
||||
- `core/include/ccm/games/<name>/<Name>GameModule.hpp`
|
||||
- `core/src/games/<name>/<Name>GameModule.cpp`
|
||||
|
||||
Header:
|
||||
|
||||
```cpp
|
||||
#pragma once
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/<name>/<Name>CardPreviewSource.hpp" // omit if no preview
|
||||
#include "ccm/games/<name>/<Name>SetSource.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class <Name>GameModule final : public IGameModule {
|
||||
public:
|
||||
explicit <Name>GameModule(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] Game id() const noexcept override { return Game::<Name>; }
|
||||
[[nodiscard]] std::string dirName() const override { return "<name>"; }
|
||||
[[nodiscard]] std::string displayName() const override { return "<Display>"; }
|
||||
|
||||
ISetSource& setSource() override { return setSource_; }
|
||||
// Omit the override below if the game has no remote preview API.
|
||||
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
|
||||
|
||||
private:
|
||||
<Name>SetSource setSource_;
|
||||
<Name>CardPreviewSource previewSource_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
```
|
||||
|
||||
Two subtle requirements:
|
||||
|
||||
- `dirName()` returns the **on-disk directory name**. Once you ship, this is forever — changing it later orphans every existing user's data. Pick something lowercase, ASCII, and short.
|
||||
- `cardPreviewSource()` defaults to `nullptr` in `IGameModule`. Only override it if you actually have a preview source. Returning `nullptr` makes `CardPreviewService::registerModule(*module)` a silent no-op for that game; the UI gracefully falls back to "no preview available".
|
||||
|
||||
The `.cpp` is one line of constructor body — see `core/src/games/pokemon/PokemonGameModule.cpp`.
|
||||
|
||||
### 3.4 Register the new sources in `core/CMakeLists.txt`
|
||||
|
||||
There is no glob. Add the new `.cpp` files (set source, card preview source, game module, and the card domain `.cpp` if you added one) to the `add_library(ccm_core ...)` argument list. Configure the build before moving on; this catches any missing headers immediately.
|
||||
|
||||
---
|
||||
|
||||
## 4. Core: tests for the new sources
|
||||
|
||||
Writing the tests now, before the UI work, makes the next sections noticeably faster — every later UI debugging session benefits from already knowing the parser and the URL builder are correct.
|
||||
|
||||
### 4.1 `<name>_set_source_tests.cpp`
|
||||
|
||||
Create `tests/<name>_set_source_tests.cpp` modeled on `tests/pokemon_set_source_tests.cpp`. The required cases are:
|
||||
|
||||
- `parseResponse` happy path with two or three sets, including the date rewrite if your API uses a non-`YYYY/MM/DD` format.
|
||||
- `parseResponse` already-sorted output (input out of order, output ascending by release date).
|
||||
- `parseResponse` empty array → empty `Result::ok(...)`.
|
||||
- `parseResponse` missing top-level container → `Result::err(...)`.
|
||||
- `parseResponse` malformed JSON → `Result::err(...)`.
|
||||
- `fetchAll` happy path through a `FixedHttpClient` fake (in-file, ~10 lines — see the existing tests). Assert the `lastUrl` equals `kEndpoint`.
|
||||
- `fetchAll` HTTP error → `Result::err(...)` propagation.
|
||||
|
||||
### 4.2 `<name>_card_preview_source_tests.cpp` (if you have a preview source)
|
||||
|
||||
Create `tests/<name>_card_preview_source_tests.cpp` modeled on `tests/pokemon_card_preview_source_tests.cpp`. The required cases are:
|
||||
|
||||
- `buildSearchUrl` percent-encodes names with spaces and reserved characters.
|
||||
- `buildSearchUrl` includes / omits the `setId` clause based on whether `setId` is empty.
|
||||
- `buildSearchUrl` includes / omits / normalizes `setNo` according to your normalization rules.
|
||||
- `parseResponse` returns the preferred image variant.
|
||||
- `parseResponse` falls back to the secondary variant when the primary is absent.
|
||||
- `parseResponse` errors on empty `data[]`, missing `images`, malformed JSON.
|
||||
- `fetchImageUrl` round-trips through `FixedHttpClient` and asserts the URL was percent-encoded as expected.
|
||||
- `fetchImageUrl` propagates HTTP errors.
|
||||
|
||||
### 4.3 Register the new test files
|
||||
|
||||
Add `<name>_set_source_tests.cpp` (and `<name>_card_preview_source_tests.cpp` if applicable) to `tests/CMakeLists.txt` `add_executable(ccm_core_tests ...)`. Build and run `ctest --test-dir build --output-on-failure`. **Do not** continue until these pass.
|
||||
|
||||
### 4.4 Sorter / filter tests
|
||||
|
||||
If you introduced a new card type in §2.2, add cases to `tests/card_sorter_tests.cpp` and `tests/card_filter_tests.cpp` covering the new sort columns and filter columns introduced by your domain type. The boolean-flag exclusion rule (filter only checks string/number columns; flag columns are skipped) must be covered explicitly so future refactors don't quietly break it.
|
||||
|
||||
### 4.5 Set-service routing test
|
||||
|
||||
Append a case to `tests/set_service_tests.cpp` that registers your new module alongside Magic and verifies that `updateSets(Game::<Name>)` does not perturb cached data for the other game. Routing isolation is what `SetService` exists for; one test per game keeps it honest.
|
||||
|
||||
---
|
||||
|
||||
## 5. UI: derive from the three base templates
|
||||
|
||||
The UI layer is built on three header-only class templates that own all the wxWidgets-specific machinery. Each has a small, well-documented set of virtual hooks; deriving for a new game is a hook-implementation exercise, not a wxWidgets exercise. Read `ui_wx/include/ccm/ui/Base*.hpp` once before starting.
|
||||
|
||||
### 5.1 SVG icons
|
||||
|
||||
If your game introduces flag columns whose glyphs do not already exist in `ui_wx/include/ccm/ui/SvgIcons.hpp`, add them now:
|
||||
|
||||
- Declare each new icon as `extern const char* const kSvg<Name>;` in the header.
|
||||
- Define them in `ui_wx/src/SvgIcons.cpp`. Keep the `@FILL@` placeholder so the rasterizer can substitute the active palette text color at draw time. **Do not** bake a color into the SVG — that breaks dark mode.
|
||||
- Re-use existing glyphs (`kSvgSigned`, `kSvgAltered`, `kSvgFoil`, `kSvgHolo`) when the meaning matches.
|
||||
|
||||
### 5.2 Sort and filter helpers
|
||||
|
||||
Add a `<Name>SortColumn` enum to `core/include/ccm/services/CardSorter.hpp`, plus the corresponding `sort<Name>Cards(std::vector<<Name>Card>&, <Name>SortColumn, bool)` declaration. Implement it in `core/src/services/CardSorter.cpp` mirroring the existing per-column dispatch (each enum entry maps to a comparator).
|
||||
|
||||
Add `[[nodiscard]] bool matches<Name>Filter(const <Name>Card&, std::string_view)` to `core/include/ccm/services/CardFilter.hpp` and implement it in `core/src/services/CardFilter.cpp`. Walk the same value-key columns that the list panel will display, lowercase both sides, and short-circuit on any substring hit. Boolean flag columns are intentionally excluded — only string/number columns participate in filtering.
|
||||
|
||||
These functions are also the targets of §4.4's tests; you'll have already written the tests if you followed the order.
|
||||
|
||||
### 5.3 `<Name>CardListPanel`
|
||||
|
||||
Create:
|
||||
|
||||
- `ui_wx/include/ccm/ui/<Name>CardListPanel.hpp`
|
||||
- `ui_wx/src/<Name>CardListPanel.cpp`
|
||||
|
||||
The header declares a `final class` deriving from `BaseCardListPanel<<Name>Card, <Name>SortColumn>`, with overrides for:
|
||||
|
||||
- `declareTextColumns()` — return a `std::vector<TextColumnSpec>` of `{label, width, format, optional<sortColumn>}`. The order is left-to-right on screen. The **last** entry must be the `Note` column; the base reserves it.
|
||||
- `declareIconColumns()` — return a `std::vector<IconColumnSpec>` of `{svg, width, optional<sortColumn>}`. These render between the leading text columns and the Note column.
|
||||
- `renderTextCell(card, idx)` — return the cell text for the `idx`-th text column. The base passes index `0..textCols-1` for the leading rows and `textCols-1` for the Note row, so you usually `switch (idx)`.
|
||||
- `isIconColumnSet(card, idx)` — return whether the `idx`-th icon column should render its glyph for this row. Index `0` is the first icon column declared in `declareIconColumns()`.
|
||||
- `sortBy(column, ascending)` — call `sort<Name>Cards(mutableCards(), column, ascending)`. **Use `mutableCards()`**, not `cards()`, because `sortBy` writes through the underlying vector.
|
||||
- `matchesFilter(card, filter)` — call `matches<Name>Filter(card, filter)`.
|
||||
|
||||
In the constructor body, call `buildLayout()` (from `BaseCardListPanel`) so the base wires up the `wxListCtrl`, the themed header row, and the image lists.
|
||||
|
||||
The reference implementation at `ui_wx/src/PokemonCardListPanel.cpp` is ~75 lines including the icon-column declaration and the `switch`-based renderer. Yours should land in the same ballpark.
|
||||
|
||||
### 5.4 `<Name>SelectedCardPanel`
|
||||
|
||||
Create:
|
||||
|
||||
- `ui_wx/include/ccm/ui/<Name>SelectedCardPanel.hpp`
|
||||
- `ui_wx/src/<Name>SelectedCardPanel.cpp`
|
||||
|
||||
Inside the `.cpp`, define an unnamed-namespace `enum <Name>DetailKey : int { ... };` with one entry per detail row and one per flag column. Keep these names local — they're only used between this file's hook overrides.
|
||||
|
||||
Override:
|
||||
|
||||
- `declareDetailRows()` — return a `std::vector<DetailRowSpec>` of `{label, key, emptyLabel}`. The **first** row should typically be `Name`; its `emptyLabel` is what the panel shows when no card is selected.
|
||||
- `declareFlagIcons()` — return a `std::vector<FlagIconSpec>` of `{svg, tooltip, key}`.
|
||||
- `detailValueFor(card, key)` — `switch (key)` and return the appropriate string. **Also handle `kNoteKey`** (defined in `BaseSelectedCardPanel` as `-1`); the base calls `detailValueFor(card, kNoteKey)` to populate the bottom Note row.
|
||||
- `isFlagSet(card, key)` — `switch (key)` and return the matching boolean.
|
||||
- `previewKey(card)` — return `std::tuple<std::string, std::string, std::string>` of `(name, setId, setNo)`. Use empty `setNo` for games whose preview API does not need a collector number.
|
||||
- `gameId()` — return `Game::<Name>`.
|
||||
|
||||
In the constructor body, call `buildLayout()` so the base wires up the preview area, detail grid, flag strip, and image list.
|
||||
|
||||
The reference implementation is `ui_wx/src/PokemonSelectedCardPanel.cpp`.
|
||||
|
||||
### 5.5 `<Name>CardEditDialog`
|
||||
|
||||
Create:
|
||||
|
||||
- `ui_wx/include/ccm/ui/<Name>CardEditDialog.hpp`
|
||||
- `ui_wx/src/<Name>CardEditDialog.cpp`
|
||||
|
||||
Derive from `BaseCardEditDialog<<Name>Card>`. Override:
|
||||
|
||||
- `buildFlagsRow(wxBoxSizer* flagsBox)` — create your `wxCheckBox`es and `flagsBox->Add(...)` them. The base owns the surrounding `Flags` label and sizer.
|
||||
- `appendExtraRows(wxFlexGridSizer* grid)` — only if your game has fields beyond the standard set. Use the inherited `appendRow(grid, label, ctrl)` helper. (Pokemon adds a `Set #` text input here.)
|
||||
- `readExtraFromCard()` — copy fields from `constCard()` into your widgets.
|
||||
- `writeExtraToCard()` — copy values from your widgets back into `mutableCard()`.
|
||||
- `updateMenuName()` — return `"Update <Display>"`. This is what the dialog's "no sets cached" hint shows the user.
|
||||
|
||||
In the constructor:
|
||||
|
||||
1. Pass through to the `BaseCardEditDialog` constructor with the dialog title (e.g. `"Add <Display> Card"` or `"Edit <Display> Card"` based on `EditMode`), `imageService`, `setService`, `mode`, `std::move(initial)`, `Game::<Name>`, and the optional `preloadedSets` pointer.
|
||||
2. Call `buildAndPopulate()` (from the base) to build the form, populate the choices, and call `readExtraFromCard()`.
|
||||
|
||||
The reference implementation is `ui_wx/src/PokemonCardEditDialog.cpp`.
|
||||
|
||||
### 5.6 `<Name>GameView`
|
||||
|
||||
This is the polymorphic glue between the new game's panels and the rest of the app. Create:
|
||||
|
||||
- `ui_wx/include/ccm/ui/<Name>GameView.hpp`
|
||||
- `ui_wx/src/<Name>GameView.cpp`
|
||||
|
||||
Derive from `IGameView`. The constructor takes references to the shared services (`ConfigService`, `SetService`, `ImageService`, `CardPreviewService`), the typed `CollectionService<<Name>Card>&`, and the `IGameModule&`. Members:
|
||||
|
||||
- `<Name>CardListPanel* listPanel_{nullptr};`
|
||||
- `<Name>SelectedCardPanel* selectedPanel_{nullptr};`
|
||||
- `std::vector<Set> setsCache_;` — populated lazily by `setsForDialog()` so each Add/Edit open does not re-read from `SetService`.
|
||||
|
||||
Implement the virtuals:
|
||||
|
||||
- `gameId()` returns `Game::<Name>`.
|
||||
- `displayName()` returns `"<Display>"`.
|
||||
- `listPanel(parent)` — lazily allocates the list panel as a child of `parent`; on first allocation, also `Bind(EVT_CARD_SELECTED, ...)` to push `listPanel_->selected()` into `selectedPanel_`. **The binding must live here**, in the typed `IGameView`, not in `MainFrame` — `MainFrame` only sees `IGameView` and never `<Name>Card`.
|
||||
- `selectedPanel(parent)` — lazily allocates the selected panel.
|
||||
- `refreshCollection()` — calls `collection_.list(Game::<Name>)`, handles errors with `wxMessageBox`, and pushes the new vector into `listPanel_->setCards(...)`. Also re-syncs the selected panel.
|
||||
- `onAddCard(parent)`, `onEditCard(parent)`, `onDeleteCard(parent)` — open the typed `<Name>CardEditDialog` (or pop a confirm dialog for delete), call the typed `CollectionService` to commit, and refresh on success.
|
||||
- `onUpdateSets(parent)` — calls `sets_.updateSets(Game::<Name>)`, refreshes `setsCache_`, returns a status string.
|
||||
- `setFilter(filter)` — forwards to `listPanel_->setFilter(filter)`.
|
||||
- `applyTheme(palette)` — forwards to both panels' `applyTheme`.
|
||||
- `updateSetsMenuLabel()` — returns `"Update <Display>"`. This is what the `Sets` menu entry shows.
|
||||
|
||||
The reference implementation is `ui_wx/src/PokemonGameView.cpp`. It's about 160 lines and is the same shape for every game.
|
||||
|
||||
### 5.7 Register the new UI sources
|
||||
|
||||
Add **all** new UI `.cpp` files to `ui_wx/CMakeLists.txt`:
|
||||
|
||||
- `<Name>CardListPanel.cpp`
|
||||
- `<Name>SelectedCardPanel.cpp`
|
||||
- `<Name>CardEditDialog.cpp`
|
||||
- `<Name>GameView.cpp`
|
||||
|
||||
There is no glob.
|
||||
|
||||
---
|
||||
|
||||
## 6. Composition root
|
||||
|
||||
Edit `app/main.cpp` to wire the new game in. Read `app/AGENTS.md` first — destruction-order rules apply.
|
||||
|
||||
### 6.1 New members
|
||||
|
||||
Add `std::unique_ptr<...>` members to `CcmApp`. Order matters (destruction is reverse — deps before dependents):
|
||||
|
||||
```cpp
|
||||
std::unique_ptr<ccm::<Name>GameModule> <name>Mod_;
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::<Name>Card>> <name>Repo_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::<Name>Card>> <name>CollSvc_;
|
||||
std::unique_ptr<ccm::ui::<Name>GameView> <name>View_;
|
||||
```
|
||||
|
||||
Place them next to the existing Magic/Pokemon members in the matching position — game module after `http_`, repo after the module, collection service after the repo and the image store, view at the end before `ctx_`.
|
||||
|
||||
### 6.2 New constructions in `OnInit()`
|
||||
|
||||
```cpp
|
||||
<name>Mod_ = std::make_unique<ccm::<Name>GameModule>(*http_);
|
||||
|
||||
<name>Repo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::<Name>Card>>(
|
||||
*fs_, *config_, &dirNameForGame);
|
||||
|
||||
<name>CollSvc_ = std::make_unique<ccm::CollectionService<ccm::<Name>Card>>(
|
||||
*<name>Repo_, *imgStore_);
|
||||
|
||||
setSvc_->registerModule(<name>Mod_.get());
|
||||
previewSvc_->registerModule(*<name>Mod_); // no-op when the module has no preview source
|
||||
|
||||
<name>View_ = std::make_unique<ccm::ui::<Name>GameView>(
|
||||
*config_, *<name>CollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *<name>Mod_);
|
||||
```
|
||||
|
||||
### 6.3 `dirNameForGame`
|
||||
|
||||
Add a `case ccm::Game::<Name>: return "<name>";` arm. The string must match `<Name>GameModule::dirName()`.
|
||||
|
||||
### 6.4 `AppContext`
|
||||
|
||||
`AppContext` (`ui_wx/include/ccm/ui/AppContext.hpp`) currently holds explicit references to `magicModule` and `pokemonModule`. Add an `<Name>Module` reference field — keep the alphabetical / canonical order — and pass `*<name>Mod_` for it in the `AppContext{...}` brace-init in `OnInit()`. Also append `<name>View_.get()` to the `gameViews` vector.
|
||||
|
||||
The `Game` and `Sets` menus in `MainFrame` are built dynamically from `gameViews`, so once the new view is in the vector its menu entries (the `Game > <Display>` radio item and the `Sets > Update <Display>` action) appear automatically.
|
||||
|
||||
---
|
||||
|
||||
## 7. AGENTS.md and tests housekeeping
|
||||
|
||||
After all the above compiles and tests pass:
|
||||
|
||||
1. **`AGENTS.md`** (root) — the "After adding a new game module you must" required follow-up should already cover your work; read it and confirm. If you added a new domain type, the matching `tests/domain_json_tests.cpp` round-trip test is required (per `core/AGENTS.md`).
|
||||
2. **`core/AGENTS.md`** — update only if your game required a new core seam shape (a new port, a new service, a new shared helper). Describing the new game itself is not required; the doc is meant to stay game-agnostic.
|
||||
3. **`ui_wx/AGENTS.md`** — same: update only if you needed a new template hook or had to teach the `Base*` templates a new behaviour. Describing the new game's panel set is not required.
|
||||
4. **`app/AGENTS.md`** — confirm the "Composition root is the only place" allowlist still mentions the concrete adapter types. If you added new ones (a new `<Name>GameView`, `<Name>GameModule`), append them.
|
||||
5. **`tests/AGENTS.md`** — add the new test file names to the file map and the "Required follow-ups" list.
|
||||
6. **This file** (`docs/adding-a-new-game.md`) — only edit when the procedure itself changes (new template hook, new service registration, new composition-root step). Do not insert your specific game's quirks here; capture those in code comments next to the relevant overrides.
|
||||
|
||||
---
|
||||
|
||||
## 8. Verification checklist
|
||||
|
||||
Run, in order, from the workspace root. Do not skip any step.
|
||||
|
||||
1. **Configure**: `cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release` (or your usual generator). Configuration must succeed without warnings about a missing source file.
|
||||
2. **Build**: `cmake --build build --parallel`. Must succeed cleanly. Pay close attention to template-instantiation errors — those usually indicate one of the `Base*` hooks is missing or wrongly-typed.
|
||||
3. **Tests**: `ctest --test-dir build --output-on-failure`. Every existing test plus the new `<name>_set_source_tests`, `<name>_card_preview_source_tests`, the extended `domain_json_tests`, the extended `card_sorter_tests`, the extended `card_filter_tests`, and the extended `set_service_tests` must pass.
|
||||
4. **Smoke test**: launch `./build/bin/ccm3` (or `.\build\bin\ccm3.exe`). Note: the CMake target is `ccm` but the executable is renamed to `ccm3` via `set_target_properties(... OUTPUT_NAME ccm3)`.
|
||||
- The `Game` menu shows your new game alongside Magic and Pokemon and switching is instantaneous (no panel re-creation cost on subsequent switches).
|
||||
- The `Sets > Update <Display>` action fetches sets and reports a count.
|
||||
- With sets cached, opening the new game's Add dialog populates the set picker and the dialog can be dismissed with `OK`.
|
||||
- Adding, editing, and deleting a card all round-trip through disk: close and re-open the app and the card persists.
|
||||
- Selecting a card kicks off a preview fetch (if the game has a preview source) and renders the image; the status line returns to `"Ready"` when the preview lands.
|
||||
- The flag-icon strip shows / hides per card depending on which flags are set.
|
||||
- Theme switching applies to all of the new game's panels (light → dark → light).
|
||||
|
||||
---
|
||||
|
||||
## 9. Common traps
|
||||
|
||||
These do not match a single seam in this guide but are worth calling out explicitly.
|
||||
|
||||
- **Stale set caches.** Each `IGameView` caches `std::vector<Set> setsCache_`. After `onUpdateSets` succeeds, refresh the cache (assign the new vector). The reference implementations do this.
|
||||
- **`signed_` / `signed`.** The C++ field is `signed_`; the JSON key is `"signed"`. This is intentional and must not be changed. The same convention applies to any new field where the natural name collides with a C++ keyword — pick a trailing-underscore C++ name and an unaliased JSON key.
|
||||
- **Spacer column index.** `BaseCardListPanel` reserves index `0` for a hidden zero-width spacer column (MSW comctl32 image-list gutter workaround). Real columns start at index `1`. If you ever need to call into `wxListCtrl` directly from a derived panel (you should not), remember this.
|
||||
- **Preview-fetch threading.** The async preview fetch in `BaseSelectedCardPanel` uses a `shared_ptr<State>` + `std::atomic alive` + `std::atomic currentGen` triple. Do not capture `this` raw in any background work you add to a new game's selected panel; copy that pattern verbatim.
|
||||
- **First-paint perf.** `MainFrame` defers initial collection load with `CallAfter(...)` and `BaseCardListPanel` defers the initial selection the same way. Don't move that work back into the constructor for "convenience" — it makes startup visibly slower.
|
||||
- **`previewKey` for games without `setNo`.** If your preview API only needs `(name, setId)`, return an empty string for the third tuple element. The base will pass `""` through to the source, which is exactly what `MagicCardPreviewSource` is built to handle.
|
||||
- **Filter exclusion.** The filter intentionally ignores boolean-flag columns. If you find yourself wanting `signed:true` style filters, that is a future feature, not a fix; don't smuggle it into `matches<Name>Filter` without a design discussion.
|
||||
- **Theming dialogs.** Always `applyThemeToWindowTree(&dlg, palette, theme)` before `ShowModal()` for any dialog you open. The reference `<Name>GameView::onAddCard` / `onEditCard` show the canonical pattern.
|
||||
|
||||
---
|
||||
|
||||
## 10. Where to read first when something does not work
|
||||
|
||||
- The new game compiles but its menu entries do not appear — check that the view was appended to `AppContext::gameViews` in `app/main.cpp`.
|
||||
- The list panel is empty even after `Sets > Update <Display>` succeeds — check `dirNameForGame`. The repository writes to `<dataStorage>/<dirName>/collection.json`, and a typo here makes the load silently return an empty list on next launch.
|
||||
- The filter input does nothing on the new game — check that `<Name>GameView::setFilter(...)` forwards to the list panel and that `matches<Name>Filter` actually evaluates the active filter substring (the empty filter must match every row).
|
||||
- The Add dialog shows `(no sets cached - use Sets > Update <Display>)` even after a successful update — `setsCache_` was not refreshed in `onUpdateSets`. The reference views assign `out.value()` into the cache.
|
||||
- Preview never resolves — first add a unit test that hits `parseResponse` with a real captured response body. If that passes, log the URL `IHttpClient::get` is called with and try it in a browser or `curl`. Most "broken preview" bugs are URL encoding or a wrong shape in `buildSearchUrl`.
|
||||
- Sort works but its arrow indicator is wrong — column `0` is the spacer, so the visual column index sort key cares about is one higher than you might expect. The base handles this; if it goes wrong, check that your `TextColumnSpec`/`IconColumnSpec` order matches `renderTextCell` / `isIconColumnSet` indexing exactly.
|
||||
- Tests pass but the app crashes on shutdown — destruction order in `CcmApp` is wrong. Move `<name>View_` so it is declared **after** `<name>CollSvc_`, `setSvc_`, `imgSvc_`, `previewSvc_`, `<name>Mod_` — the view must be torn down before any of its referenced services.
|
||||
@@ -0,0 +1,52 @@
|
||||
#documentation #apis #integrations #ccm3
|
||||
|
||||
# Asset And Info APIs
|
||||
|
||||
This document explains which external APIs Card Collection Manager 3 uses, and what each API is responsible for in the app. Use this page with [adding-a-new-game.md](adding-a-new-game.md) when you are wiring a new game module or debugging API behavior.
|
||||
|
||||
## API Roles
|
||||
|
||||
The code separates remote APIs into two roles: **info APIs** and **asset APIs**. Info APIs provide set metadata used to populate local set lists (ID, name, release date). Asset APIs resolve a card lookup into an image URL, then `CardPreviewService` downloads the raw preview image bytes for the UI.
|
||||
|
||||
## Magic: The Gathering APIs
|
||||
|
||||
**Info API:** `https://api.scryfall.com/sets`
|
||||
Used by `MagicSetSource` to fetch all sets. The parser drops digital-only sets, maps Scryfall fields to the internal `Set` type, rewrites `released_at` from `YYYY-MM-DD` to `YYYY/MM/DD`, and sorts ascending by release date.
|
||||
|
||||
**Asset API:** `https://api.scryfall.com/cards/search?q=...`
|
||||
Used by `MagicCardPreviewSource` to find a card printing from `name` + `setId`, then extract `data[0].image_uris.normal` as the preview URL. The search query is percent-encoded and card names apply `&` -> `and` normalization before lookup.
|
||||
|
||||
## Pokemon APIs
|
||||
|
||||
**Info API:** `https://api.pokemontcg.io/v2/sets`
|
||||
Used by `PokemonSetSource` to fetch all sets. The parser maps `id`, `name`, and `releaseDate` directly into `Set`, then sorts ascending by release date.
|
||||
|
||||
**Asset API:** `https://api.pokemontcg.io/v2/cards?q=...`
|
||||
Used by `PokemonCardPreviewSource` to search by `name` plus optional `set.id` and collector number. It extracts `data[0].images.large` first and falls back to `images.small` if needed.
|
||||
|
||||
The Pokemon source also normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote query expects only the printed number component.
|
||||
|
||||
## Runtime Flow In CCM3
|
||||
|
||||
The app uses the same flow for both games:
|
||||
|
||||
- `SetService` asks the game's `ISetSource` (info API) for the latest set list.
|
||||
- `CardPreviewService` asks the game's `ICardPreviewSource` (asset API) for a preview image URL.
|
||||
- `CardPreviewService` performs a second HTTP GET to that URL and returns raw bytes to the UI layer.
|
||||
- If preview lookup fails (or returns empty bytes), the UI fetches a per-game fallback card-back image URL through `CardPreviewService::fetchImageBytesByUrl(...)` and shows that image in the selected-card preview panel.
|
||||
|
||||
Current fallback image URLs (kept in `BaseSelectedCardPanel` for CCM2 parity):
|
||||
|
||||
- Magic: `https://gamepedia.cursecdn.com/mtgsalvation_gamepedia/f/f8/Magic_card_back.jpg`
|
||||
- Pokemon: `https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg`
|
||||
|
||||
If a game module does not provide a preview source (`cardPreviewSource() == nullptr`), preview registration is skipped and the UI behaves as "no remote preview API available."
|
||||
|
||||
## Error Surface And Debugging Intent
|
||||
|
||||
Both source types return `Result<T, std::string>` errors so failures cross boundaries without exceptions. In practice, this keeps failures debuggable by separating:
|
||||
|
||||
- info API failures (bad set payload, schema mismatch, endpoint/network failure), and
|
||||
- asset API failures (query mismatch, no matching card, missing image fields, image download failure).
|
||||
|
||||
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions (`data`, `image_uris`, `images.large`/`images.small`). If the fallback fetch succeeds, the panel intentionally shows the card-back image and the inline label `(image preview unavailable)`.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 569 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 648 KiB |
@@ -0,0 +1,90 @@
|
||||
#documentation #ci-cd #github-actions
|
||||
|
||||
# CI/CD Guide
|
||||
|
||||
This guide defines the CI/CD behavior for Card Collection Manager 3. For the complete versioning policy, including prefix semantics and tag rules, see [Versioning Guide](versioning.md). For how the Windows installer artifact is produced and configured, see [Windows Installer Guide](windows-installer.md).
|
||||
|
||||
**Quick Setup:** protect `master` with the required check `Require semver prefix in PR title`, then keep release PR titles aligned with the accepted prefixes.
|
||||
|
||||
## Workflow Map
|
||||
|
||||
The repository uses GitHub Actions workflows split by branch intent, with one orchestrator per branch intent:
|
||||
|
||||
- `feature-ci.yml`: single workflow run for non-`master` pushes; orchestrates Linux and Windows feature builds.
|
||||
- `feature-linux.yml`: reusable Linux build/test/package workflow invoked by `feature-ci.yml`.
|
||||
- `feature-windows.yml`: reusable Windows build/test/package workflow invoked by `feature-ci.yml`.
|
||||
- `master-pr-title-guard.yml`: validate PR title prefixes for PRs targeting `master`.
|
||||
- `master-ci.yml`: single workflow run on merged PRs to `master`; computes semver, invokes Windows reusable build, then tags/publishes release assets.
|
||||
- `master-windows.yml`: reusable Windows build/test/package workflow invoked by `master-ci.yml`.
|
||||
|
||||
## Version Flow
|
||||
|
||||
Feature branches and `master` use different version modes because they solve different problems: feature builds need traceability to a commit, while `master` builds need stable semantic releases.
|
||||
|
||||
### Feature Branch Builds
|
||||
|
||||
On pushes to non-`master` branches, `feature-ci.yml` fans out to Linux and Windows reusable workflows. Each platform workflow computes a version string in the format `<branch-name>-<short-sha>` via `scripts/compute_feature_version.sh` (for example `feature-dark-mode-a1b2c3d`).
|
||||
|
||||
That version is used in two places:
|
||||
|
||||
- artifact file names
|
||||
- app embedded version via `-DCCM_APP_VERSION=...`
|
||||
|
||||
### Master Releases
|
||||
|
||||
When a PR is merged into `master`, `master-ci.yml` computes the next semantic version from the latest semver tag and the PR title prefix using `scripts/compute_master_semver.sh`.
|
||||
|
||||
Accepted prefix mapping:
|
||||
|
||||
- `major...` -> major bump
|
||||
- `minor...` -> minor bump
|
||||
- `fix...` -> patch bump
|
||||
- `patch...` -> patch bump
|
||||
- `path...` -> patch bump (accepted alias in current setup)
|
||||
|
||||
The resulting version is embedded in the app (`CCM_APP_VERSION`), used in artifact names, and tagged as `v<version>`.
|
||||
|
||||
## Master Merge Guard
|
||||
|
||||
PRs targeting `master` must start with one of the accepted prefixes:
|
||||
|
||||
- `major`
|
||||
- `minor`
|
||||
- `fix`
|
||||
- `patch`
|
||||
- `path`
|
||||
|
||||
If a title does not match, `master-pr-title-guard.yml` fails and the PR should not be merged.
|
||||
|
||||
## Artifact Naming
|
||||
|
||||
Feature workflow artifacts (produced by jobs inside `feature-ci.yml`):
|
||||
|
||||
- Windows: `ccm3-windows-<version>.zip`, `ccm3-windows-installer-<version>`
|
||||
- Linux: `ccm3-linux-<version>.zip`
|
||||
|
||||
Master release artifacts (`master-ci.yml`, built via `master-windows.yml`):
|
||||
|
||||
- `ccm3-windows-<semver>.zip`
|
||||
- `ccm3-windows-installer-<semver>.exe`
|
||||
|
||||
## Release Procedure
|
||||
|
||||
Follow this flow for every release:
|
||||
|
||||
1. Open a PR to `master`.
|
||||
2. Use a valid semver prefix in the PR title (`major:`, `minor:`, `fix:`, `patch:`, or `path:`).
|
||||
3. Wait for all required checks to pass.
|
||||
4. Merge the PR.
|
||||
5. Wait for `master-ci.yml` to complete semver computation, Windows build/test, tag creation, and release publishing.
|
||||
6. Verify the `vX.Y.Z` tag and release assets in GitHub.
|
||||
|
||||
## Local Build Version Behavior
|
||||
|
||||
Outside CI, the app version defaults to `${PROJECT_VERSION} (localbuild)` through `CCM_APP_VERSION` in the top-level `CMakeLists.txt`. This keeps local binaries easy to distinguish from CI and release outputs.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`msys2: command not found` in workflow logs:** ensure the MSYS2 setup step runs before jobs that use `shell: msys2 {0}`.
|
||||
- **`Permission denied` while linking `ccm3.exe`:** the app is still running; close it and rebuild.
|
||||
- **No release after merge:** verify the PR merged into `master` and used a valid prefix, then inspect `master-ci.yml` logs for version/tag/release failures.
|
||||
@@ -0,0 +1,129 @@
|
||||
#documentation #build #cmake
|
||||
|
||||
# Build Locally Guide
|
||||
|
||||
This guide explains how to build Card Collection Manager 3 on Windows and Linux, how dependencies are resolved, and how to run tests locally. For architecture orientation, see [Intro For New Developers](intro-to-new-developers.md).
|
||||
|
||||
**Quick Setup:** run CMake configure, build, launch `ccm3`, then run `ctest` from the same build directory.
|
||||
|
||||
## Build Model
|
||||
|
||||
The project uses CMake and builds one desktop executable:
|
||||
|
||||
- executable target: `ccm` (output binary: `ccm3` / `ccm3.exe`)
|
||||
- language standard: C++20
|
||||
- layered targets: `ccm_core` (logic), `ccm_ui_wx` (wx UI), `ccm` (composition root)
|
||||
|
||||
## Dependency Model
|
||||
|
||||
Dependencies are managed with CMake `FetchContent` in `cmake/Dependencies.cmake`.
|
||||
|
||||
Pinned versions:
|
||||
|
||||
- `nlohmann/json` `v3.11.3`
|
||||
- `libcpr/cpr` `1.10.5`
|
||||
- `wxWidgets` `v3.2.5`
|
||||
- `doctest` `v2.4.11` (only when tests are enabled)
|
||||
|
||||
On first configure, CMake downloads sources. On first full build, heavy dependencies (especially wxWidgets and curl) build locally. Later builds reuse cached dependencies under `build/_deps`.
|
||||
|
||||
## Use System wxWidgets
|
||||
|
||||
By default, the build fetches wxWidgets. For faster local iteration with an installed wxWidgets, set `-DCCM_USE_SYSTEM_WX=ON`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Windows
|
||||
|
||||
Recommended: Clang + Ninja
|
||||
|
||||
- LLVM/Clang 14+ on `PATH`
|
||||
- CMake 3.22+
|
||||
- Ninja on `PATH`
|
||||
|
||||
Verified fallback: MSYS2 UCRT64 + MinGW-w64 GCC
|
||||
|
||||
- MSYS2 UCRT64 toolchain (`gcc`, `cmake`, `make` or `ninja`)
|
||||
- CMake 3.22+
|
||||
|
||||
MSVC is intentionally not supported.
|
||||
|
||||
### Linux
|
||||
|
||||
- CMake 3.22+
|
||||
- Clang or GCC with C++20 support
|
||||
- Ninja recommended
|
||||
- required system packages when using system wxWidgets (for example GTK/wx dev packages)
|
||||
|
||||
## Build Commands
|
||||
|
||||
Run from repository root.
|
||||
|
||||
### Windows (Clang + Ninja)
|
||||
|
||||
```powershell
|
||||
cmake -S . -B build -G Ninja
|
||||
cmake --build build --parallel
|
||||
.\build\bin\ccm3.exe
|
||||
```
|
||||
|
||||
### Windows (MinGW Makefiles)
|
||||
|
||||
```powershell
|
||||
cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build --parallel 4
|
||||
.\build\bin\ccm3.exe
|
||||
```
|
||||
|
||||
### Linux (Ninja)
|
||||
|
||||
```bash
|
||||
cmake -S . -B build -G Ninja
|
||||
cmake --build build --parallel
|
||||
./build/bin/ccm3
|
||||
```
|
||||
|
||||
## Build Options
|
||||
|
||||
- `CCM_USE_SYSTEM_WX` (default `OFF`): use installed wxWidgets instead of fetched wxWidgets.
|
||||
- `CCM_BUILD_TESTS` (default `ON`): build `ccm_core_tests`.
|
||||
- `CMAKE_BUILD_TYPE` (commonly `Release`): standard CMake build type.
|
||||
- `CCM_APP_VERSION` (default `${PROJECT_VERSION} (localbuild)`): app version string shown in About dialog.
|
||||
|
||||
Example for faster local iteration:
|
||||
```bash
|
||||
cmake -S . -B build -DCCM_USE_SYSTEM_WX=ON -DCCM_BUILD_TESTS=OFF
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
## Run Tests Locally
|
||||
|
||||
From repository root:
|
||||
```bash
|
||||
cmake -S . -B build -DCCM_BUILD_TESTS=ON
|
||||
cmake --build build --target ccm_core_tests
|
||||
ctest --test-dir build --output-on-failure
|
||||
```
|
||||
|
||||
Automated tests primarily cover `core/` and infrastructure adapters. UI testing is currently manual.
|
||||
|
||||
## Runtime Notes
|
||||
|
||||
### Windows Runtime DLLs
|
||||
|
||||
`cpr` builds as shared, so `build/bin` contains runtime DLLs (for example `libcpr.dll`, `libcurl.dll`, `libzlib.dll`) next to `ccm3.exe`.
|
||||
|
||||
For MinGW/MSYS2 builds, UCRT runtime DLLs must be available (typically via MSYS2 UCRT64 `bin` on `PATH`).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **First build is slow:** expected on cold dependency fetch/build, especially wxWidgets and curl.
|
||||
- **`Permission denied` while linking `ccm3.exe`:** the app is still running; close it and rebuild.
|
||||
- **Generator mismatch:** reuse the same generator for a build directory or create a new build directory.
|
||||
- **Windows CI/local shell mismatch:** in CI jobs using `shell: msys2 {0}`, ensure MSYS2 setup runs before shell commands.
|
||||
|
||||
## Related Docs
|
||||
|
||||
- [CI/CD Guide](ci-cd-guide.md)
|
||||
- [Versioning Guide](versioning.md)
|
||||
- [Adding a new game to Card Collection Manager](adding-a-new-game.md)
|
||||
@@ -0,0 +1,108 @@
|
||||
#documentation #onboarding #architecture
|
||||
|
||||
# Intro For New Developers
|
||||
|
||||
This document orients new contributors to Card Collection Manager 3. For local setup and build commands, start with [Build Locally Guide](dow-doc-build-locally.md); then use this guide to understand architecture boundaries and daily workflows.
|
||||
|
||||
**Quick Setup:** read `AGENTS.md` files first, build once, run tests once, then make one small layer-scoped change to validate your environment.
|
||||
|
||||
## Toolchain Snapshot
|
||||
|
||||
Card Collection Manager 3 builds as a native C++ desktop binary with CMake. The project standard is C++20, with Clang as the preferred compiler on Windows and Linux, plus a verified MinGW-w64 fallback path on Windows.
|
||||
|
||||
- build system: CMake 3.22+ with `FetchContent`
|
||||
- language/toolchain: C++20, Clang preferred, MinGW-w64 GCC fallback on Windows
|
||||
- UI toolkit: wxWidgets (`ui_wx/` only)
|
||||
- REST/HTTP client library: `cpr` (libcurl-based, used through `IHttpClient`/`CprHttpClient`)
|
||||
- JSON library: `nlohmann/json`
|
||||
- test framework: `doctest`
|
||||
|
||||
Use [Build Locally Guide](dow-doc-build-locally.md) for exact commands, generator options, runtime DLL notes, and troubleshooting details.
|
||||
|
||||
## Project Shape
|
||||
|
||||
Card Collection Manager 3 is a native desktop app written in C++20 with wxWidgets. The architecture is intentionally layered so core logic stays UI-agnostic.
|
||||
|
||||
- `core/`: domain logic, services, and infrastructure adapters
|
||||
- `ui_wx/`: wxWidgets UI code only
|
||||
- `app/`: composition root that wires adapters, services, and views
|
||||
|
||||
Dependency direction is strict: `app -> ui_wx -> core`.
|
||||
|
||||
## Architecture Rules
|
||||
|
||||
These rules are the baseline for all feature work:
|
||||
|
||||
- `core/` must never include or depend on wxWidgets.
|
||||
- UI code consumes services through `ccm::ui::AppContext`.
|
||||
- Concrete adapter wiring belongs in `app/main.cpp`.
|
||||
- JSON keys and aliases are contract-sensitive and must stay stable.
|
||||
|
||||
## Repository Map
|
||||
|
||||
### `core/`
|
||||
|
||||
`core/` contains domain and non-UI behavior:
|
||||
|
||||
- `domain/`: value types and enums (`MagicCard`, `PokemonCard`, `Set`, `Configuration`)
|
||||
- `ports/`: seam interfaces (`IHttpClient`, `IFileSystem`, repositories, game seams)
|
||||
- `services/`: use-case logic (`CollectionService`, `SetService`, `ConfigService`)
|
||||
- `infra/`: concrete adapters (`Json*Repository`, `StdFileSystem`, `CprHttpClient`, `LocalImageStore`)
|
||||
- `games/`: per-game modules (`magic`, `pokemon`)
|
||||
|
||||
### `ui_wx/`
|
||||
|
||||
`ui_wx/` contains all presentation code:
|
||||
|
||||
- `MainFrame`: top-level shell and menu/split-view orchestration
|
||||
- `BaseCardListPanel`, `BaseCardEditDialog`, `BaseSelectedCardPanel`: reusable UI templates
|
||||
- `Magic*` and `Pokemon*` classes: game-specific view/panel implementations
|
||||
- `Theme.cpp`, `SvgIcons.cpp`, `IconListCtrl.cpp`: theming and visual behavior
|
||||
|
||||
### `app/`
|
||||
|
||||
`app/main.cpp` is the composition root:
|
||||
|
||||
- instantiate adapters, services, and modules
|
||||
- register game modules and game views
|
||||
- build `AppContext`
|
||||
- create and show `MainFrame`
|
||||
|
||||
Keep this file focused on wiring, not business logic.
|
||||
|
||||
### `tests/`
|
||||
|
||||
Tests target non-UI behavior with deterministic fakes and in-memory adapters. When a domain JSON contract or filesystem naming rule changes, update the matching tests in the same change.
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Add A Small Feature
|
||||
|
||||
1. Identify the correct layer (`core`, `ui_wx`, or both).
|
||||
2. Make the smallest coherent change in that layer.
|
||||
3. Rebuild the affected target.
|
||||
4. Run tests when core behavior changes.
|
||||
|
||||
### Add A New Game
|
||||
|
||||
Use [Adding a new game to Card Collection Manager](adding-a-new-game.md). Do not bypass the existing seams or invent parallel architecture for a new game.
|
||||
|
||||
### Release-Oriented Changes
|
||||
|
||||
Use [CI/CD Guide](ci-cd-guide.md) and [Versioning Guide](versioning.md) for workflow and release policy decisions.
|
||||
|
||||
## Avoid These Pitfalls
|
||||
|
||||
- adding wx headers in `core/`
|
||||
- putting business logic in `app/main.cpp`
|
||||
- accessing concrete adapters directly from UI code instead of `AppContext`
|
||||
- changing JSON key spellings casually
|
||||
- using unpinned dependency versions
|
||||
|
||||
## First-Day Checklist
|
||||
|
||||
1. Read repository `AGENTS.md` files (`core/`, `ui_wx/`, `app/`, `tests/`).
|
||||
2. Build locally with [Build Locally Guide](dow-doc-build-locally.md).
|
||||
3. Run the test suite once.
|
||||
4. Make one small layer-contained change.
|
||||
5. Rebuild and rerun relevant tests.
|
||||
@@ -0,0 +1,116 @@
|
||||
#documentation #testing #quality
|
||||
|
||||
# Testing Guide And Test Code Of Conduct
|
||||
|
||||
This guide defines how testing works in Card Collection Manager 3 and which standards test code must meet. For local build setup and toolchain prerequisites, see [Build Locally Guide](dow-doc-build-locally.md).
|
||||
|
||||
**Quick Setup:** run `ccm_core_tests` from a clean build, keep tests hermetic, and update contract tests in the same change when contracts move.
|
||||
|
||||
## Testing Focus
|
||||
|
||||
The project prioritizes deterministic, fast, behavior-oriented testing. Most automated coverage intentionally targets `core/` logic and infrastructure/service behavior, while UI validation remains manual.
|
||||
|
||||
## Test Setup
|
||||
|
||||
- framework: `doctest`
|
||||
- primary target: `ccm_core_tests`
|
||||
- location: `tests/`
|
||||
- default behavior: tests enabled via `CCM_BUILD_TESTS=ON`
|
||||
|
||||
## Run Tests
|
||||
|
||||
From repository root:
|
||||
```bash
|
||||
cmake -S . -B build -DCCM_BUILD_TESTS=ON
|
||||
cmake --build build --target ccm_core_tests
|
||||
ctest --test-dir build --output-on-failure
|
||||
```
|
||||
|
||||
Windows and Linux use the same logical flow; only generator and compiler setup differ.
|
||||
|
||||
## Coverage Surface
|
||||
|
||||
Current automated tests cover non-UI behavior, including:
|
||||
|
||||
- filesystem naming and parsing behavior
|
||||
- domain JSON round-trip behavior
|
||||
- service behavior (`CollectionService`, `ConfigService`, `SetService`)
|
||||
- repository behavior with in-memory filesystem fakes
|
||||
- game set-source parsing behavior
|
||||
|
||||
## Manual UI Validation
|
||||
|
||||
Because there is no UI automation, UI-affecting changes require manual checks:
|
||||
|
||||
- theme switching (dark and light)
|
||||
- dialog and popup behavior
|
||||
- add/edit/delete card flows
|
||||
- set update flows
|
||||
- preview and image interactions
|
||||
|
||||
For theming work, rebuild and run the final app target (`ccm`) instead of validating only static library targets.
|
||||
|
||||
## Test Code Of Conduct
|
||||
|
||||
### Keep Tests Hermetic
|
||||
|
||||
- do not call real network services
|
||||
- do not depend on local machine files
|
||||
- use fakes and in-memory adapters where possible
|
||||
|
||||
### Test Behavior, Not Internals
|
||||
|
||||
- assert externally visible outcomes
|
||||
- avoid brittle assertions tied to incidental implementation details
|
||||
- prefer domain-level expectations over call-level trivia
|
||||
|
||||
### Keep Tests Deterministic
|
||||
|
||||
- no unseeded randomness
|
||||
- no timing-sensitive assertions that can flap
|
||||
- no ordering assumptions unless ordering is part of the contract
|
||||
|
||||
### Keep Tests Readable
|
||||
|
||||
- one intent per test case
|
||||
- descriptive test names
|
||||
- clear arrange/act/assert flow
|
||||
- minimal abstraction for small tests
|
||||
|
||||
### Update Tests With Contract Changes
|
||||
|
||||
When contracts change, update tests in the same change:
|
||||
|
||||
- domain JSON schema or aliases -> round-trip tests
|
||||
- filename formatting or parsing -> filesystem naming tests
|
||||
- service semantics -> matching service tests
|
||||
|
||||
Behavior changes without aligned tests are incomplete.
|
||||
|
||||
### Avoid Over-Mocking
|
||||
|
||||
- prefer realistic fakes over mock-heavy tests
|
||||
- mock at external boundaries only when needed
|
||||
- preserve confidence in integration-shaped behavior paths
|
||||
|
||||
### Keep Runtime Practical
|
||||
|
||||
- keep suite runtime fast enough for frequent local execution
|
||||
- avoid repeated expensive setup when shared setup works
|
||||
- justify any expensive new suite and keep scope narrow
|
||||
|
||||
## Review Checklist
|
||||
|
||||
Before merging test changes, verify:
|
||||
|
||||
- tests pass locally
|
||||
- no new flakiness risk
|
||||
- no external dependency introduced
|
||||
- assertions reflect intended behavior
|
||||
- failure messages are clear and actionable
|
||||
|
||||
## Related Docs
|
||||
|
||||
- [Build Locally Guide](dow-doc-build-locally.md)
|
||||
- [Intro For New Developers](intro-to-new-developers.md)
|
||||
- [Adding a new game to Card Collection Manager](adding-a-new-game.md)
|
||||
@@ -0,0 +1,89 @@
|
||||
#documentation #versioning #releases
|
||||
|
||||
# Versioning Guide
|
||||
|
||||
This guide defines how Card Collection Manager 3 assigns versions in CI and release flows. For workflow wiring and release execution details, see [CI/CD Guide](ci-cd-guide.md).
|
||||
|
||||
**Quick Setup:** choose a valid PR title prefix before opening a `master` PR because the prefix determines the release bump.
|
||||
|
||||
## Versioning Model
|
||||
|
||||
The project uses two versioning modes:
|
||||
|
||||
- feature-build versioning for non-`master` branches
|
||||
- semantic versioning for merged PRs into `master`
|
||||
|
||||
## Feature-Build Versioning
|
||||
|
||||
Non-`master` pushes use `<branch-name>-<short-sha>` (for example `feature-dark-theme-a1b2c3d` or `fix-sort-order-f91d2ab`).
|
||||
|
||||
Rules:
|
||||
|
||||
- branch names are sanitized and lowercased
|
||||
- commit SHA is shortened to 7 characters
|
||||
- computation runs in `scripts/compute_feature_version.sh`
|
||||
|
||||
Usage:
|
||||
|
||||
- artifact names
|
||||
- app embedded version (`CCM_APP_VERSION`, visible in `Help -> About`)
|
||||
|
||||
## Master Semantic Versioning
|
||||
|
||||
Merged PRs into `master` use semantic versions in `MAJOR.MINOR.PATCH` format (for example `1.4.2`).
|
||||
|
||||
CI computes the next version from the latest semver tag and the PR title prefix:
|
||||
|
||||
- `major...` -> bump `MAJOR`, reset `MINOR` and `PATCH` to `0`
|
||||
- `minor...` -> bump `MINOR`, reset `PATCH` to `0`
|
||||
- `fix...` -> bump `PATCH`
|
||||
- `patch...` -> bump `PATCH`
|
||||
- `path...` -> bump `PATCH` (accepted alias in current setup)
|
||||
|
||||
Computation runs in `scripts/compute_master_semver.sh`.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
If the PR title prefix is not accepted, two controls fail by design:
|
||||
|
||||
- PR title guard check for `master` PRs
|
||||
- semantic-version script validation
|
||||
|
||||
This enforcement keeps release bumps deterministic and reviewable.
|
||||
|
||||
## Tag Format
|
||||
|
||||
Master releases create git tags in this format:
|
||||
|
||||
- `v<semantic-version>`
|
||||
|
||||
Examples:
|
||||
|
||||
- `v1.0.0`
|
||||
- `v2.3.7`
|
||||
|
||||
## Embedded App Version
|
||||
|
||||
The app embeds a build-time version string through CMake variable `CCM_APP_VERSION`.
|
||||
|
||||
CI behavior:
|
||||
|
||||
- feature workflows set it to `<branch>-<sha>`
|
||||
- master release workflow sets it to semantic version
|
||||
|
||||
Local/manual behavior:
|
||||
|
||||
- default is `${PROJECT_VERSION} (localbuild)` unless overridden
|
||||
|
||||
This default makes local binaries easy to distinguish from CI and release outputs.
|
||||
|
||||
## PR Title Conventions
|
||||
|
||||
Use explicit prefixes in this shape:
|
||||
|
||||
- `major: <summary>`
|
||||
- `minor: <summary>`
|
||||
- `fix: <summary>`
|
||||
- `patch: <summary>`
|
||||
|
||||
Example: `minor: add custom themed confirmation dialogs`.
|
||||
@@ -0,0 +1,128 @@
|
||||
#documentation #installer #nsis #windows
|
||||
|
||||
# Windows Installer Guide
|
||||
|
||||
This guide explains how the Windows installer for Card Collection Manager 3 is built, how it is configured, and how it cooperates with the rest of the build/release pipeline. For CI flow and artifact naming, see [CI/CD Guide](ci-cd-guide.md). For where the version string comes from, see [Versioning Guide](versioning.md).
|
||||
|
||||
**Quick Setup:** install NSIS (`makensis` on `PATH`), build the app into `build/bin/`, then run `makensis -DAPP_VERSION="<version>" scripts/installer.nsi` from the repository root.
|
||||
|
||||
## Installer Model
|
||||
|
||||
The installer is a single NSIS script: `scripts/installer.nsi`. It produces one self-contained executable that ships the entire `build/bin/` directory plus an embedded uninstaller.
|
||||
|
||||
Key facts:
|
||||
|
||||
- installer technology: NSIS (`makensis`) with the Modern UI 2 (MUI2) library
|
||||
- output file: `ccm3-windows-installer.exe`, written to the repository root (resolved as `..\ccm3-windows-installer.exe` from `scripts/`)
|
||||
- payload source: every file under `build/bin/` (resolved as `..\build\bin\*.*` from `scripts/`)
|
||||
- icon: `scripts/installer_icon.ico` (resolved relative to `scripts/`)
|
||||
- default install location: `%PROGRAMFILES64%\Card Collection Manager 3`
|
||||
- elevation: `RequestExecutionLevel admin`
|
||||
|
||||
Because the installer pulls from `build/bin/` directly, it must run **after** a successful release build that has been bundled with all required runtime DLLs (see [Build Locally Guide](dow-doc-build-locally.md) for what ends up in `build/bin/`).
|
||||
|
||||
## Configuration Inputs
|
||||
|
||||
Almost everything the installer needs is hard-coded in `scripts/installer.nsi`. The only configurable input is the version string, supplied at `makensis` time:
|
||||
|
||||
- `APP_VERSION` — passed via `-DAPP_VERSION="<value>"`. Falls back to `"localbuild"` if not provided, so manual local runs still work.
|
||||
|
||||
This single value is reused in three visible places, so the installer, the uninstaller, and the OS Programs and Features entry all advertise the same version:
|
||||
|
||||
- installer/uninstaller window title (NSIS `Name`): `Card Collection Manager 3 <version>`
|
||||
- footer / branding text on every wizard page (NSIS `BrandingText`): `Card Collection Manager 3 <version>`
|
||||
- Add/Remove Programs `DisplayVersion` registry value, so Windows shows the version in its own column
|
||||
|
||||
The MUI welcome page and the uninstall confirm page reference `$(^Name)` internally, so embedding the version into `Name` is enough to make those pages say "Welcome to the Card Collection Manager 3 \<version\> Setup Wizard" and "Card Collection Manager 3 \<version\> will be uninstalled..." respectively, without any extra wiring.
|
||||
|
||||
## Installed Sections
|
||||
|
||||
The installer presents three sections on the Components page:
|
||||
|
||||
- **Core files (required)** — read-only (`SectionIn RO`). Copies the full `build/bin/` payload into `$INSTDIR`, writes the uninstaller, and registers the Add/Remove Programs entry plus an `App Paths` entry so `ccm3` resolves from `Win+R`.
|
||||
- **Start Menu shortcuts** — creates a shortcut at the top level of the Start Menu plus a `Card Collection Manager 3` folder containing both the app shortcut and an "Uninstall" shortcut. Uses `SetShellVarContext all` so shortcuts go to the all-users Start Menu.
|
||||
- **Desktop shortcut** — creates a desktop shortcut for all users.
|
||||
|
||||
Shortcut names intentionally do **not** include the version, so installing a newer version overwrites the existing shortcuts cleanly instead of leaving orphaned per-version entries behind.
|
||||
|
||||
## Registry Layout
|
||||
|
||||
The installer writes two registry roots, both under `HKLM` so an uninstall removes them cleanly regardless of which user launches it:
|
||||
|
||||
- `Software\Microsoft\Windows\CurrentVersion\Uninstall\Card Collection Manager 3` (the Add/Remove Programs entry):
|
||||
- `DisplayName` — product name
|
||||
- `DisplayVersion` — value of `APP_VERSION`
|
||||
- `DisplayIcon` — path to `ccm3.exe`
|
||||
- `UninstallString` / `QuietUninstallString` — interactive and silent uninstall commands
|
||||
- `InstallLocation` — `$INSTDIR`
|
||||
- `NoModify` / `NoRepair` — both `1` (we do not implement modify/repair flows)
|
||||
- `Software\Microsoft\Windows\CurrentVersion\App Paths\ccm3.exe`:
|
||||
- default value — full path to `ccm3.exe`
|
||||
- `Path` — `$INSTDIR` so child processes inherit DLL search rights
|
||||
|
||||
The uninstall section (`Section "Uninstall"`) deletes both roots and removes the install directory and all created shortcuts. It uses `RMDir /r "$INSTDIR"` because the install dir is owned by the app.
|
||||
|
||||
## Build Commands
|
||||
|
||||
Run from the repository root.
|
||||
|
||||
### Local manual build
|
||||
|
||||
```powershell
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build --parallel
|
||||
makensis -DAPP_VERSION="0.1.0-localbuild" scripts/installer.nsi
|
||||
```
|
||||
|
||||
The output is `ccm3-windows-installer.exe` at the repository root. With no `-DAPP_VERSION`, the installer self-labels as `localbuild` instead.
|
||||
|
||||
### CI build
|
||||
|
||||
Both Windows workflows install NSIS via MSYS2 (`mingw-w64-ucrt-x86_64-nsis`) and invoke the same script:
|
||||
|
||||
```yaml
|
||||
- name: Build Windows installer
|
||||
run: makensis -DAPP_VERSION="${VERSION}" scripts/installer.nsi
|
||||
```
|
||||
|
||||
The `${VERSION}` value comes from:
|
||||
|
||||
- `scripts/compute_master_semver.sh` for merged `master` PRs (semantic version, e.g. `1.2.3`)
|
||||
- `scripts/compute_feature_version.sh` for non-`master` branches (e.g. `feature-dark-mode-a1b2c3d`)
|
||||
|
||||
The exact same `${VERSION}` is also passed to `cmake -DCCM_APP_VERSION=...`, so the installer/uninstaller, the Programs and Features entry, and the running app's About dialog always agree.
|
||||
|
||||
## Artifact Names
|
||||
|
||||
The CI artifacts produced from a single installer build are documented in [CI/CD Guide](ci-cd-guide.md), but for reference:
|
||||
|
||||
- raw build output: `ccm3-windows-installer.exe` (in repo root, regardless of version)
|
||||
- feature workflow artifact: `ccm3-windows-installer-<version>` (folder containing the exe)
|
||||
- master release asset: `ccm3-windows-installer-<semver>.exe` (renamed at release-asset packaging time)
|
||||
|
||||
Renaming happens in the workflow's release-assets step, not in `installer.nsi`, so the script's `OutFile` is intentionally fixed.
|
||||
|
||||
## Editing Rules
|
||||
|
||||
When changing the installer:
|
||||
|
||||
- edit `scripts/installer.nsi` and keep both `.github/workflows/feature-windows.yml` and `.github/workflows/master-windows.yml` invoking it the same way
|
||||
- pass the version through `-DAPP_VERSION="${VERSION}"` so the installer, uninstaller, and Programs and Features stay in sync with `CCM_APP_VERSION`
|
||||
- keep installer assets that should not be generated at runtime (such as `installer_icon.ico`) committed in `scripts/`
|
||||
- keep shortcut display names version-agnostic so upgrades do not orphan old shortcuts
|
||||
- if you add a new registry value to the uninstall key, mirror it in the `Section "Uninstall"` cleanup if it lives outside that key
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`makensis: command not found`:** install NSIS and ensure `makensis` is on `PATH`. In CI this is provided by the MSYS2 package `mingw-w64-ucrt-x86_64-nsis`.
|
||||
- **`File: ... \build\bin\*.*` failures:** the build payload is missing. Run `cmake --build build --parallel` first and confirm `build/bin/ccm3.exe` plus the runtime DLLs exist (see [Build Locally Guide](dow-doc-build-locally.md)).
|
||||
- **Installer self-labels as `localbuild` in CI:** `-DAPP_VERSION="${VERSION}"` was not passed, or `${VERSION}` was empty. Check that the workflow's version-computation step ran before the installer step and exported `VERSION`.
|
||||
- **Programs and Features does not show a version:** the `DisplayVersion` registry write was skipped because the installer was built without `APP_VERSION` (or with an empty value). Rebuild with the define set.
|
||||
- **Old shortcuts left behind after upgrade:** shortcut display names were changed (or were made version-specific). Restore the version-agnostic names so upgrades overwrite cleanly.
|
||||
- **Uninstaller appears to leave files:** `RMDir /r "$INSTDIR"` does not remove files outside `$INSTDIR`. Anything written by the app at runtime under user profile paths is intentionally kept; the uninstaller only manages what the installer placed.
|
||||
|
||||
## Related Docs
|
||||
|
||||
- [CI/CD Guide](ci-cd-guide.md)
|
||||
- [Versioning Guide](versioning.md)
|
||||
- [Build Locally Guide](dow-doc-build-locally.md)
|
||||
@@ -0,0 +1,28 @@
|
||||
# AGENTS.md
|
||||
|
||||
Utility scripts and CI helper assets live in this folder.
|
||||
|
||||
## Scope
|
||||
|
||||
- Keep CI-facing scripts and config here so workflows stay concise.
|
||||
- Current contents:
|
||||
- `installer.nsi` — NSIS installer definition used by the Windows CI job.
|
||||
- `installer_icon.ico` — committed installer icon consumed by `installer.nsi`.
|
||||
- `compute_feature_version.sh` — branch+sha version formatter for feature CI workflows.
|
||||
- `compute_master_semver.sh` — semantic version bump logic for merged PRs to `master`.
|
||||
|
||||
## Editing rules
|
||||
|
||||
- Prefer referencing script files from workflows instead of embedding large inline scripts.
|
||||
- Keep scripts deterministic and non-interactive so CI can run unattended.
|
||||
- Keep versioning scripts strict and fail-fast (non-zero exit) when required title/version conventions are violated.
|
||||
- When changing installer behavior, edit `installer.nsi` and ensure both `.github/workflows/feature-windows.yml` and `.github/workflows/master-windows.yml` still invoke it via `makensis -DAPP_VERSION="${VERSION}" scripts/installer.nsi`.
|
||||
- Keep installer assets that should not be generated at runtime (such as icon files) committed in this folder.
|
||||
- `installer.nsi` path semantics:
|
||||
- `installer_icon.ico` is resolved relative to `scripts/`.
|
||||
- build payload is loaded from `..\build\bin\*.*` (project root build output).
|
||||
- installer output is written to `..\ccm3-windows-installer.exe` so CI upload paths can stay root-based.
|
||||
- `installer.nsi` version semantics:
|
||||
- `APP_VERSION` is supplied by CI (`makensis -DAPP_VERSION=...`) using the same `VERSION` value that is fed into `-DCCM_APP_VERSION` for the C++ build, so the installer, uninstaller, Add/Remove Programs entry, and the running app all advertise the same version.
|
||||
- When `APP_VERSION` is not provided (manual local `makensis` runs), it defaults to `"localbuild"`.
|
||||
- If a script depends on generated files, document those expectations in the script or workflow step that creates them.
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Usage: $0 <branch-name> <commit-sha>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
branch_name="$1"
|
||||
commit_sha="$2"
|
||||
|
||||
branch_safe="$(echo "${branch_name}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^-+|-+$//g')"
|
||||
short_sha="$(echo "${commit_sha}" | cut -c1-7)"
|
||||
|
||||
if [[ -z "${branch_safe}" ]]; then
|
||||
branch_safe="branch"
|
||||
fi
|
||||
|
||||
echo "${branch_safe}-${short_sha}"
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: $0 <pr-title>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pr_title="$(echo "$1" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
if [[ "${pr_title}" == major* ]]; then
|
||||
bump="major"
|
||||
elif [[ "${pr_title}" == minor* ]]; then
|
||||
bump="minor"
|
||||
elif [[ "${pr_title}" == fix* || "${pr_title}" == patch* || "${pr_title}" == path* ]]; then
|
||||
bump="patch"
|
||||
else
|
||||
echo "Invalid PR title prefix. Must start with: major, minor, fix, patch, or path." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
latest="$(git tag --list | sed -E 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -t. -k1,1nr -k2,2nr -k3,3nr | head -n1 || true)"
|
||||
if [[ -z "${latest}" ]]; then
|
||||
latest="0.0.0"
|
||||
fi
|
||||
|
||||
IFS='.' read -r major minor patch <<< "${latest}"
|
||||
|
||||
case "${bump}" in
|
||||
major)
|
||||
major=$((major + 1))
|
||||
minor=0
|
||||
patch=0
|
||||
;;
|
||||
minor)
|
||||
minor=$((minor + 1))
|
||||
patch=0
|
||||
;;
|
||||
patch)
|
||||
patch=$((patch + 1))
|
||||
;;
|
||||
esac
|
||||
|
||||
version="${major}.${minor}.${patch}"
|
||||
release_tag="v${version}"
|
||||
|
||||
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
|
||||
{
|
||||
echo "version=${version}"
|
||||
echo "release_tag=${release_tag}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "${version}"
|
||||
fi
|
||||
@@ -0,0 +1,76 @@
|
||||
Unicode True
|
||||
!include "MUI2.nsh"
|
||||
|
||||
!define APP_NAME "Card Collection Manager 3"
|
||||
!define APP_DIR "Card Collection Manager 3"
|
||||
!define APP_EXE "ccm3.exe"
|
||||
!define APP_ICON "installer_icon.ico"
|
||||
!define REG_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Card Collection Manager 3"
|
||||
!define APP_PATHS_KEY "Software\Microsoft\Windows\CurrentVersion\App Paths\ccm3.exe"
|
||||
|
||||
; APP_VERSION is supplied by CI via `makensis -DAPP_VERSION=...`. Falls back to
|
||||
; "localbuild" so manual runs from a developer machine still work.
|
||||
!ifndef APP_VERSION
|
||||
!define APP_VERSION "localbuild"
|
||||
!endif
|
||||
|
||||
Name "${APP_NAME} ${APP_VERSION}"
|
||||
OutFile "..\ccm3-windows-installer.exe"
|
||||
Icon "${APP_ICON}"
|
||||
UninstallIcon "${APP_ICON}"
|
||||
BrandingText "${APP_NAME} ${APP_VERSION}"
|
||||
InstallDir "$PROGRAMFILES64\${APP_DIR}"
|
||||
RequestExecutionLevel admin
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
!insertmacro MUI_PAGE_COMPONENTS
|
||||
!insertmacro MUI_PAGE_INSTFILES
|
||||
!insertmacro MUI_PAGE_FINISH
|
||||
|
||||
!insertmacro MUI_UNPAGE_CONFIRM
|
||||
!insertmacro MUI_UNPAGE_INSTFILES
|
||||
!insertmacro MUI_LANGUAGE "English"
|
||||
|
||||
Section "!Core files (required)"
|
||||
SectionIn RO
|
||||
SetOutPath "$INSTDIR"
|
||||
File /r "..\build\bin\*.*"
|
||||
WriteUninstaller "$INSTDIR\Uninstall.exe"
|
||||
WriteRegStr HKLM "${REG_KEY}" "DisplayName" "${APP_NAME}"
|
||||
WriteRegStr HKLM "${REG_KEY}" "DisplayVersion" "${APP_VERSION}"
|
||||
WriteRegStr HKLM "${REG_KEY}" "DisplayIcon" "$INSTDIR\${APP_EXE}"
|
||||
WriteRegStr HKLM "${REG_KEY}" "UninstallString" "$\"$INSTDIR\Uninstall.exe$\""
|
||||
WriteRegStr HKLM "${REG_KEY}" "QuietUninstallString" "$\"$INSTDIR\Uninstall.exe$\" /S"
|
||||
WriteRegStr HKLM "${REG_KEY}" "InstallLocation" "$INSTDIR"
|
||||
WriteRegDWORD HKLM "${REG_KEY}" "NoModify" 1
|
||||
WriteRegDWORD HKLM "${REG_KEY}" "NoRepair" 1
|
||||
WriteRegStr HKLM "${APP_PATHS_KEY}" "" "$INSTDIR\${APP_EXE}"
|
||||
WriteRegStr HKLM "${APP_PATHS_KEY}" "Path" "$INSTDIR"
|
||||
SectionEnd
|
||||
|
||||
Section "Start Menu shortcuts"
|
||||
SetShellVarContext all
|
||||
CreateDirectory "$SMPROGRAMS\${APP_DIR}"
|
||||
CreateShortcut "$SMPROGRAMS\${APP_NAME}.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0
|
||||
CreateShortcut "$SMPROGRAMS\${APP_DIR}\${APP_NAME}.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0
|
||||
CreateShortcut "$SMPROGRAMS\${APP_DIR}\Uninstall ${APP_NAME}.lnk" "$INSTDIR\Uninstall.exe"
|
||||
SectionEnd
|
||||
|
||||
Section "Desktop shortcut"
|
||||
SetShellVarContext all
|
||||
CreateShortcut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0
|
||||
SectionEnd
|
||||
|
||||
Section "Uninstall"
|
||||
SetShellVarContext all
|
||||
Delete "$DESKTOP\${APP_NAME}.lnk"
|
||||
Delete "$SMPROGRAMS\${APP_NAME}.lnk"
|
||||
Delete "$SMPROGRAMS\${APP_DIR}\${APP_NAME}.lnk"
|
||||
Delete "$SMPROGRAMS\${APP_DIR}\Uninstall ${APP_NAME}.lnk"
|
||||
RMDir "$SMPROGRAMS\${APP_DIR}"
|
||||
|
||||
RMDir /r "$INSTDIR"
|
||||
DeleteRegKey HKLM "${REG_KEY}"
|
||||
DeleteRegKey HKLM "${APP_PATHS_KEY}"
|
||||
SectionEnd
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,58 @@
|
||||
# tests/AGENTS.md
|
||||
|
||||
`ccm_core_tests` — doctest unit tests for `ccm_core`. Hermetic, fast, no real network or disk. Read the root `AGENTS.md` first.
|
||||
|
||||
## File pointers
|
||||
|
||||
- `main.cpp` — doctest entry point with `DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN`. Do not put tests here.
|
||||
- `fakes/InMemoryFileSystem.{hpp,cpp}` — `IFileSystem` implementation backed by `std::map`. Normalizes paths via `lexically_normal().generic_string()` (always `/` separators).
|
||||
- `fs_names_tests.cpp` — `formatTextForFs` + `parseIndexFromFilename`. Both are compatibility ports of the original Rust path rules.
|
||||
- `domain_json_tests.cpp` — JSON round-trip tests for every domain type. **Update this file whenever a domain type changes.**
|
||||
- `image_service_tests.cpp` — `ImageService` (uses inline `RecordingImageStore` fake).
|
||||
- `collection_service_tests.cpp` — `CollectionService<MagicCard>` (uses inline `InMemoryRepo` + `StubImageStore`).
|
||||
- `config_service_tests.cpp` — `ConfigService` against `InMemoryFileSystem`.
|
||||
- `json_collection_repository_tests.cpp`, `json_set_repository_tests.cpp` — repository round-trips against `InMemoryFileSystem`.
|
||||
- `set_service_tests.cpp` — `SetService` with `FakeSetSource` + `InMemSetRepo`.
|
||||
- `magic_set_source_tests.cpp` — `MagicSetSource::parseResponse` (Scryfall mapping). Drives `fetchAll` via `FixedHttpClient` fake.
|
||||
- `magic_card_preview_source_tests.cpp` — `MagicCardPreviewSource::buildSearchUrl` URL-encoding rules + `parseResponse` (`data[0].image_uris.normal`). Drives `fetchImageUrl` via `FixedHttpClient`.
|
||||
- `card_preview_service_tests.cpp` — `CardPreviewService` registry/orchestration through `registerModule(IGameModule&)` with an inline `FakeGameModule` returning a `FakeSource : ICardPreviewSource` and a `FixedHttpClient`. Pin-down for the "module returning nullptr is silently skipped" rule.
|
||||
- `pokemon_set_source_tests.cpp` — `PokemonSetSource::parseResponse` (api.pokemontcg.io/v2/sets shape — `data[].id`, `name`, `releaseDate` already in `YYYY/MM/DD`) + sort-by-release-date stability. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL.
|
||||
- `pokemon_card_preview_source_tests.cpp` — `PokemonCardPreviewSource::buildSearchUrl` (percent-encoded `name:` / `set.id:` / `number:` triple, with collector-number `4/102` -> `4` normalization) + `parseResponse` (`data[0].images.large` with `images.small` fallback). Drives `fetchImageUrl` via `FixedHttpClient`.
|
||||
- `card_sorter_tests.cpp` — `sortMagicCards` / `sortPokemonCards` per-column behavior. Pin-down tests for `byField`-equivalent semantics: case-insensitive strings, chronological set sort via `set.releaseDate`, numeric `amount`, `false < true` boolean order, stable composition (sort by name then by set keeps inner-name order). Update this file whenever you add a new column / sort key.
|
||||
- `card_filter_tests.cpp` — `matchesMagicFilter` / `matchesPokemonFilter` row-matcher behavior. Pin-down tests for `applyFilter`-equivalent semantics: case-insensitive substring match across `tableFields` valueKeys (name, set.name, language, condition, amount-as-string, note; Pokemon adds `setNo`), boolean flag columns (foil/signed/altered/holo/firstEdition) intentionally excluded, empty filter matches everything. Update this file whenever you add a new searchable column.
|
||||
- `CMakeLists.txt` — explicit list of every `.cpp` (no glob).
|
||||
|
||||
## Conventions
|
||||
|
||||
1. **Framework**: doctest. Each test file `#include <doctest/doctest.h>` and uses `TEST_SUITE("...")` + `TEST_CASE("...")`. Asserts: `CHECK`, `REQUIRE`, `CHECK_THROWS`.
|
||||
2. **No real I/O.** Everything goes through `ccm::testing::InMemoryFileSystem` or an inline test-local fake. If you need HTTP, write a fake `IHttpClient` like `FixedHttpClient` in `magic_set_source_tests.cpp`.
|
||||
3. **Fakes for narrow concerns stay in the test file** as anonymous-namespace classes (e.g. `RecordingImageStore`, `InMemoryRepo`). Promote a fake to `tests/fakes/` only when more than one test file needs it.
|
||||
4. **Path strings** in expectations must use forward slashes. The fake normalizes everything to `generic_string()`. Do not hard-code `\` separators.
|
||||
5. **Test names** describe behavior, not implementation. Prefer "missing file is created with defaults" over "test_init_no_file".
|
||||
6. **Add a `.cpp` to the `add_executable` call in `tests/CMakeLists.txt`.** No glob.
|
||||
|
||||
## Required follow-ups
|
||||
|
||||
- After modifying any domain type field or alias you **must** extend the matching test in `domain_json_tests.cpp`.
|
||||
- After modifying `formatTextForFs` or `parseIndexFromFilename` you **must** extend `fs_names_tests.cpp` — these are byte-compatibility shims with the original Rust code.
|
||||
- After adding a new service in `core/` you **must** add a corresponding `<name>_service_tests.cpp` with at least the happy-path and one error-path test.
|
||||
- After adding a new game's set source / card preview source you **must** add `tests/<name>_set_source_tests.cpp` and (if applicable) `tests/<name>_card_preview_source_tests.cpp` mirroring the Magic and Pokemon files. Add them to `tests/CMakeLists.txt`.
|
||||
|
||||
## Commands
|
||||
|
||||
- Configure with tests on:
|
||||
`cmake -S . -B build -DCCM_BUILD_TESTS=ON`
|
||||
- Build the suite:
|
||||
`cmake --build build --target ccm_core_tests`
|
||||
- Run all tests:
|
||||
`ctest --test-dir build --output-on-failure`
|
||||
- Run a single test by name pattern:
|
||||
`./build/bin/ccm_core_tests --test-case="*nextImageIndex*"`
|
||||
- Run a single suite:
|
||||
`./build/bin/ccm_core_tests --test-suite="MagicSetSource::parseResponse"`
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Don't depend on `ccm_ui_wx` from tests. UI is out of scope here.
|
||||
- Don't rely on file paths existing on the host (no `/tmp`, no `C:\Users\...`). Use `InMemoryFileSystem`.
|
||||
- Don't add tests that require network access. The Pokemon stub test checks the message, not a real API call.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Unit tests for ccm_core. Wx and infra-bound tests are out of scope here -
|
||||
# everything in this target operates against the ccm_core ports/services using
|
||||
# in-memory fakes, so the suite is fast and hermetic.
|
||||
|
||||
add_executable(ccm_core_tests
|
||||
fakes/InMemoryFileSystem.cpp
|
||||
|
||||
fs_names_tests.cpp
|
||||
domain_json_tests.cpp
|
||||
image_service_tests.cpp
|
||||
collection_service_tests.cpp
|
||||
config_service_tests.cpp
|
||||
json_collection_repository_tests.cpp
|
||||
json_set_repository_tests.cpp
|
||||
set_service_tests.cpp
|
||||
magic_set_source_tests.cpp
|
||||
magic_card_preview_source_tests.cpp
|
||||
card_preview_service_tests.cpp
|
||||
pokemon_set_source_tests.cpp
|
||||
pokemon_card_preview_source_tests.cpp
|
||||
card_sorter_tests.cpp
|
||||
card_filter_tests.cpp
|
||||
|
||||
main.cpp
|
||||
)
|
||||
|
||||
target_include_directories(ccm_core_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
target_link_libraries(ccm_core_tests
|
||||
PRIVATE
|
||||
ccm_core
|
||||
doctest::doctest
|
||||
ccm_warnings
|
||||
)
|
||||
|
||||
include(${doctest_SOURCE_DIR}/scripts/cmake/doctest.cmake)
|
||||
doctest_discover_tests(ccm_core_tests)
|
||||
@@ -0,0 +1,160 @@
|
||||
// CardFilter tests - exercises the per-row matcher ported from the table
|
||||
// TableTemplate.tsx::applyFilter. Each TEST_CASE pins down a behavior the
|
||||
// original UI relied on, so a regression here implies the C++ table no
|
||||
// longer filters like the TypeScript reference.
|
||||
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/services/CardFilter.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
MagicCard mc(std::string name,
|
||||
std::string setName,
|
||||
std::uint8_t amount = 1,
|
||||
Language lang = Language::English,
|
||||
Condition cond = Condition::NearMint,
|
||||
std::string note = "",
|
||||
bool foil = false, bool sgnd = false, bool altered = false) {
|
||||
MagicCard c;
|
||||
c.id = 1;
|
||||
c.name = std::move(name);
|
||||
c.set.name = std::move(setName);
|
||||
c.amount = amount;
|
||||
c.language = lang;
|
||||
c.condition = cond;
|
||||
c.note = std::move(note);
|
||||
c.foil = foil;
|
||||
c.signed_ = sgnd;
|
||||
c.altered = altered;
|
||||
return c;
|
||||
}
|
||||
|
||||
PokemonCard pc(std::string name,
|
||||
std::string setName,
|
||||
std::string setNo = "",
|
||||
std::uint8_t amount = 1,
|
||||
std::string note = "") {
|
||||
PokemonCard c;
|
||||
c.id = 1;
|
||||
c.name = std::move(name);
|
||||
c.set.name = std::move(setName);
|
||||
c.setNo = std::move(setNo);
|
||||
c.amount = amount;
|
||||
c.note = std::move(note);
|
||||
return c;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("CardFilter::matchesMagicFilter") {
|
||||
TEST_CASE("empty filter matches every row (mirrors JS \"\".includes(\"\"))") {
|
||||
CHECK(matchesMagicFilter(mc("Brainstorm", "Alpha"), ""));
|
||||
}
|
||||
|
||||
TEST_CASE("matches by name (case-insensitive substring)") {
|
||||
const MagicCard c = mc("Lightning Bolt", "Beta");
|
||||
CHECK(matchesMagicFilter(c, "lightning"));
|
||||
CHECK(matchesMagicFilter(c, "BOLT")); // both sides lowercased
|
||||
CHECK(matchesMagicFilter(c, "ning Bo")); // mid-string substring
|
||||
CHECK_FALSE(matchesMagicFilter(c, "fireball"));
|
||||
}
|
||||
|
||||
TEST_CASE("matches by set.name (valueKey 'set.name', not 'set')") {
|
||||
const MagicCard c = mc("Brainstorm", "Modern Horizons");
|
||||
CHECK(matchesMagicFilter(c, "Modern"));
|
||||
CHECK(matchesMagicFilter(c, "horizons"));
|
||||
CHECK_FALSE(matchesMagicFilter(c, "Zendikar"));
|
||||
}
|
||||
|
||||
TEST_CASE("matches by language label") {
|
||||
const MagicCard c = mc("Brainstorm", "Alpha", 1, Language::Japanese);
|
||||
CHECK(matchesMagicFilter(c, "japanese"));
|
||||
CHECK_FALSE(matchesMagicFilter(c, "german"));
|
||||
}
|
||||
|
||||
TEST_CASE("matches by condition label") {
|
||||
const MagicCard c = mc("Brainstorm", "Alpha", 1,
|
||||
Language::English, Condition::Played);
|
||||
CHECK(matchesMagicFilter(c, "played"));
|
||||
CHECK_FALSE(matchesMagicFilter(c, "mint"));
|
||||
}
|
||||
|
||||
TEST_CASE("matches by amount as decimal string (val.toString())") {
|
||||
// JS used `val.toString().toLowerCase().includes(filter)` so the
|
||||
// *integer* amount becomes searchable as its decimal representation.
|
||||
const MagicCard c = mc("Brainstorm", "Alpha", 17);
|
||||
CHECK(matchesMagicFilter(c, "17"));
|
||||
CHECK(matchesMagicFilter(c, "1")); // substring match: "1" in "17"
|
||||
CHECK_FALSE(matchesMagicFilter(c, "99"));
|
||||
}
|
||||
|
||||
TEST_CASE("matches by note (case-insensitive)") {
|
||||
const MagicCard c = mc("Brainstorm", "Alpha", 1,
|
||||
Language::English, Condition::NearMint,
|
||||
"Birthday gift");
|
||||
CHECK(matchesMagicFilter(c, "Birthday"));
|
||||
CHECK(matchesMagicFilter(c, "GIFT"));
|
||||
CHECK_FALSE(matchesMagicFilter(c, "trade"));
|
||||
}
|
||||
|
||||
TEST_CASE("boolean flag columns (foil / signed / altered) are NOT matched") {
|
||||
// Filtering checks only string- or number-typed cells (typeof check). The
|
||||
// bool flag columns therefore must not match the literal "true" /
|
||||
// "false" — even when the flag is set.
|
||||
const MagicCard c = mc("Brainstorm", "Alpha", 1,
|
||||
Language::English, Condition::NearMint, "",
|
||||
/*foil=*/true, /*sgnd=*/true, /*altered=*/true);
|
||||
CHECK_FALSE(matchesMagicFilter(c, "true"));
|
||||
CHECK_FALSE(matchesMagicFilter(c, "false"));
|
||||
// ...unless the literal happens to be a substring of an actual
|
||||
// string-typed column. Sanity-check that the matcher still fires on
|
||||
// the name field with the same haystack.
|
||||
CHECK(matchesMagicFilter(c, "brain"));
|
||||
}
|
||||
|
||||
TEST_CASE("filter is lowercased so uppercase input matches lowercased data") {
|
||||
// The original path only lowercased the cell value, so "Brainstorm" filter against
|
||||
// a card named "brainstorm" never matched. We lowercase both sides;
|
||||
// pin that down here so a regression to the literal JS behavior gets
|
||||
// caught.
|
||||
const MagicCard c = mc("brainstorm", "alpha");
|
||||
CHECK(matchesMagicFilter(c, "BRAIN"));
|
||||
CHECK(matchesMagicFilter(c, "Alpha"));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("CardFilter::matchesPokemonFilter") {
|
||||
TEST_CASE("matches by name and set.name") {
|
||||
const PokemonCard c = pc("Charizard", "Base Set");
|
||||
CHECK(matchesPokemonFilter(c, "char"));
|
||||
CHECK(matchesPokemonFilter(c, "BASE"));
|
||||
CHECK_FALSE(matchesPokemonFilter(c, "pikachu"));
|
||||
}
|
||||
|
||||
TEST_CASE("Pokemon adds setNo to the searchable columns") {
|
||||
// PokemonTable.tsx tableFields includes a "Set No" column whose
|
||||
// valueKey is `setNo`. Pin that down — `setNo` is Pokemon-specific so
|
||||
// the Magic matcher does not see it.
|
||||
const PokemonCard c = pc("Charizard", "Base Set", "4/102");
|
||||
CHECK(matchesPokemonFilter(c, "4/102"));
|
||||
CHECK(matchesPokemonFilter(c, "/102"));
|
||||
}
|
||||
|
||||
TEST_CASE("amount is searchable as decimal string for Pokemon too") {
|
||||
const PokemonCard c = pc("Charizard", "Base Set", "4/102", 23);
|
||||
CHECK(matchesPokemonFilter(c, "23"));
|
||||
CHECK_FALSE(matchesPokemonFilter(c, "99"));
|
||||
}
|
||||
|
||||
TEST_CASE("empty filter matches everything") {
|
||||
CHECK(matchesPokemonFilter(pc("Charizard", "Base Set"), ""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
#include "ccm/services/CardPreviewService.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
class FakeSource final : public ICardPreviewSource {
|
||||
public:
|
||||
std::string url = "https://example.com/preview.jpg";
|
||||
bool ok = true;
|
||||
std::string err = "boom";
|
||||
|
||||
// Capture inputs so tests can assert routing.
|
||||
std::string lastName;
|
||||
std::string lastSetId;
|
||||
std::string lastSetNo;
|
||||
|
||||
Result<std::string> fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override {
|
||||
lastName = std::string(name);
|
||||
lastSetId = std::string(setId);
|
||||
lastSetNo = std::string(setNo);
|
||||
return ok ? Result<std::string>::ok(url)
|
||||
: Result<std::string>::err(err);
|
||||
}
|
||||
};
|
||||
|
||||
class FixedHttpClient final : public IHttpClient {
|
||||
public:
|
||||
std::string lastUrl;
|
||||
std::string body;
|
||||
bool ok = true;
|
||||
std::string err = "offline";
|
||||
|
||||
Result<std::string> get(std::string_view url) override {
|
||||
lastUrl = std::string(url);
|
||||
return ok ? Result<std::string>::ok(body)
|
||||
: Result<std::string>::err(err);
|
||||
}
|
||||
};
|
||||
|
||||
// Minimal IGameModule fake that exposes a configurable preview source.
|
||||
class FakeGameModule final : public IGameModule {
|
||||
public:
|
||||
Game gameId = Game::Magic;
|
||||
ICardPreviewSource* preview = nullptr;
|
||||
|
||||
[[nodiscard]] Game id() const noexcept override { return gameId; }
|
||||
[[nodiscard]] std::string dirName() const override { return "fake"; }
|
||||
[[nodiscard]] std::string displayName() const override { return "Fake"; }
|
||||
|
||||
ISetSource& setSource() override {
|
||||
// Tests in this file never call setSource(); a never-returned helper
|
||||
// would clutter the fake. Throwing keeps misuse loud.
|
||||
throw std::logic_error("FakeGameModule::setSource() not used in this test");
|
||||
}
|
||||
ICardPreviewSource* cardPreviewSource() noexcept override { return preview; }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("CardPreviewService::fetchPreviewBytes") {
|
||||
TEST_CASE("happy path: routes through registered module then http GET") {
|
||||
FakeSource source;
|
||||
source.url = "https://example.com/img.png";
|
||||
FakeGameModule module;
|
||||
module.gameId = Game::Magic;
|
||||
module.preview = &source;
|
||||
|
||||
FixedHttpClient http;
|
||||
http.body = std::string("\x89PNG\r\n\x1a\n", 8); // arbitrary binary
|
||||
|
||||
CardPreviewService svc{http};
|
||||
svc.registerModule(module);
|
||||
|
||||
const auto out = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == http.body);
|
||||
CHECK(http.lastUrl == "https://example.com/img.png");
|
||||
CHECK(source.lastName == "Lightning Bolt");
|
||||
CHECK(source.lastSetId == "lea");
|
||||
CHECK(source.lastSetNo.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("module returning nullptr preview source is skipped silently") {
|
||||
FakeGameModule module;
|
||||
module.gameId = Game::Pokemon;
|
||||
module.preview = nullptr;
|
||||
|
||||
FixedHttpClient http;
|
||||
CardPreviewService svc{http};
|
||||
svc.registerModule(module); // no-op
|
||||
|
||||
const auto out = svc.fetchPreviewBytes(Game::Pokemon, "Pikachu", "sv3", "1");
|
||||
CHECK(out.isErr());
|
||||
CHECK(out.error().find("No preview source registered") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("unregistered game returns an explicit error") {
|
||||
FixedHttpClient http;
|
||||
CardPreviewService svc{http};
|
||||
const auto out = svc.fetchPreviewBytes(Game::Pokemon, "Pikachu", "sv3", "1");
|
||||
CHECK(out.isErr());
|
||||
CHECK(out.error().find("No preview source registered") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("source error propagates and http is not called") {
|
||||
FakeSource source;
|
||||
source.ok = false;
|
||||
source.err = "scryfall 404";
|
||||
FakeGameModule module;
|
||||
module.gameId = Game::Magic;
|
||||
module.preview = &source;
|
||||
|
||||
FixedHttpClient http;
|
||||
http.lastUrl = "<unset>";
|
||||
|
||||
CardPreviewService svc{http};
|
||||
svc.registerModule(module);
|
||||
|
||||
const auto out = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
|
||||
CHECK(out.isErr());
|
||||
CHECK(out.error() == "scryfall 404");
|
||||
CHECK(http.lastUrl == "<unset>");
|
||||
}
|
||||
|
||||
TEST_CASE("http GET error propagates") {
|
||||
FakeSource source;
|
||||
FakeGameModule module;
|
||||
module.gameId = Game::Magic;
|
||||
module.preview = &source;
|
||||
|
||||
FixedHttpClient http;
|
||||
http.ok = false;
|
||||
http.err = "net down";
|
||||
|
||||
CardPreviewService svc{http};
|
||||
svc.registerModule(module);
|
||||
|
||||
const auto out = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
|
||||
CHECK(out.isErr());
|
||||
CHECK(out.error() == "net down");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// CardSorter tests - exercises the per-column comparators ported from the
|
||||
// TableTemplate.tsx::byField. Each TEST_CASE pins down a specific behavior
|
||||
// the original UI relied on, so a regression here implies the C++ table no
|
||||
// longer behaves like the TypeScript reference.
|
||||
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
#include "ccm/services/CardSorter.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
MagicCard mc(std::uint32_t id, std::string name,
|
||||
std::string setName, std::string releaseDate,
|
||||
std::uint8_t amount = 1,
|
||||
bool foil = false, bool sgnd = false, bool altered = false,
|
||||
Language lang = Language::English,
|
||||
Condition cond = Condition::NearMint,
|
||||
std::string note = "") {
|
||||
MagicCard c;
|
||||
c.id = id;
|
||||
c.name = std::move(name);
|
||||
c.set.name = std::move(setName);
|
||||
c.set.releaseDate = std::move(releaseDate);
|
||||
c.amount = amount;
|
||||
c.foil = foil;
|
||||
c.signed_ = sgnd;
|
||||
c.altered = altered;
|
||||
c.language = lang;
|
||||
c.condition = cond;
|
||||
c.note = std::move(note);
|
||||
return c;
|
||||
}
|
||||
|
||||
PokemonCard pc(std::uint32_t id, std::string name,
|
||||
std::string setName, std::string releaseDate,
|
||||
std::uint8_t amount = 1,
|
||||
bool holo = false, bool firstEdition = false,
|
||||
bool sgnd = false, bool altered = false) {
|
||||
PokemonCard c;
|
||||
c.id = id;
|
||||
c.name = std::move(name);
|
||||
c.set.name = std::move(setName);
|
||||
c.set.releaseDate = std::move(releaseDate);
|
||||
c.amount = amount;
|
||||
c.holo = holo;
|
||||
c.firstEdition = firstEdition;
|
||||
c.signed_ = sgnd;
|
||||
c.altered = altered;
|
||||
return c;
|
||||
}
|
||||
|
||||
std::vector<std::uint32_t> ids(const std::vector<MagicCard>& v) {
|
||||
std::vector<std::uint32_t> out;
|
||||
out.reserve(v.size());
|
||||
for (const auto& c : v) out.push_back(c.id);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<std::uint32_t> ids(const std::vector<PokemonCard>& v) {
|
||||
std::vector<std::uint32_t> out;
|
||||
out.reserve(v.size());
|
||||
for (const auto& c : v) out.push_back(c.id);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("CardSorter - Magic columns") {
|
||||
TEST_CASE("Name sorts case-insensitively (matches String.toLowerCase())") {
|
||||
// Three cards whose names only differ in casing - if sort were a
|
||||
// plain `<` this would put "ABC" before "abc" and wedge "Brainstorm"
|
||||
// somewhere wrong. The JS path normalizes to lowercase first.
|
||||
std::vector<MagicCard> v = {
|
||||
mc(1, "brainstorm", "X", "2000/01/01"),
|
||||
mc(2, "ABC", "X", "2000/01/01"),
|
||||
mc(3, "abc", "X", "2000/01/01"),
|
||||
};
|
||||
sortMagicCards(v, MagicSortColumn::Name, /*ascending=*/true);
|
||||
// ABC and abc tie under case-insensitive compare; stable sort keeps
|
||||
// the input order (id 2 before id 3).
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
|
||||
|
||||
sortMagicCards(v, MagicSortColumn::Name, /*ascending=*/false);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2, 3});
|
||||
}
|
||||
|
||||
TEST_CASE("Set column sorts by release date (chronological), not by name") {
|
||||
// The whole point of having distinct valueKey/sortKey: the
|
||||
// table *displays* set.name, but ascending order is chronological.
|
||||
std::vector<MagicCard> v = {
|
||||
mc(1, "x", "Zendikar", "2009/10/02"),
|
||||
mc(2, "y", "Alpha", "1993/08/05"),
|
||||
mc(3, "z", "Modern Horizons","2019/06/14"),
|
||||
};
|
||||
sortMagicCards(v, MagicSortColumn::SetReleaseDate, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1, 3}); // 1993 < 2009 < 2019
|
||||
|
||||
sortMagicCards(v, MagicSortColumn::SetReleaseDate, /*ascending=*/false);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
|
||||
}
|
||||
|
||||
TEST_CASE("Amount sorts numerically (no string-compare 10 < 2 trap)") {
|
||||
std::vector<MagicCard> v = {
|
||||
mc(1, "a", "X", "2000/01/01", /*amount=*/10),
|
||||
mc(2, "b", "X", "2000/01/01", /*amount=*/2),
|
||||
mc(3, "c", "X", "2000/01/01", /*amount=*/4),
|
||||
};
|
||||
sortMagicCards(v, MagicSortColumn::Amount, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // 2 < 4 < 10
|
||||
}
|
||||
|
||||
TEST_CASE("boolean flag column orders false < true (asc puts unset first)") {
|
||||
std::vector<MagicCard> v = {
|
||||
mc(1, "a", "X", "2000/01/01", 1, /*foil=*/true),
|
||||
mc(2, "b", "X", "2000/01/01", 1, /*foil=*/false),
|
||||
mc(3, "c", "X", "2000/01/01", 1, /*foil=*/true),
|
||||
mc(4, "d", "X", "2000/01/01", 1, /*foil=*/false),
|
||||
};
|
||||
sortMagicCards(v, MagicSortColumn::Foil, /*ascending=*/true);
|
||||
// Stable: relative order within each bucket preserved.
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 4, 1, 3});
|
||||
|
||||
sortMagicCards(v, MagicSortColumn::Foil, /*ascending=*/false);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2, 4});
|
||||
}
|
||||
|
||||
TEST_CASE("Signed and Altered booleans sort independently") {
|
||||
std::vector<MagicCard> v = {
|
||||
mc(1, "a", "X", "2000/01/01", 1, false, /*sgnd=*/false, /*alt=*/true),
|
||||
mc(2, "b", "X", "2000/01/01", 1, false, /*sgnd=*/true, /*alt=*/false),
|
||||
};
|
||||
sortMagicCards(v, MagicSortColumn::Signed, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2});
|
||||
|
||||
sortMagicCards(v, MagicSortColumn::Altered, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
|
||||
}
|
||||
|
||||
TEST_CASE("Language and Condition sort by their string label, lowercased") {
|
||||
std::vector<MagicCard> v = {
|
||||
mc(1, "a", "X", "2000/01/01", 1, false, false, false,
|
||||
Language::Japanese, Condition::Mint),
|
||||
mc(2, "b", "X", "2000/01/01", 1, false, false, false,
|
||||
Language::English, Condition::Played),
|
||||
mc(3, "c", "X", "2000/01/01", 1, false, false, false,
|
||||
Language::German, Condition::NearMint),
|
||||
};
|
||||
sortMagicCards(v, MagicSortColumn::Language, /*ascending=*/true);
|
||||
// english < german < japanese (lowercased compare)
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
|
||||
|
||||
sortMagicCards(v, MagicSortColumn::Condition, /*ascending=*/true);
|
||||
// mint < nearmint < played (lowercased compare)
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
|
||||
}
|
||||
|
||||
TEST_CASE("Note sorts case-insensitively") {
|
||||
std::vector<MagicCard> v = {
|
||||
mc(1, "a", "X", "2000/01/01", 1, false, false, false,
|
||||
Language::English, Condition::NearMint, "Zeta"),
|
||||
mc(2, "b", "X", "2000/01/01", 1, false, false, false,
|
||||
Language::English, Condition::NearMint, "alpha"),
|
||||
mc(3, "c", "X", "2000/01/01", 1, false, false, false,
|
||||
Language::English, Condition::NearMint, "Beta"),
|
||||
};
|
||||
sortMagicCards(v, MagicSortColumn::Note, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // alpha, beta, zeta
|
||||
}
|
||||
|
||||
TEST_CASE("stable: sort by name then by set keeps name order within each set") {
|
||||
// Mirrors the UX expectation: a user clicks Name, then Set, and
|
||||
// sees rows grouped by set with names alphabetical inside each group.
|
||||
std::vector<MagicCard> v = {
|
||||
mc(1, "Counterspell", "Beta", "1993/10/04"),
|
||||
mc(2, "Brainstorm", "Alpha", "1993/08/05"),
|
||||
mc(3, "Lightning Bolt","Beta", "1993/10/04"),
|
||||
mc(4, "Ancestral Recall","Alpha","1993/08/05"),
|
||||
};
|
||||
sortMagicCards(v, MagicSortColumn::Name, /*asc=*/true);
|
||||
sortMagicCards(v, MagicSortColumn::SetReleaseDate, /*asc=*/true);
|
||||
// Alpha (1993/08/05) first: ancestral, brainstorm
|
||||
// Beta (1993/10/04) next: counterspell, lightning bolt
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{4, 2, 1, 3});
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("CardSorter - Pokemon-specific columns") {
|
||||
TEST_CASE("Holo and FirstEdition each sort their own bool field") {
|
||||
std::vector<PokemonCard> v = {
|
||||
pc(1, "a", "X", "2000/01/01", 1, /*holo=*/true, /*1st=*/false),
|
||||
pc(2, "b", "X", "2000/01/01", 1, /*holo=*/false, /*1st=*/true),
|
||||
pc(3, "c", "X", "2000/01/01", 1, /*holo=*/false, /*1st=*/false),
|
||||
};
|
||||
sortPokemonCards(v, PokemonSortColumn::Holo, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
|
||||
|
||||
sortPokemonCards(v, PokemonSortColumn::FirstEdition, /*ascending=*/true);
|
||||
// After previous sort: 2,3,1 (false=2, false=1 actually... let me think
|
||||
// -- with v in order {2,3,1} their firstEdition = {true,false,false}.
|
||||
// Stable asc on firstEdition keeps 3 before 1 in the false bucket.)
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
|
||||
}
|
||||
|
||||
TEST_CASE("Set column sorts by release date for Pokemon too") {
|
||||
std::vector<PokemonCard> v = {
|
||||
pc(1, "x", "Sun & Moon", "2017/02/03"),
|
||||
pc(2, "y", "Base Set", "1999/01/09"),
|
||||
pc(3, "z", "Sword & Shield","2020/02/07"),
|
||||
};
|
||||
sortPokemonCards(v, PokemonSortColumn::SetReleaseDate, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1, 3});
|
||||
}
|
||||
|
||||
TEST_CASE("Amount sorts numerically") {
|
||||
std::vector<PokemonCard> v = {
|
||||
pc(1, "a", "X", "2000/01/01", 9),
|
||||
pc(2, "b", "X", "2000/01/01", 11),
|
||||
pc(3, "c", "X", "2000/01/01", 1),
|
||||
};
|
||||
sortPokemonCards(v, PokemonSortColumn::Amount, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") {
|
||||
TEST_CASE("empty vector stays empty") {
|
||||
std::vector<MagicCard> v;
|
||||
sortMagicCards(v, MagicSortColumn::Name, true);
|
||||
CHECK(v.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("single element preserved") {
|
||||
std::vector<MagicCard> v = { mc(42, "Solo", "X", "2000/01/01") };
|
||||
sortMagicCards(v, MagicSortColumn::Amount, false);
|
||||
CHECK(v.size() == 1);
|
||||
CHECK(v.front().id == 42);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/ports/ICollectionRepository.hpp"
|
||||
#include "ccm/ports/IImageStore.hpp"
|
||||
#include "ccm/services/CollectionService.hpp"
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
class InMemoryRepo final : public ICollectionRepository<MagicCard> {
|
||||
public:
|
||||
Map storage;
|
||||
|
||||
Result<Map> load(Game) override { return Result<Map>::ok(storage); }
|
||||
Result<void> save(Game, const Map& m) override {
|
||||
storage = m;
|
||||
return Result<void>::ok();
|
||||
}
|
||||
};
|
||||
|
||||
class StubImageStore final : public IImageStore {
|
||||
public:
|
||||
std::vector<std::pair<Game, std::string>> removed;
|
||||
|
||||
Result<std::string> copyIn(Game, const std::filesystem::path&, const std::string& n) override {
|
||||
return Result<std::string>::ok(n);
|
||||
}
|
||||
Result<void> remove(Game g, const std::string& n) override {
|
||||
removed.emplace_back(g, n);
|
||||
return Result<void>::ok();
|
||||
}
|
||||
std::filesystem::path resolvePath(Game, const std::string& n) const override {
|
||||
return std::filesystem::path(n);
|
||||
}
|
||||
};
|
||||
|
||||
MagicCard makeCard(const std::string& name, std::vector<std::string> imgs = {}) {
|
||||
MagicCard c;
|
||||
c.name = name;
|
||||
c.set = Set{"alp", "Alpha", "1993/08/05"};
|
||||
c.images = std::move(imgs);
|
||||
return c;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("CollectionService<MagicCard>") {
|
||||
TEST_CASE("nextId on empty map is 0, then strictly increments") {
|
||||
InMemoryRepo repo;
|
||||
StubImageStore store;
|
||||
CollectionService<MagicCard> svc{repo, store};
|
||||
|
||||
const auto id0 = svc.add(Game::Magic, makeCard("A"));
|
||||
REQUIRE(id0.isOk());
|
||||
CHECK(id0.value() == 0);
|
||||
|
||||
const auto id1 = svc.add(Game::Magic, makeCard("B"));
|
||||
REQUIRE(id1.isOk());
|
||||
CHECK(id1.value() == 1);
|
||||
|
||||
const auto listed = svc.list(Game::Magic);
|
||||
REQUIRE(listed.isOk());
|
||||
CHECK(listed.value().size() == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("update modifies an existing card and is rejected for unknown ids") {
|
||||
InMemoryRepo repo;
|
||||
StubImageStore store;
|
||||
CollectionService<MagicCard> svc{repo, store};
|
||||
|
||||
const auto id = svc.add(Game::Magic, makeCard("Initial")).value();
|
||||
|
||||
MagicCard updated = makeCard("Renamed");
|
||||
updated.id = id;
|
||||
CHECK(svc.update(Game::Magic, updated).isOk());
|
||||
|
||||
const auto found = svc.findById(Game::Magic, id);
|
||||
REQUIRE(found.isOk());
|
||||
REQUIRE(found.value().has_value());
|
||||
CHECK(found.value()->name == "Renamed");
|
||||
|
||||
MagicCard ghost = makeCard("Ghost");
|
||||
ghost.id = 999;
|
||||
CHECK(svc.update(Game::Magic, ghost).isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("remove deletes images and the entry") {
|
||||
InMemoryRepo repo;
|
||||
StubImageStore store;
|
||||
CollectionService<MagicCard> svc{repo, store};
|
||||
|
||||
const auto id = svc.add(
|
||||
Game::Magic, makeCard("With Images", {"a.png", "b.png"})).value();
|
||||
|
||||
REQUIRE(svc.remove(Game::Magic, id).isOk());
|
||||
|
||||
// Both images should have been requested for deletion.
|
||||
REQUIRE(store.removed.size() == 2);
|
||||
CHECK(store.removed[0].second == "a.png");
|
||||
CHECK(store.removed[1].second == "b.png");
|
||||
|
||||
const auto listed = svc.list(Game::Magic);
|
||||
REQUIRE(listed.isOk());
|
||||
CHECK(listed.value().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("remove of unknown id returns an error") {
|
||||
InMemoryRepo repo;
|
||||
StubImageStore store;
|
||||
CollectionService<MagicCard> svc{repo, store};
|
||||
|
||||
CHECK(svc.remove(Game::Magic, 12345).isErr());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
|
||||
#include "fakes/InMemoryFileSystem.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
using namespace ccm;
|
||||
using ccm::testing::InMemoryFileSystem;
|
||||
|
||||
TEST_SUITE("ConfigService") {
|
||||
TEST_CASE("missing file is created with defaults") {
|
||||
InMemoryFileSystem fs;
|
||||
ConfigService svc{fs, "/app/config.json", "/data"};
|
||||
|
||||
REQUIRE(svc.initialize().isOk());
|
||||
CHECK(svc.current().dataStorage == "/data");
|
||||
CHECK(svc.current().defaultGame == Game::Magic);
|
||||
CHECK(svc.current().theme == Theme::Light);
|
||||
|
||||
const auto& written = fs.files();
|
||||
REQUIRE(written.count("/app/config.json"));
|
||||
const auto j = nlohmann::json::parse(written.at("/app/config.json"));
|
||||
CHECK(j.at("dataStorage") == "/data");
|
||||
CHECK(j.at("defaultGame") == "Magic");
|
||||
CHECK(j.at("theme") == "Light");
|
||||
}
|
||||
|
||||
TEST_CASE("existing file is loaded verbatim") {
|
||||
InMemoryFileSystem fs;
|
||||
REQUIRE(fs.writeText("/app/config.json",
|
||||
R"({"dataStorage":"/somewhere","defaultGame":"Pokemon","theme":"Dark"})").isOk());
|
||||
|
||||
ConfigService svc{fs, "/app/config.json", "/default"};
|
||||
REQUIRE(svc.initialize().isOk());
|
||||
CHECK(svc.current().dataStorage == "/somewhere");
|
||||
CHECK(svc.current().defaultGame == Game::Pokemon);
|
||||
CHECK(svc.current().theme == Theme::Dark);
|
||||
}
|
||||
|
||||
TEST_CASE("store updates both the live config and the file") {
|
||||
InMemoryFileSystem fs;
|
||||
ConfigService svc{fs, "/app/config.json", "/data"};
|
||||
REQUIRE(svc.initialize().isOk());
|
||||
|
||||
Configuration next;
|
||||
next.dataStorage = "/new/place";
|
||||
next.defaultGame = Game::Pokemon;
|
||||
next.theme = Theme::Dark;
|
||||
REQUIRE(svc.store(next).isOk());
|
||||
|
||||
CHECK(svc.current() == next);
|
||||
const auto j = nlohmann::json::parse(fs.files().at("/app/config.json"));
|
||||
CHECK(j.at("dataStorage") == "/new/place");
|
||||
CHECK(j.at("defaultGame") == "Pokemon");
|
||||
CHECK(j.at("theme") == "Dark");
|
||||
}
|
||||
|
||||
TEST_CASE("missing theme field defaults to light for compatibility") {
|
||||
InMemoryFileSystem fs;
|
||||
REQUIRE(fs.writeText("/app/config.json",
|
||||
R"({"dataStorage":"/somewhere","defaultGame":"Pokemon"})").isOk());
|
||||
|
||||
ConfigService svc{fs, "/app/config.json", "/default"};
|
||||
REQUIRE(svc.initialize().isOk());
|
||||
CHECK(svc.current().theme == Theme::Light);
|
||||
}
|
||||
|
||||
TEST_CASE("malformed JSON surfaces a clear error") {
|
||||
InMemoryFileSystem fs;
|
||||
REQUIRE(fs.writeText("/app/config.json", "not-json").isOk());
|
||||
ConfigService svc{fs, "/app/config.json", "/default"};
|
||||
const auto r = svc.initialize();
|
||||
REQUIRE(r.isErr());
|
||||
CHECK(r.error().find("config.json parse error") != std::string::npos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/Configuration.hpp"
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
TEST_SUITE("domain enums round-trip JSON as strings") {
|
||||
TEST_CASE("Game") {
|
||||
nlohmann::json j = Game::Magic;
|
||||
CHECK(j.get<std::string>() == "Magic");
|
||||
CHECK(j.get<Game>() == Game::Magic);
|
||||
|
||||
nlohmann::json j2 = "Pokemon";
|
||||
CHECK(j2.get<Game>() == Game::Pokemon);
|
||||
|
||||
nlohmann::json j3 = Theme::Dark;
|
||||
CHECK(j3.get<std::string>() == "Dark");
|
||||
CHECK(j3.get<Theme>() == Theme::Dark);
|
||||
}
|
||||
|
||||
TEST_CASE("Language and Condition") {
|
||||
nlohmann::json l = Language::Japanese;
|
||||
CHECK(l.get<std::string>() == "Japanese");
|
||||
CHECK(l.get<Language>() == Language::Japanese);
|
||||
|
||||
nlohmann::json c = Condition::LightPlayed;
|
||||
CHECK(c.get<std::string>() == "LightPlayed");
|
||||
CHECK(c.get<Condition>() == Condition::LightPlayed);
|
||||
}
|
||||
|
||||
TEST_CASE("invalid enum string throws") {
|
||||
nlohmann::json bad = "Spanglish";
|
||||
CHECK_THROWS(bad.get<Language>());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("Set JSON shape stays stable") {
|
||||
TEST_CASE("uses 'releaseDate' alias") {
|
||||
Set s{"abc", "Test Set", "2024/05/01"};
|
||||
const nlohmann::json j = s;
|
||||
CHECK(j.contains("releaseDate"));
|
||||
CHECK_FALSE(j.contains("release_date"));
|
||||
CHECK(j.at("releaseDate") == "2024/05/01");
|
||||
|
||||
const Set back = j.get<Set>();
|
||||
CHECK(back == s);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("MagicCard JSON") {
|
||||
TEST_CASE("round-trips with all original field names") {
|
||||
MagicCard c;
|
||||
c.id = 42;
|
||||
c.amount = 3;
|
||||
c.name = "Lightning Bolt";
|
||||
c.set = Set{"lea", "Limited Edition Alpha", "1993/08/05"};
|
||||
c.note = "rare promo";
|
||||
c.images = {"foo+bar+0.png"};
|
||||
c.language = Language::English;
|
||||
c.condition = Condition::NearMint;
|
||||
c.foil = true;
|
||||
c.signed_ = false;
|
||||
c.altered = false;
|
||||
|
||||
nlohmann::json j = c;
|
||||
CHECK(j.contains("signed"));
|
||||
CHECK(j.at("signed") == false);
|
||||
CHECK(j.at("foil") == true);
|
||||
|
||||
const MagicCard back = j.get<MagicCard>();
|
||||
CHECK(back == c);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("PokemonCard JSON") {
|
||||
TEST_CASE("uses 'setNo' and 'firstEdition' aliases") {
|
||||
PokemonCard c;
|
||||
c.id = 7;
|
||||
c.amount = 1;
|
||||
c.name = "Charizard";
|
||||
c.set = Set{"base1", "Base Set", "1999/01/09"};
|
||||
c.setNo = "4/102";
|
||||
c.note = "";
|
||||
c.images = {};
|
||||
c.language = Language::English;
|
||||
c.condition = Condition::Excellent;
|
||||
c.firstEdition = true;
|
||||
c.holo = true;
|
||||
c.signed_ = false;
|
||||
c.altered = false;
|
||||
|
||||
nlohmann::json j = c;
|
||||
CHECK(j.at("setNo") == "4/102");
|
||||
CHECK(j.at("firstEdition") == true);
|
||||
CHECK(j.at("signed") == false);
|
||||
|
||||
const PokemonCard back = j.get<PokemonCard>();
|
||||
CHECK(back == c);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("Configuration JSON matches Rust serde aliases") {
|
||||
TEST_CASE("dataStorage / defaultGame / theme keys are present") {
|
||||
Configuration cfg;
|
||||
cfg.dataStorage = "/some/path";
|
||||
cfg.defaultGame = Game::Pokemon;
|
||||
cfg.theme = Theme::Dark;
|
||||
|
||||
nlohmann::json j = cfg;
|
||||
CHECK(j.at("dataStorage") == "/some/path");
|
||||
CHECK(j.at("defaultGame") == "Pokemon");
|
||||
CHECK(j.at("theme") == "Dark");
|
||||
|
||||
const auto back = j.get<Configuration>();
|
||||
CHECK(back == cfg);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user