mirror of
https://github.com/steredbits/pelagia.git
synced 2026-10-17 18:03:07 +02:00
495 lines
17 KiB
C++
495 lines
17 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2026 steredbits and Pelagia contributors
|
|
// UI navigation without rendering: in-memory backend, tasks run
|
|
// by hand (deterministic), abstract inputs as with a gamepad. Checks
|
|
// the screens reached, the focus, text entry (on-screen and physical keyboard), the
|
|
// saved session, sign-out and stale results.
|
|
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "test_framework.h"
|
|
#include "ui/app.h"
|
|
#include "ui/screens/detail_screen.h"
|
|
#include "ui/screens/grid_screen.h"
|
|
#include "ui/screens/home_screen.h"
|
|
#include "ui/screens/login_screen.h"
|
|
#include "ui/screens/series_screen.h"
|
|
#include "ui/screens/splash_screen.h"
|
|
#include "ui_harness.h"
|
|
#include "util/version.h"
|
|
|
|
namespace {
|
|
|
|
using namespace uitest;
|
|
|
|
// Sign-in with a given password, on the physical keyboard.
|
|
void login_with_keyboard(Harness& h, const std::string& password) {
|
|
h.type("srv:8096");
|
|
h.press(InputEvent::Down);
|
|
h.type("test");
|
|
h.press(InputEvent::Down);
|
|
h.type(password);
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Ok);
|
|
h.settle();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
static void test_login_keyboard_and_session() {
|
|
Harness h;
|
|
h.app->start("");
|
|
CHECK(h.screen() == "login");
|
|
auto* login = h.top<ui::LoginScreen>();
|
|
CHECK_EQ(login->focus(), ui::LoginScreen::kServer); // no known address
|
|
CHECK(h.app->wants_text_input()); // the physical keyboard types
|
|
// Empty fields: no request, message.
|
|
h.press(InputEvent::Down, 3);
|
|
h.press(InputEvent::Ok);
|
|
CHECK_EQ(h.backend.login_calls, 0);
|
|
CHECK(login->error());
|
|
CHECK_EQ(login->focus(), ui::LoginScreen::kServer);
|
|
|
|
// Wrong password: message, password erased, we stay.
|
|
login_with_keyboard(h, "faux");
|
|
CHECK(h.screen() == "login");
|
|
CHECK(login->message() == "Username or password refused");
|
|
CHECK(login->field(ui::LoginScreen::kPassword).empty());
|
|
CHECK_EQ(login->focus(), ui::LoginScreen::kPassword);
|
|
CHECK(!h.files.files.count("/conf/session.json"));
|
|
|
|
// Q, space, comma... typed as text in a field; Backspace
|
|
// (Erase) erases.
|
|
h.type("Q ,");
|
|
CHECK(login->field(ui::LoginScreen::kPassword) == "Q ,");
|
|
h.press(InputEvent::Erase, 3);
|
|
h.type("test");
|
|
h.press(InputEvent::Down);
|
|
CHECK(!h.app->wants_text_input()); // on the button: no more typing
|
|
h.press(InputEvent::Ok);
|
|
CHECK(login->connecting());
|
|
h.settle();
|
|
CHECK(h.screen() == "home");
|
|
CHECK(!h.app->wants_text_input());
|
|
// Saved session: completed address + token, private, without a password.
|
|
const MemoryFileStore::File& f = h.files.files["/conf/session.json"];
|
|
CHECK(f.private_file);
|
|
CHECK(f.data.find("http://srv:8096") != std::string::npos);
|
|
CHECK(f.data.find("tok-new") != std::string::npos);
|
|
CHECK(f.data.find("\"test\"") == std::string::npos);
|
|
}
|
|
|
|
// Gamepad navigation, on the French (AZERTY) layout.
|
|
static void test_virtual_keyboard() {
|
|
util::ScopedLanguage french(util::Lang::Fr);
|
|
Harness h;
|
|
h.app->start("http://srv:8096");
|
|
auto* login = h.top<ui::LoginScreen>();
|
|
CHECK_EQ(login->focus(), ui::LoginScreen::kUser); // adresse connue
|
|
h.press(InputEvent::Ok); // clavier virtuel
|
|
const ui::VirtualKeyboard& vk = login->keyboard();
|
|
CHECK(vk.is_open());
|
|
CHECK(vk.focused_label() == "a");
|
|
h.press(InputEvent::Right); // z
|
|
h.press(InputEvent::Ok);
|
|
h.press(InputEvent::Down); // next row, same column: s
|
|
h.press(InputEvent::Ok);
|
|
CHECK(login->field(ui::LoginScreen::kUser) == "zs");
|
|
h.press(InputEvent::Left, 2); // wrapping: from s (col 1) to m (col 9)
|
|
CHECK(vk.focused_label() == "m");
|
|
// Bottom row: wide "Space" key, then going back up through the center.
|
|
h.press(InputEvent::Down, 3);
|
|
CHECK(vk.focused_label() == "Valider");
|
|
h.press(InputEvent::Left);
|
|
CHECK(vk.focused_label() == "Espace");
|
|
h.press(InputEvent::Up);
|
|
CHECK(vk.focused_label() == "_"); // center of "Space" = column 4.5
|
|
h.press(InputEvent::Left, 3);
|
|
CHECK(vk.focused_label() == "Maj");
|
|
h.press(InputEvent::Ok);
|
|
CHECK(vk.layout() == ui::VirtualKeyboard::Layout::Upper);
|
|
h.press(InputEvent::Up, 3); // A
|
|
h.press(InputEvent::Ok);
|
|
CHECK(login->field(ui::LoginScreen::kUser) == "zsA");
|
|
// Shortcuts: square erases, R1 space, physical text accepted.
|
|
h.press(InputEvent::Erase);
|
|
h.press(InputEvent::SeekFwd);
|
|
h.type("x");
|
|
CHECK(login->field(ui::LoginScreen::kUser) == "zs x");
|
|
// Circle closes without changing field; Start/triangle confirms and moves to the next one.
|
|
h.press(InputEvent::Back);
|
|
CHECK(!vk.is_open());
|
|
CHECK_EQ(login->focus(), ui::LoginScreen::kUser);
|
|
h.press(InputEvent::Ok);
|
|
h.press(InputEvent::PlayPause);
|
|
CHECK(!vk.is_open());
|
|
CHECK_EQ(login->focus(), ui::LoginScreen::kPassword);
|
|
// Symbols keyboard, then back to letters.
|
|
h.press(InputEvent::Ok);
|
|
h.press(InputEvent::Down, 4);
|
|
h.press(InputEvent::Ok); // "&?123"
|
|
CHECK(vk.layout() == ui::VirtualKeyboard::Layout::Symbols);
|
|
h.press(InputEvent::Ok); // "abc"
|
|
CHECK(vk.layout() == ui::VirtualKeyboard::Layout::Lower);
|
|
}
|
|
|
|
static void test_saved_session() {
|
|
{
|
|
Harness h;
|
|
save_session(h, "good");
|
|
h.app->start("");
|
|
CHECK(h.screen() == "splash");
|
|
h.settle();
|
|
CHECK(h.screen() == "home");
|
|
}
|
|
{
|
|
// Revoked token: session erased, sign-in with the pre-filled address.
|
|
Harness h;
|
|
save_session(h, "revoked");
|
|
h.app->start("");
|
|
h.settle();
|
|
CHECK(h.screen() == "login");
|
|
CHECK(h.top<ui::LoginScreen>()->field(0) == "http://srv:8096");
|
|
CHECK(h.top<ui::LoginScreen>()->message() == "Session expired: sign in again.");
|
|
CHECK(!h.files.files.count("/conf/session.json"));
|
|
}
|
|
{
|
|
// Server unreachable: session kept, "Retry" or "Change server".
|
|
Harness h;
|
|
save_session(h, "offline");
|
|
h.app->start("");
|
|
h.settle();
|
|
CHECK(h.screen() == "splash");
|
|
CHECK(h.top<ui::SplashScreen>()->failed());
|
|
CHECK(h.files.files.count("/conf/session.json"));
|
|
h.press(InputEvent::Ok); // retry
|
|
CHECK(!h.top<ui::SplashScreen>()->failed());
|
|
h.settle();
|
|
h.press(InputEvent::Right);
|
|
h.press(InputEvent::Ok); // change server
|
|
h.settle();
|
|
CHECK(h.screen() == "login");
|
|
}
|
|
{
|
|
// --server different from the saved session: sign-in to that server.
|
|
Harness h;
|
|
save_session(h, "good");
|
|
h.app->start("autre:8096");
|
|
CHECK(h.screen() == "login");
|
|
CHECK(h.top<ui::LoginScreen>()->field(0) == "http://autre:8096");
|
|
}
|
|
}
|
|
|
|
static void test_home_grid_detail() {
|
|
Harness h;
|
|
save_session(h, "good");
|
|
h.app->start("");
|
|
h.settle();
|
|
auto* home = h.top<ui::HomeScreen>();
|
|
CHECK(home->loaded());
|
|
CHECK_EQ(home->rows().size(), 4u);
|
|
CHECK(home->rows()[0].title == "Continue watching");
|
|
CHECK(home->rows()[1].kind == ui::HomeScreen::RowKind::Libraries);
|
|
CHECK(home->rows()[2].title == "Latest additions: Films");
|
|
CHECK_EQ(home->focused_row(), 0);
|
|
// Position memory per row.
|
|
h.press(InputEvent::Down, 2);
|
|
h.press(InputEvent::Right, 2);
|
|
h.press(InputEvent::Up);
|
|
h.press(InputEvent::Down);
|
|
CHECK_EQ(home->focused_index(), 2);
|
|
h.press(InputEvent::Right, 10); // edge: stays on the last one
|
|
CHECK_EQ(home->focused_index(), 3);
|
|
|
|
// Movies library: sorted grid (U+200E and accents fixed).
|
|
h.press(InputEvent::Up);
|
|
h.press(InputEvent::Ok);
|
|
h.settle();
|
|
CHECK(h.screen() == "grid");
|
|
auto* grid = h.top<ui::GridScreen>();
|
|
CHECK_EQ(grid->items().size(), 12u);
|
|
CHECK(grid->items()[0].name == "\xE2\x80\x8E" "À bout de souffle");
|
|
CHECK(grid->items()[3].name == "Été 85");
|
|
CHECK(grid->items()[11].name == "Zodiac");
|
|
h.press(InputEvent::Right, 2);
|
|
h.press(InputEvent::Down); // 2 + 7 = 9
|
|
CHECK_EQ(grid->focus(), 9);
|
|
h.press(InputEvent::Down); // no 3rd row: stays
|
|
CHECK_EQ(grid->focus(), 9);
|
|
h.press(InputEvent::Up);
|
|
h.press(InputEvent::Ok); // "Crazy", started
|
|
h.settle();
|
|
CHECK(h.screen() == "detail");
|
|
auto* detail = h.top<ui::DetailScreen>();
|
|
CHECK(detail->refreshed());
|
|
CHECK(detail->item().overview == "Résumé à jour");
|
|
CHECK_EQ(detail->buttons().size(), 2u);
|
|
CHECK(detail->buttons()[0] == "Resume at 20:00");
|
|
CHECK(detail->buttons()[1] == "Play from the beginning");
|
|
|
|
// End of a playback (stop report sent): the page is re-read.
|
|
const int calls = h.backend.item_calls;
|
|
h.app->playback_stop_started();
|
|
h.settle();
|
|
CHECK_EQ(h.backend.item_calls, calls); // not during the stop
|
|
h.app->playback_stop_finished();
|
|
h.settle();
|
|
CHECK_EQ(h.backend.item_calls, calls + 1);
|
|
|
|
h.press(InputEvent::Back);
|
|
h.settle();
|
|
CHECK(h.screen() == "grid");
|
|
CHECK_EQ(h.top<ui::GridScreen>()->focus(), 2); // focus kept
|
|
h.press(InputEvent::Back);
|
|
h.settle();
|
|
CHECK(h.screen() == "home");
|
|
CHECK_EQ(h.top<ui::HomeScreen>()->focused_row(), 1);
|
|
}
|
|
|
|
static void test_series() {
|
|
Harness h;
|
|
save_session(h, "good");
|
|
h.app->start("");
|
|
h.settle();
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Right);
|
|
h.press(InputEvent::Ok); // Series
|
|
h.settle();
|
|
h.press(InputEvent::Ok); // the series
|
|
h.settle();
|
|
CHECK(h.screen() == "series");
|
|
auto* s = h.top<ui::SeriesScreen>();
|
|
CHECK_EQ(s->seasons().size(), 2u);
|
|
CHECK_EQ(s->shown_season(), 0);
|
|
CHECK_EQ(s->episodes().size(), 2u);
|
|
// Season 2 requested then left before the response: the stale result
|
|
// (season 2) is ignored, season 1 is displayed.
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Up);
|
|
h.settle();
|
|
CHECK_EQ(s->shown_season(), 0);
|
|
CHECK_EQ(s->episodes().size(), 2u);
|
|
h.press(InputEvent::Down);
|
|
h.settle();
|
|
CHECK_EQ(s->shown_season(), 1);
|
|
CHECK_EQ(s->episodes().size(), 3u);
|
|
h.press(InputEvent::Right);
|
|
CHECK_EQ(s->column(), 1);
|
|
h.press(InputEvent::Down, 5);
|
|
CHECK_EQ(s->episode_focus(), 2);
|
|
h.press(InputEvent::Ok);
|
|
h.settle();
|
|
CHECK(h.screen() == "detail");
|
|
CHECK(h.top<ui::DetailScreen>()->item().id == "season2-e3");
|
|
}
|
|
|
|
static void test_screen_closed_before_result() {
|
|
Harness h;
|
|
save_session(h, "good");
|
|
h.app->start("");
|
|
h.settle();
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Ok); // grid: loading started...
|
|
h.app->update(h.now += 16);
|
|
h.press(InputEvent::Back); // ... and left before the response
|
|
h.settle(); // result ignored, without a crash
|
|
CHECK(h.screen() == "home");
|
|
CHECK(h.app->idle());
|
|
}
|
|
|
|
static void test_options_logout_quit() {
|
|
Harness h;
|
|
save_session(h, "good");
|
|
h.app->start("");
|
|
h.settle();
|
|
h.press(InputEvent::Back); // circle on the home screen: menu
|
|
CHECK(h.app->menu_open());
|
|
CHECK_EQ(h.app->menu_items().size(), 5u); // Refresh, Language, sign out, About, Quit
|
|
CHECK(h.app->menu_title() == std::string("Options — Pelagia ") + util::kVersionTag);
|
|
h.press(InputEvent::Back);
|
|
CHECK(!h.app->menu_open());
|
|
h.press(InputEvent::Menu);
|
|
h.press(InputEvent::Down, 2);
|
|
h.press(InputEvent::Ok); // Sign out
|
|
CHECK(!h.files.files.count("/conf/session.json")); // erased right away
|
|
h.settle();
|
|
CHECK_EQ(h.backend.logout_calls, 1);
|
|
CHECK(h.screen() == "login");
|
|
CHECK(h.top<ui::LoginScreen>()->field(0) == "http://srv:8096");
|
|
CHECK(h.top<ui::LoginScreen>()->message() == "You are signed out.");
|
|
// Quit from the menu of the sign-in screen (Language, Quit), and Quit event
|
|
// (Ctrl+Q, window closed).
|
|
h.press(InputEvent::Menu);
|
|
CHECK_EQ(h.app->menu_items().size(), 2u);
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Ok);
|
|
CHECK(h.app->quit_requested());
|
|
Harness h2;
|
|
h2.app->start("");
|
|
h2.press(InputEvent::Quit);
|
|
CHECK(h2.app->quit_requested());
|
|
}
|
|
|
|
// Options > About: static screen, circle to go back to the home screen.
|
|
static void test_about_screen() {
|
|
Harness h;
|
|
save_session(h, "good");
|
|
h.app->start("");
|
|
h.settle();
|
|
h.press(InputEvent::Menu);
|
|
h.press(InputEvent::Down, 3);
|
|
CHECK(h.app->menu_items()[3].label == "About");
|
|
h.press(InputEvent::Ok);
|
|
CHECK(h.screen() == "about");
|
|
h.press(InputEvent::Back);
|
|
CHECK(h.screen() == "home");
|
|
}
|
|
|
|
// After a playback, the "Continue watching" row is reloaded and reordered
|
|
// (most recent first): the focus follows the element, not the position.
|
|
static void test_resume_row_follows_item() {
|
|
Harness h;
|
|
h.backend.resume_list = {h.backend.movies[3], h.backend.movies[5]};
|
|
save_session(h, "good");
|
|
h.app->start("");
|
|
h.settle();
|
|
auto* home = h.top<ui::HomeScreen>();
|
|
h.press(InputEvent::Right);
|
|
CHECK(home->rows()[0].items[home->focused_index()].id == "m5");
|
|
h.backend.resume_list = {h.backend.movies[5], h.backend.movies[3]};
|
|
h.app->playback_stop_started();
|
|
h.app->playback_stop_finished(); // new version of the data
|
|
h.settle();
|
|
CHECK(home->rows()[0].items[0].id == "m5");
|
|
CHECK_EQ(home->focused_index(), 0);
|
|
}
|
|
|
|
// Out-of-order responses (libraries before "Continue watching"): the focus
|
|
// still starts from the first row, and navigation waits for the end of
|
|
// loading (finding from capture mode).
|
|
static void test_home_focus_with_out_of_order_results() {
|
|
Harness h;
|
|
save_session(h, "good");
|
|
h.app->start("");
|
|
h.api.run_all(); // session restoration only
|
|
h.app->update(h.now += 16); // -> home, loads posted
|
|
CHECK(h.screen() == "home");
|
|
auto* home = h.top<ui::HomeScreen>();
|
|
h.press(InputEvent::Down); // ignored: not loaded yet
|
|
h.api.run_all_reversed();
|
|
h.settle();
|
|
CHECK(home->loaded());
|
|
CHECK(home->rows()[0].kind == ui::HomeScreen::RowKind::Resume);
|
|
CHECK_EQ(home->focused_row(), 0);
|
|
h.press(InputEvent::Down);
|
|
CHECK(home->rows()[home->focused_row()].kind == ui::HomeScreen::RowKind::Libraries);
|
|
}
|
|
|
|
// Options > Language: automatic (system locales), manual choice, saved setting,
|
|
// texts kept by the screens, keyboard layout.
|
|
static void test_language_option() {
|
|
const util::ScopedLanguage restore(util::current_language());
|
|
Harness h(true, {"fr_FR", "en_US"});
|
|
CHECK(util::current_language() == util::Lang::Fr); // automatic: the system's language
|
|
CHECK(h.app->language_setting() == util::LanguageSetting::Auto);
|
|
save_session(h, "good");
|
|
h.app->start("");
|
|
h.settle();
|
|
auto* home = h.top<ui::HomeScreen>();
|
|
CHECK(home->rows()[0].title == "Reprendre la lecture");
|
|
|
|
h.press(InputEvent::Menu);
|
|
CHECK(h.app->menu_items()[1].label == "Langue : Automatique (Français)");
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Ok); // language menu
|
|
CHECK(h.app->menu_open());
|
|
CHECK(h.app->menu_title() == "Langue");
|
|
CHECK_EQ(h.app->menu_items().size(), 3u);
|
|
CHECK(h.app->menu_items()[0].label == "Automatique (Français)" && h.app->menu_items()[0].checked);
|
|
CHECK(h.app->menu_items()[1].label == "English" && !h.app->menu_items()[1].checked);
|
|
CHECK(h.app->menu_items()[2].label == "Français");
|
|
CHECK_EQ(h.app->menu_index(), 0);
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Ok); // English
|
|
h.settle();
|
|
CHECK(!h.app->menu_open());
|
|
CHECK(util::current_language() == util::Lang::En);
|
|
CHECK(h.app->language_setting() == util::LanguageSetting::English);
|
|
CHECK(h.settings.load().language == util::LanguageSetting::English); // saved
|
|
CHECK(h.top<ui::HomeScreen>()->rows()[0].title == "Continue watching");
|
|
|
|
// The Options menu follows, and the choice is checked when the menu is reopened.
|
|
h.press(InputEvent::Menu);
|
|
CHECK(h.app->menu_items()[1].label == "Language: English");
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Ok);
|
|
CHECK_EQ(h.app->menu_index(), 1);
|
|
CHECK(h.app->menu_items()[1].checked);
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Ok); // French
|
|
CHECK(util::current_language() == util::Lang::Fr);
|
|
CHECK(h.settings.load().language == util::LanguageSetting::French);
|
|
|
|
// Back to automatic: unknown system languages mean English.
|
|
Harness other(true, {"ja_JP"});
|
|
CHECK(util::current_language() == util::Lang::En);
|
|
CHECK(other.app->language_setting() == util::LanguageSetting::Auto);
|
|
other.app->start("");
|
|
other.press(InputEvent::Menu); // sign-in screen: Language, Quit
|
|
CHECK(other.app->menu_items()[0].label == "Language: Automatic (English)");
|
|
|
|
// A saved setting wins over the system.
|
|
Harness saved(false);
|
|
ui::Settings s;
|
|
s.language = util::LanguageSetting::French;
|
|
saved.settings.save(s);
|
|
{
|
|
ui::App::Deps d;
|
|
d.backend = &saved.backend;
|
|
d.api = &saved.api;
|
|
d.main = &saved.main;
|
|
d.posters = &saved.posters;
|
|
d.text = &saved.text;
|
|
d.canvas = &saved.canvas;
|
|
d.sessions = &saved.sessions;
|
|
d.settings = &saved.settings;
|
|
d.system_locales = {"en_US"};
|
|
ui::App app(d);
|
|
CHECK(util::current_language() == util::Lang::Fr);
|
|
}
|
|
}
|
|
|
|
// QWERTY layout in English, AZERTY in French (letter rows from the string catalog).
|
|
static void test_keyboard_layouts() {
|
|
{
|
|
util::ScopedLanguage english(util::Lang::En);
|
|
Harness h;
|
|
h.app->start("http://srv:8096");
|
|
h.press(InputEvent::Ok);
|
|
const ui::VirtualKeyboard& vk = h.top<ui::LoginScreen>()->keyboard();
|
|
CHECK(vk.focused_label() == "q");
|
|
h.press(InputEvent::Down, 3);
|
|
CHECK(vk.focused_label() == "Shift");
|
|
// The layout follows a language change while the keyboard is open.
|
|
util::set_current_language(util::Lang::Fr);
|
|
CHECK(vk.focused_label() == "Maj");
|
|
}
|
|
}
|
|
|
|
int main() {
|
|
test_language_option();
|
|
test_keyboard_layouts();
|
|
test_login_keyboard_and_session();
|
|
test_virtual_keyboard();
|
|
test_saved_session();
|
|
test_home_grid_detail();
|
|
test_series();
|
|
test_screen_closed_before_result();
|
|
test_options_logout_quit();
|
|
test_about_screen();
|
|
test_resume_row_follows_item();
|
|
test_home_focus_with_out_of_order_results();
|
|
return testfw::test_failures();
|
|
}
|