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