#include #include #include #include #include #include #include #include #include #include #include #include "app/shell/cards.hpp" #include "app/shell/music_player.hpp" #include "player/queue.hpp" #include "app/shell/nav_bar.hpp" #include "app/shell/screens.hpp" #include "app/shell/session.hpp" #include "app/settings.hpp" #include "core/task.hpp" #include "core/version.hpp" #include "support/fake_jellyfin.hpp" #include "support/files.hpp" #include "support/fixed_metrics.hpp" #include "ui/application.hpp" #include "ui/i18n.hpp" #include "ui/image_view.hpp" #include "ui/label.hpp" #include "ui/theme.hpp" #include "ui/video_source.hpp" #include "ui/scroll.hpp" #include "ui/widgets.hpp" using namespace playfin; using platform::Action; using platform::Event; namespace { struct TempDir { std::string path; TempDir() { char tmpl[] = "/tmp/playfin-screens-XXXXXX"; REQUIRE(mkdtemp(tmpl) != nullptr); path = tmpl; } ~TempDir() { std::filesystem::remove_all(path); } }; net::HttpConfig testHttp() { net::HttpConfig config; config.caFile = test::fixturePath("tls/ca.crt"); return config; } // The shell on an Application with network jobs inline (as in the screenshot tests). struct Shell { TempDir dir; core::WorkerPool pool{1}; core::MainQueue mainQueue; test::FixedMetrics metrics; app::AppSession session{testHttp(), dir.path + "/servers.json", dir.path + "/settings.json"}; std::unique_ptr application; Shell() { ui::translator().load(std::string(PLAYFIN_SOURCE_DIR) + "/resources/i18n", "en"); REQUIRE(session.load().ok()); session.synchronous = true; session.discovery = false; session.licensesDir = std::string(PLAYFIN_SOURCE_DIR) + "/resources/licenses"; session.noticesFile = std::string(PLAYFIN_SOURCE_DIR) + "/THIRD_PARTY_LICENSES"; application = std::make_unique(ui::Application::Services{pool, mainQueue, metrics, nullptr}); } // A server known at `url` with user u-demo signed in with `token`, used last. void remember(const std::string& url, const std::string& token) { auto& server = jellyfin::upsertServer(session.data(), "f1e2d3c4b5a6"); server.name = "Test Server"; jellyfin::rememberUrl(server, url); auto& user = jellyfin::upsertUser(server, "u-demo"); user.name = "demo"; user.token = token; session.data().lastServerId = "f1e2d3c4b5a6"; session.data().lastUserId = "u-demo"; REQUIRE(session.save()); } void frames(int count = 5) { for (int i = 0; i < count; ++i) { session.updateMusic(); application->update(1000.0f / 60.0f); } } void press(Action action) { Event event; event.type = Event::Type::ActionDown; event.action = action; application->handle(event); frames(); } std::string storedToken() { jellyfin::ServerStore store(dir.path + "/servers.json"); const auto data = store.load(); REQUIRE(data.ok()); for (const auto& server : data.value().servers) { for (const auto& user : server.users) { if (user.id == "u-demo") { return user.token; } } } return {}; } }; // Every visible label text under `view`. void texts(const ui::View& view, std::vector& out) { if (!view.visible) { return; } if (const auto* label = dynamic_cast(&view)) { out.push_back(label->text()); } for (const auto& child : view.children()) { texts(*child, out); } } bool shows(ui::Application& app, const std::string& text) { std::vector all; texts(app.root(), all); for (const std::string& t : all) { if (t == text) { return true; } } return false; } jellyfin::Item episode() { jellyfin::Item item; item.id = "e-1"; item.name = "Pilot"; item.type = jellyfin::ItemType::Episode; item.seriesId = "s-1"; item.seasonId = "se-1"; item.indexNumber = 1; item.runTimeTicks = 40000000; return item; } // The shell signed in on a fake server (direct play of the test clip). struct Playing { test::FakeJellyfin server; Shell shell; Playing() { shell.remember(server.baseUrl(), "tok-demo"); shell.session.useServer("f1e2d3c4b5a6", server.baseUrl()); jellyfin::StoredUser user; user.id = "u-demo"; user.token = "tok-demo"; shell.session.useStoredUser(user); shell.application->push(std::make_unique()); // what the player goes back to } // frames with the video source polled, as the app's frame loop does void frames(int count = 5) { for (int i = 0; i < count; ++i) { if (ui::VideoSource* video = shell.application->videoSource()) { video->renderVideo(); } shell.application->update(1000.0f / 60.0f); } } }; } // namespace TEST_CASE("details of a partly watched item offer to resume or to start over") { test::FakeJellyfin server; Shell shell; shell.remember(server.baseUrl(), "tok-demo"); shell.session.useServer("f1e2d3c4b5a6", server.baseUrl()); jellyfin::StoredUser user; user.id = "u-demo"; user.token = "tok-demo"; shell.session.useStoredUser(user); jellyfin::Item movie; movie.id = "m-1"; movie.type = jellyfin::ItemType::Movie; shell.application->push(app::makeDetails(shell.session, movie)); shell.frames(10); CHECK(shows(*shell.application, "Resume (2:00)")); CHECK(shows(*shell.application, "Play from the beginning")); } TEST_CASE("the player reports the session to the server and stops when leaving") { Playing p; p.shell.application->push(app::makePlayer(p.shell.session, episode(), 0)); p.frames(10); REQUIRE(p.shell.application->videoSource() != nullptr); CHECK(p.shell.application->videoSource()->videoLayer().width == 320); auto reports = p.server.reports(); REQUIRE_FALSE(reports.empty()); CHECK(reports[0].path == "/Sessions/Playing"); CHECK(reports[0].body.find(R"("PlaySessionId":"ps-direct-1")") != std::string::npos); p.shell.press(Action::Back); CHECK(p.shell.application->depth() == 1); CHECK(p.shell.application->videoSource() == nullptr); reports = p.server.reports(); CHECK(reports.back().path == "/Sessions/Playing/Stopped"); } TEST_CASE("the player asks for the quality chosen in the settings") { Playing p; p.shell.session.settings().maxBitrate = 4000000; p.shell.application->push(app::makePlayer(p.shell.session, episode(), 0)); p.frames(10); CHECK(p.server.playbackInfoBody().find(R"("MaxStreamingBitrate":4000000)") != std::string::npos); p.shell.press(Action::Back); } TEST_CASE("settings are saved next to servers.json and read back by a new session") { TempDir dir; { app::AppSession session(testHttp(), dir.path + "/servers.json", dir.path + "/settings.json"); session.settings().accent = "green"; CHECK(session.saveSettings()); } app::AppSession session(testHttp(), dir.path + "/servers.json", dir.path + "/settings.json"); CHECK(session.settings().accent == "green"); app::AppSession noFile(testHttp(), dir.path + "/servers.json"); CHECK_FALSE(noFile.saveSettings()); } TEST_CASE("the intro skip button shows at the start of an episode with an intro and seeks past it") { Playing p; jellyfin::Item alone = episode(); alone.seasonId.clear(); // no next episode: the 4 s clip is all "last 30 s", the next-episode button would win p.shell.application->push(app::makePlayer(p.shell.session, alone, 0)); p.frames(10); CHECK(shows(*p.shell.application, "Skip intro")); p.shell.press(Action::Confirm); p.frames(30); CHECK_FALSE(shows(*p.shell.application, "Skip intro")); } TEST_CASE("the track menu lists the audio and subtitle choices") { Playing p; p.shell.application->push(app::makePlayer(p.shell.session, episode(), 0)); p.frames(10); p.shell.press(Action::Options); CHECK(p.shell.application->hasModal()); CHECK(shows(*p.shell.application, "Audio")); CHECK(shows(*p.shell.application, "Subtitles")); p.shell.press(Action::Back); CHECK_FALSE(p.shell.application->hasModal()); } TEST_CASE("an audio change that restarts a transcode ends the first play session and starts the next") { Playing p; jellyfin::Item film = episode(); film.id = "m-transcode"; p.shell.application->push(app::makePlayer(p.shell.session, film, 0)); p.frames(10); p.shell.press(Action::Options); p.shell.press(Action::Confirm); // Audio p.frames(2); REQUIRE(p.shell.application->hasModal()); p.shell.press(Action::Confirm); // the first audio track: a transcode restarts for it p.frames(10); CHECK(p.server.count("/Items/m-transcode/PlaybackInfo") == 2); std::vector paths; for (const auto& report : p.server.reports()) { if (report.path != "/Sessions/Playing/Progress") { paths.push_back(report.path); } } CHECK(paths == std::vector{"/Sessions/Playing", "/Sessions/Playing/Stopped", "/Sessions/Playing"}); p.shell.press(Action::Back); } TEST_CASE("near the end of an episode with a next one, the next-episode button shows") { Playing p; p.shell.application->push(app::makePlayer(p.shell.session, episode(), 0)); p.frames(10); CHECK(shows(*p.shell.application, "Next episode")); } TEST_CASE("at the end of a film the player closes itself cleanly") { Playing p; jellyfin::Item film = episode(); film.id = "m-1"; // no segments: no skip button takes the Confirm press film.seasonId.clear(); // no next episode: the end goes back p.shell.application->push(app::makePlayer(p.shell.session, film, 35000000)); // 3.5 s of 4 p.frames(5); REQUIRE(p.shell.application->depth() == 2); p.shell.press(Action::Confirm); // the test player starts paused: play to the end for (int i = 0; i < 400 && p.shell.application->depth() == 2; ++i) { p.frames(1); std::this_thread::sleep_for(std::chrono::milliseconds(10)); } CHECK(p.shell.application->depth() == 1); CHECK(p.shell.application->videoSource() == nullptr); } TEST_CASE("leaving the player while it opens the stream is clean and reports at most one stop") { Playing p; p.shell.application->push(app::makePlayer(p.shell.session, episode(), 0)); p.shell.press(Action::Back); p.frames(5); int stops = 0; for (const auto& report : p.server.reports()) { stops += report.path == "/Sessions/Playing/Stopped" ? 1 : 0; } CHECK(stops <= 1); CHECK(p.shell.application->videoSource() == nullptr); } TEST_CASE("a refused PlaybackInfo shows the error with retry and back") { test::FakeServer refusing; refusing.http().Post(R"(/Items/([^/]+)/PlaybackInfo)", [](const httplib::Request&, httplib::Response& res) { res.set_content(R"({"MediaSources":[],"ErrorCode":"NoCompatibleStream"})", "application/json"); }); refusing.start(); Shell shell; shell.remember(refusing.baseUrl(), "tok-demo"); shell.session.useServer("f1e2d3c4b5a6", refusing.baseUrl()); shell.application->push(std::make_unique()); shell.application->push(app::makePlayer(shell.session, episode(), 0)); shell.frames(10); CHECK(shell.application->hasModal()); CHECK(shows(*shell.application, "This video cannot be played.")); CHECK(shows(*shell.application, "Try again")); } TEST_CASE("a library whose server counts more items than it returns stops asking") { test::FakeServer server; std::atomic pages{0}; server.http().Get(R"(/(Users/[^/]+/)?Items)", [&pages](const httplib::Request& req, httplib::Response& res) { ++pages; const bool first = !req.has_param("startIndex") || req.get_param_value("startIndex") == "0"; std::string items; for (int i = 0; first && i < 20; ++i) { items += std::string(i > 0 ? "," : "") + R"({"Id":"m-)" + std::to_string(i) + R"(","Name":"Film","Type":"Movie"})"; } res.set_content(R"({"Items":[)" + items + R"(],"TotalRecordCount":50})", "application/json"); }); server.start(); Shell shell; shell.remember(server.baseUrl(), "tok-demo"); shell.session.useServer("f1e2d3c4b5a6", server.baseUrl()); shell.session.useStoredUser(shell.session.data().servers.front().users.front()); jellyfin::Item library; library.id = "lib-movies"; library.collectionType = "movies"; library.type = jellyfin::ItemType::CollectionFolder; shell.application->push(app::makeLibrary(shell.session, library)); shell.frames(10); for (int i = 0; i < 4; ++i) { shell.press(Action::Down); } for (int i = 0; i < 6; ++i) { shell.press(i % 2 == 0 ? Action::Right : Action::Left); } CHECK(pages == 2); } TEST_CASE("a network error when picking a saved user keeps the saved token") { test::FakeServer flaky; flaky.http().Get("/Users/Public", [](const httplib::Request&, httplib::Response& res) { res.set_content(test::readFixture("jellyfin/users_public.json"), "application/json"); }); flaky.http().Get("/QuickConnect/Enabled", [](const httplib::Request&, httplib::Response& res) { res.set_content("false", "application/json"); }); flaky.http().Get("/Users/Me", [](const httplib::Request&, httplib::Response& res) { res.status = 503; }); flaky.start(); Shell shell; shell.remember(flaky.baseUrl(), "tok-demo"); shell.session.useServer("f1e2d3c4b5a6", flaky.baseUrl()); shell.application->push(app::makeUsers(shell.session)); shell.frames(); shell.press(Action::Confirm); // "demo", the first user CHECK(shell.storedToken() == "tok-demo"); CHECK(shell.application->depth() == 1); // still choosing: the error is shown there } TEST_CASE("a token refused at startup leads to the users screen, with the servers screen behind it") { test::FakeJellyfin server; Shell shell; shell.remember(server.baseUrl(), "revoked"); shell.application->push(app::makeStart(shell.session)); shell.frames(10); CHECK(shell.storedToken().empty()); REQUIRE(shell.application->depth() == 2); shell.press(Action::Back); CHECK(shell.application->depth() == 1); } TEST_CASE("a server pressed twice while it connects opens one users screen") { test::FakeJellyfin server; Shell shell; shell.remember(server.baseUrl(), "tok"); shell.session.synchronous = false; shell.application->push(app::makeServers(shell.session)); shell.frames(2); Event confirm; confirm.type = Event::Type::ActionDown; confirm.action = Action::Confirm; shell.application->handle(confirm); shell.application->handle(confirm); for (int i = 0; i < 200; ++i) { shell.frames(1); std::this_thread::sleep_for(std::chrono::milliseconds(10)); } CHECK(shell.application->depth() == 2); CHECK(server.count("/System/Info/Public") == 1); } TEST_CASE("a card steps forward when focused and back when the focus leaves") { Shell shell; struct Cards : ui::Screen { app::ItemCard* first; app::ItemCard* second; Cards() { auto* row = add(ui::Axis::Row); first = row->add(true); second = row->add(true); } }; auto* screen = static_cast(shell.application->push(std::make_unique())); shell.frames(20); // 333 ms CHECK(screen->first->focusProgress() == doctest::Approx(1)); CHECK(screen->second->focusProgress() == doctest::Approx(0)); CHECK(screen->first->drawsOwnFocus); shell.press(Action::Right); shell.frames(20); CHECK(screen->first->focusProgress() == doctest::Approx(0)); CHECK(screen->second->focusProgress() == doctest::Approx(1)); } TEST_CASE("cards have the identity's sizes") { CHECK(app::cardImageSize(true).width == doctest::Approx(400)); CHECK(app::cardImageSize(true).height == doctest::Approx(225)); CHECK(app::cardImageSize(false).width == doctest::Approx(260)); CHECK(app::cardImageSize(false).height == doctest::Approx(390)); } namespace { // the URL of every ImageView under `view` void imageUrls(const ui::View& view, std::vector& out) { if (const auto* image = dynamic_cast(&view)) { out.push_back(image->url()); } for (const auto& child : view.children()) { imageUrls(*child, out); } } } // namespace TEST_CASE("the home screen shows the logo and who is watching") { test::FakeJellyfin server; Shell shell; shell.remember(server.baseUrl(), "tok-demo"); shell.session.useServer("f1e2d3c4b5a6", server.baseUrl()); jellyfin::StoredUser user; user.id = "u-demo"; user.token = "tok-demo"; shell.session.useStoredUser(user); shell.application->push(app::makeHome(shell.session)); shell.frames(10); CHECK(shows(*shell.application, "Playfin")); CHECK(shows(*shell.application, "Test Server · demo")); CHECK(shows(*shell.application, "D")); // the avatar's initial } TEST_CASE("details show the item's backdrop behind the text") { test::FakeJellyfin server; Shell shell; shell.remember(server.baseUrl(), "tok-demo"); shell.session.useServer("f1e2d3c4b5a6", server.baseUrl()); jellyfin::StoredUser user; user.id = "u-demo"; user.token = "tok-demo"; shell.session.useStoredUser(user); jellyfin::Item movie; movie.id = "m-1"; movie.type = jellyfin::ItemType::Movie; shell.application->push(app::makeDetails(shell.session, movie)); shell.frames(10); std::vector urls; imageUrls(shell.application->root(), urls); bool backdrop = false; for (const auto& url : urls) { backdrop = backdrop || url.find("/Images/Backdrop") != std::string::npos; } CHECK(backdrop); } namespace { jellyfin::Item moviesLibrary() { jellyfin::Item library; library.id = "lib-movies"; library.name = "Movies"; library.type = jellyfin::ItemType::CollectionFolder; library.collectionType = "movies"; return library; } // Signed in on `server` as demo. void signIn(Shell& shell, const test::FakeJellyfin& server) { shell.remember(server.baseUrl(), "tok-demo"); shell.session.useServer("f1e2d3c4b5a6", server.baseUrl()); jellyfin::StoredUser user; user.id = "u-demo"; user.token = "tok-demo"; shell.session.useStoredUser(user); } const ui::Button* focusedButton(ui::Application& app) { return dynamic_cast(app.focus().focused()); } std::string lastItemsTarget(const test::FakeJellyfin& server) { std::string target; for (const auto& seen : server.requests()) { if (seen.path == "/Items") { target = seen.target; } } return target; } } // namespace TEST_CASE("a library can be sorted and filtered, and keeps the choice for the session") { test::FakeJellyfin server; Shell shell; signIn(shell, server); shell.application->push(std::make_unique()); shell.application->push(app::makeLibrary(shell.session, moviesLibrary())); shell.frames(10); CHECK(shows(*shell.application, "250 items")); CHECK(shows(*shell.application, "Sort: Name")); CHECK(shows(*shell.application, "Ascending")); CHECK(shows(*shell.application, "Filter: All")); CHECK(dynamic_cast(shell.application->focus().focused()) != nullptr); // content first shell.press(Action::Up); REQUIRE(focusedButton(*shell.application) != nullptr); shell.press(Action::Confirm); CHECK(shows(*shell.application, "Sort: Date added")); CHECK(shows(*shell.application, "Descending")); CHECK(lastItemsTarget(server).find("sortBy=DateCreated") != std::string::npos); CHECK(lastItemsTarget(server).find("sortOrder=Descending") != std::string::npos); CHECK(shows(*shell.application, "Movie 249")); // the fake reverses descending lists shell.press(Action::Right); // the order button shell.press(Action::Confirm); CHECK(shows(*shell.application, "Ascending")); CHECK(shows(*shell.application, "Movie 000")); shell.press(Action::Right); // the filter button shell.press(Action::Confirm); shell.press(Action::Confirm); CHECK(shows(*shell.application, "Filter: Favorites")); CHECK(shows(*shell.application, "25 items")); CHECK(lastItemsTarget(server).find("filters=IsFavorite") != std::string::npos); shell.press(Action::Back); shell.application->push(app::makeLibrary(shell.session, moviesLibrary())); shell.frames(10); CHECK(shows(*shell.application, "Sort: Date added")); CHECK(shows(*shell.application, "Filter: Favorites")); CHECK(shows(*shell.application, "25 items")); } TEST_CASE("a page of the previous order arriving after a change of sort is dropped") { test::FakeJellyfin server; Shell shell; signIn(shell, server); shell.session.synchronous = false; // pages arrive later, through the main queue shell.application->push(std::make_unique()); shell.application->push(app::makeLibrary(shell.session, moviesLibrary())); shell.frames(1); // the first page is asked for; its answer waits in the main queue until the next frame // change the sort before the first page arrived std::vector buttons; std::function collect = [&](ui::View& view) { if (auto* button = dynamic_cast(&view)) { buttons.push_back(button); } for (const auto& child : view.children()) { collect(*child); } }; collect(*shell.application->top()); REQUIRE(buttons.size() == 3); shell.application->focus().focus(buttons[0]); shell.press(Action::Confirm); for (int i = 0; i < 200 && !shows(*shell.application, "Movie 249"); ++i) { std::this_thread::sleep_for(std::chrono::milliseconds(5)); shell.frames(1); } shell.frames(20); CHECK(shows(*shell.application, "Movie 249")); CHECK_FALSE(shows(*shell.application, "Movie 000")); } namespace { // the visible button showing `text` under `view` ui::Button* findButton(ui::View& view, const std::string& text) { if (!view.visible) { return nullptr; } if (auto* button = dynamic_cast(&view)) { std::vector labels; texts(*button, labels); if (!labels.empty() && labels.front() == text) { return button; } } for (const auto& child : view.children()) { if (ui::Button* found = findButton(*child, text)) { return found; } } return nullptr; } // focuses the button showing `text` on the top screen and presses it void pressButton(Shell& shell, const std::string& text) { ui::Button* button = findButton(*shell.application->top(), text); REQUIRE_MESSAGE(button != nullptr, text); REQUIRE(shell.application->focus().focus(button)); shell.press(Action::Confirm); } jellyfin::Item bigBuckBunny() { jellyfin::Item movie; movie.id = "m-1"; movie.name = "Big Buck Bunny"; movie.type = jellyfin::ItemType::Movie; return movie; } int requestsTo(const test::FakeJellyfin& server, const std::string& method, const std::string& path) { int n = 0; for (const auto& seen : server.requests()) { n += seen.method == method && seen.path == path ? 1 : 0; } return n; } } // namespace TEST_CASE("details show the genres and toggle favourite and watched") { test::FakeJellyfin server; Shell shell; signIn(shell, server); shell.application->push(std::make_unique()); shell.application->push(app::makeDetails(shell.session, bigBuckBunny())); shell.frames(10); CHECK(shows(*shell.application, "Animation · Comedy")); CHECK(shows(*shell.application, "\xE2\x99\xA5 Favorite")); // a favourite already CHECK(shows(*shell.application, "Watched")); pressButton(shell, "\xE2\x99\xA5 Favorite"); CHECK(requestsTo(server, "DELETE", "/UserFavoriteItems/m-1") == 1); CHECK(shows(*shell.application, "Favorite")); CHECK(dynamic_cast(shell.application->focus().focused()) == findButton(*shell.application->top(), "Favorite")); pressButton(shell, "Watched"); CHECK(requestsTo(server, "POST", "/UserPlayedItems/m-1") == 1); CHECK(shows(*shell.application, "\xE2\x9C\x93 Watched")); pressButton(shell, "\xE2\x9C\x93 Watched"); CHECK(requestsTo(server, "DELETE", "/UserPlayedItems/m-1") == 1); CHECK(shows(*shell.application, "Watched")); } TEST_CASE("a series opens on the first season with unwatched episodes and switches seasons in place") { test::FakeJellyfin server; Shell shell; signIn(shell, server); jellyfin::Item series; series.id = "s-1"; series.name = "Test Show"; series.type = jellyfin::ItemType::Series; shell.application->push(std::make_unique()); shell.application->push(app::makeDetails(shell.session, series)); shell.frames(10); CHECK(shows(*shell.application, "Season 1")); CHECK(shows(*shell.application, "Third")); // season 2's episode CHECK_FALSE(shows(*shell.application, "Pilot")); shell.press(Action::Down); // from the action buttons to the seasons, Season 1 first REQUIRE(dynamic_cast(shell.application->focus().focused()) != nullptr); shell.press(Action::Confirm); CHECK(shows(*shell.application, "Pilot")); CHECK_FALSE(shows(*shell.application, "Third")); CHECK(shell.application->depth() == 2); } TEST_CASE("an episode offers the next one and swaps the page for it") { test::FakeJellyfin server; Shell shell; signIn(shell, server); shell.application->push(std::make_unique()); shell.application->push(app::makeDetails(shell.session, episode())); shell.frames(10); CHECK(shows(*shell.application, "Next episode")); CHECK_FALSE(shows(*shell.application, "Previous episode")); pressButton(shell, "Next episode"); shell.frames(10); CHECK(shell.application->depth() == 2); CHECK(shows(*shell.application, "Second")); CHECK(shows(*shell.application, "Previous episode")); CHECK_FALSE(shows(*shell.application, "Next episode")); shell.press(Action::Back); CHECK(shell.application->depth() == 1); } TEST_CASE("details reload the item when shown again (back from the player) and keep the focus") { test::FakeJellyfin server; Shell shell; signIn(shell, server); shell.application->push(std::make_unique()); shell.application->push(app::makeDetails(shell.session, bigBuckBunny())); shell.frames(10); CHECK(server.count("/Items/m-1") == 1); pressButton(shell, "Watched"); ui::View* focused = shell.application->focus().focused(); shell.application->push(std::make_unique()); // the player shell.frames(5); shell.application->pop(); shell.frames(10); CHECK(server.count("/Items/m-1") == 2); CHECK(shell.application->focus().focused() == focused); } TEST_CASE("details slide up further than other screens") { test::FakeJellyfin server; Shell shell; signIn(shell, server); ui::Screen* details = shell.application->push(app::makeDetails(shell.session, bigBuckBunny())); CHECK(details->shiftY == doctest::Approx(40)); } TEST_CASE("replacing the top screen keeps the ones below") { Shell shell; shell.application->push(std::make_unique()); shell.application->push(std::make_unique()); ui::Screen* swapped = shell.application->replaceTop(std::make_unique()); CHECK(shell.application->depth() == 2); CHECK(shell.application->top() == swapped); } namespace { // opens the search field, types `term` and accepts it void search(Shell& shell, const std::string& term) { ui::View* field = nullptr; std::function find = [&](ui::View& view) { if (dynamic_cast(&view) != nullptr) { field = &view; } for (const auto& child : view.children()) { find(*child); } }; find(*shell.application->top()); REQUIRE(field != nullptr); static_cast(field)->setValue(""); // the editor starts from the field's value REQUIRE(shell.application->focus().focus(field)); shell.press(Action::Confirm); Event text; text.type = Event::Type::Text; text.text = term; shell.application->handle(text); shell.press(Action::Confirm); shell.frames(5); } } // namespace TEST_CASE("search shows movies, series and episodes in their rows and remembers the search") { test::FakeJellyfin server; Shell shell; signIn(shell, server); shell.application->push(app::makeSearch(shell.session)); shell.frames(5); search(shell, "movie 01"); CHECK(shows(*shell.application, "Movies")); CHECK(shows(*shell.application, "Movie 010")); CHECK_FALSE(shows(*shell.application, "Series")); CHECK_FALSE(shows(*shell.application, "Episodes")); REQUIRE_FALSE(shell.session.settings().recentSearches.empty()); CHECK(shell.session.settings().recentSearches.front() == "movie 01"); CHECK(lastItemsTarget(server).find("searchTerm=movie") != std::string::npos); CHECK(lastItemsTarget(server).find("limit=24") != std::string::npos); search(shell, "pilot"); CHECK(shows(*shell.application, "Episodes")); CHECK(shows(*shell.application, "Test Show")); CHECK_FALSE(shows(*shell.application, "Movies")); CHECK_FALSE(shows(*shell.application, "Movie 010")); } TEST_CASE("search says when nothing matches") { test::FakeJellyfin server; Shell shell; signIn(shell, server); shell.application->push(app::makeSearch(shell.session)); shell.frames(5); search(shell, "zzz"); CHECK(shows(*shell.application, "No results for \u201Czzz\u201D")); } TEST_CASE("recent searches show while the field is empty and search again when chosen") { test::FakeJellyfin server; Shell shell; signIn(shell, server); shell.session.settings().recentSearches = {"movie 24", "pilot"}; shell.application->push(app::makeSearch(shell.session)); shell.frames(5); CHECK(shows(*shell.application, "Recent searches")); pressButton(shell, "movie 24"); shell.frames(5); CHECK(shows(*shell.application, "Movie 240")); CHECK_FALSE(shows(*shell.application, "Recent searches")); CHECK(shell.session.settings().recentSearches.front() == "movie 24"); } TEST_CASE("search results are still there when coming back from an item") { test::FakeJellyfin server; Shell shell; signIn(shell, server); shell.application->push(app::makeSearch(shell.session)); shell.frames(5); search(shell, "movie 01"); shell.application->push(app::makeDetails(shell.session, bigBuckBunny())); shell.frames(5); shell.press(Action::Back); shell.frames(5); CHECK(shows(*shell.application, "Movie 010")); } namespace { // the visible button or value row whose first label is `text` ui::View* findPressable(ui::View& view, const std::string& text) { if (!view.visible) { return nullptr; } if (dynamic_cast(&view) != nullptr || dynamic_cast(&view) != nullptr) { std::vector labels; texts(view, labels); if (!labels.empty() && labels.front() == text) { return &view; } } for (const auto& child : view.children()) { if (ui::View* found = findPressable(*child, text)) { return found; } } return nullptr; } void pressAnywhere(Shell& shell, const std::string& text) { ui::View* view = findPressable(shell.application->root(), text); REQUIRE_MESSAGE(view != nullptr, text); REQUIRE(shell.application->focus().focus(view)); shell.press(Action::Confirm); shell.frames(3); } struct SettingsShell { test::FakeJellyfin server; Shell shell; SettingsShell() { signIn(shell, server); shell.application->push(app::makeSettings(shell.session)); shell.frames(10); } ~SettingsShell() { ui::applyAccent(ui::accentPalette().front()); } }; } // namespace TEST_CASE("the accent colour applies at once and is saved") { SettingsShell s; CHECK(shows(*s.shell.application, "Accent colour")); CHECK(shows(*s.shell.application, "PlayStation blue")); pressAnywhere(s.shell, "Accent colour"); REQUIRE(s.shell.application->hasModal()); pressAnywhere(s.shell, "Red"); CHECK_FALSE(s.shell.application->hasModal()); CHECK(ui::theme().accent.r == 0xE5); CHECK(ui::theme().accent.g == 0x48); CHECK(shows(*s.shell.application, "Red")); CHECK(app::loadSettings(s.shell.dir.path + "/settings.json").accent == "red"); } TEST_CASE("the playback quality is saved") { SettingsShell s; CHECK(shows(*s.shell.application, "Auto (40 Mbit/s)")); pressAnywhere(s.shell, "Maximum playback quality"); pressAnywhere(s.shell, "1.5 Mbit/s"); CHECK(s.shell.session.settings().maxBitrate == 1500000); CHECK(shows(*s.shell.application, "1.5 Mbit/s")); CHECK(app::loadSettings(s.shell.dir.path + "/settings.json").maxBitrate == 1500000); } TEST_CASE("a new language is saved and applies at the next start") { SettingsShell s; CHECK(shows(*s.shell.application, "System")); pressAnywhere(s.shell, "Language"); pressAnywhere(s.shell, "Deutsch"); CHECK(s.shell.session.settings().language == "de"); CHECK(shows(*s.shell.application, "Deutsch (at next start)")); pressAnywhere(s.shell, "Language"); pressAnywhere(s.shell, "System"); CHECK(shows(*s.shell.application, "System")); } TEST_CASE("about shows the version and the server, and the licences page opens each licence") { SettingsShell s; CHECK(shows(*s.shell.application, core::version())); CHECK(shows(*s.shell.application, "Test Server \xC2\xB7 10.10.7")); pressAnywhere(s.shell, "Third-party licences"); s.shell.frames(10); CHECK(s.shell.application->depth() == 2); CHECK(shows(*s.shell.application, "FFmpeg")); CHECK(shows(*s.shell.application, "LGPL-3.0-or-later")); pressAnywhere(s.shell, "FFmpeg"); s.shell.frames(10); CHECK(s.shell.application->depth() == 3); std::vector all; texts(s.shell.application->root(), all); bool lgpl = false; for (const auto& text : all) { lgpl = lgpl || text.find("GNU LESSER GENERAL PUBLIC LICENSE") != std::string::npos; } CHECK(lgpl); } TEST_CASE("the licences page opens the copyright notices, with the FreeType credit") { SettingsShell s; pressAnywhere(s.shell, "Third-party licences"); s.shell.frames(10); pressAnywhere(s.shell, "Copyright notices"); s.shell.frames(10); CHECK(s.shell.application->depth() == 3); std::vector all; texts(s.shell.application->root(), all); bool freetype = false; bool fribidi = false; for (const auto& text : all) { freetype = freetype || text.find("Portions of this software are copyright \xC2\xA9 2024 The FreeType Project") != std::string::npos; fribidi = fribidi || text.find("Copyright (C) 1999-2000, 2017 Dov Grobgeld") != std::string::npos; } CHECK(freetype); CHECK(fribidi); } TEST_CASE("signing out from the settings forgets the token and shows the users") { SettingsShell s; pressAnywhere(s.shell, "Sign out"); s.shell.frames(10); CHECK(s.shell.storedToken().empty()); CHECK(shows(*s.shell.application, "demo")); } namespace { struct HomeShell { test::FakeJellyfin server; Shell shell; HomeShell() { signIn(shell, server); shell.application->replace(app::makeHome(shell.session)); shell.frames(10); } }; bool focusedIsTab(ui::Application& app, const std::string& text) { ui::View* focused = app.focus().focused(); if (focused == nullptr) { return false; } std::vector labels; texts(*focused, labels); return dynamic_cast(focused) != nullptr && !labels.empty() && labels.front() == text; } } // namespace TEST_CASE("the home screen has the navigation bar and starts on its content") { HomeShell h; CHECK(shows(*h.shell.application, "Home")); CHECK(shows(*h.shell.application, "Search")); CHECK(shows(*h.shell.application, "Settings")); CHECK(dynamic_cast(h.shell.application->focus().focused()) != nullptr); h.shell.press(Action::Up); CHECK(focusedIsTab(*h.shell.application, "Home")); h.shell.press(Action::Down); CHECK(dynamic_cast(h.shell.application->focus().focused()) != nullptr); } TEST_CASE("a tab replaces the screen, and back on a tab screen does nothing") { HomeShell h; h.shell.press(Action::Up); h.shell.press(Action::Right); REQUIRE(focusedIsTab(*h.shell.application, "Search")); h.shell.press(Action::Confirm); h.shell.frames(5); CHECK(h.shell.application->depth() == 1); CHECK(shows(*h.shell.application, "Recent searches") == false); // none yet CHECK(dynamic_cast(h.shell.application->focus().focused()) != nullptr); // the field first ui::Screen* search = h.shell.application->top(); h.shell.press(Action::Back); CHECK(h.shell.application->depth() == 1); CHECK(h.shell.application->top() == search); h.shell.press(Action::Up); h.shell.press(Action::Right); REQUIRE(focusedIsTab(*h.shell.application, "Settings")); h.shell.press(Action::Confirm); h.shell.frames(10); CHECK(shows(*h.shell.application, "Accent colour")); CHECK(dynamic_cast(h.shell.application->focus().focused()) != nullptr); h.shell.press(Action::Up); h.shell.press(Action::Left); h.shell.press(Action::Left); REQUIRE(focusedIsTab(*h.shell.application, "Home")); h.shell.press(Action::Confirm); h.shell.frames(10); CHECK(dynamic_cast(h.shell.application->focus().focused()) != nullptr); } TEST_CASE("coming back to the home screen refreshes Resume without moving the focus") { HomeShell h; CHECK(h.server.count("/UserItems/Resume") == 1); h.shell.press(Action::Right); // the second card of the first row ui::View* focused = h.shell.application->focus().focused(); h.shell.application->push(app::makeDetails(h.shell.session, bigBuckBunny())); h.shell.frames(10); h.shell.press(Action::Back); h.shell.frames(10); CHECK(h.server.count("/UserItems/Resume") == 2); CHECK(h.server.count("/Shows/NextUp") == 2); CHECK(h.shell.application->focus().focused() == focused); } TEST_CASE("home rows arrive one after the other") { HomeShell h; // transitions are on in unit tests; the rows were revealed when loaded h.shell.application->replace(app::makeHome(h.shell.session)); h.shell.frames(2); // load ran; the rows started their arrival bool arriving = false; std::function scan = [&](const ui::View& view) { arriving = arriving || (view.opacity < 1 && view.shiftY > 0); for (const auto& child : view.children()) { scan(*child); } }; scan(*h.shell.application->top()); CHECK(arriving); } TEST_CASE("profiles show the users' pictures or their initial, then another user and Quick Connect") { test::FakeJellyfin server; Shell shell; shell.session.useServer("f1e2d3c4b5a6", server.baseUrl()); shell.application->push(app::makeUsers(shell.session)); shell.frames(10); std::vector urls; imageUrls(shell.application->root(), urls); bool demoPicture = false; for (const auto& url : urls) { demoPicture = demoPicture || url.find("/Users/u-demo/Images/Primary?tag=img-demo") != std::string::npos; } CHECK(demoPicture); CHECK(shows(*shell.application, "demo")); CHECK(shows(*shell.application, "kids")); CHECK(shows(*shell.application, "K")); // no picture: the initial CHECK(shows(*shell.application, "Another user")); CHECK(shows(*shell.application, "Quick Connect")); CHECK(dynamic_cast(shell.application->focus().focused()) != nullptr); ui::View* quick = nullptr; std::function find = [&](ui::View& view) { if (auto* card = dynamic_cast(&view)) { std::vector labels; texts(*card, labels); if (!labels.empty() && labels.back() == "Quick Connect") { quick = card; } } for (const auto& child : view.children()) { find(*child); } }; find(*shell.application->top()); REQUIRE(quick != nullptr); REQUIRE(shell.application->focus().focus(quick)); server.approveQuickConnect(); // the screen polls until the code is approved (inline in tests) shell.press(Action::Confirm); shell.frames(10); CHECK(shell.storedToken() == "tok-demo"); // signed in through Quick Connect } TEST_CASE("after coming back to the home screen, up still reaches the navigation bar") { HomeShell h; h.shell.application->push(app::makeDetails(h.shell.session, bigBuckBunny())); h.shell.frames(10); h.shell.press(Action::Back); h.shell.frames(10); REQUIRE(dynamic_cast(h.shell.application->focus().focused()) != nullptr); h.shell.press(Action::Up); CHECK(focusedIsTab(*h.shell.application, "Home")); } namespace { std::vector albumTracks() { auto page = jellyfin::parseItemPage(test::readFixture("jellyfin/ts/album_tracks.json")); REQUIRE(page.ok()); return page.value().items; } // updates the music player (as the frame loop does) until `done` or 5 s template bool pump(app::MusicPlayer& music, Done done) { for (int i = 0; i < 500; ++i) { music.update(); if (done()) { return true; } std::this_thread::sleep_for(std::chrono::milliseconds(10)); } return false; } bool reported(const test::FakeJellyfin& server, const std::string& path, const std::string& itemId) { for (const auto& report : server.reports()) { if (report.path == path && report.body.find(R"("ItemId":")" + itemId + "\"") != std::string::npos) { return true; } } return false; } jellyfin::Item missingTrack(const std::string& id) { jellyfin::Item item; item.id = id; item.name = id; item.type = jellyfin::ItemType::Audio; return item; } } // namespace TEST_CASE("music plays an album from a track, reports it and skips to the next one") { test::FakeJellyfin server; Shell shell; signIn(shell, server); app::MusicPlayer& music = shell.session.music(); CHECK_FALSE(music.active()); const auto tracks = albumTracks(); music.play(tracks, 2, false); CHECK(music.active()); REQUIRE(music.status().track != nullptr); CHECK(music.status().track->name == "Minuit"); CHECK(pump(music, [&] { return reported(server, "/Sessions/Playing", tracks[2].id); })); CHECK(server.count("/Audio/" + tracks[2].id + "/stream") >= 1); music.next(); CHECK(pump(music, [&] { return reported(server, "/Sessions/Playing", tracks[3].id); })); CHECK(reported(server, "/Sessions/Playing/Stopped", tracks[2].id)); CHECK(music.status().track->name == "Rétroviseur"); music.stop(); CHECK_FALSE(music.active()); CHECK(reported(server, "/Sessions/Playing/Stopped", tracks[3].id)); } TEST_CASE("a track that ends hands over to the next one") { test::FakeJellyfin server; Shell shell; signIn(shell, server); app::MusicPlayer& music = shell.session.music(); const auto tracks = albumTracks(); music.play({tracks[0], tracks[1]}, 0, false); CHECK(pump(music, [&] { return reported(server, "/Sessions/Playing", tracks[1].id); })); CHECK(music.status().track->name == "Autoroute"); CHECK(pump(music, [&] { return !music.active(); })); // the queue ends after the last track CHECK(reported(server, "/Sessions/Playing/Stopped", tracks[1].id)); } TEST_CASE("tracks that cannot be played are skipped; a queue of them stops") { test::FakeJellyfin server; Shell shell; signIn(shell, server); app::MusicPlayer& music = shell.session.music(); const auto tracks = albumTracks(); music.play({missingTrack("gone-1"), tracks[0]}, 0, false); CHECK(pump(music, [&] { return reported(server, "/Sessions/Playing", tracks[0].id); })); music.stop(); music.play({missingTrack("gone-1"), missingTrack("gone-2")}, 0, false); CHECK(pump(music, [&] { return !music.active(); })); } TEST_CASE("switching user stops the music before the session changes") { test::FakeJellyfin server; Shell shell; signIn(shell, server); app::MusicPlayer& music = shell.session.music(); const auto tracks = albumTracks(); music.play(tracks, 0, false); CHECK(pump(music, [&] { return reported(server, "/Sessions/Playing", tracks[0].id); })); shell.session.forgetToken(); CHECK_FALSE(music.active()); CHECK(reported(server, "/Sessions/Playing/Stopped", tracks[0].id)); } namespace { jellyfin::Item fixtureItem(const char* fixture, const std::string& name) { auto page = jellyfin::parseItemPage(test::readFixture(fixture)); REQUIRE(page.ok()); for (const auto& item : page.value().items) { if (item.name == name) { return item; } } FAIL("no item " << name); return {}; } jellyfin::Item musicLibrary() { return fixtureItem("jellyfin/ts/views_media.json", "Musique"); } // the view named `name` under the top screen (track rows are named after their track) ui::View* named(ui::View& view, const std::string& name) { if (view.name == name && view.visible) { return &view; } for (const auto& child : view.children()) { if (ui::View* found = named(*child, name)) { return found; } } return nullptr; } // waits for the music to play (frames with time passing) void untilPlaying(Shell& shell) { for (int i = 0; i < 300 && (!shell.session.music().active() || shell.session.music().status().loading); ++i) { std::this_thread::sleep_for(std::chrono::milliseconds(10)); shell.frames(1); } } } // namespace TEST_CASE("a music library shows its albums, artists and playlists") { test::FakeJellyfin server; Shell shell; signIn(shell, server); shell.application->push(std::make_unique()); shell.application->push(app::makeMusicLibrary(shell.session, musicLibrary())); shell.frames(10); CHECK(shows(*shell.application, "Aurore synthétique")); CHECK(shows(*shell.application, "Les Ondes Calmes")); // the album's artist under its cover pressButton(shell, "Artists"); shell.frames(10); CHECK(shows(*shell.application, "Kilohertz")); CHECK(server.count("/Artists/AlbumArtists") >= 1); pressButton(shell, "Playlists"); shell.frames(10); CHECK(shows(*shell.application, "Soirée tests")); } TEST_CASE("an album lists its tracks by disc and plays from the chosen one") { test::FakeJellyfin server; Shell shell; signIn(shell, server); shell.application->push(std::make_unique()); shell.application->push(app::makeMusicItem(shell.session, fixtureItem("jellyfin/ts/music_albums.json", "Circuit doré"))); shell.frames(10); CHECK(shows(*shell.application, "Circuit doré")); CHECK(shows(*shell.application, "Néon")); CHECK(shows(*shell.application, "Disc 2")); CHECK(shows(*shell.application, "Rétroviseur")); ui::View* row = named(*shell.application->top(), "Minuit"); REQUIRE(row != nullptr); REQUIRE(shell.application->focus().focus(row)); shell.press(Action::Confirm); untilPlaying(shell); REQUIRE(shell.session.music().active()); CHECK(shell.session.music().status().track->name == "Minuit"); CHECK(shell.application->depth() == 3); // Now playing on top CHECK(shows(*shell.application, "Minuit")); CHECK(shows(*shell.application, "Kilohertz")); CHECK(shows(*shell.application, "Up next")); CHECK(shows(*shell.application, "Rétroviseur")); shell.session.music().stop(); } TEST_CASE("now playing skips, goes back, pauses and jumps in the queue") { test::FakeJellyfin server; Shell shell; signIn(shell, server); shell.application->push(std::make_unique()); shell.session.music().play(albumTracks(), 0, false); shell.application->push(app::makeNowPlaying(shell.session)); untilPlaying(shell); pressButton(shell, "Next"); untilPlaying(shell); CHECK(shell.session.music().status().track->name == "Autoroute"); CHECK(shows(*shell.application, "Autoroute")); pressButton(shell, "Previous"); untilPlaying(shell); CHECK(shell.session.music().status().track->name == "Néon"); pressButton(shell, "Pause"); shell.frames(5); CHECK(shell.session.music().status().paused); CHECK(findButton(*shell.application->top(), "Play") != nullptr); pressButton(shell, "Repeat: off"); CHECK(shell.session.music().queue().repeat() == player::Repeat::All); CHECK(shows(*shell.application, "Repeat: all")); ui::View* upcoming = named(*shell.application->top(), "Rétroviseur"); REQUIRE(upcoming != nullptr); REQUIRE(shell.application->focus().focus(upcoming)); shell.press(Action::Confirm); untilPlaying(shell); CHECK(shell.session.music().status().track->name == "Rétroviseur"); shell.press(Action::R1); untilPlaying(shell); CHECK(shell.session.music().status().track->name == "Chrome"); pressButton(shell, "Stop"); shell.frames(5); CHECK_FALSE(shell.session.music().active()); CHECK(shell.application->depth() == 1); // nothing left to show: back } TEST_CASE("the navigation bar shows the music playing and opens now playing") { test::FakeJellyfin server; Shell shell; signIn(shell, server); shell.session.music().play(albumTracks(), 1, false); shell.application->replace(app::makeHome(shell.session)); shell.frames(10); ui::View* chip = named(*shell.application->top(), "now playing"); REQUIRE(chip != nullptr); CHECK(shows(*shell.application, "Autoroute")); REQUIRE(shell.application->focus().focus(chip)); shell.press(Action::Confirm); shell.frames(5); CHECK(shell.application->depth() == 2); CHECK(shows(*shell.application, "Up next")); shell.session.music().stop(); shell.press(Action::Back); shell.frames(5); CHECK(named(*shell.application->top(), "now playing") == nullptr); // hidden once the music stopped } TEST_CASE("the chip that keeps the focus after the music ends does not open an empty screen") { test::FakeJellyfin server; Shell shell; signIn(shell, server); shell.session.music().play(albumTracks(), 1, false); shell.application->replace(app::makeHome(shell.session)); shell.frames(10); ui::View* chip = named(*shell.application->top(), "now playing"); REQUIRE(chip != nullptr); REQUIRE(shell.application->focus().focus(chip)); shell.session.music().stop(); shell.frames(5); shell.press(Action::Confirm); shell.frames(5); CHECK(shell.application->depth() == 1); } TEST_CASE("a collection opens as a library of its own items") { test::FakeJellyfin server; Shell shell; signIn(shell, server); jellyfin::Item collection; collection.id = "bs-1"; collection.type = jellyfin::ItemType::BoxSet; collection.name = "Trilogie"; app::openItem(shell.session, collection); shell.frames(5); bool listed = false; for (const auto& request : server.requests()) { listed = listed || (request.target.find("parentId=bs-1") != std::string::npos && request.target.find("recursive=false") != std::string::npos); } CHECK(listed); } TEST_CASE("a playlist of videos opens as a library, a playlist of music as music") { test::FakeJellyfin server; Shell shell; signIn(shell, server); jellyfin::Item playlist; playlist.id = "pl-video"; playlist.type = jellyfin::ItemType::Playlist; playlist.mediaType = "Video"; app::openItem(shell.session, playlist); shell.frames(5); bool listed = false; for (const auto& request : server.requests()) { listed = listed || request.target.find("parentId=pl-video") != std::string::npos; } CHECK(listed); CHECK_FALSE(shows(*shell.application, "Shuffle")); } TEST_CASE("starting a video stops the music first") { Playing p; p.shell.session.music().play(albumTracks(), 0, false); untilPlaying(p.shell); p.shell.application->push(app::makePlayer(p.shell.session, episode(), 0)); p.frames(10); CHECK_FALSE(p.shell.session.music().active()); int stopped = -1; int videoInfo = -1; const auto requests = p.server.requests(); for (std::size_t i = 0; i < requests.size(); ++i) { if (requests[i].path == "/Sessions/Playing/Stopped" && stopped < 0) { stopped = static_cast(i); } if (requests[i].path == "/Items/e-1/PlaybackInfo" && videoInfo < 0) { videoInfo = static_cast(i); } } CHECK(stopped >= 0); CHECK(stopped < videoInfo); p.shell.press(Action::Back); } TEST_CASE("home shows music as square covers and no latest row for Live TV") { test::FakeJellyfin server; server.showMediaViews(); Shell shell; signIn(shell, server); shell.application->replace(app::makeHome(shell.session)); shell.frames(10); CHECK(shows(*shell.application, "Musique")); CHECK_FALSE(shows(*shell.application, "Latest in Live TV")); CHECK(shows(*shell.application, "Latest in Musique")); bool square = false; std::function scan = [&](const ui::View& view) { if (const auto* card = dynamic_cast(&view)) { const ui::Size size = app::cardSize(app::CardShape::Square); square = square || (card->frame.width == doctest::Approx(size.width) && card->frame.height == doctest::Approx(size.height)); } for (const auto& child : view.children()) { scan(*child); } }; scan(*shell.application->top()); CHECK(square); } namespace { // signed in on a fake server with Live TV; the guide's clock is 2026-10-16 21:45 in Paris (UTC+2) struct TvShell { test::FakeJellyfin server; Shell shell; TvShell() { server.showMediaViews(); signIn(shell, server); shell.session.fixedNow = jellyfin::parseUtc("2026-10-16T19:45:00Z"); shell.session.fixedUtcOffset = 120; shell.application->push(std::make_unique()); } void openLiveTv() { shell.application->push(app::makeLiveTv(shell.session, fixtureItem("jellyfin/ts/views_media.json", "Live TV"))); shell.frames(10); } int requests(const std::string& method, const std::string& path) const { return requestsTo(server, method, path); } }; } // namespace TEST_CASE("Live TV lists the channels with the programme on now") { TvShell tv; tv.openLiveTv(); CHECK(shows(*tv.shell.application, "Playfin Info")); CHECK(shows(*tv.shell.application, "Playfin Sport")); CHECK(shows(*tv.shell.application, "21:30 \xE2\x80\x93 22:30 \xC2\xB7 Le point du jour")); CHECK(dynamic_cast(tv.shell.application->focus().focused()) == nullptr); // a channel first } TEST_CASE("the guide lists a channel's programmes by day and records a later one") { TvShell tv; tv.openLiveTv(); pressButton(tv.shell, "Guide"); tv.shell.frames(10); CHECK(tv.server.count("/LiveTv/Programs") >= 1); CHECK(shows(*tv.shell.application, "Today")); CHECK(shows(*tv.shell.application, "Tomorrow")); CHECK(shows(*tv.shell.application, "Live")); // the programme on now ui::View* later = named(*tv.shell.application->top(), "Le journal"); REQUIRE(later != nullptr); REQUIRE(tv.shell.application->focus().focus(later)); tv.shell.press(Action::Confirm); REQUIRE(tv.shell.application->hasModal()); CHECK(shows(*tv.shell.application, "Record")); pressAnywhere(tv.shell, "Record"); CHECK(tv.server.timerBody().find(R"("ProgramId")") != std::string::npos); CHECK_FALSE(tv.shell.application->hasModal()); } TEST_CASE("the guide's channels scroll, however many there are") { TvShell tv; tv.openLiveTv(); pressButton(tv.shell, "Guide"); tv.shell.frames(10); ui::View* row = named(*tv.shell.application->top(), "channel Playfin Info"); REQUIRE(row != nullptr); bool scrolled = false; for (ui::View* view = row->parent(); view != nullptr; view = view->parent()) { scrolled = scrolled || dynamic_cast(view) != nullptr; } CHECK(scrolled); } TEST_CASE("recordings list the scheduled ones, which can be cancelled") { TvShell tv; tv.openLiveTv(); pressButton(tv.shell, "Recordings"); tv.shell.frames(10); CHECK(shows(*tv.shell.application, "No recordings yet.")); CHECK(shows(*tv.shell.application, "Scheduled")); ui::View* timer = named(*tv.shell.application->top(), "Reportage"); REQUIRE(timer != nullptr); REQUIRE(tv.shell.application->focus().focus(timer)); tv.shell.press(Action::Confirm); pressAnywhere(tv.shell, "Cancel the recording"); CHECK(tv.requests("DELETE", "/LiveTv/Timers/0b4eabd5708f3dc71f2c03c524916899") == 1); } TEST_CASE("home shows what is on TV now when the server has Live TV") { TvShell tv; tv.shell.application->replace(app::makeHome(tv.shell.session)); tv.shell.frames(10); CHECK(shows(*tv.shell.application, "On TV now")); CHECK(shows(*tv.shell.application, "Tennis")); CHECK(tv.server.count("/LiveTv/Programs/Recommended") == 1); } namespace { jellyfin::Item channel(const std::string& name) { return fixtureItem("jellyfin/ts/livetv_channels.json", name); } const char* const kLiveStreamId = "e2329f4997b378e64ccf8fa396deb76e_af999c25a00715699361240d4c6c7a53_5d3aff9566faa64d7ae0e237d2d7499c"; } // namespace TEST_CASE("a channel plays live through the server, cannot be sought and closes its live stream once") { Playing p; p.server.showMediaViews(); p.shell.session.fixedNow = jellyfin::parseUtc("2026-10-16T19:45:00Z"); p.shell.session.fixedUtcOffset = 120; p.shell.application->push(app::makePlayer(p.shell.session, channel("Playfin Info"), 0)); p.frames(10); CHECK(p.server.playbackInfoBody().find(R"("AutoOpenLiveStream":true)") != std::string::npos); REQUIRE(p.shell.application->videoSource() != nullptr); CHECK(shows(*p.shell.application, "Playfin Info \xC2\xB7 Le point du jour")); CHECK(shows(*p.shell.application, "21:30 \xE2\x80\x93 22:30")); p.shell.press(Action::Right); // no seeking in a live channel: the bar keeps the programme's times p.frames(5); CHECK(shows(*p.shell.application, "21:30 \xE2\x80\x93 22:30")); p.shell.press(Action::Back); p.frames(5); CHECK(p.shell.application->depth() == 1); CHECK(requestsTo(p.server, "POST", "/LiveStreams/Close") == 1); CHECK(p.server.last().target.find(kLiveStreamId) != std::string::npos); } TEST_CASE("R1 and L1 change channel in the live player") { Playing p; p.server.showMediaViews(); p.shell.application->push(app::makePlayer(p.shell.session, channel("Playfin Info"), 0)); p.frames(10); p.shell.press(Action::R1); p.frames(10); CHECK(p.shell.application->depth() == 2); CHECK(shows(*p.shell.application, "Playfin Sport \xC2\xB7 Tennis")); CHECK(requestsTo(p.server, "POST", "/LiveStreams/Close") == 1); // the first channel's stream CHECK(p.server.count("/Items/" + channel("Playfin Sport").id + "/PlaybackInfo") == 1); p.shell.press(Action::L1); p.frames(10); CHECK(shows(*p.shell.application, "Playfin Info \xC2\xB7 Le point du jour")); p.shell.press(Action::L1); // wraps to the last channel p.frames(10); CHECK(shows(*p.shell.application, "Playfin Cin\xC3\xA9ma \xC2\xB7 Court m\xC3\xA9trage")); p.shell.press(Action::Back); } TEST_CASE("a programme on now opens its channel with the channel's name") { Playing p; p.server.showMediaViews(); const jellyfin::Item tennis = fixtureItem("jellyfin/ts/livetv_airing.json", "Tennis"); app::openItem(p.shell.session, tennis); p.frames(10); CHECK(shows(*p.shell.application, "Playfin Sport \xC2\xB7 Tennis")); p.shell.press(Action::Back); } TEST_CASE("profiles also offer the users saved on this console that the server does not list") { test::FakeJellyfin server; Shell shell; shell.remember(server.baseUrl(), "tok-demo"); auto& stored = jellyfin::upsertServer(shell.session.data(), "f1e2d3c4b5a6"); auto& hidden = jellyfin::upsertUser(stored, "u-hidden"); hidden.name = "hidden user"; hidden.token = "tok-demo"; REQUIRE(shell.session.save()); shell.session.useServer("f1e2d3c4b5a6", server.baseUrl()); shell.application->push(app::makeUsers(shell.session)); shell.frames(10); CHECK(shows(*shell.application, "hidden user")); CHECK(shows(*shell.application, "demo")); int listed = 0; std::function count = [&](ui::View& view) { if (dynamic_cast(&view) != nullptr) { std::vector labels; texts(view, labels); listed += !labels.empty() && labels.back() == "demo" ? 1 : 0; } for (const auto& child : view.children()) { count(*child); } }; count(*shell.application->top()); CHECK(listed == 1); // a saved user the server lists is shown once ui::View* card = nullptr; std::function find = [&](ui::View& view) { if (dynamic_cast(&view) != nullptr) { std::vector labels; texts(view, labels); if (!labels.empty() && labels.back() == "hidden user") { card = &view; } } for (const auto& child : view.children()) { find(*child); } }; find(*shell.application->top()); REQUIRE(card != nullptr); REQUIRE(shell.application->focus().focus(card)); shell.press(Action::Confirm); shell.frames(10); CHECK(shell.session.data().lastUserId == "u-hidden"); CHECK(shell.application->depth() == 1); // signed in with the saved token: home }