mirror of
https://github.com/DevvNirvana/afterglow.git
synced 2026-10-23 11:00:39 +02:00
222 stations in 17 genres, 56 visualizer scenes, Ogg and Opus playback, station logos, crossfade, My Music from USB, a command box and live BPM sync.
786 lines
29 KiB
C++
786 lines
29 KiB
C++
// ps5-homebrew-ui - Tests: NotificationStack, NotificationCenter and NotificationBell behaviour.
|
|
// Copyright (C) 2026 BlackBearReloaded
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
#include "component_fixture.hpp"
|
|
#include "concepts/components/page.hpp"
|
|
#include "ui/components/notification.hpp"
|
|
#include "ui/components/notification_bell.hpp"
|
|
#include "ui/components/notification_center.hpp"
|
|
|
|
#include <gtest/gtest.h>
|
|
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace
|
|
{
|
|
|
|
using hui::Action;
|
|
using hui::Direction;
|
|
using hui::audio::Cue;
|
|
using hui::gfx::Rect;
|
|
using hui::ui::CloseReason;
|
|
using hui::ui::Event;
|
|
using hui::ui::Notification;
|
|
using hui::ui::NotificationBell;
|
|
using hui::ui::NotificationCenter;
|
|
using hui::ui::NotificationLook;
|
|
using hui::ui::NotificationStack;
|
|
using hui::ui::StatusKind;
|
|
using hui::ui::ToastAnchor;
|
|
|
|
class ComponentsNotifications : public hui::testing::ComponentFixture
|
|
{
|
|
protected:
|
|
struct Closed
|
|
{
|
|
int id;
|
|
CloseReason reason;
|
|
};
|
|
|
|
static Notification note(const char *title, float seconds = -1.0f,
|
|
std::vector<std::string> actions = {})
|
|
{
|
|
Notification out;
|
|
out.source = "System";
|
|
out.time = "now";
|
|
out.title = title;
|
|
out.body = "A line of text that says what happened and what can be done about it.";
|
|
out.seconds = seconds;
|
|
out.actions = std::move(actions);
|
|
return out;
|
|
}
|
|
|
|
// Runs the stack for a while, keeping every cue it asks for.
|
|
void run(NotificationStack &stack, float seconds)
|
|
{
|
|
for (int i = 0, frames = static_cast<int>(seconds / kFrame); i < frames; ++i)
|
|
stack.update(kFrame, feedback_);
|
|
}
|
|
|
|
// The stack reports through its slot; the tests read it from here.
|
|
void listen(NotificationStack &stack)
|
|
{
|
|
stack.on_close = [this](int id, CloseReason reason) { closed_.push_back({id, reason}); };
|
|
}
|
|
|
|
int count(Cue cue) const
|
|
{
|
|
int total = 0;
|
|
for (const hui::audio::CueEvent &event : feedback_.cues)
|
|
total += event.cue == cue ? 1 : 0;
|
|
return total;
|
|
}
|
|
|
|
std::vector<Closed> closed_;
|
|
};
|
|
|
|
TEST_F(ComponentsNotifications, TimedOnesLeaveStickyOnesStayAndBothSayWhy)
|
|
{
|
|
NotificationStack stack;
|
|
listen(stack);
|
|
const int timed = stack.push(note("Friend online", 2.0f));
|
|
const int sticky = stack.push(note("Update available"));
|
|
const int usual = stack.push(note("Saved", 0.0f)); // style.duration
|
|
EXPECT_EQ(timed, 1);
|
|
EXPECT_EQ(stack.queued_count(), 3);
|
|
EXPECT_FLOAT_EQ(stack.remaining(timed), -1.0f); // not on screen yet
|
|
|
|
run(stack, 1.0f);
|
|
EXPECT_EQ(stack.visible_count(), 3);
|
|
EXPECT_EQ(stack.newest(), usual);
|
|
EXPECT_NEAR(stack.remaining(timed), 0.5f, 0.05f);
|
|
EXPECT_FLOAT_EQ(stack.remaining(sticky), 1.0f);
|
|
EXPECT_NEAR(stack.remaining(usual), 1.0f - 1.0f / stack.style.duration, 0.05f);
|
|
|
|
run(stack, 1.5f);
|
|
EXPECT_EQ(stack.visible_count(), 2);
|
|
ASSERT_EQ(closed_.size(), 1u);
|
|
EXPECT_EQ(closed_[0].id, timed);
|
|
EXPECT_EQ(closed_[0].reason, CloseReason::timed_out);
|
|
|
|
run(stack, 30.0f);
|
|
EXPECT_EQ(stack.visible_count(), 1);
|
|
EXPECT_NE(stack.find(sticky), nullptr);
|
|
EXPECT_EQ(stack.find(timed), nullptr);
|
|
EXPECT_FALSE(stack.empty());
|
|
}
|
|
|
|
TEST_F(ComponentsNotifications, TimersStandStillWhileTheStackHasTheFocus)
|
|
{
|
|
NotificationStack stack;
|
|
const int id = stack.push(note("Low battery", 2.0f, {"Dismiss"}));
|
|
run(stack, 0.5f);
|
|
const float before = stack.remaining(id);
|
|
|
|
stack.set_active(true);
|
|
EXPECT_TRUE(stack.active());
|
|
EXPECT_EQ(stack.focus_id(), id);
|
|
run(stack, 5.0f);
|
|
EXPECT_FLOAT_EQ(stack.remaining(id), before);
|
|
EXPECT_EQ(stack.visible_count(), 1);
|
|
|
|
stack.set_active(false);
|
|
run(stack, 2.0f);
|
|
EXPECT_EQ(stack.visible_count(), 0);
|
|
|
|
// The knob turns the pause off.
|
|
stack.style.pause_when_active = false;
|
|
stack.push(note("Low battery", 1.0f, {"Dismiss"}));
|
|
run(stack, 0.2f);
|
|
stack.set_active(true);
|
|
run(stack, 1.5f);
|
|
EXPECT_EQ(stack.visible_count(), 0);
|
|
}
|
|
|
|
TEST_F(ComponentsNotifications, MoreThanTheLimitWaitAndEachChimesWhenItAppears)
|
|
{
|
|
NotificationStack stack;
|
|
stack.style.max_visible = 2;
|
|
stack.style.duration = 1.0f;
|
|
for (int i = 0; i < 5; ++i)
|
|
stack.push(note("Saved", 0.0f));
|
|
EXPECT_EQ(stack.visible_count(), 0);
|
|
EXPECT_EQ(stack.queued_count(), 5);
|
|
|
|
feedback_.clear();
|
|
run(stack, 0.5f);
|
|
EXPECT_EQ(stack.visible_count(), 2);
|
|
EXPECT_EQ(stack.queued_count(), 3);
|
|
EXPECT_EQ(count(Cue::notify), 2);
|
|
|
|
run(stack, 8.0f);
|
|
EXPECT_EQ(count(Cue::notify), 5);
|
|
EXPECT_TRUE(stack.empty());
|
|
|
|
// The kind sets the pitch: danger lower, success higher.
|
|
feedback_.clear();
|
|
Notification failed = note("Save failed");
|
|
failed.kind = StatusKind::danger;
|
|
stack.push(failed);
|
|
run(stack, 0.1f);
|
|
ASSERT_EQ(feedback_.cues.size(), 1u);
|
|
EXPECT_LT(feedback_.cues[0].pitch, 1.0f);
|
|
|
|
stack.style.sounds.notify = Cue::count;
|
|
feedback_.clear();
|
|
stack.push(note("Quiet"));
|
|
run(stack, 0.2f);
|
|
EXPECT_TRUE(feedback_.cues.empty());
|
|
}
|
|
|
|
TEST_F(ComponentsNotifications, AStackThatIsFullKeepsTheNextOneWaiting)
|
|
{
|
|
NotificationStack stack;
|
|
stack.style.max_visible = 5;
|
|
stack.set_bounds({0.0f, 0.0f, 1920.0f, 380.0f});
|
|
hui::ui::Canvas target = canvas();
|
|
stack.draw(target); // heights need the fonts of a draw
|
|
const int first = stack.push(note("One", -1.0f, {"Open"}));
|
|
stack.push(note("Two", -1.0f, {"Open"}));
|
|
stack.push(note("Three", -1.0f, {"Open"}));
|
|
run(stack, 1.0f);
|
|
EXPECT_EQ(stack.visible_count(), 1);
|
|
EXPECT_EQ(stack.queued_count(), 2);
|
|
const Rect card = stack.card_rect(fonts_, first);
|
|
EXPECT_GT(card.h, 150.0f);
|
|
EXPECT_FLOAT_EQ(card.w, stack.style.width);
|
|
EXPECT_FLOAT_EQ(card.x + card.w, 1920.0f - stack.style.margin);
|
|
EXPECT_FLOAT_EQ(card.y, stack.style.margin);
|
|
const Rect button = stack.control_rect(fonts_, first, 0);
|
|
EXPECT_GT(button.y, card.y);
|
|
EXPECT_LE(button.y + button.h, card.y + card.h);
|
|
|
|
// Room is made: the next one comes in.
|
|
stack.dismiss(first);
|
|
run(stack, 2.0f);
|
|
EXPECT_EQ(stack.visible_count(), 1);
|
|
EXPECT_EQ(stack.queued_count(), 1);
|
|
|
|
stack.style.fit_bounds = false;
|
|
run(stack, 0.5f);
|
|
EXPECT_EQ(stack.visible_count(), 2);
|
|
|
|
stack.style.anchor = ToastAnchor::bottom_left;
|
|
const Rect low = stack.card_rect(fonts_, stack.newest());
|
|
EXPECT_FLOAT_EQ(low.x, stack.style.margin);
|
|
EXPECT_NEAR(low.y + low.h, 380.0f - stack.style.margin, 0.5f);
|
|
}
|
|
|
|
TEST_F(ComponentsNotifications, ContentChangesInPlaceUnderTheSameId)
|
|
{
|
|
NotificationStack stack;
|
|
listen(stack);
|
|
Notification update = note("Version 2.4 is ready", -1.0f, {"Update now", "Later", "Never"});
|
|
update.tag = 7;
|
|
const int id = stack.push(update);
|
|
ASSERT_NE(stack.find(id), nullptr);
|
|
EXPECT_EQ(stack.find(id)->actions.size(), 2u); // more than two are dropped
|
|
// Still waiting: the change is taken as well.
|
|
EXPECT_TRUE(stack.set_title(id, "Version 2.5 is ready"));
|
|
run(stack, 0.5f);
|
|
EXPECT_EQ(stack.find(id)->title, "Version 2.5 is ready");
|
|
EXPECT_EQ(stack.controls(id), 3);
|
|
|
|
Notification download = note("Downloading", -1.0f);
|
|
download.closable = false;
|
|
download.progress = 0.0f;
|
|
EXPECT_TRUE(stack.update_notification(id, download));
|
|
EXPECT_EQ(stack.visible_count(), 1);
|
|
EXPECT_EQ(stack.find(id)->title, "Downloading");
|
|
EXPECT_EQ(stack.controls(id), 0);
|
|
EXPECT_FALSE(stack.focusable());
|
|
|
|
EXPECT_TRUE(stack.set_progress(id, 0.5f));
|
|
EXPECT_TRUE(stack.set_body(id, "60 of 120 MB"));
|
|
EXPECT_FLOAT_EQ(stack.find(id)->progress, 0.5f);
|
|
EXPECT_EQ(stack.find(id)->body, "60 of 120 MB");
|
|
hui::ui::Canvas target = canvas();
|
|
stack.draw(target); // half way through the cross-fade
|
|
run(stack, 1.0f);
|
|
stack.draw(target);
|
|
expect_drawn("progress");
|
|
|
|
EXPECT_TRUE(stack.set_kind(id, StatusKind::success));
|
|
EXPECT_TRUE(stack.set_actions(id, {"Restart"}));
|
|
EXPECT_EQ(stack.find(id)->kind, StatusKind::success);
|
|
EXPECT_EQ(stack.controls(id), 1);
|
|
EXPECT_TRUE(stack.focusable());
|
|
|
|
// A timed replacement starts its own clock.
|
|
EXPECT_TRUE(stack.update_notification(id, note("Installed", 1.0f)));
|
|
run(stack, 2.0f);
|
|
ASSERT_EQ(closed_.size(), 1u);
|
|
EXPECT_EQ(closed_[0].reason, CloseReason::timed_out);
|
|
run(stack, 2.0f);
|
|
EXPECT_FALSE(stack.update_notification(id, note("Too late")));
|
|
EXPECT_FALSE(stack.set_progress(id, 1.0f));
|
|
EXPECT_FALSE(stack.set_title(99, "Nobody"));
|
|
}
|
|
|
|
TEST_F(ComponentsNotifications, TheShortcutFiresTheNewestMainActionFromAnywhere)
|
|
{
|
|
NotificationStack stack;
|
|
listen(stack);
|
|
int slot_id = 0;
|
|
int slot_action = -1;
|
|
stack.on_action = [&](int id, int action)
|
|
{
|
|
slot_id = id;
|
|
slot_action = action;
|
|
};
|
|
const int older = stack.push(note("Friend request", -1.0f, {"Accept"}));
|
|
const int plain = stack.push(note("Trophy earned"));
|
|
run(stack, 0.5f);
|
|
|
|
// No shortcut is set: the button is not the stack's.
|
|
EXPECT_EQ(stack.handle_shortcut(press(Action::north), feedback_), Event::none);
|
|
|
|
stack.style.shortcut = Action::north;
|
|
stack.style.dismiss_shortcut = Action::west;
|
|
feedback_.clear();
|
|
EXPECT_EQ(stack.handle_shortcut(idle(), feedback_), Event::none);
|
|
EXPECT_EQ(stack.handle_shortcut(press(Action::north), feedback_), Event::activated);
|
|
EXPECT_EQ(stack.event_id(), older); // the newest one that has an action
|
|
EXPECT_EQ(stack.event_action(), 0);
|
|
EXPECT_EQ(slot_id, older);
|
|
EXPECT_EQ(slot_action, 0);
|
|
EXPECT_TRUE(asked(Cue::select));
|
|
EXPECT_FALSE(stack.active());
|
|
// The answer can still read what it answered.
|
|
ASSERT_NE(stack.find(older), nullptr);
|
|
EXPECT_EQ(stack.find(older)->title, "Friend request");
|
|
run(stack, 0.1f);
|
|
ASSERT_EQ(closed_.size(), 1u);
|
|
EXPECT_EQ(closed_[0].id, older);
|
|
EXPECT_EQ(closed_[0].reason, CloseReason::action);
|
|
|
|
// Nothing with an action is left: the press is not taken.
|
|
EXPECT_EQ(stack.handle_shortcut(press(Action::north), feedback_), Event::none);
|
|
|
|
feedback_.clear();
|
|
EXPECT_EQ(stack.handle_shortcut(press(Action::west), feedback_), Event::changed);
|
|
EXPECT_EQ(stack.event_id(), plain);
|
|
EXPECT_TRUE(asked(Cue::modal_close));
|
|
run(stack, 0.1f);
|
|
ASSERT_EQ(closed_.size(), 2u);
|
|
EXPECT_EQ(closed_[1].reason, CloseReason::closed);
|
|
|
|
// The glyph makes the main action wider when the buttons hug their labels.
|
|
stack.style.stretch_actions = false;
|
|
const int one = stack.push(note("Friend request", -1.0f, {"Accept"}));
|
|
run(stack, 1.5f);
|
|
const float with_glyph = stack.control_rect(fonts_, one, 0).w;
|
|
stack.style.shortcut = Action::count;
|
|
EXPECT_LT(stack.control_rect(fonts_, one, 0).w, with_glyph);
|
|
}
|
|
|
|
TEST_F(ComponentsNotifications, TheFocusMovesBetweenControlsAndNotifications)
|
|
{
|
|
NotificationStack stack;
|
|
listen(stack);
|
|
const int lower = stack.push(note("Save failed", -1.0f, {"Retry", "Details"}));
|
|
const int upper = stack.push(note("Friend request", -1.0f, {"Accept"}));
|
|
run(stack, 0.5f);
|
|
// Not active: it takes nothing.
|
|
EXPECT_EQ(stack.handle(press(Action::confirm), feedback_), Event::none);
|
|
|
|
stack.set_active(true);
|
|
EXPECT_EQ(stack.focus_id(), upper); // the newest, on its main control
|
|
EXPECT_EQ(stack.focus_control(), 0);
|
|
|
|
feedback_.clear();
|
|
EXPECT_EQ(stack.handle(nav(Direction::right), feedback_), Event::moved);
|
|
EXPECT_TRUE(asked(Cue::focus));
|
|
EXPECT_EQ(stack.focus_control(), 1); // the close control
|
|
feedback_.clear();
|
|
EXPECT_EQ(stack.handle(nav(Direction::right), feedback_), Event::refused);
|
|
EXPECT_TRUE(asked(Cue::error));
|
|
hui::InputFrame held = nav(Direction::right);
|
|
held.nav_repeat = true;
|
|
feedback_.clear();
|
|
EXPECT_EQ(stack.handle(held, feedback_), Event::refused);
|
|
EXPECT_TRUE(feedback_.cues.empty());
|
|
|
|
// The newest is at the top (top anchor, newest first).
|
|
EXPECT_EQ(stack.handle(nav(Direction::up), feedback_), Event::refused);
|
|
// From a close control the focus goes to the other close control.
|
|
EXPECT_EQ(stack.handle(nav(Direction::down), feedback_), Event::moved);
|
|
EXPECT_EQ(stack.focus_id(), lower);
|
|
EXPECT_EQ(stack.focus_control(), 2);
|
|
EXPECT_EQ(stack.handle(nav(Direction::down), feedback_), Event::refused);
|
|
EXPECT_EQ(stack.handle(nav(Direction::left), feedback_), Event::moved);
|
|
EXPECT_EQ(stack.focus_control(), 1);
|
|
EXPECT_EQ(stack.handle(nav(Direction::up), feedback_), Event::moved);
|
|
EXPECT_EQ(stack.focus_id(), upper);
|
|
EXPECT_EQ(stack.focus_control(), 0);
|
|
EXPECT_TRUE(stack.set_focus(lower, 1));
|
|
EXPECT_FALSE(stack.set_focus(99));
|
|
|
|
feedback_.clear();
|
|
EXPECT_EQ(stack.handle(press(Action::confirm), feedback_), Event::activated);
|
|
EXPECT_TRUE(asked(Cue::select));
|
|
EXPECT_EQ(stack.event_id(), lower);
|
|
EXPECT_EQ(stack.event_action(), 1);
|
|
run(stack, 0.1f);
|
|
EXPECT_EQ(stack.focus_id(), upper); // the neighbour took the focus
|
|
|
|
EXPECT_EQ(stack.handle(nav(Direction::right), feedback_), Event::moved);
|
|
feedback_.clear();
|
|
EXPECT_EQ(stack.handle(press(Action::confirm), feedback_), Event::changed);
|
|
EXPECT_TRUE(asked(Cue::modal_close));
|
|
EXPECT_EQ(stack.event_id(), upper);
|
|
run(stack, 0.1f);
|
|
ASSERT_EQ(closed_.size(), 2u);
|
|
EXPECT_EQ(closed_[0].reason, CloseReason::action);
|
|
EXPECT_EQ(closed_[1].reason, CloseReason::closed);
|
|
|
|
// Nothing is left: the screen gets the focus back.
|
|
EXPECT_EQ(stack.handle(idle(), feedback_), Event::cancelled);
|
|
EXPECT_FALSE(stack.active());
|
|
|
|
// Back does the same and says so.
|
|
stack.push(note("One more", -1.0f, {"Open"}));
|
|
run(stack, 0.5f);
|
|
stack.set_active(true);
|
|
feedback_.clear();
|
|
EXPECT_EQ(stack.handle(press(Action::back), feedback_), Event::cancelled);
|
|
EXPECT_TRUE(asked(Cue::back));
|
|
EXPECT_FALSE(stack.active());
|
|
}
|
|
|
|
TEST_F(ComponentsNotifications, TheFocusGoesToANeighbourWhenItsNotificationLeaves)
|
|
{
|
|
NotificationStack stack;
|
|
const int first = stack.push(note("One", -1.0f, {"Open"}));
|
|
const int second = stack.push(note("Two", -1.0f, {"Open"}));
|
|
const int third = stack.push(note("Three", -1.0f, {"Open"}));
|
|
const int silent = stack.push(note("No controls"));
|
|
run(stack, 0.5f);
|
|
EXPECT_EQ(stack.visible_count(), 3);
|
|
stack.set_active(true);
|
|
EXPECT_TRUE(stack.set_focus(second));
|
|
|
|
// The application sends the focused one away: the one that slides into
|
|
// its place (the older one, further from the edge) takes the focus.
|
|
stack.dismiss(second);
|
|
run(stack, 0.1f);
|
|
EXPECT_EQ(stack.focus_id(), first);
|
|
stack.dismiss(first);
|
|
run(stack, 0.1f);
|
|
EXPECT_EQ(stack.focus_id(), third);
|
|
|
|
// The last one with a control goes: nothing can hold the focus.
|
|
Notification bare = note("No controls");
|
|
bare.closable = false;
|
|
EXPECT_TRUE(stack.update_notification(silent, bare));
|
|
stack.dismiss(third);
|
|
run(stack, 0.1f);
|
|
EXPECT_EQ(stack.focus_id(), 0);
|
|
EXPECT_EQ(stack.handle(nav(Direction::down), feedback_), Event::cancelled);
|
|
EXPECT_FALSE(stack.active());
|
|
}
|
|
|
|
TEST_F(ComponentsNotifications, AnActionAnsweredWithNewContentKeepsItsNotification)
|
|
{
|
|
NotificationStack stack;
|
|
listen(stack);
|
|
const int id = stack.push(note("Version 2.4 is ready", -1.0f, {"Update now", "Later"}));
|
|
run(stack, 0.5f);
|
|
stack.set_active(true);
|
|
EXPECT_EQ(stack.handle(press(Action::confirm), feedback_), Event::activated);
|
|
EXPECT_EQ(stack.visible_count(), 0); // on its way out...
|
|
Notification download = note("Downloading");
|
|
download.closable = false;
|
|
download.progress = 0.1f;
|
|
EXPECT_TRUE(stack.update_notification(id, download));
|
|
EXPECT_EQ(stack.visible_count(), 1); // ... and back
|
|
run(stack, 1.0f);
|
|
EXPECT_TRUE(closed_.empty());
|
|
EXPECT_EQ(stack.find(id)->title, "Downloading");
|
|
// It has no control any more: the focus returns to the screen.
|
|
EXPECT_EQ(stack.handle(idle(), feedback_), Event::cancelled);
|
|
|
|
// The same from the slot, and with the knob that keeps them open.
|
|
EXPECT_TRUE(stack.update_notification(id, note("Installed", -1.0f, {"Restart"})));
|
|
stack.style.close_on_action = false;
|
|
stack.set_active(true);
|
|
EXPECT_EQ(stack.handle(press(Action::confirm), feedback_), Event::activated);
|
|
run(stack, 1.0f);
|
|
EXPECT_EQ(stack.visible_count(), 1);
|
|
EXPECT_TRUE(closed_.empty());
|
|
|
|
stack.style.close_on_action = true;
|
|
stack.on_action = [&stack](int which, int)
|
|
{ stack.update_notification(which, note("Restarting", 1.0f)); };
|
|
EXPECT_EQ(stack.handle(press(Action::confirm), feedback_), Event::activated);
|
|
run(stack, 0.5f);
|
|
EXPECT_EQ(stack.find(id)->title, "Restarting");
|
|
EXPECT_TRUE(closed_.empty());
|
|
}
|
|
|
|
TEST_F(ComponentsNotifications, DismissAndClearReportEveryOneAsDismissed)
|
|
{
|
|
NotificationStack stack;
|
|
listen(stack);
|
|
stack.style.max_visible = 2;
|
|
const int a = stack.push(note("A"));
|
|
stack.push(note("B"));
|
|
const int c = stack.push(note("C"));
|
|
run(stack, 0.5f);
|
|
EXPECT_EQ(stack.queued_count(), 1);
|
|
|
|
stack.dismiss(c); // still waiting: it never appears
|
|
stack.dismiss(a);
|
|
EXPECT_EQ(stack.visible_count(), 1);
|
|
run(stack, 0.1f);
|
|
ASSERT_EQ(closed_.size(), 2u);
|
|
EXPECT_EQ(closed_[0].reason, CloseReason::dismissed);
|
|
EXPECT_EQ(closed_[1].reason, CloseReason::dismissed);
|
|
|
|
stack.push(note("D"));
|
|
stack.push(note("E"));
|
|
stack.clear();
|
|
EXPECT_EQ(stack.visible_count(), 0);
|
|
EXPECT_EQ(stack.queued_count(), 0);
|
|
run(stack, 3.0f);
|
|
EXPECT_EQ(closed_.size(), 5u);
|
|
EXPECT_TRUE(stack.empty());
|
|
|
|
stack.push(note("F"));
|
|
run(stack, 0.2f);
|
|
stack.clear(true);
|
|
EXPECT_TRUE(stack.empty());
|
|
run(stack, 0.1f);
|
|
EXPECT_EQ(closed_.size(), 6u);
|
|
}
|
|
|
|
TEST_F(ComponentsNotifications, TheCenterRecordsWhatLeftAndCountsWhatWasMissed)
|
|
{
|
|
NotificationStack stack;
|
|
NotificationCenter center;
|
|
center.attach(stack);
|
|
center.set_bounds({1300.0f, 100.0f, 520.0f, 800.0f});
|
|
EXPECT_EQ(center.count(), 0);
|
|
EXPECT_EQ(center.focus(), -1);
|
|
EXPECT_EQ(center.handle(press(Action::confirm), feedback_), Event::none);
|
|
|
|
stack.push(note("Friend online", 1.0f));
|
|
const int request = stack.push(note("Friend request", -1.0f, {"Accept"}));
|
|
const int failed = stack.push(note("Save failed"));
|
|
run(stack, 2.0f);
|
|
ASSERT_EQ(center.count(), 1);
|
|
EXPECT_EQ(center.unread(), 1); // timed out unseen
|
|
EXPECT_EQ(center.records()[0].reason, CloseReason::timed_out);
|
|
|
|
// What the player answered or closed has been seen.
|
|
stack.set_active(true);
|
|
EXPECT_TRUE(stack.set_focus(request));
|
|
EXPECT_EQ(stack.handle(press(Action::confirm), feedback_), Event::activated);
|
|
run(stack, 0.2f);
|
|
EXPECT_TRUE(stack.set_focus(failed));
|
|
EXPECT_EQ(stack.handle(press(Action::confirm), feedback_), Event::changed);
|
|
run(stack, 0.2f);
|
|
ASSERT_EQ(center.count(), 3);
|
|
EXPECT_EQ(center.unread(), 1);
|
|
EXPECT_EQ(center.records()[0].notification.title, "Save failed"); // newest first
|
|
EXPECT_EQ(center.records()[0].reason, CloseReason::closed);
|
|
EXPECT_EQ(center.records()[1].reason, CloseReason::action);
|
|
EXPECT_EQ(center.records()[1].action, 0);
|
|
EXPECT_EQ(center.records()[1].id, request);
|
|
|
|
// The list: "New" with the missed one first, then "Earlier".
|
|
center.enter();
|
|
EXPECT_EQ(center.focus(), 2);
|
|
feedback_.clear();
|
|
EXPECT_EQ(center.handle(press(Action::confirm), feedback_), Event::refused); // no action
|
|
EXPECT_TRUE(asked(Cue::error));
|
|
EXPECT_EQ(center.handle(nav(Direction::down), feedback_), Event::moved);
|
|
EXPECT_EQ(center.focus(), 0);
|
|
EXPECT_EQ(center.handle(nav(Direction::down), feedback_), Event::moved);
|
|
EXPECT_EQ(center.focus(), 1);
|
|
int again = -1;
|
|
center.on_action = [&](const hui::ui::NotificationRecord &record, int action)
|
|
{ again = record.id * 10 + action; };
|
|
feedback_.clear();
|
|
EXPECT_EQ(center.handle(press(Action::confirm), feedback_), Event::activated);
|
|
EXPECT_TRUE(asked(Cue::select));
|
|
ASSERT_NE(center.event_record(), nullptr);
|
|
EXPECT_EQ(center.event_record()->notification.title, "Friend request");
|
|
EXPECT_EQ(again, request * 10);
|
|
EXPECT_EQ(center.handle(nav(Direction::down), feedback_), Event::refused);
|
|
|
|
center.mark_all_read();
|
|
EXPECT_EQ(center.unread(), 0);
|
|
center.update(kFrame);
|
|
EXPECT_EQ(center.focus(), 1); // the focus stayed on its record
|
|
|
|
// Above the first row is "Clear all".
|
|
center.enter();
|
|
EXPECT_EQ(center.focus(), 0);
|
|
EXPECT_EQ(center.handle(nav(Direction::up), feedback_), Event::moved);
|
|
EXPECT_EQ(center.focus(), -1);
|
|
EXPECT_EQ(center.handle(nav(Direction::up), feedback_), Event::refused);
|
|
EXPECT_EQ(center.handle(nav(Direction::down), feedback_), Event::moved);
|
|
EXPECT_EQ(center.handle(nav(Direction::up), feedback_), Event::moved);
|
|
EXPECT_EQ(center.handle(press(Action::confirm), feedback_), Event::changed);
|
|
EXPECT_EQ(center.count(), 0);
|
|
feedback_.clear();
|
|
EXPECT_EQ(center.handle(press(Action::back), feedback_), Event::cancelled);
|
|
EXPECT_TRUE(asked(Cue::back));
|
|
|
|
// By hand, with a limit, and with everything counted as unread.
|
|
center.style.max_records = 3;
|
|
center.style.missed_only = false;
|
|
for (int i = 0; i < 5; ++i)
|
|
center.record(note("By hand"), CloseReason::closed);
|
|
EXPECT_EQ(center.count(), 3);
|
|
EXPECT_EQ(center.unread(), 3);
|
|
center.clear();
|
|
EXPECT_EQ(center.count(), 0);
|
|
|
|
center.detach(stack);
|
|
stack.push(note("Unrecorded", 0.5f));
|
|
run(stack, 2.0f);
|
|
EXPECT_EQ(center.count(), 0);
|
|
}
|
|
|
|
TEST_F(ComponentsNotifications, TheBellCountsRingsAndOpens)
|
|
{
|
|
NotificationBell bell;
|
|
bell.set_bounds({1700.0f, 60.0f, 80.0f, 64.0f});
|
|
EXPECT_FLOAT_EQ(bell.rect().w, 64.0f); // a square in the middle of the bounds
|
|
EXPECT_FLOAT_EQ(bell.rect().x, 1708.0f);
|
|
EXPECT_EQ(bell.count(), 0);
|
|
EXPECT_FALSE(bell.ringing());
|
|
|
|
bell.set_count(2);
|
|
EXPECT_EQ(bell.count(), 2);
|
|
EXPECT_TRUE(bell.ringing()); // something arrived
|
|
hui::ui::Canvas target = canvas();
|
|
for (int i = 0; i < 12; ++i)
|
|
bell.update(kFrame);
|
|
bell.draw(target); // mid swing
|
|
expect_drawn("bell");
|
|
for (int i = 0; i < 120; ++i)
|
|
bell.update(kFrame);
|
|
EXPECT_FALSE(bell.ringing());
|
|
|
|
bell.set_count(1); // fewer: no ring
|
|
EXPECT_FALSE(bell.ringing());
|
|
bell.style.ring_on_increase = false;
|
|
bell.set_count(5);
|
|
EXPECT_FALSE(bell.ringing());
|
|
bell.ring();
|
|
EXPECT_TRUE(bell.ringing());
|
|
|
|
bell.set_active(true);
|
|
EXPECT_TRUE(bell.active());
|
|
feedback_.clear();
|
|
EXPECT_EQ(bell.handle(nav(Direction::left), feedback_), Event::none);
|
|
EXPECT_TRUE(feedback_.cues.empty());
|
|
EXPECT_EQ(bell.handle(press(Action::confirm), feedback_), Event::activated);
|
|
EXPECT_TRUE(asked(Cue::select));
|
|
}
|
|
|
|
TEST_F(ComponentsNotifications, DrawInEveryThemeAndLook)
|
|
{
|
|
const NotificationLook looks[] = {NotificationLook::card, NotificationLook::compact,
|
|
NotificationLook::accent};
|
|
const ToastAnchor anchors[] = {ToastAnchor::top_left, ToastAnchor::top_center,
|
|
ToastAnchor::top_right, ToastAnchor::bottom_left,
|
|
ToastAnchor::bottom_center, ToastAnchor::bottom_right};
|
|
const StatusKind kinds[] = {StatusKind::info, StatusKind::success, StatusKind::warning,
|
|
StatusKind::danger, StatusKind::question, StatusKind::none};
|
|
int round = 0;
|
|
for (const hui::ui::Theme &theme : hui::ui::themes())
|
|
{
|
|
for (const NotificationLook look : looks)
|
|
{
|
|
++round;
|
|
const bool odd = (round & 1) != 0;
|
|
NotificationStack stack;
|
|
stack.style.theme = theme;
|
|
stack.style.look = look;
|
|
stack.style.anchor = anchors[round % 6];
|
|
stack.style.reduced_motion = odd;
|
|
stack.style.frosted = !odd;
|
|
stack.style.time_bar = odd;
|
|
stack.style.stretch_actions = !odd;
|
|
stack.style.newest_first = round % 3 != 0;
|
|
stack.style.fit_bounds = false;
|
|
stack.style.max_visible = 4;
|
|
stack.style.shortcut = odd ? Action::north : Action::count;
|
|
stack.style.dismiss_shortcut = odd ? Action::west : Action::count;
|
|
|
|
Notification wordy =
|
|
note("A title that is far too long for a notification of this width to hold", 5.0f,
|
|
{"A main action with a long label", "Another long label"});
|
|
wordy.kind = kinds[round % 6];
|
|
wordy.source = "A source with a name that does not fit beside the time either";
|
|
wordy.time = "12 minutes ago";
|
|
stack.push(wordy);
|
|
Notification bare = note("Bare");
|
|
bare.source.clear();
|
|
bare.time.clear();
|
|
bare.body.clear();
|
|
bare.kind = kinds[(round + 1) % 6];
|
|
bare.closable = odd;
|
|
stack.push(bare);
|
|
Notification loading = note("Downloading");
|
|
loading.kind = kinds[(round + 2) % 6];
|
|
loading.progress = 0.4f;
|
|
loading.icon = [](hui::ui::Canvas &target, const Rect &box)
|
|
{ target.list.circle(box.cx(), box.cy(), box.w * 0.5f, {1.0f, 1.0f, 1.0f, 1.0f}); };
|
|
const int id = stack.push(loading);
|
|
for (int i = 0; i < 4; ++i)
|
|
stack.push(note("Waiting")); // the "+N more" marker
|
|
|
|
hui::ui::Canvas target = canvas();
|
|
run(stack, 0.05f); // half way in
|
|
stack.draw(target);
|
|
stack.set_active(true);
|
|
run(stack, 0.6f);
|
|
stack.handle(nav(Direction::right), feedback_);
|
|
stack.update_notification(id, note("Installed", -1.0f, {"Restart"}));
|
|
run(stack, 0.1f); // in the cross-fade, the ring in place
|
|
stack.draw(target);
|
|
stack.dismiss(id);
|
|
run(stack, 0.1f); // one leaving
|
|
stack.draw(target);
|
|
expect_drawn(theme.id);
|
|
|
|
NotificationCenter center;
|
|
center.style.theme = theme;
|
|
center.style.reduced_motion = odd;
|
|
center.style.on_panel = !odd;
|
|
center.style.clear_button = round % 4 != 0;
|
|
center.style.relative_time = odd;
|
|
center.set_bounds({1300.0f, 120.0f, 540.0f, 700.0f});
|
|
center.update(kFrame);
|
|
center.draw(target); // the empty state
|
|
center.record(wordy, CloseReason::timed_out);
|
|
center.record(bare, CloseReason::closed);
|
|
center.record(loading, CloseReason::dismissed);
|
|
center.enter();
|
|
center.handle(nav(Direction::down), feedback_);
|
|
for (int i = 0; i < 6; ++i)
|
|
center.update(kFrame);
|
|
center.draw(target);
|
|
|
|
NotificationBell bell;
|
|
bell.style.theme = theme;
|
|
bell.style.reduced_motion = odd;
|
|
bell.style.shape =
|
|
odd ? hui::ui::IconButtonShape::square : hui::ui::IconButtonShape::round;
|
|
bell.style.role = odd ? hui::ui::ButtonRole::ghost : hui::ui::ButtonRole::secondary;
|
|
bell.set_bounds({1700.0f, 40.0f, 64.0f, 64.0f});
|
|
bell.set_count(round);
|
|
bell.set_active(odd);
|
|
for (int i = 0; i < 8; ++i)
|
|
bell.update(kFrame);
|
|
bell.draw(target);
|
|
expect_drawn(theme.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_F(ComponentsNotifications, GalleryPageTourLeavesItAsItWasFound)
|
|
{
|
|
const std::unique_ptr<hui::concepts::gallery::Page> page =
|
|
hui::concepts::gallery::make_notifications_page(context_);
|
|
EXPECT_STREQ(page->title(), "Notifications");
|
|
const std::string first = page->variant();
|
|
const std::size_t hints = page->hints().size();
|
|
const std::string first_hint = page->hints()[2].label;
|
|
int pictures = 0;
|
|
const auto frame = [&](const hui::InputFrame &input)
|
|
{
|
|
feedback_.clear();
|
|
page->update(input, kFrame, feedback_);
|
|
// The stack measures with the fonts of a draw, as it would on screen.
|
|
hui::ui::Canvas target = canvas();
|
|
page->draw(target);
|
|
page->draw_modal(target);
|
|
};
|
|
for (const hui::app::TourStep &step : page->tour())
|
|
{
|
|
for (int i = 0, frames = static_cast<int>(step.wait / kFrame); i < frames; ++i)
|
|
frame(idle());
|
|
if (step.capture != nullptr)
|
|
{
|
|
EXPECT_EQ(std::strncmp(step.capture, "notifications", 13), 0) << step.capture;
|
|
++pictures;
|
|
expect_drawn(step.capture);
|
|
}
|
|
hui::InputFrame input = idle();
|
|
input.pressed = step.press;
|
|
input.held = step.press;
|
|
input.nav = step.nav;
|
|
frame(input);
|
|
}
|
|
EXPECT_GE(pictures, 4);
|
|
EXPECT_LE(pictures, 6);
|
|
EXPECT_GE(page->tour().size(), 10u);
|
|
EXPECT_LE(page->tour().size(), 25u);
|
|
for (int i = 0; i < 120; ++i)
|
|
frame(idle());
|
|
EXPECT_EQ(first, page->variant());
|
|
EXPECT_EQ(page->hints().size(), hints) << "nothing is open";
|
|
EXPECT_EQ(first_hint, page->hints()[2].label) << "the page has the focus";
|
|
// Every look restyles in every theme without losing what is on screen.
|
|
for (const hui::ui::Theme &theme : hui::ui::themes())
|
|
{
|
|
page->restyle(theme, false);
|
|
frame(press(Action::confirm));
|
|
frame(press(Action::west));
|
|
for (int i = 0; i < 20; ++i)
|
|
frame(idle());
|
|
expect_drawn(theme.id);
|
|
}
|
|
}
|
|
|
|
} // namespace
|