diff --git a/core/include/ccm/infra/CprHttpClient.hpp b/core/include/ccm/infra/CprHttpClient.hpp index 4280081..8b4d2a2 100644 --- a/core/include/ccm/infra/CprHttpClient.hpp +++ b/core/include/ccm/infra/CprHttpClient.hpp @@ -24,11 +24,21 @@ namespace ccm { // only fires one outbound request at a time anyway. class CprHttpClient final : public IHttpClient { public: + struct RawResponse { + bool transportError{false}; + std::string transportMessage; + int statusCode{0}; + std::string body; + }; + using GetExecutor = std::function(std::string_view)>; + using RawGetExecutor = std::function; explicit CprHttpClient(std::chrono::milliseconds timeout = std::chrono::milliseconds{30000}); CprHttpClient(GetExecutor executor, std::chrono::milliseconds timeout = std::chrono::milliseconds{30000}); + CprHttpClient(RawGetExecutor rawExecutor, + std::chrono::milliseconds timeout = std::chrono::milliseconds{30000}); ~CprHttpClient() override; Result get(std::string_view url) override; @@ -37,6 +47,7 @@ private: std::chrono::milliseconds timeout_; std::unique_ptr session_; GetExecutor executor_; + RawGetExecutor rawExecutor_; std::mutex sessionMutex_; }; diff --git a/core/src/games/yugioh/YuGiOhSetSource.cpp b/core/src/games/yugioh/YuGiOhSetSource.cpp index d47acd1..6d3ebff 100644 --- a/core/src/games/yugioh/YuGiOhSetSource.cpp +++ b/core/src/games/yugioh/YuGiOhSetSource.cpp @@ -3,9 +3,42 @@ #include #include +#include #include namespace ccm { +namespace { + +struct YuGiOhSetAlias { + const char* code; + const char* name; + const char* releaseDate; +}; + +constexpr std::array kMissing25thAnniversaryReprints{{ + {"LOB-25TH", "Legend of Blue Eyes White Dragon (25th Anniversary Edition)", "2023/04/20"}, + {"MRD-25TH", "Metal Raiders (25th Anniversary Edition)", "2023/04/20"}, + {"SRL-25TH", "Spell Ruler (25th Anniversary Edition)", "2023/04/20"}, + {"PSV-25TH", "Pharaoh's Servant (25th Anniversary Edition)", "2023/04/20"}, + {"DCR-25TH", "Dark Crisis (25th Anniversary Edition)", "2023/04/20"}, + {"IOC-25TH", "Invasion of Chaos (25th Anniversary Edition)", "2023/06/08"}, +}}; + +void appendMissingSetAliases(std::vector& sets) { + for (const auto& alias : kMissing25thAnniversaryReprints) { + const bool exists = std::any_of( + sets.begin(), sets.end(), [&](const Set& s) { return s.name == alias.name; }); + if (exists) continue; + + Set s; + s.id = alias.code; + s.name = alias.name; + s.releaseDate = alias.releaseDate; + sets.push_back(std::move(s)); + } +} + +} // namespace YuGiOhSetSource::YuGiOhSetSource(IHttpClient& http) : http_(http) {} @@ -29,6 +62,7 @@ Result> YuGiOhSetSource::parseResponse(const std::string& body) s.releaseDate = std::move(release); out.push_back(std::move(s)); } + appendMissingSetAliases(out); std::sort(out.begin(), out.end(), [](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; }); return Result>::ok(std::move(out)); diff --git a/core/src/infra/CprHttpClient.cpp b/core/src/infra/CprHttpClient.cpp index c43ccc5..e58d766 100644 --- a/core/src/infra/CprHttpClient.cpp +++ b/core/src/infra/CprHttpClient.cpp @@ -26,16 +26,15 @@ CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout) /*follow=*/true, /*cont_send_cred=*/false, cpr::PostRedirectFlags::POST_ALL}); - executor_ = [this](std::string_view url) -> Result { + rawExecutor_ = [this](std::string_view url) -> RawResponse { 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); + return RawResponse{ + .transportError = static_cast(r.error), + .transportMessage = r.error.message, + .statusCode = static_cast(r.status_code), + .body = std::move(r.text), + }; }; } @@ -45,6 +44,12 @@ CprHttpClient::CprHttpClient(GetExecutor executor, session_(nullptr), executor_(std::move(executor)) {} +CprHttpClient::CprHttpClient(RawGetExecutor rawExecutor, + std::chrono::milliseconds timeout) + : timeout_(timeout), + session_(nullptr), + rawExecutor_(std::move(rawExecutor)) {} + CprHttpClient::~CprHttpClient() = default; Result CprHttpClient::get(std::string_view url) { @@ -53,10 +58,18 @@ Result CprHttpClient::get(std::string_view url) { // (one fetch per BaseSelectedCardPanel selection change), so contention // is negligible. std::lock_guard lock(sessionMutex_); - if (!executor_) { - return Result::err("HTTP error: no executor configured"); + if (executor_) { + return executor_(url); } - return executor_(url); + if (rawExecutor_) { + RawResponse raw = rawExecutor_(url); + return mapHttpGetResponse(raw.transportError, + raw.transportMessage, + raw.statusCode, + std::move(raw.body), + url); + } + return Result::err("HTTP error: no executor configured"); } } // namespace ccm diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ac019eb..8f8c5d9 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -21,6 +21,7 @@ add_executable(ccm_core_tests std_file_system_tests.cpp pokemon_set_source_tests.cpp pokemon_card_preview_source_tests.cpp + icard_preview_source_tests.cpp yugioh_set_source_tests.cpp yugioh_card_preview_source_tests.cpp game_module_tests.cpp diff --git a/tests/card_preview_service_tests.cpp b/tests/card_preview_service_tests.cpp index 302c16d..a1061f9 100644 --- a/tests/card_preview_service_tests.cpp +++ b/tests/card_preview_service_tests.cpp @@ -703,6 +703,29 @@ TEST_SUITE("CardPreviewService caching") { CHECK(http.calls == 1); } + TEST_CASE("fetchImageBytesByUrl serves from persistent cache hit without HTTP") { + FixedHttpClient http; + http.body = "warm-card-back"; + InMemoryByteCache disk; + + // Seed persistent cache via first service instance. + { + CardPreviewService seed{http, &disk}; + const auto seeded = seed.fetchImageBytesByUrl("https://cdn.example/back.png"); + REQUIRE(seeded.isOk()); + CHECK(seeded.value() == "warm-card-back"); + } + REQUIRE(http.calls == 1); + + // Fresh service instance: force HTTP failure and ensure disk hit is used. + CardPreviewService warm{http, &disk}; + http.ok = false; + const auto warmHit = warm.fetchImageBytesByUrl("https://cdn.example/back.png"); + REQUIRE(warmHit.isOk()); + CHECK(warmHit.value() == "warm-card-back"); + CHECK(http.calls == 1); + } + TEST_CASE("in-memory LRU evicts oldest entry after exceeding capacity") { FakeSource source; FakeGameModule module; diff --git a/tests/collection_service_tests.cpp b/tests/collection_service_tests.cpp index 276d73f..837c3b1 100644 --- a/tests/collection_service_tests.cpp +++ b/tests/collection_service_tests.cpp @@ -16,9 +16,15 @@ namespace { class InMemoryRepo final : public ICollectionRepository { public: Map storage; + bool failLoad{false}; + bool failSave{false}; - Result load(Game) override { return Result::ok(storage); } + Result load(Game) override { + if (failLoad) return Result::err("load failed"); + return Result::ok(storage); + } Result save(Game, const Map& m) override { + if (failSave) return Result::err("save failed"); storage = m; return Result::ok(); } @@ -27,12 +33,14 @@ public: class StubImageStore final : public IImageStore { public: std::vector> removed; + bool failRemove{false}; Result copyIn(Game, const std::filesystem::path&, const std::string& n) override { return Result::ok(n); } Result remove(Game g, const std::string& n) override { removed.emplace_back(g, n); + if (failRemove) return Result::err("remove failed for " + n); return Result::ok(); } std::filesystem::path resolvePath(Game, const std::string& n) const override { @@ -117,4 +125,72 @@ TEST_SUITE("CollectionService") { CHECK(svc.remove(Game::Magic, 12345).isErr()); } + + TEST_CASE("load errors are propagated by list and findById") { + InMemoryRepo repo; + repo.failLoad = true; + StubImageStore store; + CollectionService svc{repo, store}; + + const auto listed = svc.list(Game::Magic); + REQUIRE(listed.isErr()); + CHECK(listed.error() == "load failed"); + + const auto found = svc.findById(Game::Magic, 1); + REQUIRE(found.isErr()); + CHECK(found.error() == "load failed"); + } + + TEST_CASE("save errors are propagated by add and update") { + InMemoryRepo repo; + repo.failSave = true; + StubImageStore store; + CollectionService svc{repo, store}; + + const auto addRes = svc.add(Game::Magic, makeCard("A")); + REQUIRE(addRes.isErr()); + CHECK(addRes.error() == "save failed"); + + repo.failSave = false; + const auto id = svc.add(Game::Magic, makeCard("B")); + REQUIRE(id.isOk()); + + repo.failSave = true; + MagicCard updated = makeCard("Renamed"); + updated.id = id.value(); + const auto updateRes = svc.update(Game::Magic, updated); + REQUIRE(updateRes.isErr()); + CHECK(updateRes.error() == "save failed"); + } + + TEST_CASE("findById returns nullopt for missing id") { + InMemoryRepo repo; + StubImageStore store; + CollectionService svc{repo, store}; + + const auto out = svc.findById(Game::Magic, 99); + REQUIRE(out.isOk()); + CHECK_FALSE(out.value().has_value()); + } + + TEST_CASE("remove reports image cleanup issues but still removes card") { + InMemoryRepo repo; + StubImageStore store; + store.failRemove = true; + CollectionService svc{repo, store}; + + const auto id = svc.add( + Game::Magic, makeCard("With Images", {"a.png", "b.png"})); + REQUIRE(id.isOk()); + + const auto removed = svc.remove(Game::Magic, id.value()); + REQUIRE(removed.isErr()); + CHECK(removed.error().find("Card removed but image cleanup had issues:") != std::string::npos); + CHECK(removed.error().find("remove failed for a.png") != std::string::npos); + CHECK(removed.error().find("remove failed for b.png") != std::string::npos); + + const auto listed = svc.list(Game::Magic); + REQUIRE(listed.isOk()); + CHECK(listed.value().empty()); + } } diff --git a/tests/cpr_http_client_tests.cpp b/tests/cpr_http_client_tests.cpp index 0825ecd..b6c308b 100644 --- a/tests/cpr_http_client_tests.cpp +++ b/tests/cpr_http_client_tests.cpp @@ -34,4 +34,60 @@ TEST_SUITE("CprHttpClient injected executor") { REQUIRE(out.isErr()); CHECK(out.error() == "HTTP 503 from https://example.com"); } + + TEST_CASE("returns an explicit error when executor is empty") { + CprHttpClient::GetExecutor empty; + CprHttpClient client{empty}; + + const auto out = client.get("https://example.com"); + REQUIRE(out.isErr()); + CHECK(out.error() == "HTTP error: no executor configured"); + } +} + +TEST_SUITE("CprHttpClient injected raw executor") { + TEST_CASE("maps 2xx raw response to success body") { + CprHttpClient client{ + [](std::string_view) -> CprHttpClient::RawResponse { + return CprHttpClient::RawResponse{ + .transportError = false, + .transportMessage = "", + .statusCode = 200, + .body = "ok-body", + }; + } + }; + + const auto out = client.get("https://example.com/success"); + REQUIRE(out.isOk()); + CHECK(out.value() == "ok-body"); + } + + TEST_CASE("maps transport error via shared http mapping") { + CprHttpClient client{ + [](std::string_view) -> CprHttpClient::RawResponse { + return CprHttpClient::RawResponse{ + .transportError = true, + .transportMessage = "timeout", + .statusCode = 0, + .body = "", + }; + } + }; + + const auto out = client.get("https://example.com/timeout"); + REQUIRE(out.isErr()); + CHECK(out.error().find("timeout") != std::string::npos); + } +} + +TEST_SUITE("CprHttpClient real session") { + TEST_CASE("default constructor handles malformed URL without crashing") { + // Exercise the real cpr::Session-backed constructor/lambda path + // without depending on external network availability. + CprHttpClient client{}; + const auto out = client.get("://not-a-valid-url"); + REQUIRE(out.isErr()); + CHECK(out.error().find("HTTP") != std::string::npos); + } } diff --git a/tests/icard_preview_source_tests.cpp b/tests/icard_preview_source_tests.cpp new file mode 100644 index 0000000..7b9bb55 --- /dev/null +++ b/tests/icard_preview_source_tests.cpp @@ -0,0 +1,41 @@ +#include + +#include "ccm/ports/ICardPreviewSource.hpp" + +using namespace ccm; + +namespace { + +class MinimalPreviewSource final : public ICardPreviewSource { +public: + Result + fetchImageUrl(std::string_view, + std::string_view, + std::string_view) override { + return Result::err( + PreviewLookupError{PreviewLookupError::Kind::NotFound, "not found"}); + } +}; + +} // namespace + +TEST_SUITE("ICardPreviewSource defaults") { + TEST_CASE("auto-detect is disabled by default") { + MinimalPreviewSource src; + CHECK_FALSE(src.supportsAutoDetectPrint()); + } + + TEST_CASE("default detectFirstPrint returns explicit unsupported error") { + MinimalPreviewSource src; + const auto out = src.detectFirstPrint("Card", "Set"); + REQUIRE(out.isErr()); + CHECK(out.error() == "Auto-detect not supported by this game."); + } + + TEST_CASE("default detectPrintVariants returns explicit unsupported error") { + MinimalPreviewSource src; + const auto out = src.detectPrintVariants("Card", "Set"); + REQUIRE(out.isErr()); + CHECK(out.error() == "Print variant listing not supported by this game."); + } +} diff --git a/tests/image_service_tests.cpp b/tests/image_service_tests.cpp index 68e7b48..921814d 100644 --- a/tests/image_service_tests.cpp +++ b/tests/image_service_tests.cpp @@ -19,16 +19,24 @@ public: std::vector copies; std::vector> removes; std::string returnedExt = ".png"; + int failCopyAt = -1; + int failRemoveAt = -1; Result copyIn(Game game, const std::filesystem::path& srcPath, const std::string& targetName) override { copies.push_back({game, srcPath, targetName}); + if (failCopyAt >= 0 && static_cast(copies.size()) == failCopyAt) { + return Result::err("copy failed at " + std::to_string(failCopyAt)); + } return Result::ok(targetName + returnedExt); } Result remove(Game game, const std::string& imageName) override { removes.emplace_back(game, imageName); + if (failRemoveAt >= 0 && static_cast(removes.size()) == failRemoveAt) { + return Result::err("remove failed at " + std::to_string(failRemoveAt)); + } return Result::ok(); } @@ -55,6 +63,11 @@ TEST_SUITE("ImageService::nextImageIndex") { std::vector imgs2 = {"otherIMG_BACK.png"}; CHECK(ImageService::nextImageIndex(imgs2) == 0); } + + TEST_CASE("index increments from two-digit legacy cap") { + std::vector imgs = {"set+name+99.png"}; + CHECK(ImageService::nextImageIndex(imgs) == 100); + } } TEST_SUITE("ImageService::buildTargetName") { @@ -128,4 +141,38 @@ TEST_SUITE("ImageService::normalizeNamesForPersistedCard") { CHECK(store.copies.empty()); CHECK(store.removes.empty()); } + + TEST_CASE("copy failure rolls back already-created names and returns error") { + RecordingImageStore store; + store.failCopyAt = 2; + ImageService svc{store}; + + const std::vector images{ + "Beta+BlackLotus+0.png", + "Beta+BlackLotus+1.jpg" + }; + const auto out = svc.normalizeNamesForPersistedCard( + Game::Magic, 42, "Beta", "Black Lotus", images); + + REQUIRE(out.isErr()); + CHECK(out.error().find("copy failed at 2") != std::string::npos); + // Second copy failed, so first created file should be rolled back. + REQUIRE(store.removes.size() == 1); + CHECK(store.removes[0].second == "42+Beta+BlackLotus+0.png"); + } + + TEST_CASE("remove failure after rename returns error") { + RecordingImageStore store; + store.failRemoveAt = 1; + ImageService svc{store}; + + const std::vector images{ + "Beta+BlackLotus+0.png" + }; + const auto out = svc.normalizeNamesForPersistedCard( + Game::Magic, 42, "Beta", "Black Lotus", images); + + REQUIRE(out.isErr()); + CHECK(out.error().find("remove failed at 1") != std::string::npos); + } } diff --git a/tests/json_set_repository_tests.cpp b/tests/json_set_repository_tests.cpp index d23e35c..0f47e4c 100644 --- a/tests/json_set_repository_tests.cpp +++ b/tests/json_set_repository_tests.cpp @@ -22,6 +22,41 @@ ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) { cfg.initialize(); return cfg; } + +class FailingSetFs final : public IFileSystem { +public: + bool ensureOk{true}; + bool writeOk{true}; + bool readOk{true}; + std::string readPayload{"[]"}; + std::filesystem::path lastWritePath; + std::string lastWriteBody; + + [[nodiscard]] bool exists(const std::filesystem::path&) const override { return true; } + [[nodiscard]] bool isDirectory(const std::filesystem::path&) const override { return true; } + + Result ensureDirectory(const std::filesystem::path&) override { + if (!ensureOk) return Result::err("ensure failed"); + return Result::ok(); + } + Result readText(const std::filesystem::path&) override { + if (!readOk) return Result::err("read failed"); + return Result::ok(readPayload); + } + Result writeText(const std::filesystem::path& p, std::string_view contents) override { + if (!writeOk) return Result::err("write failed"); + lastWritePath = p; + lastWriteBody = std::string(contents); + return Result::ok(); + } + Result copyFile(const std::filesystem::path&, const std::filesystem::path&, bool) override { + return Result::ok(); + } + Result remove(const std::filesystem::path&) override { return Result::ok(); } + Result> listDirectory(const std::filesystem::path&) override { + return Result>::ok({}); + } +}; } // namespace TEST_SUITE("JsonSetRepository") { @@ -49,4 +84,48 @@ TEST_SUITE("JsonSetRepository") { const auto loaded = repo.load(Game::Pokemon); CHECK(loaded.isErr()); } + + TEST_CASE("load propagates read errors from filesystem") { + InMemoryFileSystem configFs; + auto cfg = makeConfig(configFs, "/data"); + FailingSetFs fs; + fs.readOk = false; + JsonSetRepository repo{fs, cfg, dirNameFn}; + + const auto loaded = repo.load(Game::Magic); + REQUIRE(loaded.isErr()); + CHECK(loaded.error() == "read failed"); + } + + TEST_CASE("load reports parse error for malformed sets.json") { + InMemoryFileSystem configFs; + auto cfg = makeConfig(configFs, "/data"); + FailingSetFs fs; + fs.readPayload = "{bad json"; + JsonSetRepository repo{fs, cfg, dirNameFn}; + + const auto loaded = repo.load(Game::Magic); + REQUIRE(loaded.isErr()); + CHECK(loaded.error().find("sets.json parse error:") != std::string::npos); + } + + TEST_CASE("save propagates ensureDirectory and writeText failures") { + InMemoryFileSystem configFs; + auto cfg = makeConfig(configFs, "/data"); + FailingSetFs fs; + JsonSetRepository repo{fs, cfg, dirNameFn}; + + const std::vector sets = {{"lea", "Limited Edition Alpha", "1993/08/05"}}; + + fs.ensureOk = false; + const auto ensureFail = repo.save(Game::Magic, sets); + REQUIRE(ensureFail.isErr()); + CHECK(ensureFail.error() == "ensure failed"); + + fs.ensureOk = true; + fs.writeOk = false; + const auto writeFail = repo.save(Game::Magic, sets); + REQUIRE(writeFail.isErr()); + CHECK(writeFail.error() == "write failed"); + } } diff --git a/tests/yugioh_card_preview_source_tests.cpp b/tests/yugioh_card_preview_source_tests.cpp index bc609d2..f043265 100644 --- a/tests/yugioh_card_preview_source_tests.cpp +++ b/tests/yugioh_card_preview_source_tests.cpp @@ -642,6 +642,12 @@ TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants yugioh_same_card_set_var } TEST_SUITE("YuGiOhCardPreviewSource::fetchImageUrl") { + TEST_CASE("supports auto-detect print metadata") { + FixedHttpClient http; + YuGiOhCardPreviewSource src{http}; + CHECK(src.supportsAutoDetectPrint()); + } + TEST_CASE("queries Yugipedia first and uses the per-printing scan when found") { // Two same-passcode reprints with genuinely different art (LOB vs // SDK Blue-Eyes). Yugipedia hosts both, so we should always pick the diff --git a/tests/yugioh_set_source_tests.cpp b/tests/yugioh_set_source_tests.cpp index d0224a2..2543f59 100644 --- a/tests/yugioh_set_source_tests.cpp +++ b/tests/yugioh_set_source_tests.cpp @@ -29,9 +29,18 @@ TEST_SUITE("YuGiOhSetSource::parseResponse") { ])"; const auto out = YuGiOhSetSource::parseResponse(json); REQUIRE(out.isOk()); - REQUIRE(out.value().size() == 2); - CHECK(out.value()[0].id == "AAA"); - CHECK(out.value()[0].releaseDate == "2020/01/01"); + bool foundA = false; + bool foundB = false; + for (const auto& set : out.value()) { + if (set.id == "AAA" && set.name == "Set A" && set.releaseDate == "2020/01/01") { + foundA = true; + } + if (set.id == "BBB" && set.name == "Set B" && set.releaseDate == "2021/02/03") { + foundB = true; + } + } + CHECK(foundA); + CHECK(foundB); } TEST_CASE("sorts by release date ascending") { @@ -47,6 +56,48 @@ TEST_SUITE("YuGiOhSetSource::parseResponse") { TEST_CASE("missing array returns error") { CHECK(YuGiOhSetSource::parseResponse(R"({"data":[]})").isErr()); } + + TEST_CASE("adds 25th Anniversary aliases when upstream list misses them") { + const auto out = YuGiOhSetSource::parseResponse(R"([ + {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB","tcg_date":"2002-03-08"} + ])"); + REQUIRE(out.isOk()); + + bool foundLob25th = false; + bool foundIoc25th = false; + for (const auto& set : out.value()) { + if (set.name == "Legend of Blue Eyes White Dragon (25th Anniversary Edition)" + && set.id == "LOB-25TH") { + foundLob25th = true; + } + if (set.name == "Invasion of Chaos (25th Anniversary Edition)" && set.id == "IOC-25TH") { + foundIoc25th = true; + } + } + CHECK(foundLob25th); + CHECK(foundIoc25th); + } + + TEST_CASE("does not duplicate aliases that already exist by name") { + const auto out = YuGiOhSetSource::parseResponse(R"json([ + {"set_name":"Legend of Blue Eyes White Dragon (25th Anniversary Edition)","set_code":"LOB-25TH","tcg_date":"2023-04-20"} + ])json"); + REQUIRE(out.isOk()); + + int aliasCount = 0; + for (const auto& set : out.value()) { + if (set.name == "Legend of Blue Eyes White Dragon (25th Anniversary Edition)") { + ++aliasCount; + } + } + CHECK(aliasCount == 1); + } + + TEST_CASE("malformed json returns parse error") { + const auto out = YuGiOhSetSource::parseResponse("{bad json"); + REQUIRE(out.isErr()); + CHECK(out.error().find("YGOPRODeck set parse error:") != std::string::npos); + } } TEST_SUITE("YuGiOhSetSource::fetchAll") { @@ -59,4 +110,13 @@ TEST_SUITE("YuGiOhSetSource::fetchAll") { CHECK(out.value().front().id == "X"); CHECK(http.lastUrl == "https://db.ygoprodeck.com/api/v7/cardsets.php"); } + + TEST_CASE("network error is propagated") { + FixedHttpClient http; + http.ok = false; + YuGiOhSetSource src{http}; + const auto out = src.fetchAll(); + REQUIRE(out.isErr()); + CHECK(out.error() == "offline"); + } }