feat(io): MusicController + MusicBackend (testable music logic)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 22:50:58 +02:00
parent 3dd27a1b63
commit 977872e77a
3 changed files with 156 additions and 0 deletions

View File

@@ -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();
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}