Files
2026-10-17 05:23:01 +02:00

1640 lines
62 KiB
C++

#include <doctest/doctest.h>
#include <unistd.h>
#include <atomic>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <functional>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#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<ui::Application> 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>(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<std::string>& out) {
if (!view.visible) {
return;
}
if (const auto* label = dynamic_cast<const ui::Label*>(&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<std::string> 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<ui::Screen>()); // 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<std::string> paths;
for (const auto& report : p.server.reports()) {
if (report.path != "/Sessions/Playing/Progress") {
paths.push_back(report.path);
}
}
CHECK(paths == std::vector<std::string>{"/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<ui::Screen>());
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<int> 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::Box>(ui::Axis::Row);
first = row->add<app::ItemCard>(true);
second = row->add<app::ItemCard>(true);
}
};
auto* screen = static_cast<Cards*>(shell.application->push(std::make_unique<Cards>()));
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<std::string>& out) {
if (const auto* image = dynamic_cast<const ui::ImageView*>(&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<std::string> 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<const ui::Button*>(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<ui::Screen>());
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<const app::ItemCard*>(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<ui::Screen>());
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<ui::Button*> buttons;
std::function<void(ui::View&)> collect = [&](ui::View& view) {
if (auto* button = dynamic_cast<ui::Button*>(&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<ui::Button*>(&view)) {
std::vector<std::string> 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<ui::Screen>());
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<ui::Button*>(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<ui::Screen>());
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<app::ItemCard*>(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<ui::Screen>());
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<ui::Screen>());
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<ui::Screen>()); // 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<ui::Screen>());
shell.application->push(std::make_unique<ui::Screen>());
ui::Screen* swapped = shell.application->replaceTop(std::make_unique<ui::Screen>());
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<void(ui::View&)> find = [&](ui::View& view) {
if (dynamic_cast<ui::TextField*>(&view) != nullptr) {
field = &view;
}
for (const auto& child : view.children()) {
find(*child);
}
};
find(*shell.application->top());
REQUIRE(field != nullptr);
static_cast<ui::TextField*>(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<ui::Button*>(&view) != nullptr || dynamic_cast<ui::ValueRow*>(&view) != nullptr) {
std::vector<std::string> 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<std::string> 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<std::string> 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<std::string> labels;
texts(*focused, labels);
return dynamic_cast<app::NavTab*>(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<app::ItemCard*>(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<app::ItemCard*>(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<ui::TextField*>(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<ui::ValueRow*>(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<app::ItemCard*>(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<void(const ui::View&)> 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<std::string> 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<app::ProfileCard*>(shell.application->focus().focused()) != nullptr);
ui::View* quick = nullptr;
std::function<void(ui::View&)> find = [&](ui::View& view) {
if (auto* card = dynamic_cast<app::ProfileCard*>(&view)) {
std::vector<std::string> 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<app::ItemCard*>(h.shell.application->focus().focused()) != nullptr);
h.shell.press(Action::Up);
CHECK(focusedIsTab(*h.shell.application, "Home"));
}
namespace {
std::vector<jellyfin::Item> 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 <typename Done>
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<ui::Screen>());
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<ui::Screen>());
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<ui::Screen>());
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<int>(i);
}
if (requests[i].path == "/Items/e-1/PlaybackInfo" && videoInfo < 0) {
videoInfo = static_cast<int>(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<void(const ui::View&)> scan = [&](const ui::View& view) {
if (const auto* card = dynamic_cast<const app::ItemCard*>(&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<ui::Screen>());
}
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<ui::Button*>(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<ui::VScroll*>(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<void(ui::View&)> count = [&](ui::View& view) {
if (dynamic_cast<app::ProfileCard*>(&view) != nullptr) {
std::vector<std::string> 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<void(ui::View&)> find = [&](ui::View& view) {
if (dynamic_cast<app::ProfileCard*>(&view) != nullptr) {
std::vector<std::string> 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
}