feat(io): MusicController + MusicBackend (testable music logic)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
package thb.jeanluc.adventure.io;
|
||||
|
||||
/** Raw audio operations — the only part that touches sound hardware. */
|
||||
public interface MusicBackend {
|
||||
|
||||
/** Fade the current track out, then start {@code track} (looping) and fade in to {@code gainDb}. */
|
||||
void fadeTo(String track, float gainDb);
|
||||
|
||||
/** Fade the current track out and stop. */
|
||||
void fadeOut();
|
||||
|
||||
/** Set the playback volume immediately. */
|
||||
void setGainDb(float gainDb);
|
||||
|
||||
/** Stop playback and release resources. */
|
||||
void shutdown();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package thb.jeanluc.adventure.io;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Frontend-agnostic music decision logic. Idempotent per track (never restarts
|
||||
* the same track), fades to silence on a null/blank track, and honours the
|
||||
* {@link MusicLevel} (OFF disables playback). Delegates raw operations to a
|
||||
* {@link MusicBackend}.
|
||||
*/
|
||||
public final class MusicController {
|
||||
|
||||
private final MusicBackend backend;
|
||||
private MusicLevel level = MusicLevel.MEDIUM;
|
||||
private String current; // currently selected track, or null = silence
|
||||
|
||||
public MusicController(MusicBackend backend) {
|
||||
this.backend = backend;
|
||||
}
|
||||
|
||||
/** Called per turn with the current room's music field (may be null/blank). */
|
||||
public void room(String track) {
|
||||
if (!level.isOn()) {
|
||||
return;
|
||||
}
|
||||
String next = (track == null || track.isBlank()) ? null : track;
|
||||
if (Objects.equals(next, current)) {
|
||||
return;
|
||||
}
|
||||
current = next;
|
||||
if (next == null) {
|
||||
backend.fadeOut();
|
||||
} else {
|
||||
backend.fadeTo(next, level.gainDb());
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies a new music level (volume / off). */
|
||||
public void level(MusicLevel newLevel) {
|
||||
this.level = newLevel;
|
||||
if (!newLevel.isOn()) {
|
||||
backend.fadeOut();
|
||||
current = null; // turning back on replays on the next room() call
|
||||
} else {
|
||||
backend.setGainDb(newLevel.gainDb());
|
||||
}
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
backend.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package thb.jeanluc.adventure.io;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class MusicControllerTest {
|
||||
|
||||
/** Records backend calls for assertions. */
|
||||
private static final class FakeBackend implements MusicBackend {
|
||||
final List<String> calls = new ArrayList<>();
|
||||
public void fadeTo(String track, float gainDb) { calls.add("fadeTo:" + track); }
|
||||
public void fadeOut() { calls.add("fadeOut"); }
|
||||
public void setGainDb(float gainDb) { calls.add("setGain:" + gainDb); }
|
||||
public void shutdown() { calls.add("shutdown"); }
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstRoomWithTrackPlaysAtDefaultLevel() {
|
||||
FakeBackend b = new FakeBackend();
|
||||
new MusicController(b).room("a");
|
||||
assertThat(b.calls).containsExactly("fadeTo:a");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameTrackDoesNotRestart() {
|
||||
FakeBackend b = new FakeBackend();
|
||||
MusicController c = new MusicController(b);
|
||||
c.room("a");
|
||||
b.calls.clear();
|
||||
c.room("a");
|
||||
assertThat(b.calls).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentTrackSwitches() {
|
||||
FakeBackend b = new FakeBackend();
|
||||
MusicController c = new MusicController(b);
|
||||
c.room("a");
|
||||
c.room("b");
|
||||
assertThat(b.calls).containsExactly("fadeTo:a", "fadeTo:b");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullOrBlankRoomFadesToSilence() {
|
||||
FakeBackend b = new FakeBackend();
|
||||
MusicController c = new MusicController(b);
|
||||
c.room("a");
|
||||
c.room(null);
|
||||
c.room(" ");
|
||||
assertThat(b.calls).containsExactly("fadeTo:a", "fadeOut");
|
||||
}
|
||||
|
||||
@Test
|
||||
void offStopsAndIgnoresRooms() {
|
||||
FakeBackend b = new FakeBackend();
|
||||
MusicController c = new MusicController(b);
|
||||
c.room("a");
|
||||
c.level(MusicLevel.OFF);
|
||||
b.calls.clear();
|
||||
c.room("b");
|
||||
assertThat(b.calls).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void levelChangeSetsGain() {
|
||||
FakeBackend b = new FakeBackend();
|
||||
MusicController c = new MusicController(b); // default MEDIUM
|
||||
c.level(MusicLevel.HIGH);
|
||||
assertThat(b.calls).containsExactly("setGain:" + MusicLevel.HIGH.gainDb());
|
||||
}
|
||||
|
||||
@Test
|
||||
void turningOffThenOnReplaysOnNextRoom() {
|
||||
FakeBackend b = new FakeBackend();
|
||||
MusicController c = new MusicController(b);
|
||||
c.room("a");
|
||||
c.level(MusicLevel.OFF); // fadeOut
|
||||
c.level(MusicLevel.MEDIUM); // setGain
|
||||
b.calls.clear();
|
||||
c.room("a"); // current was reset → plays again
|
||||
assertThat(b.calls).containsExactly("fadeTo:a");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user