mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 23:01:09 +00:00
@@ -38,7 +38,8 @@ GitHub Actions workflows for CI, release automation, and policy checks.
|
|||||||
- Prefer minimal, surgical edits; avoid large workflow rewrites unless requested.
|
- 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).
|
- Reusable workflows should declare explicit `workflow_call` inputs for required context (e.g., version, merge SHA).
|
||||||
- Sonar coverage steps that use `gcovr` must exclude third-party build trees at discovery time with `--exclude-directories` (for example `build/_deps`) so gcov does not process dependency `.gcda` files.
|
- Sonar coverage steps that use `gcovr` must exclude third-party build trees at discovery time with `--exclude-directories` (for example `build/_deps`) so gcov does not process dependency `.gcda` files.
|
||||||
- The Sonar scan step passes `-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**` so the coverage quality gate reflects **`ccm_core_tests`** only (wx UI and the composition root are not executed under test). `sonar.sources` stays `core,ui_wx,app`; bugs/security/duplications still analyze those trees.
|
- The Sonar scan step passes `-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**` so the coverage quality gate reflects **`ccm_core_tests`** only (wx UI and the composition root are not executed under test). `sonar.sources` stays `core,ui_wx,app`.
|
||||||
|
- The Sonar scan also sets `-Dsonar.cpd.exclusions=**/ui_wx/src/*GameView.cpp,**/ui_wx/src/*CardEditDialog.cpp,**/ui_wx/src/*SelectedCardPanel.cpp` so intentionally parallel wx per-game UI scaffolding does not dominate the duplication quality gate.
|
||||||
- For Linux Sonar coverage jobs, keep compiler and gcov toolchain aligned; because `cmake/Toolchain.cmake` prefers Clang by default, set `-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++` explicitly in the coverage configure step when using gcovr default `gcov`.
|
- For Linux Sonar coverage jobs, keep compiler and gcov toolchain aligned; because `cmake/Toolchain.cmake` prefers Clang by default, set `-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++` explicitly in the coverage configure step when using gcovr default `gcov`.
|
||||||
- Keep `permissions` least-privilege:
|
- Keep `permissions` least-privilege:
|
||||||
- reusable build workflows: `contents: read`
|
- reusable build workflows: `contents: read`
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ jobs:
|
|||||||
-Dsonar.cfamily.compile-commands=build/compile_commands.json
|
-Dsonar.cfamily.compile-commands=build/compile_commands.json
|
||||||
-Dsonar.coverageReportPaths=build/sonarqube-coverage.xml
|
-Dsonar.coverageReportPaths=build/sonarqube-coverage.xml
|
||||||
-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**
|
-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**
|
||||||
|
-Dsonar.cpd.exclusions=**/ui_wx/src/*GameView.cpp,**/ui_wx/src/*CardEditDialog.cpp,**/ui_wx/src/*SelectedCardPanel.cpp
|
||||||
|
|
||||||
linux:
|
linux:
|
||||||
name: Linux build + tests
|
name: Linux build + tests
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ jobs:
|
|||||||
-Dsonar.cfamily.compile-commands=build/compile_commands.json
|
-Dsonar.cfamily.compile-commands=build/compile_commands.json
|
||||||
-Dsonar.coverageReportPaths=build/sonarqube-coverage.xml
|
-Dsonar.coverageReportPaths=build/sonarqube-coverage.xml
|
||||||
-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**
|
-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**
|
||||||
|
-Dsonar.cpd.exclusions=**/ui_wx/src/*GameView.cpp,**/ui_wx/src/*CardEditDialog.cpp,**/ui_wx/src/*SelectedCardPanel.cpp
|
||||||
|
|
||||||
compute-version:
|
compute-version:
|
||||||
name: Determine semantic version
|
name: Determine semantic version
|
||||||
|
|||||||
@@ -52,9 +52,17 @@ Run from the **workspace root**.
|
|||||||
- Run the app:
|
- Run the app:
|
||||||
`./build/bin/ccm3` (`.\build\bin\ccm3.exe` on Windows)
|
`./build/bin/ccm3` (`.\build\bin\ccm3.exe` on Windows)
|
||||||
- Run tests (CCM_BUILD_TESTS defaults to ON):
|
- Run tests (CCM_BUILD_TESTS defaults to ON):
|
||||||
`ctest --test-dir build --output-on-failure` — current baseline: **185 tests, all green**.
|
`ctest --test-dir build --output-on-failure` — current baseline: **215 tests, all green**.
|
||||||
- Build tests only:
|
- Build tests only:
|
||||||
`cmake --build build --target ccm_core_tests`
|
`cmake --build build --target ccm_core_tests`
|
||||||
|
- Local coverage env setup (one-time, Windows/MSYS2):
|
||||||
|
`python -m venv .venv_cov`
|
||||||
|
`& "P:/msys2/msys64/usr/bin/pacman.exe" -S --noconfirm mingw-w64-ucrt-x86_64-python-lxml mingw-w64-ucrt-x86_64-python-gcovr`
|
||||||
|
- Coverage check (core-focused):
|
||||||
|
`cmake -S . -B build-cov -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Debug -DCCM_BUILD_TESTS=ON -DCMAKE_C_FLAGS=--coverage -DCMAKE_CXX_FLAGS=--coverage -DCMAKE_EXPORT_COMPILE_COMMANDS=ON`
|
||||||
|
`cmake --build build-cov --target ccm_core_tests --parallel`
|
||||||
|
`ctest --test-dir build-cov --output-on-failure`
|
||||||
|
`& "P:/msys2/msys64/ucrt64/bin/gcovr.exe" -r . --object-directory build-cov --filter "core/" --exclude "build/_deps/" --exclude "build-cov/_deps/" --exclude-directories "build/_deps" --exclude-directories "build-cov/_deps" --print-summary`
|
||||||
|
|
||||||
> **Windows runtime note**: `cpr` is built as a shared library, so `build/bin/` ends up with `libcpr.dll`, `libcurl.dll`, `libzlib.dll` next to `ccm3.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 runtime note**: `cpr` is built as a shared library, so `build/bin/` ends up with `libcpr.dll`, `libcurl.dll`, `libzlib.dll` next to `ccm3.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.
|
||||||
>
|
>
|
||||||
@@ -97,6 +105,9 @@ Run from the **workspace root**.
|
|||||||
- 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 `.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 dependency you **must** verify its license is compatible with this repository's MIT license before merging.
|
||||||
- After changing SonarQube coverage generation, keep dependency build outputs excluded at gcov discovery time (for example `gcovr --exclude-directories "build/_deps"`); output-only excludes are not enough for third-party `.gcda` files. The Sonar scan uses `sonar.coverage.exclusions` for `**/ui_wx/**` and `**/app/**` so the coverage percentage matches the hermetic `ccm_core_tests` surface (`core/`); analyzed sources are unchanged for other Sonar metrics.
|
- After changing SonarQube coverage generation, keep dependency build outputs excluded at gcov discovery time (for example `gcovr --exclude-directories "build/_deps"`); output-only excludes are not enough for third-party `.gcda` files. The Sonar scan uses `sonar.coverage.exclusions` for `**/ui_wx/**` and `**/app/**` so the coverage percentage matches the hermetic `ccm_core_tests` surface (`core/`); analyzed sources are unchanged for other Sonar metrics.
|
||||||
|
- For new code, keep duplication to an absolute minimum: prefer extracting shared helpers/components instead of copy/paste so Sonar duplication stays comfortably below the quality gate.
|
||||||
|
- For new code, add or update unit tests so behavior is covered and overall test coverage remains high.
|
||||||
|
- For new code, run the local coverage workflow (`build-cov` + `gcovr` with `--filter "core/"`) and keep core line coverage at or above 80% before opening or updating a PR.
|
||||||
- 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 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 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`.
|
- 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`.
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ struct YuGiOhCard {
|
|||||||
Set set;
|
Set set;
|
||||||
std::string setNo;
|
std::string setNo;
|
||||||
std::string rarity;
|
std::string rarity;
|
||||||
std::string rarityCode;
|
|
||||||
std::string note;
|
std::string note;
|
||||||
std::vector<std::string> images;
|
std::vector<std::string> images;
|
||||||
Language language{Language::English};
|
Language language{Language::English};
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ enum class YuGiOhSortColumn {
|
|||||||
Language,
|
Language,
|
||||||
Condition,
|
Condition,
|
||||||
Amount,
|
Amount,
|
||||||
|
Rarity,
|
||||||
FirstEdition,
|
FirstEdition,
|
||||||
Signed,
|
Signed,
|
||||||
Altered,
|
Altered,
|
||||||
|
|||||||
@@ -69,4 +69,31 @@ namespace ccm {
|
|||||||
&& std::isdigit(static_cast<unsigned char>(tail[1])) != 0;
|
&& std::isdigit(static_cast<unsigned char>(tail[1])) != 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Canonical short-form for Yu-Gi-Oh rarities used by the overview table.
|
||||||
|
// Returns empty when rarity is unknown.
|
||||||
|
[[nodiscard]] inline std::string ygoRarityShortCode(std::string_view rarity) {
|
||||||
|
std::string normalized;
|
||||||
|
normalized.reserve(rarity.size());
|
||||||
|
for (unsigned char c : rarity) {
|
||||||
|
if (std::isspace(c) != 0) continue;
|
||||||
|
if (c == '\'' || c == '`' || c == '-') continue;
|
||||||
|
normalized.push_back(static_cast<char>(std::tolower(c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized == "common") return "C";
|
||||||
|
if (normalized == "rare") return "R";
|
||||||
|
if (normalized == "superrare") return "SR";
|
||||||
|
if (normalized == "ultrarare") return "UR";
|
||||||
|
if (normalized == "secretrare") return "ScR";
|
||||||
|
if (normalized == "quartercenturysecretrare") return "QCScR";
|
||||||
|
if (normalized == "qcsr") return "QCScR";
|
||||||
|
if (normalized == "starlightrare") return "StR";
|
||||||
|
if (normalized == "collectorsrare") return "CR";
|
||||||
|
if (normalized == "ghostrare") return "GR";
|
||||||
|
if (normalized == "ultimaterare") return "UtR";
|
||||||
|
if (normalized == "platinumsecretrare") return "PlScR";
|
||||||
|
if (normalized == "prismaticsecretrare") return "PScR";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace ccm
|
} // namespace ccm
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ void to_json(nlohmann::json& j, const YuGiOhCard& c) {
|
|||||||
{"condition", c.condition},
|
{"condition", c.condition},
|
||||||
{"firstEdition", c.firstEdition},
|
{"firstEdition", c.firstEdition},
|
||||||
{"rarity", c.rarity},
|
{"rarity", c.rarity},
|
||||||
{"rarityCode", c.rarityCode},
|
|
||||||
{"signed", c.signed_},
|
{"signed", c.signed_},
|
||||||
{"altered", c.altered},
|
{"altered", c.altered},
|
||||||
};
|
};
|
||||||
@@ -33,8 +32,6 @@ void from_json(const nlohmann::json& j, YuGiOhCard& c) {
|
|||||||
j.at("condition").get_to(c.condition);
|
j.at("condition").get_to(c.condition);
|
||||||
j.at("firstEdition").get_to(c.firstEdition);
|
j.at("firstEdition").get_to(c.firstEdition);
|
||||||
j.at("rarity").get_to(c.rarity);
|
j.at("rarity").get_to(c.rarity);
|
||||||
if (j.contains("rarityCode")) j.at("rarityCode").get_to(c.rarityCode);
|
|
||||||
else c.rarityCode.clear();
|
|
||||||
j.at("signed").get_to(c.signed_);
|
j.at("signed").get_to(c.signed_);
|
||||||
j.at("altered").get_to(c.altered);
|
j.at("altered").get_to(c.altered);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
|
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
|
||||||
|
#include "ccm/util/YuGiOhPrintingSlot.hpp"
|
||||||
|
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
@@ -134,6 +135,10 @@ std::string YuGiOhCardPreviewSource::normalizeName(std::string_view name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName) {
|
std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName) {
|
||||||
|
if (const std::string canonical = ygoRarityShortCode(rarityName); !canonical.empty()) {
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
// Compare case-insensitively, ignoring whitespace, against a table of
|
// Compare case-insensitively, ignoring whitespace, against a table of
|
||||||
// CCM3 dialog values (see ui_wx/src/YuGiOhCardEditDialog.cpp:kRarityOptions)
|
// CCM3 dialog values (see ui_wx/src/YuGiOhCardEditDialog.cpp:kRarityOptions)
|
||||||
// plus a few extras occasionally seen in imported collections. The codes
|
// plus a few extras occasionally seen in imported collections. The codes
|
||||||
@@ -171,7 +176,7 @@ std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName)
|
|||||||
{"ultraparallelrare", "UPR"},
|
{"ultraparallelrare", "UPR"},
|
||||||
{"holographicrare", "HGR"},
|
{"holographicrare", "HGR"},
|
||||||
{"starlightrare", "StR"},
|
{"starlightrare", "StR"},
|
||||||
{"collectorsrare", "ColR"},
|
{"collectorsrare", "CR"},
|
||||||
{"prismaticcollectorsrare", "PColR"},
|
{"prismaticcollectorsrare", "PColR"},
|
||||||
{"quartercenturysecretrare", "QCScR"},
|
{"quartercenturysecretrare", "QCScR"},
|
||||||
{"prismaticultimaterare", "PUtR"},
|
{"prismaticultimaterare", "PUtR"},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "ccm/services/CardFilter.hpp"
|
#include "ccm/services/CardFilter.hpp"
|
||||||
|
|
||||||
#include "ccm/domain/Enums.hpp"
|
#include "ccm/domain/Enums.hpp"
|
||||||
|
#include "ccm/util/YuGiOhPrintingSlot.hpp"
|
||||||
|
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -72,6 +73,7 @@ bool matchesYuGiOhFilter(const YuGiOhCard& card, std::string_view filter) {
|
|||||||
if (containsLower(card.set.name, needle)) return true;
|
if (containsLower(card.set.name, needle)) return true;
|
||||||
if (containsLower(card.setNo, needle)) return true;
|
if (containsLower(card.setNo, needle)) return true;
|
||||||
if (containsLower(card.rarity, needle)) return true;
|
if (containsLower(card.rarity, needle)) return true;
|
||||||
|
if (containsLower(ygoRarityShortCode(card.rarity), needle)) return true;
|
||||||
if (containsLower(to_string(card.language), needle)) return true;
|
if (containsLower(to_string(card.language), needle)) return true;
|
||||||
if (containsLower(to_string(card.condition), needle)) return true;
|
if (containsLower(to_string(card.condition), needle)) return true;
|
||||||
if (containsLower(std::to_string(card.amount), needle)) return true;
|
if (containsLower(std::to_string(card.amount), needle)) return true;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "ccm/services/CardSorter.hpp"
|
#include "ccm/services/CardSorter.hpp"
|
||||||
|
|
||||||
#include "ccm/domain/Enums.hpp"
|
#include "ccm/domain/Enums.hpp"
|
||||||
|
#include "ccm/util/YuGiOhPrintingSlot.hpp"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
@@ -203,6 +204,12 @@ void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
|
|||||||
return a.amount < b.amount;
|
return a.amount < b.amount;
|
||||||
}, ascending));
|
}, ascending));
|
||||||
break;
|
break;
|
||||||
|
case YuGiOhSortColumn::Rarity:
|
||||||
|
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||||
|
[](const YuGiOhCard& a, const YuGiOhCard& b) {
|
||||||
|
return asciiLower(ygoRarityShortCode(a.rarity)) < asciiLower(ygoRarityShortCode(b.rarity));
|
||||||
|
}, ascending));
|
||||||
|
break;
|
||||||
case YuGiOhSortColumn::FirstEdition:
|
case YuGiOhSortColumn::FirstEdition:
|
||||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||||
[](const YuGiOhCard& a, const YuGiOhCard& b) {
|
[](const YuGiOhCard& a, const YuGiOhCard& b) {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ Used by `YuGiOhCardPreviewSource::fetchImageUrl` for the actual per-printing car
|
|||||||
The UI passes a positional tuple in `setNo` of the form `set_code||rarity||edition` (for example `SDK-001||Ultra Rare||UE`); the source splits on `||` before building filenames. Field meanings:
|
The UI passes a positional tuple in `setNo` of the form `set_code||rarity||edition` (for example `SDK-001||Ultra Rare||UE`); the source splits on `||` before building filenames. Field meanings:
|
||||||
|
|
||||||
- `set_code` — full code as printed (`LOB-005`, `SDK-001`, `RA04-EN001`). Everything before the first `-` becomes the Yugipedia `<SET>` slot (`LOB`, `SDK`, `RA04`).
|
- `set_code` — full code as printed (`LOB-005`, `SDK-001`, `RA04-EN001`). Everything before the first `-` becomes the Yugipedia `<SET>` slot (`LOB`, `SDK`, `RA04`).
|
||||||
- `rarity` — full English rarity name from the edit dialog (`Ultra Rare` → `UR`, `Quarter Century Secret Rare` → `QCScR`, …). The mapping table lives in `rarityCodeFor(...)`. Unknown values fall back to the rarity-less filename pattern.
|
- `rarity` — full English rarity name from the edit dialog (`Ultra Rare` → `UR`, `Quarter Century Secret Rare` → `QCScR`, …). The canonical short-form mapping lives in `ygoRarityShortCode(...)` (`core/include/ccm/util/YuGiOhPrintingSlot.hpp`) and is reused by both the Yu-Gi-Oh overview-table rarity rendering and preview filename construction (`rarityCodeFor(...)`). Unknown values fall back to the rarity-less filename pattern.
|
||||||
- `edition` — `1E` when the user marked the card as 1st Edition, otherwise `UE` (Unlimited).
|
- `edition` — `1E` when the user marked the card as 1st Edition, otherwise `UE` (Unlimited).
|
||||||
|
|
||||||
`buildCandidateFilenames(...)` then produces a priority-ordered list:
|
`buildCandidateFilenames(...)` then produces a priority-ordered list:
|
||||||
@@ -114,4 +114,4 @@ All source types return `Result<T, std::string>` errors so failures cross bounda
|
|||||||
|
|
||||||
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
|
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
|
||||||
|
|
||||||
For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedia’s gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedia’s `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the right code in `rarityCodeFor(...)` (extend the table when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first.
|
For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedia’s gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedia’s `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the expected short code in `ygoRarityShortCode(...)` / `rarityCodeFor(...)` (extend the mapping when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first.
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ The repository uses GitHub Actions workflows split by branch intent, with one or
|
|||||||
|
|
||||||
### SonarQube Cloud (coverage quality gate)
|
### SonarQube Cloud (coverage quality gate)
|
||||||
|
|
||||||
Both `feature-ci.yml` and `master-ci.yml` include a Linux job that configures with GCC coverage flags, builds, runs `ctest`, generates `build/sonarqube-coverage.xml` via `gcovr`, and runs the SonarCloud scan. **`sonar.coverage.exclusions`** omit `ui_wx/` and `app/` from the coverage denominator because only `ccm_core` is exercised by automated tests; Sonar still analyzes those directories for bugs, vulnerabilities, and duplications. See [Testing Guide And Test Code Of Conduct](testing-and-test-code-of-conduct.md).
|
Both `feature-ci.yml` and `master-ci.yml` include a Linux job that configures with GCC coverage flags, builds, runs `ctest`, generates `build/sonarqube-coverage.xml` via `gcovr`, and runs the SonarCloud scan. **`sonar.coverage.exclusions`** omit `ui_wx/` and `app/` from the coverage denominator because only `ccm_core` is exercised by automated tests. The scan also sets **`sonar.cpd.exclusions`** for the repeated per-game wx scaffolding files (`*GameView.cpp`, `*CardEditDialog.cpp`, `*SelectedCardPanel.cpp`) so intentional parallel UI implementations do not drive the duplication gate. See [Testing Guide And Test Code Of Conduct](testing-and-test-code-of-conduct.md).
|
||||||
|
|
||||||
## Version Flow
|
## Version Flow
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Windows and Linux use the same logical flow; only generator and compiler setup d
|
|||||||
|
|
||||||
## Coverage Surface
|
## Coverage Surface
|
||||||
|
|
||||||
**SonarCloud:** The CI Sonar scan reports coverage against `core/` paths that `ccm_core_tests` can execute. `ui_wx/` and the `app/` composition root are excluded from Sonar’s **coverage** calculation (`sonar.coverage.exclusions`) because they are not run under the doctest suite; UI behavior is covered by manual validation below. Other Sonar metrics still include those directories.
|
**SonarCloud:** The CI Sonar scan reports coverage against `core/` paths that `ccm_core_tests` can execute. `ui_wx/` and the `app/` composition root are excluded from Sonar’s **coverage** calculation (`sonar.coverage.exclusions`) because they are not run under the doctest suite; UI behavior is covered by manual validation below. For duplication, the scan excludes intentionally parallel per-game wx scaffolding (`sonar.cpd.exclusions` on `*GameView.cpp`, `*CardEditDialog.cpp`, `*SelectedCardPanel.cpp`) so CPD focuses on shared logic rather than mirrored UI wiring.
|
||||||
|
|
||||||
Current automated tests cover non-UI behavior, including:
|
Current automated tests cover non-UI behavior, including:
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -18,10 +18,11 @@
|
|||||||
- `magic_card_preview_source_tests.cpp` — `MagicCardPreviewSource::buildSearchUrl` URL-encoding rules + `parseResponse` (`data[0].image_uris.normal`). Drives `fetchImageUrl` via `FixedHttpClient`.
|
- `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` (which carries a `PreviewLookupError::Kind` knob so tests can drive both transient and not-found paths) and a `FixedHttpClient`. Both fakes count `calls` so cache-hit assertions are precise. Pin-downs include: "module returning nullptr is silently skipped", the per-game `detectFirstPrint` / `detectPrintVariants` opt-in guards, and the LRU bytes cache (repeat `fetchPreviewBytes` for the same `(game, name, setId, setNo)` returns the cached payload without touching the source or HTTP; different cards get separate cache slots; transient errors are **not** cached so a flaky connection recovers; `fetchImageBytesByUrl` is keyed by URL and serves the per-game card-back fallback from the same LRU). Production `fetchAndCache` rejects empty HTTP bodies (not exercised by these fakes unless a test sets an empty `body` deliberately). The negative-cache behavior is also pinned down: a `NotFound` source error writes through to the persistent cache *and* short-circuits the next lookup (source not re-invoked); editing a lookup-relevant field invalidates the negative entry automatically; warm-restart (a fresh service over the same cache fake) honors a previously stored negative entry; and a later positive result for the same key replaces the negative entry. The persistent-tier wiring uses an inline `InMemoryByteCache : IPreviewByteCache` fake whose `Entry { negative, payload }` carries the kind explicitly.
|
- `card_preview_service_tests.cpp` — `CardPreviewService` registry/orchestration through `registerModule(IGameModule&)` with an inline `FakeGameModule` returning a `FakeSource : ICardPreviewSource` (which carries a `PreviewLookupError::Kind` knob so tests can drive both transient and not-found paths) and a `FixedHttpClient`. Both fakes count `calls` so cache-hit assertions are precise. Pin-downs include: "module returning nullptr is silently skipped", the per-game `detectFirstPrint` / `detectPrintVariants` opt-in guards, and the LRU bytes cache (repeat `fetchPreviewBytes` for the same `(game, name, setId, setNo)` returns the cached payload without touching the source or HTTP; different cards get separate cache slots; transient errors are **not** cached so a flaky connection recovers; `fetchImageBytesByUrl` is keyed by URL and serves the per-game card-back fallback from the same LRU). Production `fetchAndCache` rejects empty HTTP bodies (not exercised by these fakes unless a test sets an empty `body` deliberately). The negative-cache behavior is also pinned down: a `NotFound` source error writes through to the persistent cache *and* short-circuits the next lookup (source not re-invoked); editing a lookup-relevant field invalidates the negative entry automatically; warm-restart (a fresh service over the same cache fake) honors a previously stored negative entry; and a later positive result for the same key replaces the negative entry. The persistent-tier wiring uses an inline `InMemoryByteCache : IPreviewByteCache` fake whose `Entry { negative, payload }` carries the kind explicitly.
|
||||||
- `local_preview_byte_cache_tests.cpp` — `LocalPreviewByteCache` adapter against `StdFileSystem` (the only test in the suite that touches real disk; each case scopes itself to a unique `temp_directory_path()/ccm_preview_cache_test_*` directory and cleans up via an RAII `TempDir`). Pin-downs: store/load round-trips bytes verbatim; missing key is a clean miss; empty payload is silently skipped; sidecar mismatch (faked hash collision) is treated as a miss so we never serve the wrong card's bytes (or wrong card's negative verdict); the cache survives an adapter restart over the same directory; total-size eviction drops the oldest `.bin` by mtime when a `store` would exceed the cap; a `load` touches the entry's mtime so frequently-viewed cards survive eviction. Negative-entry coverage: `storeNegative` round-trips as `NegativeHit` (not a miss, not a payload, and not counted against the byte cap); negatives survive an adapter restart; a later positive `store` overwrites a previous negative and a later `storeNegative` overwrites a previous positive (releasing its bytes from the cap); and the sidecar collision check applies to negative entries too.
|
- `local_preview_byte_cache_tests.cpp` — `LocalPreviewByteCache` adapter against `StdFileSystem` (the only test in the suite that touches real disk; each case scopes itself to a unique `temp_directory_path()/ccm_preview_cache_test_*` directory and cleans up via an RAII `TempDir`). Pin-downs: store/load round-trips bytes verbatim; missing key is a clean miss; empty payload is silently skipped; sidecar mismatch (faked hash collision) is treated as a miss so we never serve the wrong card's bytes (or wrong card's negative verdict); the cache survives an adapter restart over the same directory; total-size eviction drops the oldest `.bin` by mtime when a `store` would exceed the cap; a `load` touches the entry's mtime so frequently-viewed cards survive eviction. Negative-entry coverage: `storeNegative` round-trips as `NegativeHit` (not a miss, not a payload, and not counted against the byte cap); negatives survive an adapter restart; a later positive `store` overwrites a previous negative and a later `storeNegative` overwrites a previous positive (releasing its bytes from the cap); and the sidecar collision check applies to negative entries too.
|
||||||
|
- `std_file_system_tests.cpp` — `StdFileSystem` directly (`exists`, `isDirectory`, `ensureDirectory`, `readText`, `writeText`, `copyFile`, `remove`, `listDirectory`) under a unique `temp_directory_path()/ccm_std_fs_test_*` directory per case; scope matches the real-disk exception documented for preview-cache tests.
|
||||||
- `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_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`.
|
- `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`.
|
||||||
- `yugioh_set_source_tests.cpp` — `YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks.
|
- `yugioh_set_source_tests.cpp` — `YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks.
|
||||||
- `yugioh_card_preview_source_tests.cpp` — `YuGiOhCardPreviewSource` Yugipedia + YGOPRODeck unit coverage. Helper-level tests pin down `normalizeName` (whitespace + Yugipedia-policy punctuation stripping), `rarityCodeFor` (CCM3 dialog rarity names → Yugipedia codes, unknown rarity falls through), `extractSetCode` (`LOB-005` / `LOB-DE005` → `LOB`), `buildCandidateFilenames` (printed-edition first, EN/NA/EU/AU + png/jpg, rarity-less fallback round, empty list when slug or set code is missing), `buildYugipediaQueryUrl` (single `titles=File:A|File:B` batch, percent-encoded), and `parseYugipediaResponse` (returns the URL of the highest-priority filename that resolved, errors when every candidate is `missing`). End-to-end `fetchImageUrl` cases use a `RoutingHttpClient` to verify Yugipedia is queried first and the per-printing scan is returned when found, that empty/error Yugipedia responses fall through to the YGOPRODeck `card_images[0]` fallback, that the YGOPRODeck error is propagated when both upstreams fail, and that an empty `setNo` skips Yugipedia entirely. `parseFirstPrint` preferred-`set_name` lookup is also covered for the auto-detect path. `parsePrintVariants` includes synthetic scenarios aligned with the `yugioh_same_card_set_variant_tests` fixture (dual-rarity vs multi-code within one display set, duplicate suppression, and no merge across unrelated `set_name` rows when the picker label matches nothing).
|
- `yugioh_card_preview_source_tests.cpp` — `YuGiOhCardPreviewSource` Yugipedia + YGOPRODeck unit coverage. Helper-level tests pin down `normalizeName` (whitespace + Yugipedia-policy punctuation stripping), `ygoRarityShortCode` + `rarityCodeFor` (CCM3 dialog rarity names → canonical short codes used by both the YGO overview table and Yugipedia filename generation; unknown rarity falls through), `extractSetCode` (`LOB-005` / `LOB-DE005` → `LOB`), `buildCandidateFilenames` (printed-edition first, EN/NA/EU/AU + png/jpg, rarity-less fallback round, empty list when slug or set code is missing), `buildYugipediaQueryUrl` (single `titles=File:A|File:B` batch, percent-encoded), and `parseYugipediaResponse` (returns the URL of the highest-priority filename that resolved, errors when every candidate is `missing`). End-to-end `fetchImageUrl` cases use a `RoutingHttpClient` to verify Yugipedia is queried first and the per-printing scan is returned when found, that empty/error Yugipedia responses fall through to the YGOPRODeck `card_images[0]` fallback, that the YGOPRODeck error is propagated when both upstreams fail, and that an empty `setNo` skips Yugipedia entirely. `parseFirstPrint` preferred-`set_name` lookup is also covered for the auto-detect path. `parsePrintVariants` includes synthetic scenarios aligned with the `yugioh_same_card_set_variant_tests` fixture (dual-rarity vs multi-code within one display set, duplicate suppression, and no merge across unrelated `set_name` rows when the picker label matches nothing).
|
||||||
- `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_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` / `matchesYuGiOhFilter` 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`; Yu-Gi-Oh adds `setNo` + `rarity`), boolean flag columns intentionally excluded, empty filter matches everything. Update this file whenever you add a new searchable column.
|
- `card_filter_tests.cpp` — `matchesMagicFilter` / `matchesPokemonFilter` / `matchesYuGiOhFilter` 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`; Yu-Gi-Oh adds `setNo` + `rarity`), boolean flag columns 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).
|
- `CMakeLists.txt` — explicit list of every `.cpp` (no glob).
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
1. **Framework**: doctest. Each test file `#include <doctest/doctest.h>` and uses `TEST_SUITE("...")` + `TEST_CASE("...")`. Asserts: `CHECK`, `REQUIRE`, `CHECK_THROWS`.
|
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`. **One narrow exception**: `local_preview_byte_cache_tests.cpp` exercises the real filesystem because `LocalPreviewByteCache` uses `std::filesystem` directly for size + mtime queries that the `IFileSystem` port deliberately does not expose. Those tests scope themselves to a unique temp directory and clean up unconditionally — do not extend the exception to other test files.
|
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`. **Narrow exceptions** (unique temp dirs + RAII cleanup): `local_preview_byte_cache_tests.cpp` (mtime/size semantics tied to real `std::filesystem`) and `std_file_system_tests.cpp` (`StdFileSystem` integration). Do not add further real-disk suites without the same cleanup guarantees.
|
||||||
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.
|
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.
|
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".
|
5. **Test names** describe behavior, not implementation. Prefer "missing file is created with defaults" over "test_init_no_file".
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ add_executable(ccm_core_tests
|
|||||||
yugioh_card_preview_source_tests.cpp
|
yugioh_card_preview_source_tests.cpp
|
||||||
card_sorter_tests.cpp
|
card_sorter_tests.cpp
|
||||||
card_filter_tests.cpp
|
card_filter_tests.cpp
|
||||||
|
game_module_tests.cpp
|
||||||
|
std_file_system_tests.cpp
|
||||||
|
|
||||||
main.cpp
|
main.cpp
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
|
|||||||
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare");
|
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare");
|
||||||
CHECK(matchesYuGiOhFilter(c, "lob-005"));
|
CHECK(matchesYuGiOhFilter(c, "lob-005"));
|
||||||
CHECK(matchesYuGiOhFilter(c, "ultra"));
|
CHECK(matchesYuGiOhFilter(c, "ultra"));
|
||||||
|
CHECK(matchesYuGiOhFilter(c, "ur"));
|
||||||
CHECK_FALSE(matchesYuGiOhFilter(c, "secret rare"));
|
CHECK_FALSE(matchesYuGiOhFilter(c, "secret rare"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+160
-2
@@ -46,7 +46,10 @@ PokemonCard pc(std::uint32_t id, std::string name,
|
|||||||
std::string setName, std::string releaseDate,
|
std::string setName, std::string releaseDate,
|
||||||
std::uint8_t amount = 1,
|
std::uint8_t amount = 1,
|
||||||
bool holo = false, bool firstEdition = false,
|
bool holo = false, bool firstEdition = false,
|
||||||
bool sgnd = false, bool altered = false) {
|
bool sgnd = false, bool altered = false,
|
||||||
|
Language lang = Language::English,
|
||||||
|
Condition cond = Condition::NearMint,
|
||||||
|
std::string note = "") {
|
||||||
PokemonCard c;
|
PokemonCard c;
|
||||||
c.id = id;
|
c.id = id;
|
||||||
c.name = std::move(name);
|
c.name = std::move(name);
|
||||||
@@ -57,6 +60,9 @@ PokemonCard pc(std::uint32_t id, std::string name,
|
|||||||
c.firstEdition = firstEdition;
|
c.firstEdition = firstEdition;
|
||||||
c.signed_ = sgnd;
|
c.signed_ = sgnd;
|
||||||
c.altered = altered;
|
c.altered = altered;
|
||||||
|
c.language = lang;
|
||||||
|
c.condition = cond;
|
||||||
|
c.note = std::move(note);
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +70,13 @@ YuGiOhCard yc(std::uint32_t id, std::string name,
|
|||||||
std::string setName, std::string releaseDate,
|
std::string setName, std::string releaseDate,
|
||||||
std::string setNo = "",
|
std::string setNo = "",
|
||||||
std::string rarity = "",
|
std::string rarity = "",
|
||||||
std::uint8_t amount = 1) {
|
std::uint8_t amount = 1,
|
||||||
|
Language lang = Language::English,
|
||||||
|
Condition cond = Condition::NearMint,
|
||||||
|
bool firstEdition = false,
|
||||||
|
bool sgnd = false,
|
||||||
|
bool altered = false,
|
||||||
|
std::string note = "") {
|
||||||
YuGiOhCard c;
|
YuGiOhCard c;
|
||||||
c.id = id;
|
c.id = id;
|
||||||
c.name = std::move(name);
|
c.name = std::move(name);
|
||||||
@@ -73,6 +85,12 @@ YuGiOhCard yc(std::uint32_t id, std::string name,
|
|||||||
c.setNo = std::move(setNo);
|
c.setNo = std::move(setNo);
|
||||||
c.rarity = std::move(rarity);
|
c.rarity = std::move(rarity);
|
||||||
c.amount = amount;
|
c.amount = amount;
|
||||||
|
c.language = lang;
|
||||||
|
c.condition = cond;
|
||||||
|
c.firstEdition = firstEdition;
|
||||||
|
c.signed_ = sgnd;
|
||||||
|
c.altered = altered;
|
||||||
|
c.note = std::move(note);
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +159,9 @@ TEST_SUITE("CardSorter - Magic columns") {
|
|||||||
};
|
};
|
||||||
sortMagicCards(v, MagicSortColumn::Amount, /*ascending=*/true);
|
sortMagicCards(v, MagicSortColumn::Amount, /*ascending=*/true);
|
||||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // 2 < 4 < 10
|
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // 2 < 4 < 10
|
||||||
|
|
||||||
|
sortMagicCards(v, MagicSortColumn::Amount, /*ascending=*/false);
|
||||||
|
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("boolean flag column orders false < true (asc puts unset first)") {
|
TEST_CASE("boolean flag column orders false < true (asc puts unset first)") {
|
||||||
@@ -254,6 +275,67 @@ TEST_SUITE("CardSorter - Pokemon-specific columns") {
|
|||||||
sortPokemonCards(v, PokemonSortColumn::Amount, /*ascending=*/true);
|
sortPokemonCards(v, PokemonSortColumn::Amount, /*ascending=*/true);
|
||||||
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
|
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Name sorts case-insensitively") {
|
||||||
|
std::vector<PokemonCard> v = {
|
||||||
|
pc(1, "piKAchu", "X", "2000/01/01"),
|
||||||
|
pc(2, "abra", "X", "2000/01/01"),
|
||||||
|
pc(3, "CHARMANDER", "X", "2000/01/01"),
|
||||||
|
};
|
||||||
|
sortPokemonCards(v, PokemonSortColumn::Name, /*ascending=*/true);
|
||||||
|
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Language and Condition sort by lowercased labels") {
|
||||||
|
std::vector<PokemonCard> v = {
|
||||||
|
pc(1, "a", "X", "2000/01/01", 1, false, false, false, false,
|
||||||
|
Language::Japanese, Condition::Mint),
|
||||||
|
pc(2, "b", "X", "2000/01/01", 1, false, false, false, false,
|
||||||
|
Language::English, Condition::Played),
|
||||||
|
pc(3, "c", "X", "2000/01/01", 1, false, false, false, false,
|
||||||
|
Language::German, Condition::NearMint),
|
||||||
|
};
|
||||||
|
sortPokemonCards(v, PokemonSortColumn::Language, /*ascending=*/true);
|
||||||
|
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
|
||||||
|
|
||||||
|
sortPokemonCards(v, PokemonSortColumn::Condition, /*ascending=*/true);
|
||||||
|
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Signed and Altered sort like Magic booleans") {
|
||||||
|
std::vector<PokemonCard> v = {
|
||||||
|
pc(1, "a", "X", "2000/01/01", 1, false, false, /*sgnd=*/false, /*alt=*/true),
|
||||||
|
pc(2, "b", "X", "2000/01/01", 1, false, false, /*sgnd=*/true, /*alt=*/false),
|
||||||
|
};
|
||||||
|
sortPokemonCards(v, PokemonSortColumn::Signed, /*ascending=*/true);
|
||||||
|
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2});
|
||||||
|
|
||||||
|
sortPokemonCards(v, PokemonSortColumn::Altered, /*ascending=*/true);
|
||||||
|
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Note sorts case-insensitively") {
|
||||||
|
std::vector<PokemonCard> v = {
|
||||||
|
pc(1, "a", "X", "2000/01/01", 1, false, false, false, false,
|
||||||
|
Language::English, Condition::NearMint, "ZETA"),
|
||||||
|
pc(2, "b", "X", "2000/01/01", 1, false, false, false, false,
|
||||||
|
Language::English, Condition::NearMint, "alpha"),
|
||||||
|
pc(3, "c", "X", "2000/01/01", 1, false, false, false, false,
|
||||||
|
Language::English, Condition::NearMint, "Beta"),
|
||||||
|
};
|
||||||
|
sortPokemonCards(v, PokemonSortColumn::Note, /*ascending=*/true);
|
||||||
|
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("descending SetReleaseDate reverses chronological order") {
|
||||||
|
std::vector<PokemonCard> v = {
|
||||||
|
pc(1, "x", "Late", "2020/01/01"),
|
||||||
|
pc(2, "y", "Early", "1999/01/01"),
|
||||||
|
pc(3, "z", "Mid", "2015/06/01"),
|
||||||
|
};
|
||||||
|
sortPokemonCards(v, PokemonSortColumn::SetReleaseDate, /*ascending=*/false);
|
||||||
|
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") {
|
TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") {
|
||||||
@@ -272,6 +354,82 @@ TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
TEST_SUITE("CardSorter - YuGiOh columns") {
|
TEST_SUITE("CardSorter - YuGiOh columns") {
|
||||||
|
TEST_CASE("Name sorts case-insensitively") {
|
||||||
|
std::vector<YuGiOhCard> v = {
|
||||||
|
yc(1, "BLUE-EYES", "X", "2000/01/01"),
|
||||||
|
yc(2, "dark magician", "X", "2000/01/01"),
|
||||||
|
yc(3, "CELTIC_GUARDIAN", "X", "2000/01/01"),
|
||||||
|
};
|
||||||
|
sortYuGiOhCards(v, YuGiOhSortColumn::Name, /*ascending=*/true);
|
||||||
|
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("SetReleaseDate sorts chronologically") {
|
||||||
|
std::vector<YuGiOhCard> v = {
|
||||||
|
yc(1, "a", "Late", "2020/01/01"),
|
||||||
|
yc(2, "b", "Early", "1999/01/01"),
|
||||||
|
yc(3, "c", "Mid", "2015/06/01"),
|
||||||
|
};
|
||||||
|
sortYuGiOhCards(v, YuGiOhSortColumn::SetReleaseDate, /*ascending=*/true);
|
||||||
|
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
|
||||||
|
|
||||||
|
sortYuGiOhCards(v, YuGiOhSortColumn::SetReleaseDate, /*ascending=*/false);
|
||||||
|
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Language and Condition sort by lowercased labels") {
|
||||||
|
std::vector<YuGiOhCard> v = {
|
||||||
|
yc(1, "a", "X", "2000/01/01", "", "", 1,
|
||||||
|
Language::Japanese, Condition::Mint),
|
||||||
|
yc(2, "b", "X", "2000/01/01", "", "", 1,
|
||||||
|
Language::English, Condition::Played),
|
||||||
|
yc(3, "c", "X", "2000/01/01", "", "", 1,
|
||||||
|
Language::German, Condition::NearMint),
|
||||||
|
};
|
||||||
|
sortYuGiOhCards(v, YuGiOhSortColumn::Language, /*ascending=*/true);
|
||||||
|
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
|
||||||
|
|
||||||
|
sortYuGiOhCards(v, YuGiOhSortColumn::Condition, /*ascending=*/true);
|
||||||
|
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("FirstEdition Signed Altered and Note columns sort consistently") {
|
||||||
|
std::vector<YuGiOhCard> v = {
|
||||||
|
yc(1, "a", "X", "2000/01/01", "", "", 1,
|
||||||
|
Language::English, Condition::NearMint,
|
||||||
|
/*firstEdition=*/true, /*sgnd=*/false, /*alt=*/true, "Z"),
|
||||||
|
yc(2, "b", "X", "2000/01/01", "", "", 1,
|
||||||
|
Language::English, Condition::NearMint,
|
||||||
|
/*firstEdition=*/false, /*sgnd=*/true, /*alt=*/false, "a"),
|
||||||
|
yc(3, "c", "X", "2000/01/01", "", "", 1,
|
||||||
|
Language::English, Condition::NearMint,
|
||||||
|
/*firstEdition=*/false, /*sgnd=*/false, /*alt=*/false, "m"),
|
||||||
|
};
|
||||||
|
sortYuGiOhCards(v, YuGiOhSortColumn::FirstEdition, /*ascending=*/true);
|
||||||
|
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
|
||||||
|
|
||||||
|
sortYuGiOhCards(v, YuGiOhSortColumn::Signed, /*ascending=*/true);
|
||||||
|
// After FirstEdition sort order is {2,3,1}: signed=false wins first (stable: 3 then 1).
|
||||||
|
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
|
||||||
|
|
||||||
|
sortYuGiOhCards(v, YuGiOhSortColumn::Altered, /*ascending=*/true);
|
||||||
|
// Previous order {3,1,2}: altered=false entries are 3 and 2 before true (1).
|
||||||
|
CHECK(ids(v) == std::vector<std::uint32_t>{3, 2, 1});
|
||||||
|
|
||||||
|
sortYuGiOhCards(v, YuGiOhSortColumn::Note, /*ascending=*/true);
|
||||||
|
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // a, m, Z (case-insensitive)
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Rarity sorts by rarity shorthand") {
|
||||||
|
std::vector<YuGiOhCard> v = {
|
||||||
|
yc(1, "a", "X", "2000/01/01", "", "Ultra Rare", 1),
|
||||||
|
yc(2, "b", "X", "2000/01/01", "", "Common", 1),
|
||||||
|
yc(3, "c", "X", "2000/01/01", "", "Secret Rare", 1),
|
||||||
|
};
|
||||||
|
sortYuGiOhCards(v, YuGiOhSortColumn::Rarity, /*ascending=*/true);
|
||||||
|
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // C, ScR, UR
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("Amount sorts numerically") {
|
TEST_CASE("Amount sorts numerically") {
|
||||||
std::vector<YuGiOhCard> v = {
|
std::vector<YuGiOhCard> v = {
|
||||||
yc(1, "a", "X", "2000/01/01", "", "", 9),
|
yc(1, "a", "X", "2000/01/01", "", "", 9),
|
||||||
|
|||||||
@@ -42,6 +42,50 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
|
|||||||
nlohmann::json bad = "Spanglish";
|
nlohmann::json bad = "Spanglish";
|
||||||
CHECK_THROWS(bad.get<Language>());
|
CHECK_THROWS(bad.get<Language>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("all enum values round-trip through string helpers") {
|
||||||
|
for (const auto game : allGames()) {
|
||||||
|
const auto name = to_string(game);
|
||||||
|
CHECK(gameFromString(name).has_value());
|
||||||
|
CHECK(*gameFromString(name) == game);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto language : allLanguages()) {
|
||||||
|
const auto name = to_string(language);
|
||||||
|
CHECK(languageFromString(name).has_value());
|
||||||
|
CHECK(*languageFromString(name) == language);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto condition : allConditions()) {
|
||||||
|
const auto name = to_string(condition);
|
||||||
|
CHECK(conditionFromString(name).has_value());
|
||||||
|
CHECK(*conditionFromString(name) == condition);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto theme : allThemes()) {
|
||||||
|
const auto name = to_string(theme);
|
||||||
|
CHECK(themeFromString(name).has_value());
|
||||||
|
CHECK(*themeFromString(name) == theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("invalid enum helper inputs return nullopt") {
|
||||||
|
CHECK_FALSE(gameFromString("magic").has_value());
|
||||||
|
CHECK_FALSE(languageFromString("EN").has_value());
|
||||||
|
CHECK_FALSE(conditionFromString("Near Mint").has_value());
|
||||||
|
CHECK_FALSE(themeFromString("OLED").has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("invalid game, condition and theme JSON values throw") {
|
||||||
|
nlohmann::json badGame = "YGO";
|
||||||
|
CHECK_THROWS(badGame.get<Game>());
|
||||||
|
|
||||||
|
nlohmann::json badCondition = "Pristine";
|
||||||
|
CHECK_THROWS(badCondition.get<Condition>());
|
||||||
|
|
||||||
|
nlohmann::json badTheme = "Midnight";
|
||||||
|
CHECK_THROWS(badTheme.get<Theme>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_SUITE("Set JSON shape stays stable") {
|
TEST_SUITE("Set JSON shape stays stable") {
|
||||||
@@ -124,6 +168,25 @@ TEST_SUITE("Configuration JSON matches Rust serde aliases") {
|
|||||||
const auto back = j.get<Configuration>();
|
const auto back = j.get<Configuration>();
|
||||||
CHECK(back == cfg);
|
CHECK(back == cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("theme defaults to Light when missing from payload") {
|
||||||
|
const nlohmann::json j = {
|
||||||
|
{"dataStorage", "/storage"},
|
||||||
|
{"defaultGame", "Magic"},
|
||||||
|
};
|
||||||
|
|
||||||
|
const auto cfg = j.get<Configuration>();
|
||||||
|
CHECK(cfg.dataStorage == "/storage");
|
||||||
|
CHECK(cfg.defaultGame == Game::Magic);
|
||||||
|
CHECK(cfg.theme == Theme::Light);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("missing required keys still throws") {
|
||||||
|
const nlohmann::json j = {
|
||||||
|
{"theme", "Dark"},
|
||||||
|
};
|
||||||
|
CHECK_THROWS(j.get<Configuration>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_SUITE("YuGiOhCard JSON") {
|
TEST_SUITE("YuGiOhCard JSON") {
|
||||||
@@ -135,7 +198,6 @@ TEST_SUITE("YuGiOhCard JSON") {
|
|||||||
c.set = Set{"SDK-001", "Starter Deck Kaiba", "2002/03/29"};
|
c.set = Set{"SDK-001", "Starter Deck Kaiba", "2002/03/29"};
|
||||||
c.setNo = "SDK-001";
|
c.setNo = "SDK-001";
|
||||||
c.rarity = "Ultra Rare";
|
c.rarity = "Ultra Rare";
|
||||||
c.rarityCode = "(UR)";
|
|
||||||
c.note = "classic";
|
c.note = "classic";
|
||||||
c.images = {"77+starter+blue-eyes+0.png"};
|
c.images = {"77+starter+blue-eyes+0.png"};
|
||||||
c.language = Language::English;
|
c.language = Language::English;
|
||||||
@@ -147,7 +209,7 @@ TEST_SUITE("YuGiOhCard JSON") {
|
|||||||
nlohmann::json j = c;
|
nlohmann::json j = c;
|
||||||
CHECK(j.at("setNo") == "SDK-001");
|
CHECK(j.at("setNo") == "SDK-001");
|
||||||
CHECK(j.at("rarity") == "Ultra Rare");
|
CHECK(j.at("rarity") == "Ultra Rare");
|
||||||
CHECK(j.at("rarityCode") == "(UR)");
|
CHECK_FALSE(j.contains("rarityCode"));
|
||||||
CHECK(j.at("signed") == false);
|
CHECK(j.at("signed") == false);
|
||||||
|
|
||||||
const YuGiOhCard back = j.get<YuGiOhCard>();
|
const YuGiOhCard back = j.get<YuGiOhCard>();
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#include <doctest/doctest.h>
|
||||||
|
|
||||||
|
#include "ccm/games/magic/MagicGameModule.hpp"
|
||||||
|
#include "ccm/games/pokemon/PokemonGameModule.hpp"
|
||||||
|
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
|
||||||
|
#include "ccm/ports/IHttpClient.hpp"
|
||||||
|
|
||||||
|
using namespace ccm;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
class NoopHttpClient final : public IHttpClient {
|
||||||
|
public:
|
||||||
|
Result<std::string> get(std::string_view) override {
|
||||||
|
return Result<std::string>::ok("{}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_SUITE("game modules expose stable identity and wiring") {
|
||||||
|
TEST_CASE("Magic module reports canonical metadata") {
|
||||||
|
NoopHttpClient http;
|
||||||
|
MagicGameModule module(http);
|
||||||
|
|
||||||
|
CHECK(module.id() == Game::Magic);
|
||||||
|
CHECK(module.dirName() == "magic");
|
||||||
|
CHECK(module.displayName() == "Magic");
|
||||||
|
CHECK(module.cardPreviewSource() != nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Pokemon module reports canonical metadata") {
|
||||||
|
NoopHttpClient http;
|
||||||
|
PokemonGameModule module(http);
|
||||||
|
|
||||||
|
CHECK(module.id() == Game::Pokemon);
|
||||||
|
CHECK(module.dirName() == "pokemon");
|
||||||
|
CHECK(module.displayName() == "Pokemon");
|
||||||
|
CHECK(module.cardPreviewSource() != nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("YuGiOh module reports canonical metadata") {
|
||||||
|
NoopHttpClient http;
|
||||||
|
YuGiOhGameModule module(http);
|
||||||
|
|
||||||
|
CHECK(module.id() == Game::YuGiOh);
|
||||||
|
CHECK(module.dirName() == "yugioh");
|
||||||
|
CHECK(module.displayName() == "Yu-Gi-Oh!");
|
||||||
|
CHECK(module.cardPreviewSource() != nullptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
#include <doctest/doctest.h>
|
||||||
|
|
||||||
|
// StdFileSystem translates IFileSystem onto std::filesystem. Like
|
||||||
|
// LocalPreviewByteCache tests, these exercise real disk under a dedicated
|
||||||
|
// temp directory so permission errors and path normalization behave like production.
|
||||||
|
|
||||||
|
#include "ccm/infra/StdFileSystem.hpp"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <random>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
using namespace ccm;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct TempDir {
|
||||||
|
fs::path path;
|
||||||
|
|
||||||
|
TempDir() {
|
||||||
|
std::random_device rd;
|
||||||
|
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
|
||||||
|
path = fs::temp_directory_path() /
|
||||||
|
(std::string("ccm_std_fs_test_") + std::to_string(stamp) + "_" +
|
||||||
|
std::to_string(rd()));
|
||||||
|
std::error_code ec;
|
||||||
|
fs::create_directories(path, ec);
|
||||||
|
}
|
||||||
|
|
||||||
|
~TempDir() {
|
||||||
|
std::error_code ec;
|
||||||
|
fs::remove_all(path, ec);
|
||||||
|
}
|
||||||
|
|
||||||
|
TempDir(const TempDir&) = delete;
|
||||||
|
TempDir& operator=(const TempDir&) = delete;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_SUITE("StdFileSystem") {
|
||||||
|
TEST_CASE("exists and isDirectory reflect real paths") {
|
||||||
|
TempDir td;
|
||||||
|
StdFileSystem fs;
|
||||||
|
|
||||||
|
const auto nested = td.path / "a" / "b";
|
||||||
|
CHECK_FALSE(fs.exists(nested));
|
||||||
|
|
||||||
|
REQUIRE(fs.ensureDirectory(nested).isOk());
|
||||||
|
CHECK(fs.exists(nested));
|
||||||
|
CHECK(fs.isDirectory(nested));
|
||||||
|
|
||||||
|
const auto filePath = td.path / "file.bin";
|
||||||
|
REQUIRE(fs.writeText(filePath, "x").isOk());
|
||||||
|
CHECK(fs.exists(filePath));
|
||||||
|
CHECK_FALSE(fs.isDirectory(filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ensureDirectory succeeds when directory already exists") {
|
||||||
|
TempDir td;
|
||||||
|
StdFileSystem fs;
|
||||||
|
const auto dir = td.path / "existing";
|
||||||
|
REQUIRE(fs.ensureDirectory(dir).isOk());
|
||||||
|
REQUIRE(fs.ensureDirectory(dir).isOk());
|
||||||
|
CHECK(fs.isDirectory(dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ensureDirectory errors when path is a regular file") {
|
||||||
|
TempDir td;
|
||||||
|
StdFileSystem fs;
|
||||||
|
const auto clash = td.path / "notadir";
|
||||||
|
REQUIRE(fs.writeText(clash, "block").isOk());
|
||||||
|
|
||||||
|
const auto r = fs.ensureDirectory(clash);
|
||||||
|
REQUIRE(r.isErr());
|
||||||
|
CHECK(r.error().find("not a directory") != std::string::npos);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("readText round-trips bytes written by writeText") {
|
||||||
|
TempDir td;
|
||||||
|
StdFileSystem fs;
|
||||||
|
const auto p = td.path / "sub" / "cfg.json";
|
||||||
|
const std::string payload(std::string("{\"x\":") + std::string(4, '\0') + "}");
|
||||||
|
|
||||||
|
REQUIRE(fs.writeText(p, payload).isOk());
|
||||||
|
const auto readBack = fs.readText(p);
|
||||||
|
REQUIRE(readBack.isOk());
|
||||||
|
CHECK(readBack.value() == payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("readText errors when file does not exist") {
|
||||||
|
TempDir td;
|
||||||
|
StdFileSystem fs;
|
||||||
|
const auto missing = td.path / "missing.txt";
|
||||||
|
|
||||||
|
const auto r = fs.readText(missing);
|
||||||
|
REQUIRE(r.isErr());
|
||||||
|
CHECK(r.error().find("Unable to open") != std::string::npos);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("writeText truncates an existing file") {
|
||||||
|
TempDir td;
|
||||||
|
StdFileSystem fs;
|
||||||
|
const auto p = td.path / "t.txt";
|
||||||
|
REQUIRE(fs.writeText(p, "aaaaaaaaaa").isOk());
|
||||||
|
REQUIRE(fs.writeText(p, "hi").isOk());
|
||||||
|
|
||||||
|
const auto r = fs.readText(p);
|
||||||
|
REQUIRE(r.isOk());
|
||||||
|
CHECK(r.value() == "hi");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("copyFile copies bytes and respects overwrite flag") {
|
||||||
|
TempDir td;
|
||||||
|
StdFileSystem fs;
|
||||||
|
const auto src = td.path / "src.bin";
|
||||||
|
const auto dst = td.path / "nested" / "dst.bin";
|
||||||
|
|
||||||
|
REQUIRE(fs.writeText(src, "alpha").isOk());
|
||||||
|
REQUIRE(fs.copyFile(src, dst, /*overwrite=*/false).isOk());
|
||||||
|
|
||||||
|
auto rd = fs.readText(dst);
|
||||||
|
REQUIRE(rd.isOk());
|
||||||
|
CHECK(rd.value() == "alpha");
|
||||||
|
|
||||||
|
REQUIRE(fs.writeText(src, "beta").isOk());
|
||||||
|
const auto noOverwrite = fs.copyFile(src, dst, /*overwrite=*/false);
|
||||||
|
REQUIRE(noOverwrite.isErr());
|
||||||
|
|
||||||
|
REQUIRE(fs.copyFile(src, dst, /*overwrite=*/true).isOk());
|
||||||
|
rd = fs.readText(dst);
|
||||||
|
REQUIRE(rd.isOk());
|
||||||
|
CHECK(rd.value() == "beta");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("copyFile fails cleanly when source is missing") {
|
||||||
|
TempDir td;
|
||||||
|
StdFileSystem fs;
|
||||||
|
const auto src = td.path / "ghost.dat";
|
||||||
|
const auto dst = td.path / "out.dat";
|
||||||
|
|
||||||
|
const auto r = fs.copyFile(src, dst, /*overwrite=*/false);
|
||||||
|
REQUIRE(r.isErr());
|
||||||
|
CHECK(r.error().find("copy_file failed") != std::string::npos);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("remove deletes a file and tolerates repeated removes") {
|
||||||
|
TempDir td;
|
||||||
|
StdFileSystem fs;
|
||||||
|
const auto p = td.path / "gone.txt";
|
||||||
|
REQUIRE(fs.writeText(p, "body").isOk());
|
||||||
|
|
||||||
|
REQUIRE(fs.remove(p).isOk());
|
||||||
|
CHECK_FALSE(fs.exists(p));
|
||||||
|
|
||||||
|
REQUIRE(fs.remove(p).isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("listDirectory errors when path is not a directory") {
|
||||||
|
TempDir td;
|
||||||
|
StdFileSystem fs;
|
||||||
|
const auto p = td.path / "single.dat";
|
||||||
|
REQUIRE(fs.writeText(p, "").isOk());
|
||||||
|
|
||||||
|
const auto r = fs.listDirectory(p);
|
||||||
|
REQUIRE(r.isErr());
|
||||||
|
CHECK(r.error().find("Not a directory") != std::string::npos);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("listDirectory returns entries for an empty and populated folder") {
|
||||||
|
TempDir td;
|
||||||
|
StdFileSystem fs;
|
||||||
|
const auto dir = td.path / "list_me";
|
||||||
|
|
||||||
|
REQUIRE(fs.ensureDirectory(dir).isOk());
|
||||||
|
auto empty = fs.listDirectory(dir);
|
||||||
|
REQUIRE(empty.isOk());
|
||||||
|
CHECK(empty.value().empty());
|
||||||
|
|
||||||
|
REQUIRE(fs.writeText(dir / "a.txt", "a").isOk());
|
||||||
|
REQUIRE(fs.writeText(dir / "b.txt", "b").isOk());
|
||||||
|
|
||||||
|
auto filled = fs.listDirectory(dir);
|
||||||
|
REQUIRE(filled.isOk());
|
||||||
|
CHECK(filled.value().size() == 2u);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,23 @@ TEST_SUITE("ygoPrintingSlotsMatch") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_SUITE("ygoRarityShortCode") {
|
||||||
|
TEST_CASE("maps supported Yu-Gi-Oh rarity names to short form") {
|
||||||
|
CHECK(ygoRarityShortCode("Common") == "C");
|
||||||
|
CHECK(ygoRarityShortCode("Rare") == "R");
|
||||||
|
CHECK(ygoRarityShortCode("Super Rare") == "SR");
|
||||||
|
CHECK(ygoRarityShortCode("Ultra Rare") == "UR");
|
||||||
|
CHECK(ygoRarityShortCode("Secret Rare") == "ScR");
|
||||||
|
CHECK(ygoRarityShortCode("Quarter Century Secret Rare") == "QCScR");
|
||||||
|
CHECK(ygoRarityShortCode("Starlight Rare") == "StR");
|
||||||
|
CHECK(ygoRarityShortCode("Collector's Rare") == "CR");
|
||||||
|
CHECK(ygoRarityShortCode("Ghost Rare") == "GR");
|
||||||
|
CHECK(ygoRarityShortCode("Ultimate Rare") == "UtR");
|
||||||
|
CHECK(ygoRarityShortCode("Platinum Secret Rare") == "PlScR");
|
||||||
|
CHECK(ygoRarityShortCode("Prismatic Secret Rare") == "PScR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TEST_SUITE("YuGiOhCardPreviewSource::normalizeName") {
|
TEST_SUITE("YuGiOhCardPreviewSource::normalizeName") {
|
||||||
TEST_CASE("strips whitespace and policy-banned punctuation") {
|
TEST_CASE("strips whitespace and policy-banned punctuation") {
|
||||||
// Yugipedia's image policy: whitespace and a fixed punctuation set
|
// Yugipedia's image policy: whitespace and a fixed punctuation set
|
||||||
@@ -101,6 +118,8 @@ TEST_SUITE("YuGiOhCardPreviewSource::rarityCodeFor") {
|
|||||||
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Secret Rare") == "ScR");
|
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Secret Rare") == "ScR");
|
||||||
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Quarter Century Secret Rare")
|
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Quarter Century Secret Rare")
|
||||||
== "QCScR");
|
== "QCScR");
|
||||||
|
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Collector's Rare") == "CR");
|
||||||
|
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Platinum Secret Rare") == "PlScR");
|
||||||
}
|
}
|
||||||
TEST_CASE("returns empty string for unknown rarity names") {
|
TEST_CASE("returns empty string for unknown rarity names") {
|
||||||
// Unknown rarity should fall through to the rarity-less filename
|
// Unknown rarity should fall through to the rarity-less filename
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "ccm/services/CardFilter.hpp"
|
#include "ccm/services/CardFilter.hpp"
|
||||||
#include "ccm/ui/SvgIcons.hpp"
|
#include "ccm/ui/SvgIcons.hpp"
|
||||||
|
#include "ccm/util/YuGiOhPrintingSlot.hpp"
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
@@ -15,12 +16,13 @@ YuGiOhCardListPanel::YuGiOhCardListPanel(wxWindow* parent)
|
|||||||
std::vector<YuGiOhCardListPanel::TextColumnSpec>
|
std::vector<YuGiOhCardListPanel::TextColumnSpec>
|
||||||
YuGiOhCardListPanel::declareTextColumns() const {
|
YuGiOhCardListPanel::declareTextColumns() const {
|
||||||
return {
|
return {
|
||||||
{"Name", 200, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Name},
|
{"Name", 200, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Name},
|
||||||
{"Set", 160, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::SetReleaseDate},
|
{"Set", 160, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::SetReleaseDate},
|
||||||
{"Amount", 70, wxLIST_FORMAT_RIGHT, YuGiOhSortColumn::Amount},
|
{"Amount", 70, wxLIST_FORMAT_RIGHT, YuGiOhSortColumn::Amount},
|
||||||
{"Condition", 100, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Condition},
|
{"Rarity", 90, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Rarity},
|
||||||
{"Language", 100, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Language},
|
{"Condition", 100, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Condition},
|
||||||
{"Note", 180, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Note},
|
{"Language", 100, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Language},
|
||||||
|
{"Note", 180, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Note},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,9 +42,10 @@ std::string YuGiOhCardListPanel::renderTextCell(const YuGiOhCard& card,
|
|||||||
case 0: return card.name;
|
case 0: return card.name;
|
||||||
case 1: return card.set.name;
|
case 1: return card.set.name;
|
||||||
case 2: return std::to_string(card.amount);
|
case 2: return std::to_string(card.amount);
|
||||||
case 3: return std::string(to_string(card.condition));
|
case 3: return ygoRarityShortCode(card.rarity);
|
||||||
case 4: return std::string(to_string(card.language));
|
case 4: return std::string(to_string(card.condition));
|
||||||
case 5: return card.note;
|
case 5: return std::string(to_string(card.language));
|
||||||
|
case 6: return card.note;
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user