Files
DevvNirvana--afterglow/tests/unit/components_indicators_test.cpp
Tushar Rohilla e5295f36aa Add Afterglow 1.0.6, a PS5 internet radio and music visualizer
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.
2026-10-16 00:59:10 -04:00

579 lines
20 KiB
C++

// ps5-homebrew-ui - Tests: the indicator components (progress, badges, rating, counter, ...).
// Copyright (C) 2026 BlackBearReloaded
// SPDX-License-Identifier: GPL-3.0-or-later
#include "component_fixture.hpp"
#include "ui/components/badge.hpp"
#include "ui/components/counter.hpp"
#include "ui/components/progress.hpp"
#include "ui/components/rating.hpp"
#include "ui/components/skeleton.hpp"
#include "ui/components/stat.hpp"
#include <gtest/gtest.h>
#include <algorithm>
#include <array>
#include <iterator>
#include <vector>
namespace
{
using hui::Action;
using hui::Direction;
using hui::audio::Cue;
using hui::ui::Event;
namespace ui = hui::ui;
class ComponentsIndicators : public hui::testing::ComponentFixture
{
protected:
// Lets a component animate for `seconds`.
template <typename Component> static void run(Component &component, float seconds)
{
const int frames = static_cast<int>(seconds / kFrame);
for (int i = 0; i < frames; ++i)
component.update(kFrame);
}
Event send(ui::Rating &rating, const hui::InputFrame &input)
{
feedback_.clear();
const Event event = rating.handle(input, feedback_);
run(rating, 0.5f);
return event;
}
// The rectangles of the last draw, to compare two poses.
std::vector<float> pose() const
{
std::vector<float> out;
for (const hui::gfx::Instance &instance : list_.instances())
out.insert(out.end(), std::begin(instance.rect), std::end(instance.rect));
return out;
}
};
TEST_F(ComponentsIndicators, ProgressEasesToItsValueAndStaysInRange)
{
ui::ProgressBar bar;
bar.set_value(0.8f);
EXPECT_FLOAT_EQ(bar.value(), 0.8f);
EXPECT_FLOAT_EQ(bar.shown(), 0.0f); // it travels, it does not jump
float highest = 0.0f;
for (int i = 0; i < 240; ++i)
{
bar.update(kFrame);
highest = std::max(highest, bar.shown());
}
EXPECT_NEAR(bar.shown(), 0.8f, 0.001f);
EXPECT_LE(highest, 0.81f);
bar.set_value(7.0f);
EXPECT_FLOAT_EQ(bar.value(), 1.0f);
bar.set_value(-1.0f, true);
EXPECT_FLOAT_EQ(bar.shown(), 0.0f);
ui::ProgressRing ring;
ring.set_value(0.5f, true);
ring.set_buffer(2.0f);
run(ring, 2.0f);
EXPECT_FLOAT_EQ(ring.shown(), 0.5f);
}
TEST_F(ComponentsIndicators, MeterNamesItsZoneAndHoldsThePeak)
{
ui::Meter meter;
EXPECT_EQ(meter.zone(0.2f), ui::Status::success);
EXPECT_EQ(meter.zone(0.7f), ui::Status::warning);
EXPECT_EQ(meter.zone(0.9f), ui::Status::danger);
meter.set_value(0.9f, true);
meter.set_value(0.2f);
run(meter, 0.8f);
EXPECT_NEAR(meter.shown(), 0.2f, 0.02f);
EXPECT_NEAR(meter.peak(), 0.9f, 0.001f); // still held
run(meter, 3.0f);
EXPECT_NEAR(meter.peak(), meter.shown(), 0.001f); // then it fell
meter.style.peak_hold = false;
meter.style.shape = ui::MeterShape::radial;
ui::Canvas target = canvas();
meter.draw(target);
expect_drawn("radial meter");
}
TEST_F(ComponentsIndicators, SpinnerStandsStillWithReducedMotion)
{
for (ui::SpinnerKind kind : {ui::SpinnerKind::arc, ui::SpinnerKind::dots, ui::SpinnerKind::bars,
ui::SpinnerKind::orbit})
{
ui::Spinner spinner;
spinner.style.kind = kind;
spinner.style.reduced_motion = true;
spinner.update(0.1f);
ui::Canvas first = canvas();
spinner.draw(first);
const std::vector<float> before = pose();
spinner.update(0.23f);
ui::Canvas second = canvas();
spinner.draw(second);
EXPECT_EQ(before, pose()) << "a reduced-motion spinner must only pulse";
}
ui::Spinner dots;
dots.style.kind = ui::SpinnerKind::dots;
dots.update(0.1f);
ui::Canvas first = canvas();
dots.draw(first);
const std::vector<float> before = pose();
dots.update(0.23f);
ui::Canvas second = canvas();
dots.draw(second);
EXPECT_NE(before, pose());
dots.set_spinning(false, true);
ui::Canvas third = canvas();
dots.draw(third);
EXPECT_TRUE(list_.empty());
}
TEST_F(ComponentsIndicators, BadgeShowsCountsWordsAndNothing)
{
ui::Badge badge;
EXPECT_FALSE(badge.visible()); // a count of zero hides it
badge.set_count(7);
EXPECT_TRUE(badge.visible());
EXPECT_EQ(badge.text(), "7");
badge.set_count(250);
EXPECT_EQ(badge.text(), "99+");
badge.style.max_count = 999;
EXPECT_EQ(badge.text(), "250");
badge.set_text("NEW");
EXPECT_EQ(badge.text(), "NEW");
badge.set_text("");
badge.set_count(0);
EXPECT_FALSE(badge.visible());
badge.style.hide_zero = false;
EXPECT_TRUE(badge.visible());
// A wider text makes a wider pill; a dot ignores the text.
ui::Painter paint(list_, fonts_, badge.style.theme);
badge.set_count(5);
const float narrow = badge.width(paint);
badge.set_text("UPDATED");
EXPECT_GT(badge.width(paint), narrow);
badge.style.dot = true;
EXPECT_FLOAT_EQ(badge.width(paint), badge.style.dot_size);
}
TEST_F(ComponentsIndicators, AvatarDerivesInitialsAndAStableColour)
{
EXPECT_EQ(ui::Avatar::initials_of("mara voss"), "MV");
EXPECT_EQ(ui::Avatar::initials_of("Ada de Brandt"), "AB");
EXPECT_EQ(ui::Avatar::initials_of("Solo"), "S");
EXPECT_EQ(ui::Avatar::initials_of(" "), "?");
const hui::gfx::Color first = ui::Avatar::color_of("Mara Voss", 0.55f, 0.72f);
const hui::gfx::Color again = ui::Avatar::color_of("Mara Voss", 0.55f, 0.72f);
const hui::gfx::Color other = ui::Avatar::color_of("Tobi Okafor", 0.55f, 0.72f);
EXPECT_FLOAT_EQ(first.r, again.r);
EXPECT_FLOAT_EQ(first.g, again.g);
EXPECT_FLOAT_EQ(first.b, again.b);
EXPECT_TRUE(first.r != other.r || first.g != other.g || first.b != other.b);
ui::AvatarStack stack;
stack.set_bounds({0.0f, 0.0f, 400.0f, 50.0f});
std::vector<ui::AvatarPerson> people;
for (const char *name : {"A B", "C D", "E F", "G H", "I J", "K L", "M N"})
people.push_back({name});
stack.set_people(people);
EXPECT_EQ(stack.hidden(), 3);
// Four faces and the "+3", each overlapping the one before.
EXPECT_FLOAT_EQ(stack.width(), 50.0f + 4.0f * 50.0f * (1.0f - stack.style.overlap));
stack.style.max_shown = 10;
EXPECT_EQ(stack.hidden(), 0);
}
TEST_F(ComponentsIndicators, RatingStepsRefusesAtTheEndsAndClimbsInPitch)
{
ui::Rating rating;
rating.set_value(3.0f, true);
EXPECT_EQ(send(rating, nav(Direction::right)), Event::changed);
EXPECT_FLOAT_EQ(rating.value(), 4.0f);
ASSERT_TRUE(asked(Cue::slider));
const float pitch_at_four = feedback_.cues.front().pitch;
EXPECT_EQ(send(rating, nav(Direction::right)), Event::changed);
EXPECT_GT(feedback_.cues.front().pitch, pitch_at_four);
EXPECT_EQ(send(rating, nav(Direction::right)), Event::refused);
EXPECT_FLOAT_EQ(rating.value(), 5.0f);
EXPECT_TRUE(asked(Cue::error));
hui::InputFrame held = nav(Direction::right);
held.nav_repeat = true;
EXPECT_EQ(send(rating, held), Event::refused);
EXPECT_TRUE(feedback_.cues.empty());
EXPECT_EQ(send(rating, press(Action::confirm)), Event::activated);
EXPECT_TRUE(asked(Cue::select));
EXPECT_EQ(send(rating, press(Action::back)), Event::cancelled);
rating.style.allow_zero = false;
rating.set_value(1.0f, true);
EXPECT_EQ(send(rating, nav(Direction::left)), Event::refused);
EXPECT_FLOAT_EQ(rating.value(), 1.0f);
}
TEST_F(ComponentsIndicators, RatingSnapsFractionsToItsStepAndCanBeDisplayOnly)
{
ui::Rating rating;
rating.set_value(4.3f, true);
EXPECT_EQ(send(rating, nav(Direction::left)), Event::changed);
EXPECT_FLOAT_EQ(rating.value(), 4.0f);
rating.style.step = 0.5f;
rating.set_value(4.3f, true);
EXPECT_EQ(send(rating, nav(Direction::right)), Event::changed);
EXPECT_FLOAT_EQ(rating.value(), 4.5f);
rating.style.interactive = false;
EXPECT_EQ(send(rating, nav(Direction::right)), Event::none);
EXPECT_FLOAT_EQ(rating.value(), 4.5f);
EXPECT_TRUE(feedback_.cues.empty());
rating.set_value(99.0f);
EXPECT_FLOAT_EQ(rating.value(), 5.0f);
}
TEST_F(ComponentsIndicators, CounterFormatsEveryKindOfFigure)
{
ui::Counter counter;
EXPECT_EQ(counter.format(1234567.0), "1,234,567");
EXPECT_EQ(counter.format(-1200.0), "-1,200");
EXPECT_EQ(counter.format(999.0), "999");
counter.style.separator = '\0';
EXPECT_EQ(counter.format(1234567.0), "1234567");
counter.style.separator = ',';
counter.style.format = ui::CounterFormat::decimal;
counter.style.decimals = 2;
EXPECT_EQ(counter.format(1234.5), "1,234.50");
counter.style.format = ui::CounterFormat::percent;
counter.style.decimals = 1;
EXPECT_EQ(counter.format(64.25), "64.3%");
counter.style.decimals = 0;
EXPECT_EQ(counter.format(64.25), "64%");
counter.style.format = ui::CounterFormat::time;
EXPECT_EQ(counter.format(205.0), "03:25");
EXPECT_EQ(counter.format(3665.0), "1:01:05");
}
TEST_F(ComponentsIndicators, CounterCountsToItsTargetWithoutOvershoot)
{
ui::Counter counter;
counter.set_value(100.0, true);
EXPECT_EQ(counter.text(), "100");
counter.set_value(1000.0);
EXPECT_DOUBLE_EQ(counter.value(), 1000.0);
bool between = false;
for (int i = 0; i < 600; ++i)
{
counter.update(kFrame);
EXPECT_LE(counter.shown(), 1000.0);
EXPECT_GE(counter.shown(), 100.0);
between = between || (counter.shown() > 300.0 && counter.shown() < 700.0);
}
EXPECT_TRUE(between); // it passed through the middle
EXPECT_DOUBLE_EQ(counter.shown(), 1000.0);
EXPECT_EQ(counter.text(), "1,000");
// Large numbers keep their last digit (a float spring would not).
counter.set_value(123456789.0);
run(counter, 12.0f);
EXPECT_EQ(counter.text(), "123,456,789");
}
TEST_F(ComponentsIndicators, CounterDigitsShareOneCellWidthAndRoll)
{
ui::Counter counter;
ui::Painter paint(list_, fonts_, counter.style.theme);
counter.set_value(1111.0, true);
const float ones = counter.width(paint);
counter.set_value(8888.0, true);
EXPECT_FLOAT_EQ(counter.width(paint), ones); // tabular: no jitter
counter.set_value(88888.0, true);
EXPECT_GT(counter.width(paint), ones);
// Rolling: half-way between 41 and 42 the wheel still shows 41, and the
// draw holds both digits behind a clip.
counter.style.rolling = true;
counter.set_value(41.0, true);
counter.set_value(42.0);
while (counter.shown() < 41.4)
counter.update(kFrame / 8.0f);
EXPECT_EQ(counter.text(), "41");
ui::Canvas target = canvas();
counter.draw(target);
bool clipped = false;
for (const hui::gfx::Run &entry : list_.runs())
clipped = clipped || entry.clipped;
EXPECT_TRUE(clipped);
run(counter, 4.0f);
EXPECT_EQ(counter.text(), "42");
// Reduced motion: the same figure, no roll.
counter.style.reduced_motion = true;
counter.set_value(43.0);
counter.update(kFrame);
ui::Canvas plain = canvas();
counter.draw(plain);
for (const hui::gfx::Run &entry : list_.runs())
EXPECT_FALSE(entry.clipped);
}
TEST_F(ComponentsIndicators, SkeletonLaysOutBonesAndCrossFadesToContent)
{
ui::Skeleton skeleton;
skeleton.set_bounds({0.0f, 0.0f, 300.0f, 240.0f});
skeleton.style.kind = ui::SkeletonKind::list_row;
skeleton.style.rows = 3;
EXPECT_EQ(skeleton.bones().size(), 9u); // a circle and two lines per row
skeleton.style.rows = 20;
EXPECT_LT(skeleton.bones().size(), 60u); // rows that do not fit are left out
skeleton.style.kind = ui::SkeletonKind::line;
skeleton.style.lines = 4;
const std::vector<ui::Skeleton::Bone> lines = skeleton.bones();
ASSERT_EQ(lines.size(), 4u);
EXPECT_LT(lines.back().rect.w, lines.front().rect.w);
int drawn = 0;
skeleton.content = [&](ui::Canvas &, const hui::gfx::Rect &, float) { ++drawn; };
ui::Canvas before = canvas();
skeleton.draw(before);
EXPECT_EQ(drawn, 0); // nothing of the content while it loads
EXPECT_FLOAT_EQ(skeleton.content_alpha(), 0.0f);
skeleton.set_loaded(true);
run(skeleton, skeleton.style.fade * 0.5f);
EXPECT_GT(skeleton.content_alpha(), 0.05f);
EXPECT_LT(skeleton.content_alpha(), 0.95f);
ui::Canvas during = canvas();
skeleton.draw(during);
EXPECT_EQ(drawn, 1);
run(skeleton, 1.0f);
EXPECT_FLOAT_EQ(skeleton.content_alpha(), 1.0f);
skeleton.set_loaded(false, true);
EXPECT_FLOAT_EQ(skeleton.content_alpha(), 0.0f);
}
TEST_F(ComponentsIndicators, StatTileCountsItsValueInItsFormat)
{
ui::StatTile tile;
tile.label = "Frame time";
tile.style.format = ui::CounterFormat::decimal;
tile.style.decimals = 1;
tile.set_value(16.4, true);
EXPECT_EQ(tile.counter().text(), "16.4");
tile.set_value(14.2);
run(tile, 6.0f);
EXPECT_EQ(tile.counter().text(), "14.2");
tile.set_delta(-3.5f);
EXPECT_FLOAT_EQ(tile.delta(), -3.5f);
// A long figure shrinks to fit instead of leaving the tile.
const std::array<float, 5> series = {1.0f, 3.0f, 2.0f, 5.0f, 4.0f};
tile.set_series(series);
tile.style.format = ui::CounterFormat::integer;
tile.set_bounds({0.0f, 0.0f, 200.0f, 220.0f});
tile.set_value(123456789012.0, true);
ui::Canvas target = canvas();
tile.draw(target);
expect_drawn("stat tile");
for (const hui::gfx::Instance &instance : list_.instances())
EXPECT_LE(instance.rect[0] + instance.rect[2], 200.0f + 40.0f); // panel shadow aside
}
TEST_F(ComponentsIndicators, StateSurvivesRestyling)
{
ui::Rating rating;
rating.set_value(2.0f, true);
ui::Counter counter;
counter.set_value(77.0, true);
ui::Badge badge;
badge.set_count(4);
ui::Skeleton skeleton;
skeleton.set_loaded(true, true);
ui::ProgressBar bar;
bar.set_value(0.4f, true);
const hui::ui::Theme &other = hui::ui::themes()[5];
rating.style.theme = other;
counter.style.theme = other;
badge.style.theme = other;
skeleton.style.theme = other;
bar.style.theme = other;
rating.update(kFrame);
counter.update(kFrame);
badge.update(kFrame);
skeleton.update(kFrame);
bar.update(kFrame);
EXPECT_FLOAT_EQ(rating.value(), 2.0f);
EXPECT_EQ(counter.text(), "77");
EXPECT_EQ(badge.count(), 4);
EXPECT_TRUE(skeleton.loaded());
EXPECT_FLOAT_EQ(bar.shown(), 0.4f);
}
TEST_F(ComponentsIndicators, DrawsInEveryThemeAndVariant)
{
const std::array<float, 6> series = {3.0f, 5.0f, 4.0f, 8.0f, 6.0f, 9.0f};
for (const hui::ui::Theme &theme : hui::ui::themes())
{
for (int variant = 0; variant < 4; ++variant)
{
const bool reduced = variant == 3;
const auto base = [&](ui::ComponentStyle &style)
{
style.theme = theme;
style.reduced_motion = reduced;
};
ui::Canvas target = canvas();
ui::ProgressBar bar;
base(bar.style);
bar.label = "Downloading";
bar.style.mode = static_cast<ui::ProgressMode>(variant);
bar.style.placement = static_cast<ui::LabelPlacement>(variant);
bar.style.track = static_cast<ui::TrackStyle>(variant % 3);
bar.style.radius_source = static_cast<ui::RadiusSource>(variant);
bar.style.height = variant == 2 ? 28.0f : 14.0f;
bar.style.sheen = true;
bar.set_value(0.6f, true);
bar.set_buffer(0.8f, true);
bar.update(0.4f);
bar.draw(target);
ui::ProgressRing ring;
base(ring.style);
ring.label = "Left";
ring.style.mode = static_cast<ui::ProgressMode>(variant);
ring.style.two_tone = variant == 0;
ring.style.ticks = variant * 4;
ring.style.caps = static_cast<ui::RingCaps>(variant % 3);
ring.style.sweep = variant == 2 ? 4.7f : 6.2831853f;
ring.set_value(0.6f, true);
ring.update(0.4f);
ring.draw(target);
ui::Spinner spinner;
base(spinner.style);
spinner.style.kind = static_cast<ui::SpinnerKind>(variant);
spinner.update(0.4f);
spinner.draw(target);
ui::Meter meter;
base(meter.style);
meter.label = "Signal";
meter.style.shape = variant % 2 == 0 ? ui::MeterShape::linear : ui::MeterShape::radial;
meter.style.segments = variant == 2 ? 10 : 0;
meter.set_bounds({0.0f, 0.0f, 200.0f, variant % 2 == 0 ? 60.0f : 200.0f});
meter.set_value(0.9f, true);
meter.set_value(0.5f);
meter.update(0.1f);
meter.draw(target);
ui::Badge badge;
base(badge.style);
badge.style.kind = static_cast<ui::Status>(variant);
badge.style.fill = static_cast<ui::BadgeFill>(variant % 3);
badge.style.dot = variant == 3;
badge.style.pulse = true;
badge.style.cutout = 3.0f;
badge.set_count(12);
badge.update(0.1f);
badge.draw(target);
ui::Chip chip;
base(chip.style);
chip.label = "Co-op";
chip.style.leading_dot = variant % 2 == 0;
chip.style.removable = variant > 1;
chip.set_selected(variant == 1, true);
chip.set_focused(variant == 2);
chip.update(0.1f);
chip.draw(target);
ui::Avatar avatar;
base(avatar.style);
avatar.style.shape = static_cast<ui::AvatarShape>(variant % 2);
avatar.set_name("Mara Voss");
avatar.set_presence(static_cast<ui::Presence>(variant + 1));
avatar.update(0.1f);
avatar.draw(target);
ui::AvatarStack stack;
base(stack.style);
stack.style.shape = static_cast<ui::AvatarShape>(variant % 2);
stack.set_people({{"A B"}, {"C D"}, {"E F"}, {"G H"}, {"I J"}, {"K L"}});
stack.update(0.1f);
stack.draw(target);
ui::Rating rating;
base(rating.style);
rating.style.glyph = static_cast<ui::RatingGlyph>(variant % 3);
rating.style.show_value = true;
rating.set_active(true);
rating.set_value(3.5f, true);
rating.update(0.1f);
rating.draw(target);
ui::Counter counter;
base(counter.style);
counter.style.format = static_cast<ui::CounterFormat>(variant);
counter.style.face = static_cast<ui::CounterFace>(variant % 2);
counter.style.rolling = variant % 2 == 1;
counter.style.prefix = "x";
counter.style.suffix = "pts";
counter.set_value(998.5, true);
counter.set_value(1250.75);
counter.update(0.2f);
counter.draw(target);
ui::Skeleton skeleton;
base(skeleton.style);
skeleton.style.kind = static_cast<ui::SkeletonKind>(variant + 1);
skeleton.style.panel = variant % 2 == 0;
skeleton.update(0.3f);
skeleton.draw(target);
ui::StatTile tile;
base(tile.style);
tile.label = "Players online";
tile.style.rolling = variant == 1;
tile.set_value(12480.0, true);
tile.set_delta(static_cast<float>(variant) - 1.0f);
tile.set_series(series);
tile.update(0.3f);
tile.draw(target);
ui::EmptyState empty;
base(empty.style);
empty.title = "No saves yet";
empty.body = "Finish a chapter and it appears here, with its date and place.";
empty.action = "New game";
empty.style.panel = variant % 2 == 0;
empty.enter();
empty.update(0.3f);
empty.draw(target);
expect_drawn(theme.id);
}
}
}
} // namespace