Files
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

307 lines
10 KiB
C++

// ps5-homebrew-ui - Behaviour tests for the Satchel design.
// Copyright (C) 2026 BlackBearReloaded
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The bag's rules, checked through what it asks to hear. Every rule has its
// own cue (pickup, drop, rotate for a swap, merge, place for a split, connect
// for an equip, reveal for a use, flip for the last use, invalid for a
// refusal), and two of them carry numbers: `merge` is pitched by the size of
// the resulting stack and `connect` by the loadout's attack plus defence.
//
// The bag opens on its first cell. The cells the tests rely on:
//
// 0 Tidewater Sabre 1 Redcap Tonic x5 2 helm 3 Iron Ore x14
// 4 Emberfall Greatsword 5 empty 6 Waybread x8
// 9 Redcap Tonic x3 10 Scalemail 13 Firebloom Flask x1
//
// The character starts with attack 15 and defence 32.
#include "concept_fixture.hpp"
#include "concepts/concepts.hpp"
namespace
{
using hui::Action;
using hui::Direction;
using hui::audio::Cue;
using hui::audio::CueEvent;
class Inventory : public hui::testing::ConceptFixture
{
protected:
// The last send()'s request for a cue, or nullptr.
const CueEvent *heard(Cue cue) const
{
for (const CueEvent &event : last_cues_)
{
if (event.cue == cue)
return &event;
}
return nullptr;
}
// Moves the focus; every step must succeed, with the sound of looking
// (`focus`) or of carrying (`slide`).
void go(Direction direction, int steps = 1)
{
for (int i = 0; i < steps; ++i)
{
send(*design_, nav(direction), 0.1f);
ASSERT_TRUE(played(Cue::focus) || played(Cue::slide)) << "step " << i;
}
}
// Triangle on a stack until it is gone; returns how many it held.
int use_up()
{
for (int uses = 1; uses <= 30; ++uses)
{
send(*design_, press(Action::north), 0.1f);
if (played(Cue::flip))
return uses;
if (!played(Cue::reveal))
return -1;
}
return -1;
}
// What `connect` sounds like for a loadout of this attack plus defence.
static float power_pitch(int power)
{
return 0.8f + static_cast<float>(power) / 250.0f;
}
std::unique_ptr<hui::app::Concept> design_ = hui::concepts::make_inventory(context_);
};
TEST_F(Inventory, PickUpAndPlaceMovesAnItemAndCancelReturnsIt)
{
send(*design_, press(Action::confirm));
EXPECT_TRUE(played(Cue::pickup));
go(Direction::right, 5);
EXPECT_TRUE(played(Cue::slide)); // carrying sounds different from looking
send(*design_, press(Action::confirm));
EXPECT_TRUE(played(Cue::drop));
// The sabre now lives in the sixth cell: it can be lifted from there, and
// cancelling puts it back there, not in the cell it started in.
send(*design_, press(Action::confirm));
EXPECT_TRUE(played(Cue::pickup));
go(Direction::down);
send(*design_, press(Action::back));
EXPECT_TRUE(played(Cue::back));
go(Direction::up);
EXPECT_TRUE(played(Cue::focus));
send(*design_, press(Action::confirm));
EXPECT_TRUE(played(Cue::pickup));
send(*design_, press(Action::back));
// Its first cell is empty: nothing to lift.
go(Direction::left, 5);
send(*design_, press(Action::confirm));
EXPECT_TRUE(played(Cue::invalid));
EXPECT_FALSE(played(Cue::pickup));
expect_drawable(*design_);
}
TEST_F(Inventory, PlacingOnAnotherItemSwapsTheTwo)
{
send(*design_, press(Action::confirm));
go(Direction::right);
send(*design_, press(Action::confirm));
EXPECT_TRUE(played(Cue::rotate));
EXPECT_FALSE(played(Cue::drop));
// The sabre is under the focus now: Triangle equips it (attack 22).
send(*design_, press(Action::north));
const CueEvent *equip = heard(Cue::connect);
ASSERT_NE(equip, nullptr);
EXPECT_NEAR(equip->pitch, power_pitch(22 + 32), 1e-4f);
// ... and the tonics travelled to the first cell: Triangle drinks one.
go(Direction::left);
send(*design_, press(Action::north));
EXPECT_TRUE(played(Cue::reveal));
}
TEST_F(Inventory, MergingStacksSumsTheCounts)
{
go(Direction::right);
send(*design_, press(Action::confirm));
EXPECT_TRUE(played(Cue::pickup));
go(Direction::down);
send(*design_, press(Action::confirm));
const CueEvent *merge = heard(Cue::merge);
ASSERT_NE(merge, nullptr);
// Five and three: the pitch says eight, and eight uses empty the stack.
EXPECT_NEAR(merge->pitch, 0.8f + 0.02f * 8.0f, 1e-4f);
EXPECT_EQ(use_up(), 8);
send(*design_, press(Action::north));
EXPECT_TRUE(played(Cue::invalid));
// The cell the five came from is empty.
go(Direction::up);
send(*design_, press(Action::confirm));
EXPECT_TRUE(played(Cue::invalid));
expect_drawable(*design_);
}
TEST_F(Inventory, SplittingHalvesAStackIntoTheNearestEmptySlot)
{
go(Direction::right, 6);
send(*design_, press(Action::west));
EXPECT_TRUE(played(Cue::place));
// Eight rations became four here and four in the empty cell to the left.
EXPECT_EQ(use_up(), 4);
go(Direction::left);
EXPECT_EQ(use_up(), 4);
// A single item and a stack of one cannot be split.
go(Direction::left);
send(*design_, press(Action::west));
EXPECT_TRUE(played(Cue::invalid));
EXPECT_FALSE(played(Cue::place));
go(Direction::down);
go(Direction::right);
send(*design_, press(Action::west));
EXPECT_TRUE(played(Cue::invalid));
}
TEST_F(Inventory, EquippingChangesTheTotals)
{
// Carry the sabre to the main hand: attack 15 becomes 22.
send(*design_, press(Action::confirm));
go(Direction::left);
send(*design_, press(Action::confirm));
const CueEvent *sabre = heard(Cue::connect);
ASSERT_NE(sabre, nullptr);
EXPECT_NEAR(sabre->pitch, power_pitch(22 + 32), 1e-4f);
// The blade that was worn went to the sabre's cell; Triangle swaps back.
go(Direction::right);
send(*design_, press(Action::north));
const CueEvent *blade = heard(Cue::connect);
ASSERT_NE(blade, nullptr);
EXPECT_NEAR(blade->pitch, power_pitch(15 + 32), 1e-4f);
// The scalemail replaces the gambeson: defence 32 - 8 + 36.
go(Direction::right, 2);
go(Direction::down);
send(*design_, press(Action::north));
const CueEvent *mail = heard(Cue::connect);
ASSERT_NE(mail, nullptr);
EXPECT_NEAR(mail->pitch, power_pitch(15 + 60), 1e-4f);
expect_drawable(*design_);
}
TEST_F(Inventory, AWeaponOnTheHeadSlotIsRefusedAndNothingMoves)
{
send(*design_, press(Action::confirm));
go(Direction::left, 2); // main hand, then across the figure to the head
send(*design_, press(Action::confirm));
EXPECT_TRUE(played(Cue::invalid));
EXPECT_FALSE(played(Cue::connect));
expect_drawable(*design_);
// The sabre is still in the hand and the old blade still worn: equipping
// it now gives exactly the totals of a first equip.
go(Direction::right);
send(*design_, press(Action::confirm));
const CueEvent *equip = heard(Cue::connect);
ASSERT_NE(equip, nullptr);
EXPECT_NEAR(equip->pitch, power_pitch(22 + 32), 1e-4f);
// The hood never left the head: Triangle takes it off into the bag.
go(Direction::left);
send(*design_, press(Action::north));
EXPECT_TRUE(played(Cue::drop));
}
TEST_F(Inventory, UsingAConsumableCountsDownAndTheLastOneRemovesIt)
{
go(Direction::right);
send(*design_, press(Action::north));
EXPECT_TRUE(played(Cue::reveal));
EXPECT_EQ(use_up(), 4); // five tonics, one already gone
// A stack of one goes at once.
go(Direction::down);
go(Direction::right, 4);
send(*design_, press(Action::north));
EXPECT_TRUE(played(Cue::flip));
EXPECT_FALSE(played(Cue::reveal));
send(*design_, press(Action::north));
EXPECT_TRUE(played(Cue::invalid));
// Materials are not for the road.
go(Direction::up);
go(Direction::left, 2);
send(*design_, press(Action::north));
EXPECT_TRUE(played(Cue::invalid));
expect_drawable(*design_);
}
TEST_F(Inventory, CategoryTabsFilterAndTheSortMenuReordersTheBag)
{
// In "All" the second cell holds tonics; in "Weapons" the greatsword.
send(*design_, press(Action::jump_next));
EXPECT_TRUE(played(Cue::tab));
go(Direction::right);
send(*design_, press(Action::north));
const CueEvent *equip = heard(Cue::connect);
ASSERT_NE(equip, nullptr);
EXPECT_NEAR(equip->pitch, power_pitch(34 + 32), 1e-4f);
// Six weapons are shown, packed: the seventh cell is empty.
go(Direction::right, 5);
send(*design_, press(Action::confirm));
EXPECT_TRUE(played(Cue::invalid));
send(*design_, press(Action::jump_prev));
EXPECT_TRUE(played(Cue::tab));
send(*design_, press(Action::jump_prev));
EXPECT_TRUE(played(Cue::error)); // no tab before "All"
send(*design_, press(Action::north));
EXPECT_TRUE(played(Cue::reveal)); // back in "All", that cell holds rations
// Sort by rarity: the menu takes the input, then the bag is reordered.
send(*design_, press(Action::menu));
EXPECT_TRUE(played(Cue::modal_open));
send(*design_, nav(Direction::down));
EXPECT_TRUE(played(Cue::focus));
send(*design_, press(Action::confirm), 1.5f);
EXPECT_TRUE(played(Cue::cascade));
// The scalemail, the only legendary left in the bag, leads it now.
go(Direction::left, 6);
send(*design_, press(Action::north));
const CueEvent *mail = heard(Cue::connect);
ASSERT_NE(mail, nullptr);
EXPECT_NEAR(mail->pitch, power_pitch(34 + 60), 1e-4f);
expect_drawable(*design_);
}
TEST_F(Inventory, EdgesRefuseSoftlyAndAHeldDirectionStaysQuiet)
{
send(*design_, nav(Direction::up));
EXPECT_TRUE(played(Cue::error));
hui::InputFrame held = nav(Direction::up);
held.nav_repeat = true;
send(*design_, held);
EXPECT_TRUE(last_cues_.empty());
// Left from the first column is not an edge: it leads to the equipment.
send(*design_, nav(Direction::left));
EXPECT_TRUE(played(Cue::focus));
send(*design_, nav(Direction::left));
EXPECT_TRUE(played(Cue::focus));
send(*design_, nav(Direction::left));
EXPECT_TRUE(played(Cue::error));
// Sorting with an item in the hand is refused.
send(*design_, press(Action::confirm));
EXPECT_TRUE(played(Cue::pickup));
send(*design_, press(Action::menu));
EXPECT_TRUE(played(Cue::invalid));
EXPECT_FALSE(played(Cue::modal_open));
}
} // namespace