Stabilized shutdown and Studio

- Shutdown protection initializes before enable and drains inline when retention fails
- Cleanup preserves class loader retention and closes native providers in order
- Error reports retain context and handle empty stack traces
- Studio maps retain partial coverage and suspend hidden views
- Hydrology previews refresh when plans and rasters complete
- Terrain scratch hashing spreads lattice samples across cache slots
- Plugin and probe artifacts include LWJGL libraries and native resources
- Regression tests cover shutdown, error reporting, map progress, and terrain scratch
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-26 23:29:21 -04:00
1 parent f64a7c6753
commit 3dc958edcf
31 files changed
+1973 -217

No files matched your search

@@ -70,6 +70,7 @@ import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
@@ -143,6 +144,95 @@ public class HydrologyRuntimeTest {
assertTrue(planners.plannerThreads().stream().noneMatch(name -> name.equals(Thread.currentThread().getName())));
}
@Test(timeout = 180_000L)
public void previewRevisionAdvancesOnlyWhenPlansOrRastersBecomeAvailable() throws Exception {
FakePlanners planners = new FakePlanners(settings(0, false));
ExecutorService pool = pool(planners, 1);
HydrologyRuntime runtime = runtime(planners, pool, () -> false);
CountDownLatch blocker = block(pool);
CompletableFuture<Void> plans = runtime.prepareWindowsAround(0, 0, 0, 0, 1);
assertEquals(0L, runtime.previewRevision());
assertFalse(plans.isDone());
blocker.countDown();
plans.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
long planned = runtime.previewRevision();
assertTrue(planned > 0L);
assertTrue(runtime.planPreview(0, 0, 15, 15).complete());
assertEquals(planned, runtime.previewRevision());
CountDownLatch rasterBlocker = block(pool);
for (int repeat = 0; repeat < 100; repeat++) {
assertTrue(runtime.rasterPreview(0, 0).provisional());
assertEquals(planned, runtime.previewRevision());
}
rasterBlocker.countDown();
await(() -> runtime.previewRevision() == planned + 1L, "raster publication advances preview readiness");
ChunkWaterRaster ready = runtime.rasterPreview(0, 0);
assertFalse(ready.provisional());
for (int repeat = 0; repeat < 100; repeat++) {
assertSame(ready, runtime.rasterPreview(0, 0));
assertSame(ready, runtime.raster(0, 0));
assertEquals(planned + 1L, runtime.previewRevision());
}
assertFalse(runtime.raster(1, 0).provisional());
assertEquals(planned + 2L, runtime.previewRevision());
}
@Test(timeout = 180_000L)
public void concurrentRasterPublicationAdvancesPreviewRevisionOnce() throws Exception {
FakePlanners planners = new FakePlanners(settings(0, false));
ExecutorService pool = pool(planners, 1);
HydrologyRuntime runtime = runtime(planners, pool, () -> false);
runtime.prepareWindowsAround(0, 0, 0, 0, 1).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
long planned = runtime.previewRevision();
CountDownLatch gate = new CountDownLatch(1);
planners.naturalGate = gate;
AtomicReference<Throwable> failure = new AtomicReference<>();
Thread demand = new Thread(() -> {
try {
runtime.raster(0, 0);
} catch (Throwable error) {
failure.set(error);
}
});
demand.start();
try {
await(() -> planners.naturalSamples.get() > 0, "demanded raster starts");
assertTrue(runtime.rasterPreview(0, 0).provisional());
await(() -> planners.naturalSamples.get() >= 2, "preview raster starts beside demand");
assertEquals(planned, runtime.previewRevision());
} finally {
gate.countDown();
demand.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
}
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
assertFalse(demand.isAlive());
assertNull(failure.get());
assertFalse(runtime.rasterPreview(0, 0).provisional());
assertEquals(planned + 1L, runtime.previewRevision());
}
@Test(timeout = 180_000L)
public void failedRasterDoesNotAdvancePreviewRevision() throws Exception {
FakePlanners planners = new FakePlanners(settings(0, false));
ExecutorService pool = pool(planners, 1);
HydrologyPlanCache cache = new HydrologyPlanCache(planners.settings, planners::region, planners::window, pool, () -> false);
HydrologyRuntime runtime = new HydrologyRuntime(planners.settings, cache, (x, z) -> {
throw new IllegalStateException("terrain sampling failed");
}, () -> false, planners.ticker::get);
runtimes.add(runtime);
runtime.prepareWindowsAround(0, 0, 0, 0, 1).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
long planned = runtime.previewRevision();
assertThrows(IllegalStateException.class, () -> runtime.raster(0, 0));
assertEquals(planned, runtime.previewRevision());
}
@Test(timeout = 180_000L)
public void rasterCachedPerChunk() throws Exception {
FakePlanners planners = new FakePlanners(settings());
@@ -868,6 +958,7 @@ public class HydrologyRuntimeTest {
private static void assertDisabled(HydrologyRuntime runtime) {
assertFalse(runtime.enabled());
assertEquals(0L, runtime.previewRevision());
assertSame(ChunkWaterRaster.EMPTY, runtime.raster(0, 0));
assertSame(ChunkWaterRaster.EMPTY, runtime.rasterSnapshot(0, 0));
assertSame(ChunkWaterRaster.EMPTY, runtime.rasterAt(0, 0));
@@ -879,6 +970,7 @@ public class HydrologyRuntimeTest {
assertSame(ChunkWaterRaster.EMPTY, runtime.rasterPreview(0, 0));
assertEquals(HydrologyPlanPreview.EMPTY, runtime.planPreview(0, 0, 511, 511));
runtime.close();
assertEquals(0L, runtime.previewRevision());
}
private static void settle(FakePlanners planners, ExecutorService pool, int windows) throws Exception {
@@ -139,6 +139,73 @@ public class HydrologyPlanCacheTest {
assertEquals(KEY, results[0].plan().key());
}
@Test
public void readyRevisionCountsSuccessfulPlanPublicationsWithoutCountingReads() throws Exception {
FakePlanners planners = new FakePlanners(unpersisted());
ExecutorService pool = pool(1);
HydrologyPlanCache cache = cache(planners, pool, () -> false);
RegionKey region = WindowPlanner.lakeRegions(KEY).getFirst();
Gate gate = new Gate();
planners.hook = key -> gate.pass();
CompletableFuture<RegionPlan> pending = cache.previewRegion(region);
await(() -> gate.arrivals() == 1, "region planning starts before publication");
assertEquals(0L, cache.readyRevision());
assertFalse(pending.isDone());
gate.release();
RegionPlan ready = pending.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
assertEquals(1L, cache.readyRevision());
assertSame(ready, cache.regionIfReady(region).orElseThrow());
assertSame(ready, cache.previewRegion(region).get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
assertSame(ready, cache.region(region));
assertEquals(1L, cache.readyRevision());
cache.previewWindow(KEY).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
long published = WindowPlanner.lakeRegions(KEY).size() + 1L;
assertEquals(published, cache.readyRevision());
assertTrue(cache.windowIfReady(KEY).isPresent());
cache.window(KEY);
assertEquals(published, cache.readyRevision());
}
@Test
public void failedPlansDoNotAdvanceReadyRevision() throws Exception {
FakePlanners planners = new FakePlanners(unpersisted());
ExecutorService pool = pool(1);
HydrologyPlanCache cache = cache(planners, pool, () -> false);
RegionKey region = new RegionKey(0, 0);
planners.hook = key -> {
throw new IllegalStateException("region planning failed");
};
CompletableFuture<RegionPlan> failed = cache.previewRegion(region);
assertThrows(ExecutionException.class, () -> failed.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
assertEquals(0L, cache.readyRevision());
planners.hook = key -> { };
cache.previewRegion(region).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
assertEquals(1L, cache.readyRevision());
}
@Test
public void cancelledPlanCompletionDoesNotAdvanceReadyRevision() throws Exception {
FakePlanners planners = new FakePlanners(unpersisted());
ExecutorService pool = pool(1);
HydrologyPlanCache cache = cache(planners, pool, () -> false);
Gate gate = new Gate();
planners.hook = key -> gate.pass();
CompletableFuture<RegionPlan> pending = cache.previewRegion(new RegionKey(0, 0));
await(() -> gate.arrivals() == 1, "region planning starts before cancellation");
cache.close();
gate.release();
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
assertTrue(pending.isCompletedExceptionally());
assertEquals(0L, cache.readyRevision());
}
@Test
public void interruptedPlanIsNotCached() throws Exception {
FakePlanners planners = new FakePlanners(unpersisted());
@@ -0,0 +1,69 @@
package art.arcane.iris.generation.noise;
import org.junit.Test;
import java.lang.reflect.Field;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class TerrainBatchScratchTest {
@Test
public void integerLatticesDistributeWithoutLongLinearProbeClusters() throws Exception {
TerrainBatchScratch scratch = new TerrainBatchScratch();
for (int origin : new int[]{0, -1536, 1536, 16_777_212}) {
scratch.clearLattice(4096);
for (int index = 0; index < 1024; index++) {
double x = origin + (index & 31) * 6D;
double z = origin + (index >> 5) * 6D;
assertEquals(index, scratch.lattice(x, z));
}
assertEquals(1024, scratch.latticeCount());
for (int index = 1023; index >= 0; index--) {
double x = origin + (index & 31) * 6D;
double z = origin + (index >> 5) * 6D;
assertEquals(index, scratch.lattice(x, z));
assertEquals(x, scratch.latticeX(index), 0D);
assertEquals(z, scratch.latticeZ(index), 0D);
}
assertTrue("clustered lattice at " + origin, longestCluster(scratch) < 32);
}
}
@Test
public void rawCoordinateIdentityAndInsertionOrderSurviveRehashingAndReset() {
TerrainBatchScratch scratch = new TerrainBatchScratch();
long[] bits = {0L, Long.MIN_VALUE, Double.doubleToRawLongBits(1D), Double.doubleToRawLongBits(-1D),
Double.doubleToRawLongBits(0.125D), Double.doubleToRawLongBits(-0.125D),
Double.doubleToRawLongBits(Double.POSITIVE_INFINITY), Double.doubleToRawLongBits(Double.NEGATIVE_INFINITY),
0x7FF8000000000001L, 0x7FF8000000000002L};
for (int pass = 0; pass < 2; pass++) {
scratch.clearLattice(bits.length * bits.length);
for (int x = 0; x < bits.length; x++) {
for (int z = 0; z < bits.length; z++) {
int expected = x * bits.length + z;
double worldX = Double.longBitsToDouble(bits[x]);
double worldZ = Double.longBitsToDouble(bits[z]);
assertEquals(expected, scratch.lattice(worldX, worldZ));
assertEquals(expected, scratch.lattice(worldX, worldZ));
assertEquals(bits[x], Double.doubleToRawLongBits(scratch.latticeX(expected)));
assertEquals(bits[z], Double.doubleToRawLongBits(scratch.latticeZ(expected)));
}
}
assertEquals(bits.length * bits.length, scratch.latticeCount());
}
}
private static int longestCluster(TerrainBatchScratch scratch) throws Exception {
Field field = TerrainBatchScratch.class.getDeclaredField("latticeSlots");
field.setAccessible(true);
int[] slots = (int[]) field.get(scratch);
int longest = 0;
int length = 0;
for (int index = 0; index < slots.length * 2; index++) {
length = slots[index % slots.length] == 0 ? 0 : length + 1;
longest = Math.max(longest, length);
}
return longest;
}
}
@@ -0,0 +1,41 @@
package art.arcane.iris.generation.runtime;
import art.arcane.iris.spi.IrisLogging;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public class IrisEngineFailureReportingTest {
@Test
public void failedGenerationReportsOnceAndRetainsItsCause() throws Exception {
IrisEngine engine = mock(IrisEngine.class);
IllegalStateException failure = new IllegalStateException("Hydrology raster failed");
Field platformHooks = IrisEngine.class.getDeclaredField("platformHooks");
platformHooks.setAccessible(true);
platformHooks.set(engine, mock(EnginePlatformHooks.class));
doThrow(failure).when(engine).openGenerationHistoryCoordinateScope(-352, -144);
doCallRealMethod().when(engine).generate(-352, -144, null, null, false);
doCallRealMethod().when(engine).fail("Failed to generate -352, -144", failure);
doCallRealMethod().when(engine).hasFailed();
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
WrongEngineBroException reported = assertThrows(WrongEngineBroException.class,
() -> engine.generate(-352, -144, null, null, false));
assertSame(failure, reported.getCause());
assertTrue(engine.hasFailed());
logging.verify(() -> IrisLogging.error("Failed to generate -352, -144"));
logging.verify(() -> IrisLogging.reportError(failure));
logging.verifyNoMoreInteractions();
}
}
}
@@ -0,0 +1,54 @@
package art.arcane.iris.platform.bukkit;
import art.arcane.iris.spi.LogLevel;
import org.junit.After;
import org.junit.Test;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class BukkitPlatformErrorReportingTest {
@After
public void releaseHost() {
BukkitPlatform.releaseHost();
}
@Test
public void contextualReportEmitsOneCompleteTraceThroughTheHost() {
ArrayList<LogEntry> messages = new ArrayList<>();
ArrayList<Throwable> reported = new ArrayList<>();
BukkitPlatform.hostBridge(new BukkitPlatform.HostBridge(
(level, message) -> messages.add(new LogEntry(level, message)), null,
failure -> {
reported.add(failure);
failure.printStackTrace(System.err);
}, null, null, null, null, null, null));
IllegalStateException failure = new IllegalStateException("raster failure",
new ArrayIndexOutOfBoundsException("invalid sample"));
ByteArrayOutputStream output = new ByteArrayOutputStream();
PrintStream previousError = System.err;
try (PrintStream errors = new PrintStream(output, true, StandardCharsets.UTF_8)) {
System.setErr(errors);
new BukkitPlatform().reportError("Generation failed", failure);
} finally {
System.setErr(previousError);
}
assertEquals(List.of(new LogEntry(LogLevel.ERROR, "Generation failed")), messages);
assertEquals(List.of(failure), reported);
String trace = output.toString(StandardCharsets.UTF_8);
assertEquals(1L, trace.lines().filter(line -> line.contains("raster failure")).count());
assertEquals(1L, trace.lines().filter(line -> line.contains("Caused by:")).count());
assertTrue(trace.contains("BukkitPlatformErrorReportingTest.contextualReportEmitsOneCompleteTraceThroughTheHost"));
}
private record LogEntry(LogLevel level, String message) {
}
}
@@ -14,7 +14,10 @@ import org.junit.Test;
import java.util.Locale;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -38,13 +41,23 @@ public class BukkitBindingTest {
Fixture fixture = new Fixture();
when(fixture.lifecycle.isServerStopping()).thenReturn(true);
assertTrue(fixture.binding.isServerStopping());
when(fixture.lifecycle.deferPluginClassLoaderClose()).thenReturn(true);
assertTrue(fixture.binding.deferPluginClassLoaderClose());
fixture.binding.initializeLifecycle(getClass().getClassLoader());
verify(fixture.lifecycle).initializeLifecycle(getClass().getClassLoader());
fixture.binding.deferPluginClassLoaderClose();
fixture.binding.releasePluginClassLoaderClose();
verify(fixture.lifecycle).deferPluginClassLoaderClose();
verify(fixture.lifecycle).releasePluginClassLoaderClose();
}
@Test
public void limitedBindingRejectsClassLoaderProtectionAndUsesSynchronousShutdown() {
INMSBinding binding = mock(INMSBinding.class, CALLS_REAL_METHODS);
assertFalse(binding.isServerStopping());
assertThrows(IllegalStateException.class,
() -> binding.initializeLifecycle(getClass().getClassLoader()));
assertThrows(IllegalStateException.class, binding::deferPluginClassLoaderClose);
}
@Test
public void injectAttachesVanillaSpawnersOnlyToOverworldEnvironments() throws NoSuchFieldException, IllegalAccessException {
Fixture fixture = new Fixture();
@@ -71,8 +84,8 @@ public class BukkitBindingTest {
}
private static final class Fixture {
private final NativeTerrainAccess terrain = mock(NativeTerrainAccess.class);
private final NativeWorldGeneration generation = mock(NativeWorldGeneration.class);
private final NativeTerrainAccess terrain = mock(NativeTerrainAccess.class);
private final NativeWorldLifecycleFactory.Controller lifecycle = mock(NativeWorldLifecycleFactory.Controller.class);
private final BukkitBinding binding;
@@ -0,0 +1,65 @@
package art.arcane.iris.platform.bukkit.nms;
import art.arcane.iris.platform.agent.Agent;
import art.arcane.iris.platform.bootstrap.SlimJar;
import art.arcane.iris.testsupport.PlatformBinding;
import org.junit.ClassRule;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.io.IOException;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public class BukkitWorldLifecyclePolicyTest {
@ClassRule
public static final PlatformBinding PLATFORM = PlatformBinding.mockPlatform();
@Test
public void nativeProvidersCloseBeforeThePluginClassLoaderIsReleased() throws Exception {
ClassLoader loader = mock(ClassLoader.class);
try (MockedStatic<SlimJar> providers = mockStatic(SlimJar.class);
MockedStatic<Agent> agent = mockStatic(Agent.class)) {
agent.when(() -> Agent.releaseClassLoader(loader)).thenAnswer(invocation -> {
providers.verify(SlimJar::closeNativeProviders);
return null;
});
BukkitWorldLifecyclePolicy.INSTANCE.releaseClassLoader(loader);
agent.verify(() -> Agent.releaseClassLoader(loader));
}
}
@Test
public void nativeProviderCloseFailureStillReleasesThePluginClassLoader() throws Exception {
ClassLoader loader = mock(ClassLoader.class);
IOException failure = new IOException("provider close failed");
try (MockedStatic<SlimJar> providers = mockStatic(SlimJar.class);
MockedStatic<Agent> agent = mockStatic(Agent.class)) {
providers.when(SlimJar::closeNativeProviders).thenThrow(failure);
IllegalStateException reported = assertThrows(IllegalStateException.class,
() -> BukkitWorldLifecyclePolicy.INSTANCE.releaseClassLoader(loader));
assertSame(failure, reported.getCause());
agent.verify(() -> Agent.releaseClassLoader(loader));
}
}
@Test
public void providerAndLoaderCloseFailuresAreBothPreserved() throws Exception {
ClassLoader loader = mock(ClassLoader.class);
IOException providerFailure = new IOException("provider close failed");
IllegalStateException loaderFailure = new IllegalStateException("loader close failed");
try (MockedStatic<SlimJar> providers = mockStatic(SlimJar.class);
MockedStatic<Agent> agent = mockStatic(Agent.class)) {
providers.when(SlimJar::closeNativeProviders).thenThrow(providerFailure);
agent.when(() -> Agent.releaseClassLoader(loader)).thenThrow(loaderFailure);
IllegalStateException reported = assertThrows(IllegalStateException.class,
() -> BukkitWorldLifecyclePolicy.INSTANCE.releaseClassLoader(loader));
assertSame(loaderFailure, reported);
assertEquals(1, reported.getSuppressed().length);
assertSame(providerFailure, reported.getSuppressed()[0]);
}
}
}
@@ -0,0 +1,224 @@
package art.arcane.iris.studio.map;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.hydrology.engine.HydrologyPlanPreview;
import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime;
import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.stream.ProvisionalSampling;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.studio.map.components.MapLayer;
import art.arcane.iris.studio.map.components.MapStyle;
import art.arcane.iris.studio.map.components.MapTile;
import art.arcane.iris.studio.map.components.MapTileRenderer;
import art.arcane.iris.studio.map.components.PickDisplay;
import art.arcane.iris.studio.map.components.TileType;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
import org.junit.Test;
import java.awt.image.BufferedImage;
import java.util.concurrent.CancellationException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class MapTileProgressTest {
@Test
public void retriesOnlySampleUnresolvedHeightPointsAndReuseBiomeData() {
AtomicBoolean pending = new AtomicBoolean(true);
AtomicInteger heightSamples = new AtomicInteger();
AtomicInteger biomeSamples = new AtomicInteger();
Engine engine = engine(biomeSamples);
when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> {
heightSamples.incrementAndGet();
if (x == 0D && z == 0D && pending.get()) {
ProvisionalSampling.mark();
return 60D;
}
return 80D + x * 0.02D + z * 0.01D;
}));
MapTileRenderer renderer = new MapTileRenderer(engine);
MapLayer layer = MapLayer.biomes(PickDisplay.PICKS, false);
MapTileRenderer.TileRender progress = renderer.prepareTile(-8D, -8D, 0.5D, 16, layer, true);
progress.render(() -> false);
int initialHeights = heightSamples.get();
int initialBiomes = biomeSamples.get();
assertFalse(progress.complete());
assertTrue(progress.bytes() > 0L);
progress.render(() -> false);
assertEquals(initialHeights + 1, heightSamples.get());
assertEquals(initialBiomes, biomeSamples.get());
pending.set(false);
MapTile settled = progress.render(() -> false);
assertTrue(progress.complete());
assertEquals(initialHeights + 2, heightSamples.get());
assertEquals(initialBiomes, biomeSamples.get());
assertEquals(0L, progress.bytes());
progress.render(() -> false);
assertEquals(initialHeights + 2, heightSamples.get());
assertEquals(initialBiomes, biomeSamples.get());
MapTile expected = renderer.renderTile(-8D, -8D, 0.5D, 16, layer, true, () -> false);
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
assertEquals(expected.heightAt(x, z), settled.heightAt(x, z), 0D);
}
}
assertArrayEquals(pixels(expected.image(MapStyle.PLAIN.withShading(true))),
pixels(settled.image(MapStyle.PLAIN.withShading(true))));
}
@Test
public void completedWaterIsNotBlendedAgainWhileHeightsWait() {
AtomicBoolean pending = new AtomicBoolean(true);
Engine engine = engine(new AtomicInteger());
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
ChunkWaterRaster raster = mock(ChunkWaterRaster.class);
when(runtime.enabled()).thenReturn(true);
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY);
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(raster);
when(raster.role(anyInt(), anyInt())).thenReturn(WaterRole.POND);
when(engine.getComplex().getHydrologyRuntime()).thenReturn(runtime);
when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> {
if (pending.get()) {
ProvisionalSampling.mark();
}
return 60D;
}));
MapTileRenderer renderer = new MapTileRenderer(engine);
MapTileRenderer.TileRender progress = renderer.prepareTile(0D, 0D, 1D, 16, MapLayer.of(TileType.BIOME), true);
MapTile first = progress.render(() -> false);
assertFalse(progress.complete());
assertNotEquals(0xFF738249, first.image(MapStyle.PLAIN).getRGB(0, 0));
MapTile second = progress.render(() -> false);
assertArrayEquals(pixels(first.image(MapStyle.PLAIN)), pixels(second.image(MapStyle.PLAIN)));
pending.set(false);
MapTile complete = progress.render(() -> false);
assertTrue(progress.complete());
assertEquals(0L, progress.bytes());
assertArrayEquals(pixels(first.image(MapStyle.PLAIN)), pixels(complete.image(MapStyle.PLAIN)));
}
@Test
public void twoBlockPixelsRefineMixedWaterAndHeightsToTheSameFinalTile() {
AtomicBoolean pending = new AtomicBoolean(true);
Engine engine = engine(new AtomicInteger());
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
ChunkWaterRaster raster = mock(ChunkWaterRaster.class);
when(runtime.enabled()).thenReturn(true);
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY);
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(raster);
when(runtime.rasterPreview(0, 0)).thenAnswer(invocation -> pending.get() ? ChunkWaterRaster.PROVISIONAL : raster);
when(raster.role(anyInt(), anyInt())).thenAnswer(invocation -> {
int x = invocation.getArgument(0);
int z = invocation.getArgument(1);
if (x == 1 && z < 4) {
return WaterRole.POND;
}
if (x == 7 && z < 8) {
return WaterRole.WATERFALL;
}
return x < 4 && z == 9 ? WaterRole.INLET : WaterRole.NONE;
});
when(engine.getComplex().getHydrologyRuntime()).thenReturn(runtime);
when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> {
if (x == 0D && z == 0D && pending.get()) {
ProvisionalSampling.mark();
return 60D;
}
return 80D + x * 0.02D + z * 0.01D;
}));
MapTileRenderer renderer = new MapTileRenderer(engine);
MapLayer layer = MapLayer.of(TileType.BIOME);
MapTileRenderer.TileRender progress = renderer.prepareTile(-16D, -16D, 2D, 16, layer, true);
MapTile first = progress.render(() -> false);
int[] firstPixels = pixels(first.image(MapStyle.PLAIN.withShading(true)));
assertFalse(progress.complete());
assertEquals(0xFF738249, first.image(MapStyle.PLAIN).getRGB(8, 8));
MapTile waiting = progress.render(() -> false);
assertFalse(progress.complete());
assertArrayEquals(firstPixels, pixels(waiting.image(MapStyle.PLAIN.withShading(true))));
pending.set(false);
MapTile actual = progress.render(() -> false);
assertTrue(progress.complete());
assertEquals(0L, progress.bytes());
BufferedImage colors = actual.image(MapStyle.PLAIN);
assertEquals(0xFF738249, colors.getRGB(14, 14));
assertNotEquals(0xFF738249, colors.getRGB(8, 8));
assertNotEquals(colors.getRGB(8, 8), colors.getRGB(11, 8));
assertNotEquals(colors.getRGB(8, 8), colors.getRGB(8, 12));
assertNotEquals(colors.getRGB(11, 8), colors.getRGB(8, 12));
assertArrayEquals(firstPixels, pixels(first.image(MapStyle.PLAIN.withShading(true))));
MapTile expected = renderer.renderTile(-16D, -16D, 2D, 16, layer, true, () -> false);
assertArrayEquals(pixels(expected.image(MapStyle.PLAIN.withShading(true))),
pixels(actual.image(MapStyle.PLAIN.withShading(true))));
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
assertEquals(expected.heightAt(x, z), actual.heightAt(x, z), 0D);
assertEquals(expected.subjectAt(x, z), actual.subjectAt(x, z));
assertEquals(expected.regionAt(x, z), actual.regionAt(x, z));
}
}
}
@Test
public void cancelledPreparationCanResumeWithoutReusingIncompleteHeightSamples() {
Engine engine = engine(new AtomicInteger());
AtomicInteger samples = new AtomicInteger();
when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> {
samples.incrementAndGet();
return x + z + 80D;
}));
MapTileRenderer renderer = new MapTileRenderer(engine);
MapLayer layer = MapLayer.biomes(PickDisplay.PICKS, false);
MapTileRenderer.TileRender progress = renderer.prepareTile(-16D, -16D, 1D, 16, layer, true);
assertThrows(CancellationException.class, () -> progress.render(() -> samples.get() >= 9));
assertFalse(progress.complete());
MapTile actual = progress.render(() -> false);
assertTrue(progress.complete());
assertEquals(81, samples.get());
MapTile expected = renderer.renderTile(-16D, -16D, 1D, 16, layer, true, () -> false);
assertArrayEquals(pixels(expected.image(MapStyle.PLAIN.withShading(true))),
pixels(actual.image(MapStyle.PLAIN.withShading(true))));
}
private static Engine engine(AtomicInteger biomeSamples) {
Engine engine = mock(Engine.class);
IrisComplex complex = mock(IrisComplex.class);
IrisBiome biome = new IrisBiome();
biome.setColor("#738249");
IrisRegion region = new IrisRegion();
when(engine.getComplex()).thenReturn(complex);
when(engine.getHeight()).thenReturn(256);
when(complex.getBaseBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> {
biomeSamples.incrementAndGet();
return biome;
}, Interpolated.of(value -> 0D, value -> biome)));
when(complex.getRegionStream()).thenReturn(ProceduralStream.of((x, z) -> region,
Interpolated.of(value -> 0D, value -> region)));
return engine;
}
private static int[] pixels(BufferedImage image) {
return image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth());
}
}
@@ -1,20 +1,23 @@
package art.arcane.iris.studio.map;
import art.arcane.iris.generation.stream.ProvisionalSampling;
import art.arcane.iris.studio.map.components.MapLayer;
import art.arcane.iris.studio.map.components.MapTile;
import art.arcane.iris.studio.map.components.MapTileRenderer;
import art.arcane.iris.studio.map.components.TileType;
import org.junit.Test;
import java.awt.EventQueue;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BooleanSupplier;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -22,21 +25,23 @@ import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class VisionRenderControllerTest {
@Test
public void provisionalTilesRetryWithBackoffAndConvergeWithoutMoving() throws Exception {
MapTileRenderer renderer = mock(MapTileRenderer.class);
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
MapTileRenderer renderer = renderer(work);
MapTile tile = mock(MapTile.class);
AtomicInteger attempts = new AtomicInteger();
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any()))
when(work.render(any()))
.thenAnswer(invocation -> {
if (attempts.incrementAndGet() < 3) {
ProvisionalSampling.mark();
}
attempts.incrementAndGet();
return tile;
});
when(work.complete()).thenAnswer(invocation -> attempts.get() >= 3);
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame frame = controller.request(spec(renderer, 1L));
await(() -> frame.tiles().getFirst().tile() != null);
@@ -55,30 +60,47 @@ public class VisionRenderControllerTest {
});
assertEquals(3, attempts.get());
assertSame(tile, controller.request(spec(renderer, 1L)).tiles().getFirst().tile());
verify(renderer).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
}
}
@Test
public void provisionalTilesAreShownButNeverReusedAsFinal() throws Exception {
MapTileRenderer renderer = mock(MapTileRenderer.class);
public void resizeZoomAndSuspendRetainProvisionalImageWorkAndBackoff() throws Exception {
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
MapTileRenderer renderer = renderer(work);
MapTile incomplete = mock(MapTile.class);
MapTile complete = mock(MapTile.class);
AtomicInteger attempts = new AtomicInteger();
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any()))
when(work.render(any()))
.thenAnswer(invocation -> {
if (attempts.incrementAndGet() == 1) {
ProvisionalSampling.mark();
return incomplete;
}
return complete;
});
when(work.complete()).thenAnswer(invocation -> attempts.get() >= 2);
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame first = controller.request(spec(renderer, 1L));
await(() -> first.tiles().getFirst().tile() != null);
await(() -> first.tiles().getFirst().tile() != null && controller.progress(first).activeWorkers() == 0);
assertSame(incomplete, first.tiles().getFirst().tile());
VisionRenderController.Frame resized = controller.request(new VisionRenderController.RenderSpec(renderer,
MapLayer.of(TileType.RIVER), 1L, 32D, 32D, 1D, 48, 48, false));
assertSame(incomplete, resized.tiles().getFirst().tile());
assertTrue(resized.tiles().getFirst().provisional());
VisionRenderController.Frame zoomed = controller.request(new VisionRenderController.RenderSpec(renderer,
MapLayer.of(TileType.RIVER), 1L, 32D, 32D, 1.15D, 48, 48, false));
assertSame(incomplete, zoomed.tiles().getFirst().tile());
controller.suspend();
VisionRenderController.Frame second = controller.request(spec(renderer, 1L));
await(() -> second.tiles().getFirst().tile() == complete);
assertSame(incomplete, second.tiles().getFirst().tile());
assertEquals(0, controller.progress(second).ready());
assertEquals(1, attempts.get());
await(() -> {
controller.retryProvisional(second);
return controller.progress(second).ready() == 1;
});
assertEquals(2, attempts.get());
verify(renderer).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
VisionRenderController.Frame third = controller.request(spec(renderer, 1L));
assertSame(complete, third.tiles().getFirst().tile());
assertEquals(2, attempts.get());
@@ -87,10 +109,11 @@ public class VisionRenderControllerTest {
@Test
public void contentRevisionInvalidatesFinalTiles() throws Exception {
MapTileRenderer renderer = mock(MapTileRenderer.class);
MapTile tile = mock(MapTile.class);
MapTileRenderer.TileRender work = finished(tile);
MapTileRenderer renderer = renderer(work);
AtomicInteger attempts = new AtomicInteger();
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any()))
when(work.render(any()))
.thenAnswer(invocation -> {
attempts.incrementAndGet();
return tile;
@@ -109,14 +132,16 @@ public class VisionRenderControllerTest {
MapTileRenderer renderer = mock(MapTileRenderer.class);
MapTile overview = mock(MapTile.class);
MapTile incomplete = mock(MapTile.class);
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any()))
MapTileRenderer.TileRender overviewWork = finished(overview);
MapTileRenderer.TileRender detailWork = mock(MapTileRenderer.TileRender.class);
when(detailWork.render(any())).thenReturn(incomplete);
when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()))
.thenAnswer(invocation -> {
double scale = invocation.getArgument(2);
if (scale < 4D) {
ProvisionalSampling.mark();
return incomplete;
return detailWork;
}
return overview;
return overviewWork;
});
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame coarse = controller.request(new VisionRenderController.RenderSpec(renderer,
@@ -142,10 +167,11 @@ public class VisionRenderControllerTest {
@Test
public void closeRejectsLatePublication() throws Exception {
MapTileRenderer renderer = mock(MapTileRenderer.class);
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
MapTileRenderer renderer = renderer(work);
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any()))
when(work.render(any()))
.thenAnswer(invocation -> {
entered.countDown();
while (release.getCount() != 0L) {
@@ -167,7 +193,261 @@ public class VisionRenderControllerTest {
controller.close();
}
await(() -> controller.progress(frame).activeWorkers() == 0);
assertEquals(null, frame.tiles().getFirst().tile());
assertNull(frame.tiles().getFirst().tile());
}
@Test
public void resizeAndSameLevelZoomReuseFinalTiles() throws Exception {
MapTile tile = mock(MapTile.class);
MapTileRenderer.TileRender work = finished(tile);
MapTileRenderer renderer = renderer(work);
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame first = controller.request(new VisionRenderController.RenderSpec(renderer,
MapLayer.of(TileType.RIVER), 1L, 32D, 32D, 1D, 48, 48, false));
await(() -> controller.progress(first).ready() == 1);
VisionRenderController.Frame resized = controller.request(spec(renderer, 1L));
assertSame(tile, resized.tiles().getFirst().tile());
VisionRenderController.Frame zoomed = controller.request(new VisionRenderController.RenderSpec(renderer,
MapLayer.of(TileType.RIVER), 1L, 32D, 32D, 0.9D, 64, 64, false));
assertSame(tile, zoomed.tiles().getFirst().tile());
assertEquals(1, controller.progress(zoomed).ready());
verify(work).render(any());
verify(renderer).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
}
}
@Test
public void contentRevisionInvalidatesProvisionalWork() throws Exception {
MapTile tile = mock(MapTile.class);
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
MapTileRenderer renderer = renderer(work);
when(work.render(any())).thenReturn(tile);
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame first = controller.request(spec(renderer, 1L));
await(() -> first.tiles().getFirst().tile() != null);
VisionRenderController.Frame second = controller.request(spec(renderer, 2L));
await(() -> second.tiles().getFirst().tile() != null);
verify(renderer, times(2)).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
}
}
@Test
public void missingDetailUsesProvisionalOverviewWithoutMarkingItFinal() throws Exception {
MapTileRenderer renderer = mock(MapTileRenderer.class);
MapTile overview = mock(MapTile.class);
MapTileRenderer.TileRender overviewWork = mock(MapTileRenderer.TileRender.class);
when(overviewWork.render(any())).thenReturn(overview);
CountDownLatch entered = new CountDownLatch(1);
MapTileRenderer.TileRender detailWork = cancellable(entered);
when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()))
.thenAnswer(invocation -> (double) invocation.getArgument(2) < 4D ? detailWork : overviewWork);
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame coarse = controller.request(new VisionRenderController.RenderSpec(renderer,
MapLayer.of(TileType.RIVER), 1L, 128D, 128D, 4D, 64, 64, false));
await(() -> coarse.tiles().getFirst().tile() != null);
VisionRenderController.Frame fine = controller.request(spec(renderer, 1L));
assertTrue(entered.await(5L, TimeUnit.SECONDS));
assertNull(fine.tiles().getFirst().tile());
VisionRenderController.Fallback fallback = controller.fallback(fine, fine.tiles().getFirst());
assertNotNull(fallback);
assertSame(overview, fallback.pieces().getFirst().tile());
assertEquals(0, controller.progress(fine).ready());
}
}
@Test
public void zoomingOutKeepsCompleteFinerTilesOverProvisionalExactTile() throws Exception {
MapTileRenderer renderer = mock(MapTileRenderer.class);
MapTile fineTile = mock(MapTile.class);
MapTile coarseTile = mock(MapTile.class);
MapTileRenderer.TileRender fineWork = finished(fineTile);
MapTileRenderer.TileRender coarseWork = mock(MapTileRenderer.TileRender.class);
when(coarseWork.render(any())).thenReturn(coarseTile);
when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()))
.thenAnswer(invocation -> (double) invocation.getArgument(2) < 2D ? fineWork : coarseWork);
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame fine = controller.request(spec(renderer, 1L));
await(() -> controller.progress(fine).ready() == 1);
VisionRenderController.Frame coarse = controller.request(new VisionRenderController.RenderSpec(renderer,
MapLayer.of(TileType.RIVER), 1L, 64D, 64D, 2D, 64, 64, false));
await(() -> coarse.tiles().getFirst().tile() != null);
VisionRenderController.Fallback fallback = controller.fallback(coarse, coarse.tiles().getFirst());
assertNotNull(fallback);
assertSame(fineTile, fallback.pieces().getFirst().tile());
assertEquals(0.5D, fallback.pieces().getFirst().size(), 0D);
}
}
@Test
public void suspendedViewCancelsSamplingAndProbeButKeepsCompletedTiles() throws Exception {
MapTile tile = mock(MapTile.class);
MapTileRenderer renderer = mock(MapTileRenderer.class);
CountDownLatch sampling = new CountDownLatch(1);
MapTileRenderer.TileRender pending = cancellable(sampling);
MapTileRenderer.TileRender complete = finished(tile);
when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()))
.thenReturn(complete, pending);
CountDownLatch probing = new CountDownLatch(1);
CountDownLatch interrupted = new CountDownLatch(1);
AtomicInteger published = new AtomicInteger();
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame first = controller.request(spec(renderer, 1L));
await(() -> controller.progress(first).ready() == 1);
VisionRenderController.Frame next = controller.request(new VisionRenderController.RenderSpec(renderer,
MapLayer.of(TileType.RIVER), 1L, 96D, 32D, 1D, 64, 64, false));
assertTrue(sampling.await(5L, TimeUnit.SECONDS));
controller.submitProbe(next, () -> {
probing.countDown();
try {
new CountDownLatch(1).await();
return "late";
} catch (InterruptedException error) {
interrupted.countDown();
throw error;
}
}, value -> published.incrementAndGet());
assertTrue(probing.await(5L, TimeUnit.SECONDS));
controller.suspend();
assertTrue(interrupted.await(5L, TimeUnit.SECONDS));
await(() -> controller.progress(next).activeWorkers() == 0);
EventQueue.invokeAndWait(() -> { });
assertEquals(0, published.get());
assertNull(next.tiles().getFirst().tile());
VisionRenderController.Frame resumed = controller.request(spec(renderer, 1L));
assertSame(tile, resumed.tiles().getFirst().tile());
verify(renderer, times(2)).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
}
}
@Test
public void retainedWorkCountsAgainstCacheBudgetAndIsReleasedWhenComplete() throws Exception {
MapTile tile = mock(MapTile.class);
when(tile.bytes()).thenReturn(1L);
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
MapTileRenderer renderer = renderer(work);
AtomicInteger attempts = new AtomicInteger();
when(work.render(any())).thenAnswer(invocation -> {
attempts.incrementAndGet();
return tile;
});
when(work.complete()).thenAnswer(invocation -> attempts.get() >= 2);
when(work.bytes()).thenReturn(256L * 1024L * 1024L);
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame first = controller.request(spec(renderer, 1L));
await(() -> first.tiles().getFirst().tile() != null);
VisionRenderController.Frame second = controller.request(spec(renderer, 1L));
await(() -> controller.progress(second).ready() == 1);
VisionRenderController.Frame third = controller.request(spec(renderer, 1L));
assertSame(tile, third.tiles().getFirst().tile());
verify(renderer, times(2)).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
assertEquals(2, attempts.get());
}
}
@Test
public void cancelledRetryReleasesWorkAndKeepsPublishedImage() throws Exception {
MapTile incomplete = mock(MapTile.class);
MapTile complete = mock(MapTile.class);
CountDownLatch entered = new CountDownLatch(1);
MapTileRenderer.TileRender partial = mock(MapTileRenderer.TileRender.class);
MapTileRenderer.TileRender replacement = finished(complete);
AtomicInteger attempts = new AtomicInteger();
when(partial.render(any())).thenAnswer(invocation -> {
if (attempts.incrementAndGet() == 1) {
return incomplete;
}
BooleanSupplier cancelled = invocation.getArgument(0);
entered.countDown();
await(cancelled);
throw new CancellationException();
});
MapTileRenderer renderer = mock(MapTileRenderer.class);
when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()))
.thenReturn(partial, replacement);
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame first = controller.request(spec(renderer, 1L));
await(() -> first.tiles().getFirst().tile() != null);
await(() -> {
controller.retryProvisional(first);
return entered.getCount() == 0L;
});
controller.suspend();
await(() -> controller.progress(first).activeWorkers() == 0);
assertSame(incomplete, first.tiles().getFirst().tile());
VisionRenderController.Frame resumed = controller.request(spec(renderer, 1L));
await(() -> controller.progress(resumed).ready() == 1);
assertSame(complete, resumed.tiles().getFirst().tile());
verify(renderer, times(2)).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
}
}
@Test
public void previewReadinessWakesBackoffAtBoundedCadenceIncludingCompletionDuringRender() throws Exception {
MapTile tile = mock(MapTile.class);
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
MapTileRenderer renderer = renderer(work);
AtomicLong time = new AtomicLong();
AtomicLong readiness = new AtomicLong();
AtomicInteger attempts = new AtomicInteger();
when(renderer.previewRevision()).thenAnswer(invocation -> readiness.get());
when(work.render(any())).thenAnswer(invocation -> {
if (attempts.incrementAndGet() == 3) {
readiness.incrementAndGet();
}
return tile;
});
when(work.complete()).thenAnswer(invocation -> attempts.get() >= 4);
try (VisionRenderController controller = new VisionRenderController(() -> { }, time::get)) {
VisionRenderController.Frame frame = controller.request(spec(renderer, 1L));
await(() -> attempts.get() == 1 && controller.progress(frame).activeWorkers() == 0);
time.set(VisionRenderController.FIRST_RETRY_NANOS);
controller.retryProvisional(frame);
await(() -> attempts.get() == 2 && controller.progress(frame).activeWorkers() == 0);
time.addAndGet(VisionRenderController.FIRST_RETRY_NANOS);
controller.retryProvisional(frame);
assertEquals(2, attempts.get());
readiness.incrementAndGet();
controller.retryProvisional(frame);
await(() -> attempts.get() == 3 && controller.progress(frame).activeWorkers() == 0);
time.addAndGet(VisionRenderController.FIRST_RETRY_NANOS - 1L);
controller.retryProvisional(frame);
assertEquals(3, attempts.get());
time.incrementAndGet();
controller.retryProvisional(frame);
await(() -> controller.progress(frame).ready() == 1);
assertEquals(4, attempts.get());
verify(renderer).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
}
}
private static MapTileRenderer.TileRender cancellable(CountDownLatch entered) {
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
when(work.render(any())).thenAnswer(invocation -> {
BooleanSupplier cancelled = invocation.getArgument(0);
entered.countDown();
while (!cancelled.getAsBoolean() && !Thread.currentThread().isInterrupted()) {
try {
Thread.sleep(1L);
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
}
}
throw new CancellationException();
});
return work;
}
private static MapTileRenderer renderer(MapTileRenderer.TileRender work) {
MapTileRenderer renderer = mock(MapTileRenderer.class);
when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean())).thenReturn(work);
return renderer;
}
private static MapTileRenderer.TileRender finished(MapTile tile) {
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
when(work.render(any())).thenReturn(tile);
when(work.complete()).thenReturn(true);
return work;
}
private static VisionRenderController.RenderSpec spec(MapTileRenderer renderer, long revision) {
@@ -0,0 +1,100 @@
package art.arcane.iris.studio.render;
import art.arcane.iris.generation.hydrology.engine.HydrologyPlanPreview;
import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.hydrology.water.Reach;
import org.junit.Test;
import java.util.concurrent.CancellationException;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class WaterMapProgressTest {
@Test
public void completeDetailedRastersDoNotDrawPlanFallbacks() {
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
Reach reach = mock(Reach.class);
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt()))
.thenReturn(new HydrologyPlanPreview(List.of(reach), List.of(), List.of(), List.of(), true));
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(ChunkWaterRaster.EMPTY);
WaterMap map = WaterMap.draw(runtime, 32, 0D, 0D, 1D, () -> false);
assertFalse(map.provisional());
verifyNoInteractions(reach);
}
@Test
public void completedPixelsSurviveRuntimeRasterEviction() {
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
ChunkWaterRaster pond = pond();
AtomicBoolean pending = new AtomicBoolean(true);
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY);
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(ChunkWaterRaster.EMPTY);
when(runtime.rasterPreview(0, 0)).thenReturn(pond, ChunkWaterRaster.PROVISIONAL);
when(runtime.rasterPreview(1, 0)).thenAnswer(invocation -> pending.get() ? ChunkWaterRaster.PROVISIONAL : pond);
WaterMap map = new WaterMap(runtime, 32, 0D, 0D, 1D);
map.advance(() -> false);
assertTrue(map.provisional());
assertEquals(HydrologyFeatureType.POND, map.at(1, 1));
assertNull(map.at(17, 1));
pending.set(false);
map.advance(() -> false);
assertFalse(map.provisional());
assertEquals(HydrologyFeatureType.POND, map.at(1, 1));
assertEquals(HydrologyFeatureType.POND, map.at(17, 1));
assertNull(map.at(1, 17));
assertTrue(map.bytes() < 16_384L);
verify(runtime, times(1)).rasterPreview(0, 0);
verify(runtime, times(2)).rasterPreview(1, 0);
verify(runtime, times(1)).planPreview(anyInt(), anyInt(), anyInt(), anyInt());
map.advance(() -> false);
verify(runtime, times(2)).rasterPreview(1, 0);
}
@Test
public void cancelledWaterSamplingResumesToTheSameFinalPixels() {
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
ChunkWaterRaster raster = pond();
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY);
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(raster);
AtomicInteger rows = new AtomicInteger();
WaterMap map = new WaterMap(runtime, 32, -16.5D, -16.5D, 0.5D);
assertThrows(CancellationException.class, () -> map.advance(() -> rows.incrementAndGet() > 2));
assertTrue(map.provisional());
map.advance(() -> false);
assertFalse(map.provisional());
WaterMap expected = WaterMap.draw(runtime, 32, -16.5D, -16.5D, 0.5D, () -> false);
for (int z = 0; z < 32; z++) {
for (int x = 0; x < 32; x++) {
assertEquals(expected.at(x, z), map.at(x, z));
}
}
}
private static ChunkWaterRaster pond() {
ChunkWaterRaster raster = mock(ChunkWaterRaster.class);
when(raster.role(anyInt(), anyInt())).thenReturn(WaterRole.POND);
return raster;
}
}