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.
216 lines
5.7 KiB
Go
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])
|
|
}
|
|
}
|
|
}
|