Files
yellowjacket/backend/player/systemvolume_test.go
logan 867ced8c81 feat(player): leave the volume to the system where the system owns it
On Android the hardware keys are the volume control and the framework
mixes our stream against the device level, so a second control inside
the app moves something the user already moved.  Where that is true the
player's level sits at maximum, SetVolume / ChangeVolume / MuteToggle
are refused, and nothing persists a level nobody chose: restore
remembers the stored value instead of applying it, and saveState writes
that same value back rather than recording the synthetic maximum.

Mute is in that list because it is a level of zero by another name --
and because with no control rendered it would be the one state on such
a platform the user could not get out of.

The predicate is named after the capability rather than the platform,
because that is what makes it testable.  Only platformOwnsVolume is
behind a build tag, in two files that declare nothing else; everything
else is decided against Player.systemVolume, a field a test sets either
way.  That is mediacontrols' split, with androidpayload.go's reasoning
for keeping the contract out of a tagged file, and the tagged pair is
covered by a source sweep since no tier here compiles both halves.

SetDuck is deliberately untouched: it applies its attenuation by
re-applying the *user's* level through setVolumeLocked, so pinning that
level to maximum leaves the offset arithmetic exactly as it was.  It is
the only thing that may still move the output on such a platform, and
TestSystemVolumeStillDucks is that property rather than a comment.
2026-08-21 00:53:21 -04:00

216 lines
5.7 KiB
Go

package player
import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gopxl/beep/v2/effects"
"yellowjacket/backend/database"
)
// pinnedPlayer is a player on a platform whose volume belongs to the
// device. The field is set rather than the build constant read,
// because the constant is true on exactly one platform and no tier
// here runs on it -- see systemvolume.go.
func pinnedPlayer(t *testing.T, db *database.DB) *Player {
t.Helper()
p := NewPlayer(slog.Default(), db)
p.systemVolume = true
p.volume = &effects.Volume{Base: 2}
p.setVolumeLocked(MaxUserVol)
return p
}
// TestSystemVolumeRefusesEveryWayToChangeTheLevel is the first half of
// #64: where the device owns the volume, ours sits at maximum and none
// of the three routes to a level moves it. Mute is in that list
// because it is a level of zero by another name, and because with no
// control rendered it is the one state on such a platform there would
// be nothing to get out of.
func TestSystemVolumeRefusesEveryWayToChangeTheLevel(t *testing.T) {
t.Parallel()
p := pinnedPlayer(t, nil)
if !p.SystemOwnsVolume() {
t.Fatal("SystemOwnsVolume() = false on a pinned player")
}
if got := p.getUserVolume(); got != MaxUserVol {
t.Errorf("starting volume = %d, want %d", got, MaxUserVol)
}
p.SetVolume(20)
if got := p.getUserVolume(); got != MaxUserVol {
t.Errorf("volume after SetVolume(20) = %d, want %d", got, MaxUserVol)
}
if err := p.ChangeVolume(-30); err != nil {
t.Fatalf("ChangeVolume: %v", err)
}
if got := p.getUserVolume(); got != MaxUserVol {
t.Errorf("volume after ChangeVolume(-30) = %d, want %d", got, MaxUserVol)
}
if err := p.MuteToggle(); err != nil {
t.Fatalf("MuteToggle: %v", err)
}
if p.volume.Silent {
t.Error("MuteToggle silenced a player whose volume the system owns")
}
}
// TestAnUnpinnedPlayerStillChangesItsVolume is the other side of the
// same switch. Without it the test above passes on a player that
// refuses everything, which is what a mis-wired field would produce.
func TestAnUnpinnedPlayerStillChangesItsVolume(t *testing.T) {
t.Parallel()
p := NewPlayer(slog.Default(), nil)
p.volume = &effects.Volume{Base: 2}
p.setVolumeLocked(MaxUserVol)
if p.SystemOwnsVolume() {
t.Fatal("SystemOwnsVolume() = true off Android")
}
p.SetVolume(20)
if got := p.getUserVolume(); got != 20 {
t.Errorf("volume after SetVolume(20) = %d, want 20", got)
}
if err := p.MuteToggle(); err != nil {
t.Fatalf("MuteToggle: %v", err)
}
if !p.volume.Silent {
t.Error("MuteToggle did not silence an ordinary player")
}
}
// TestSystemVolumeStillDucks is the issue's second Finding, made a
// test: pinning the user's level must leave the OS's attenuation
// working, because a duck is not a volume the user chose and is the
// only thing that may move the output on such a platform.
func TestSystemVolumeStillDucks(t *testing.T) {
t.Parallel()
p := pinnedPlayer(t, nil)
open := p.volume.Volume
p.SetDuck(true)
if p.volume.Volume >= open {
t.Errorf(
"ducked output = %v, want less than %v", p.volume.Volume, open,
)
}
if got := p.getUserVolume(); got != MaxUserVol {
t.Errorf("user volume while ducked = %d, want %d", got, MaxUserVol)
}
// A refused SetVolume must not disturb the offset either: it
// returns before setVolumeLocked, which is what re-applies it.
ducked := p.volume.Volume
p.SetVolume(10)
if p.volume.Volume != ducked {
t.Errorf(
"output after a refused SetVolume = %v, want %v",
p.volume.Volume, ducked,
)
}
p.SetDuck(false)
if p.volume.Volume != open {
t.Errorf("output after unduck = %v, want %v", p.volume.Volume, open)
}
}
// TestSystemVolumeWritesBackTheLevelItFound is the rest of the
// Direction: "make sure nothing writes a persisted volume from that
// platform". The maximum the player runs at is synthetic, so saving
// must not record it over whatever the row already said.
func TestSystemVolumeWritesBackTheLevelItFound(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
// A level set by some earlier, unpinned session.
writer := NewPlayer(slog.Default(), db)
writer.volume = &effects.Volume{Base: 2}
writer.setVolumeLocked(30)
writer.SaveState()
p := pinnedPlayer(t, db)
p.RestoreState()
if got := p.getUserVolume(); got != MaxUserVol {
t.Errorf("restored volume = %d, want %d (the level is pinned)", got, MaxUserVol)
}
if p.volume.Silent {
t.Error("restore muted a player whose volume the system owns")
}
p.SaveState()
state, err := db.Queries.GetPlayerState(db.Ctx)
if err != nil {
t.Fatalf("GetPlayerState: %v", err)
}
if state.Volume != 30 {
t.Errorf("persisted volume = %d, want 30 (untouched)", state.Volume)
}
}
// TestPlatformVolumeOwnershipIsDeclaredOncePerPlatform sweeps the
// source, because the pair of tagged files is the one thing here no
// tier compiles both halves of: `make lint` and `make test` build the
// `!android` side only, so a deleted or edited android file fails
// nothing until somebody has a phone in their hand.
func TestPlatformVolumeOwnershipIsDeclaredOncePerPlatform(t *testing.T) {
t.Parallel()
want := map[string]string{
"systemvolume_other.go": "const platformOwnsVolume = false",
"systemvolume_android.go": "const platformOwnsVolume = true",
}
tags := map[string]string{
"systemvolume_other.go": "//go:build !android",
"systemvolume_android.go": "//go:build android",
}
for name, decl := range want {
src, err := os.ReadFile(filepath.Join(".", name))
if err != nil {
t.Errorf("%s: %v", name, err)
continue
}
if !strings.Contains(string(src), decl) {
t.Errorf("%s does not declare %q", name, decl)
}
if !strings.Contains(string(src), tags[name]) {
t.Errorf("%s does not carry %q", name, tags[name])
}
}
}