Fixed saved biome previews

- Pregeneration previews wait for saved biome reads on the bounded renderer worker
- Preview colors retain historical definitions through rendering
- Existing chunk previews avoid biome reads while the window is hidden
- Query completion handles cache eviction and capture refresh
- Regression tests cover read failures, interruption, and shutdown ownership
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-06 12:05:19 -04:00
1 parent 5570923d30
commit df717cf151
8 files changed
+617 -44

No files matched your search

@@ -0,0 +1,114 @@
package art.arcane.iris.core.gui;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
import art.arcane.iris.spi.IrisLogging;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.concurrent.ExecutorService;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class PregeneratorJobSavedBiomeTest {
@Test
public void generatedChunkLoadingDoesNotEscapeRendererWork() throws Exception {
Fixture fixture = new Fixture();
doThrow(new SavedBiomeUnavailableException("Saved biome information is loading.", true))
.when(fixture.engine).draw(anyDouble(), anyDouble());
fixture.job.onChunkGenerated(2, -3, false);
assertEquals(1, fixture.tasks.size());
fixture.tasks.removeFirst().run();
verify(fixture.renderer).submit(anyInt(), anyInt(), any());
}
@Test
public void existingChunkPreviewRunsOnTheRendererWorker() throws Exception {
Fixture fixture = new Fixture();
doThrow(new SavedBiomeUnavailableException("Saved biome information is loading.", true))
.when(fixture.engine).draw(anyDouble(), anyDouble());
fixture.job.onChunkExistsInRegionGen(2, -3);
verify(fixture.engine, never()).draw(anyDouble(), anyDouble());
assertEquals(1, fixture.tasks.size());
fixture.tasks.removeFirst().run();
}
@Test
public void existingChunksDoNotResolveBiomesWhileThePreviewIsHidden() throws Exception {
Fixture fixture = new Fixture();
when(fixture.renderer.isVisibleFrame()).thenReturn(false);
fixture.job.onChunkExistsInRegionGen(2, -3);
assertEquals(0, fixture.tasks.size());
verify(fixture.engine, never()).draw(anyDouble(), anyDouble());
}
@Test
public void queuedPreviewSkipsReadsWhenTheFrameBecomesHidden() throws Exception {
Fixture fixture = new Fixture();
fixture.job.onChunkGenerated(2, -3, false);
when(fixture.renderer.isVisibleFrame()).thenReturn(false);
fixture.tasks.removeFirst().run();
verify(fixture.engine, never()).draw(anyDouble(), anyDouble());
}
@Test
public void actualDrawFailureStillReportsWhenShutdownStartsDuringRendering() throws Exception {
Fixture fixture = new Fixture();
IllegalStateException failure = new IllegalStateException("Palette failure");
doAnswer(invocation -> {
when(fixture.engine.isClosing()).thenReturn(true);
throw failure;
}).when(fixture.engine).drawForPreview(anyInt(), anyInt());
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
fixture.job.onChunkGenerated(2, -3, false);
fixture.tasks.removeFirst().run();
logging.verify(() -> IrisLogging.reportError(
"Unable to draw the pregeneration preview for chunk 2,-3.", failure));
}
}
private static final class Fixture {
private final PregeneratorJob job = mock(PregeneratorJob.class, CALLS_REAL_METHODS);
private final Engine engine = mock(Engine.class, CALLS_REAL_METHODS);
private final PregenRenderer renderer = mock(PregenRenderer.class);
private final ArrayList<Runnable> tasks = new ArrayList<>();
private Fixture() throws Exception {
ExecutorService service = mock(ExecutorService.class);
doAnswer(invocation -> {
tasks.add(invocation.getArgument(0));
return null;
}).when(service).execute(any(Runnable.class));
when(renderer.isVisibleFrame()).thenReturn(true);
setField("engine", engine);
setField("renderer", renderer);
setField("service", service);
}
private void setField(String name, Object value) throws Exception {
Field field = PregeneratorJob.class.getDeclaredField(name);
field.setAccessible(true);
field.set(job, value);
}
}
}
@@ -0,0 +1,57 @@
package art.arcane.iris.engine;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.framework.BiomeEnvironment;
import art.arcane.iris.engine.framework.GenerationSessionLease;
import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter;
import art.arcane.iris.engine.history.SavedBiomeRuntime;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisRegion;
import org.junit.Test;
import java.awt.Color;
import java.lang.reflect.Field;
import java.util.Optional;
import java.util.function.Function;
import static org.junit.Assert.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class IrisEnginePreviewTest {
@Test
public void previewRendersThePreparedEnvironmentWhileItIsRetained() throws Exception {
IrisEngine engine = mock(IrisEngine.class);
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
SavedBiomeRuntime biomes = mock(SavedBiomeRuntime.class);
GenerationSessionLease lease = mock(GenerationSessionLease.class);
BiomeEnvironment environment = new BiomeEnvironment(1L, mock(IrisBiome.class), mock(IrisRegion.class),
mock(IrisDimension.class), mock(IrisData.class));
Field field = IrisEngine.class.getDeclaredField("generationHistoryRuntimeRouter");
field.setAccessible(true);
field.set(engine, router);
when(engine.acquireGenerationLease("pregen_preview")).thenReturn(lease);
when(router.biomes()).thenReturn(biomes);
when(biomes.readSurfaceBiome(eq(-24), eq(40), any())).thenAnswer(invocation -> {
Function<Optional<BiomeEnvironment>, Color> reader = invocation.getArgument(2);
Color color = reader.apply(Optional.of(environment));
verify(engine).drawBiomeEnvironment(-24, 40, environment);
return color;
});
when(engine.drawBiomeEnvironment(-24, 40, environment)).thenReturn(Color.RED);
doCallRealMethod().when(engine).drawForPreview(-24, 40);
assertSame(Color.RED, engine.drawForPreview(-24, 40));
verify(lease).close();
verify(engine, never()).getSurfaceBiomeEnvironment(anyInt(), anyInt());
verify(engine, never()).getRegion(anyInt(), anyInt());
}
}
@@ -0,0 +1,49 @@
package art.arcane.iris.engine.framework;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.util.common.data.DataProvider;
import org.junit.Test;
import java.awt.Color;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class EnginePreviewColorTest {
@Test
public void previewUsesOneSurfaceEnvironmentAndItsHistoricalRegionDefinitions() {
Engine engine = mock(Engine.class, CALLS_REAL_METHODS);
IrisData data = mock(IrisData.class);
IrisRegion region = mock(IrisRegion.class);
IrisBiome biome = mock(IrisBiome.class);
BiomeEnvironment environment = new BiomeEnvironment(1L, biome, region, mock(IrisDimension.class), data);
EngineTarget target = mock(EngineTarget.class);
doReturn(environment).when(engine).getSurfaceBiomeEnvironment(-24, 40);
doReturn(target).when(engine).getTarget();
doReturn(100).when(engine).getHeight(-24, 40);
when(target.getHeight()).thenReturn(384);
when(region.getColor(any(), any())).thenAnswer(invocation -> {
DataProvider provider = invocation.getArgument(0);
assertSame(data, provider.getData());
return Color.GREEN;
});
when(biome.getColor(any(), any())).thenReturn(Color.YELLOW);
assertNotNull(engine.draw(-24, 40));
verify(engine).getSurfaceBiomeEnvironment(-24, 40);
verify(engine, never()).getRegion(anyInt(), anyInt());
verify(engine, never()).getSurfaceBiome(anyInt(), anyInt());
}
}
@@ -11,6 +11,8 @@ import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.IOException;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -22,6 +24,9 @@ import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
@@ -30,6 +35,8 @@ import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
public class SavedBiomeRuntimeTest {
@Rule
@@ -217,9 +224,233 @@ public class SavedBiomeRuntimeTest {
Files.delete(pack.resolve("regions/main.json"));
assertSame(environment, runtime.resolve(0, 0, 0, true).orElseThrow());
assertEquals("forest", environment.biome().getLoadKey());
assertSame(environment, runtime.readSurfaceBiome(0, 0, Optional::orElseThrow));
}
}
@Test
public void backgroundPreviewWaitsForTheReadWithoutRepeatingTheQuery() throws Exception {
allowUnownedChunks();
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
when(store.get(0, 0)).thenAnswer(invocation -> {
entered.countDown();
assertTrue(release.await(5L, TimeUnit.SECONDS));
return Optional.empty();
});
try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history);
ExecutorService caller = Executors.newSingleThreadExecutor()) {
Future<Boolean> rendered = caller.submit(() -> runtime.readSurfaceBiome(0, 0, Optional::isEmpty));
try {
assertTrue(entered.await(5L, TimeUnit.SECONDS));
assertThrows(TimeoutException.class, () -> rendered.get(100L, TimeUnit.MILLISECONDS));
} finally {
release.countDown();
}
assertTrue(rendered.get(5L, TimeUnit.SECONDS));
verify(store).get(0, 0);
}
}
@Test
public void backgroundPreviewRetainsItsResultAcrossQueryCacheEviction() throws Exception {
allowUnownedChunks();
try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history);
ExecutorService caller = Executors.newSingleThreadExecutor()) {
Field field = SavedBiomeRuntime.class.getDeclaredField("consumption");
field.setAccessible(true);
ReentrantReadWriteLock consumption = (ReentrantReadWriteLock) field.get(runtime);
consumption.writeLock().lock();
Future<Boolean> rendered = caller.submit(() -> runtime.readSurfaceBiome(0, 0, Optional::isEmpty));
try {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L);
while (runtime.cachedQueryCount() == 0 && System.nanoTime() < deadline) {
Thread.sleep(1L);
}
assertEquals(1, runtime.cachedQueryCount());
for (int chunkX = 1; chunkX <= 130; chunkX++) {
int requestedX = chunkX;
assertTrue(assertThrows(SavedBiomeUnavailableException.class,
() -> runtime.prepareChunk(requestedX, 0)).isLoading());
awaitIdle(runtime);
}
assertThrows(TimeoutException.class, () -> rendered.get(100L, TimeUnit.MILLISECONDS));
} finally {
consumption.writeLock().unlock();
}
assertTrue(rendered.get(5L, TimeUnit.SECONDS));
verify(store).get(0, 0);
}
}
@Test
public void captureRefreshesThePendingPreviewWithoutAddingAnotherSlot() throws Exception {
allowUnownedChunks();
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicInteger readCount = new AtomicInteger();
when(store.get(0, 0)).thenAnswer(invocation -> {
if (readCount.getAndIncrement() == 0) {
entered.countDown();
assertTrue(release.await(5L, TimeUnit.SECONDS));
}
return Optional.empty();
});
try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history);
ExecutorService caller = Executors.newSingleThreadExecutor()) {
Future<Boolean> rendered = caller.submit(() -> runtime.readSurfaceBiome(0, 0, Optional::isEmpty));
try {
assertTrue(entered.await(5L, TimeUnit.SECONDS));
runtime.capture(unresolvedChunk(1));
assertEquals(1, runtime.pendingQueryCount());
} finally {
release.countDown();
}
assertTrue(rendered.get(5L, TimeUnit.SECONDS));
verify(store, times(2)).get(0, 0);
}
}
@Test
public void closeWaitsForBackgroundConsumptionBeforeClosingDefinitions() throws Exception {
allowUnownedChunks();
CountDownLatch consuming = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history);
try (ExecutorService callers = Executors.newFixedThreadPool(2)) {
Future<Boolean> rendered = callers.submit(() -> runtime.readSurfaceBiome(0, 0, environment -> {
consuming.countDown();
try {
assertTrue(release.await(5L, TimeUnit.SECONDS));
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new AssertionError(failure);
}
return environment.isEmpty();
}));
assertTrue(consuming.await(5L, TimeUnit.SECONDS));
Future<?> closing = callers.submit(runtime::close);
try {
assertThrows(TimeoutException.class, () -> closing.get(100L, TimeUnit.MILLISECONDS));
} finally {
release.countDown();
}
assertTrue(rendered.get(5L, TimeUnit.SECONDS));
closing.get(5L, TimeUnit.SECONDS);
assertThrows(IllegalStateException.class, () -> runtime.readSurfaceBiome(0, 0, Optional::isEmpty));
} finally {
release.countDown();
runtime.close();
}
}
@Test
public void backgroundPreviewPreservesActualReadFailure() throws Exception {
IOException failure = new IOException("Saved biome read failed");
when(store.get(0, 0)).thenThrow(failure);
try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history)) {
SavedBiomeUnavailableException unavailable = assertThrows(SavedBiomeUnavailableException.class,
() -> runtime.readSurfaceBiome(0, 0, Optional::isEmpty));
assertEquals(false, unavailable.isLoading());
assertSame(failure, unavailable.getCause());
}
}
@Test
public void closeCompletesWaitingPreviewWithoutConsumingClosedDefinitions() throws Exception {
allowUnownedChunks();
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
when(store.get(0, 0)).thenAnswer(invocation -> {
entered.countDown();
assertTrue(release.await(5L, TimeUnit.SECONDS));
return Optional.empty();
});
SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history);
AtomicInteger consumed = new AtomicInteger();
try (ExecutorService callers = Executors.newFixedThreadPool(2)) {
Future<Integer> rendered = callers.submit(() -> runtime.readSurfaceBiome(0, 0,
environment -> consumed.incrementAndGet()));
assertTrue(entered.await(5L, TimeUnit.SECONDS));
Future<?> closing = callers.submit(runtime::close);
try {
assertThrows(TimeoutException.class, () -> closing.get(100L, TimeUnit.MILLISECONDS));
} finally {
release.countDown();
}
ExecutionException unavailable = assertThrows(ExecutionException.class,
() -> rendered.get(5L, TimeUnit.SECONDS));
assertTrue(unavailable.getCause() instanceof IllegalStateException);
assertEquals(0, consumed.get());
closing.get(5L, TimeUnit.SECONDS);
} finally {
release.countDown();
runtime.close();
}
}
@Test
public void fullReadQueueLeavesThePreviewPendingWithoutAddingWork() throws Exception {
allowUnownedChunks();
CountDownLatch entered = new CountDownLatch(2);
CountDownLatch release = new CountDownLatch(1);
when(store.get(anyInt(), anyInt())).thenAnswer(invocation -> {
entered.countDown();
assertTrue(release.await(5L, TimeUnit.SECONDS));
return Optional.empty();
});
SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history);
try {
for (int chunkX = 0; chunkX < 256; chunkX++) {
int requestedX = chunkX;
assertTrue(assertThrows(SavedBiomeUnavailableException.class,
() -> runtime.prepareChunk(requestedX, 0)).isLoading());
}
assertTrue(entered.await(5L, TimeUnit.SECONDS));
assertEquals(256, runtime.pendingQueryCount());
assertTrue(assertThrows(SavedBiomeUnavailableException.class,
() -> runtime.readSurfaceBiome(256 * 16, 0, Optional::isEmpty)).isLoading());
assertEquals(256, runtime.pendingQueryCount());
} finally {
release.countDown();
runtime.close();
}
}
@Test
public void interruptingPreviewDoesNotCancelTheSharedRead() throws Exception {
allowUnownedChunks();
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
when(store.get(0, 0)).thenAnswer(invocation -> {
entered.countDown();
assertTrue(release.await(5L, TimeUnit.SECONDS));
return Optional.empty();
});
SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history);
try (ExecutorService caller = Executors.newSingleThreadExecutor()) {
Future<Boolean> rendered = caller.submit(() -> runtime.readSurfaceBiome(0, 0, Optional::isEmpty));
try {
assertTrue(entered.await(5L, TimeUnit.SECONDS));
assertTrue(rendered.cancel(true));
assertEquals(1, runtime.pendingQueryCount());
} finally {
release.countDown();
}
assertTrue(runtime.readSurfaceBiome(0, 0, Optional::isEmpty));
verify(store).get(0, 0);
} finally {
release.countDown();
runtime.close();
}
}
private void allowUnownedChunks() throws IOException {
when(store.get(anyInt(), anyInt())).thenReturn(Optional.empty());
when(history.isActiveUnowned(anyInt(), anyInt())).thenReturn(true);
when(history.semantics(anyInt(), anyInt())).thenReturn(Optional.empty());
}
private static void awaitIdle(SavedBiomeRuntime runtime) throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L);
while (runtime.pendingQueryCount() != 0 && System.nanoTime() < deadline) {