mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-29 01:08:49 +00:00
fix: unittests
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
|
||||
@@ -23,7 +24,11 @@ namespace ccm {
|
||||
// only fires one outbound request at a time anyway.
|
||||
class CprHttpClient final : public IHttpClient {
|
||||
public:
|
||||
using GetExecutor = std::function<Result<std::string>(std::string_view)>;
|
||||
|
||||
explicit CprHttpClient(std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
|
||||
CprHttpClient(GetExecutor executor,
|
||||
std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
|
||||
~CprHttpClient() override;
|
||||
|
||||
Result<std::string> get(std::string_view url) override;
|
||||
@@ -31,6 +36,7 @@ public:
|
||||
private:
|
||||
std::chrono::milliseconds timeout_;
|
||||
std::unique_ptr<cpr::Session> session_;
|
||||
GetExecutor executor_;
|
||||
std::mutex sessionMutex_;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// ASCII-only tolower for sort/filter parity with the legacy TS path:
|
||||
// String.prototype.toLowerCase() on English/German/etc. card metadata behaves
|
||||
// identically for this byte range.
|
||||
[[nodiscard]] inline 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;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// Shared classification for raw HTTP GET outcomes (transport vs status vs OK).
|
||||
// `CprHttpClient::get` delegates here so doctest can exercise the branches
|
||||
// without touching libcpr or the network stack.
|
||||
[[nodiscard]] inline Result<std::string> mapHttpGetResponse(bool curlTransportError,
|
||||
std::string_view curlErrorMessage,
|
||||
long httpStatusCode,
|
||||
std::string responseBody,
|
||||
std::string_view requestUrl) {
|
||||
if (curlTransportError) {
|
||||
return Result<std::string>::err(std::string("HTTP error: ") +
|
||||
std::string(curlErrorMessage));
|
||||
}
|
||||
if (httpStatusCode < 200 || httpStatusCode >= 300) {
|
||||
return Result<std::string>::err(
|
||||
"HTTP " + std::to_string(httpStatusCode) + " from " +
|
||||
std::string(requestUrl));
|
||||
}
|
||||
return Result<std::string>::ok(std::move(responseBody));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// Percent-encode all bytes that are not unreserved per RFC 3986
|
||||
// (A-Z / a-z / 0-9 / - . _ ~). Needed because cpr does not encode the URL
|
||||
// string passed to IHttpClient::get.
|
||||
[[nodiscard]] inline std::string rfc3986PercentEncode(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();
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,40 +1,16 @@
|
||||
#include "ccm/games/magic/MagicCardPreviewSource.hpp"
|
||||
|
||||
#include "ccm/util/Rfc3986.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);
|
||||
@@ -59,7 +35,8 @@ std::string MagicCardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
query += sanitized;
|
||||
query += "\" AND set:";
|
||||
query += std::string(setId);
|
||||
return std::string("https://api.scryfall.com/cards/search?q=") + urlEncode(query);
|
||||
return std::string("https://api.scryfall.com/cards/search?q=") +
|
||||
rfc3986PercentEncode(query);
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
|
||||
@@ -1,39 +1,16 @@
|
||||
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
|
||||
|
||||
#include "ccm/util/Rfc3986.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.
|
||||
@@ -67,7 +44,8 @@ std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
query += " number:";
|
||||
query += num;
|
||||
}
|
||||
return std::string("https://api.pokemontcg.io/v2/cards?q=") + urlEncode(query);
|
||||
return std::string("https://api.pokemontcg.io/v2/cards?q=") +
|
||||
rfc3986PercentEncode(query);
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
|
||||
#include "ccm/util/YuGiOhPrintingSlot.hpp"
|
||||
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
@@ -17,31 +18,6 @@ namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// RFC 3986 percent-encoder. Same rules as the Magic implementation; private
|
||||
// here so the YGO and Magic code paths can drift independently if the future
|
||||
// requires it (Yugipedia's MediaWiki API is fine with %20 for spaces and %7C
|
||||
// for the `|` separator inside `titles=`).
|
||||
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();
|
||||
}
|
||||
|
||||
std::string trim(std::string s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
|
||||
@@ -271,7 +247,7 @@ std::string YuGiOhCardPreviewSource::buildYugipediaQueryUrl(
|
||||
std::string url =
|
||||
"https://yugipedia.com/api.php?action=query&format=json"
|
||||
"&prop=imageinfo&iiprop=url&titles=";
|
||||
url += urlEncode(joined);
|
||||
url += rfc3986PercentEncode(joined);
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -335,10 +311,10 @@ Result<std::string, PreviewLookupError> YuGiOhCardPreviewSource::parseYugipediaR
|
||||
std::string YuGiOhCardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
std::string_view setName) {
|
||||
std::string url =
|
||||
std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=") + urlEncode(name);
|
||||
std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=") + rfc3986PercentEncode(name);
|
||||
if (!setName.empty()) {
|
||||
url += "&cardset=";
|
||||
url += urlEncode(setName);
|
||||
url += rfc3986PercentEncode(setName);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "ccm/infra/CprHttpClient.hpp"
|
||||
|
||||
#include "ccm/util/HttpGetMapping.hpp"
|
||||
|
||||
#include <cpr/cpr.h>
|
||||
|
||||
#include <string>
|
||||
@@ -24,8 +26,25 @@ CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout)
|
||||
/*follow=*/true,
|
||||
/*cont_send_cred=*/false,
|
||||
cpr::PostRedirectFlags::POST_ALL});
|
||||
executor_ = [this](std::string_view url) -> Result<std::string> {
|
||||
session_->SetUrl(cpr::Url{std::string(url)});
|
||||
cpr::Response r = session_->Get();
|
||||
|
||||
if (r.error) {
|
||||
return mapHttpGetResponse(true, r.error.message, r.status_code, {},
|
||||
url);
|
||||
}
|
||||
return mapHttpGetResponse(false, {}, r.status_code, std::move(r.text),
|
||||
url);
|
||||
};
|
||||
}
|
||||
|
||||
CprHttpClient::CprHttpClient(GetExecutor executor,
|
||||
std::chrono::milliseconds timeout)
|
||||
: timeout_(timeout),
|
||||
session_(nullptr),
|
||||
executor_(std::move(executor)) {}
|
||||
|
||||
CprHttpClient::~CprHttpClient() = default;
|
||||
|
||||
Result<std::string> CprHttpClient::get(std::string_view url) {
|
||||
@@ -34,18 +53,10 @@ Result<std::string> CprHttpClient::get(std::string_view url) {
|
||||
// (one fetch per BaseSelectedCardPanel selection change), so contention
|
||||
// is negligible.
|
||||
std::lock_guard<std::mutex> lock(sessionMutex_);
|
||||
|
||||
session_->SetUrl(cpr::Url{std::string(url)});
|
||||
cpr::Response r = session_->Get();
|
||||
|
||||
if (r.error) {
|
||||
return Result<std::string>::err("HTTP error: " + r.error.message);
|
||||
if (!executor_) {
|
||||
return Result<std::string>::err("HTTP error: no executor configured");
|
||||
}
|
||||
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));
|
||||
return executor_(url);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -1,29 +1,14 @@
|
||||
#include "ccm/services/CardFilter.hpp"
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/util/YuGiOhPrintingSlot.hpp"
|
||||
#include "ccm/util/AsciiUtils.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;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,15 @@
|
||||
#include "ccm/services/CardSorter.hpp"
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/util/YuGiOhPrintingSlot.hpp"
|
||||
#include "ccm/util/AsciiUtils.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>
|
||||
|
||||
Reference in New Issue
Block a user