diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/HdrGainmapPipelineInstrumentedTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/HdrGainmapPipelineInstrumentedTest.kt new file mode 100644 index 0000000000..3e301ea775 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/HdrGainmapPipelineInstrumentedTest.kt @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.images + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import android.graphics.Gainmap +import android.os.Build +import androidx.core.graphics.createBitmap +import androidx.core.net.toUri +import androidx.exifinterface.media.ExifInterface +import androidx.test.ext.junit.runners.AndroidJUnit4 +import coil3.BitmapImage +import coil3.ImageLoader +import coil3.annotation.ExperimentalCoilApi +import coil3.decode.DataSource +import coil3.disk.DiskCache +import coil3.memory.MemoryCache +import coil3.network.DeDupeConcurrentRequestStrategy +import coil3.request.ErrorResult +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.request.allowHardware +import coil3.size.Precision +import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.appContext +import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MetadataStripper +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okio.Path.Companion.toOkioPath +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.net.InetAddress +import java.net.ServerSocket +import java.util.UUID +import kotlin.concurrent.thread + +/** + * Walks an Ultra HDR JPEG (a JPEG with an ISO 21496-1 / Android gain map) through every stage an + * uploaded or viewed photo passes, asserting the gain map survives each one. Losing it anywhere + * turns the photo into plain SDR, which is what "HDR photos don't work" looks like to a user. + */ +@RunWith(AndroidJUnit4::class) +class HdrGainmapPipelineInstrumentedTest { + private lateinit var hdrJpeg: File + + @Before + fun setUp() { + assumeTrue("Gain maps need API 34+", Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + hdrJpeg = File(appContext.cacheDir.also { it.mkdirs() }, "${UUID.randomUUID()}.jpg") + hdrJpeg.outputStream().use { ultraHdrBitmap().compress(Bitmap.CompressFormat.JPEG, 95, it) } + } + + @After + fun tearDown() { + if (::hdrJpeg.isInitialized) hdrJpeg.delete() + } + + @Test + fun theFixtureIsAnUltraHdrJpeg() { + assertTrue("fixture lost its gain map", decodesWithGainmap(hdrJpeg)) + } + + @Test + fun metadataStrippingKeepsTheGainmap() { + val stripped = MetadataStripper.stripImageMetadata(hdrJpeg.toUri(), appContext) + assertTrue("stripper did not run", stripped.stripped) + assertTrue("stripping dropped the gain map", decodesWithGainmap(File(requireNotNull(stripped.uri.path)))) + } + + @Test + fun compressionKeepsTheGainmap() = + runBlocking { + val out = compress(hdrJpeg) + val bounds = bounds(out) + // 4000x3000 against the compressor's 640x816 target: BitmapFactory subsamples by 2. + assertEquals("the compressor did not downscale", 2000, bounds.outWidth) + assertTrue("compression dropped the gain map", decodesWithGainmap(out)) + } + + @Test + fun compressionOfARotatedPhotoKeepsTheGainmap() = + runBlocking { + // Portrait phone photos are stored landscape plus an EXIF rotation, which the + // compressor bakes in through Bitmap.createBitmap(..., matrix, ...). + ExifInterface(hdrJpeg).apply { + setAttribute(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_ROTATE_90.toString()) + saveAttributes() + } + val out = compress(hdrJpeg) + val bounds = bounds(out) + assertTrue("the compressor did not rotate", bounds.outHeight > bounds.outWidth) + assertTrue("rotating dropped the gain map", decodesWithGainmap(out)) + } + + @Test + fun coilDecodeKeepsTheGainmap() = + runBlocking { + val loader = ImageLoader.Builder(appContext).build() + for (hardware in listOf(true, false)) { + val request = + ImageRequest + .Builder(appContext) + .data(hdrJpeg) + .size(400, 300) + .allowHardware(hardware) + .build() + val result = loader.execute(request) as SuccessResult + val bitmap = (result.image as BitmapImage).bitmap + assertTrue("Coil dropped the gain map (allowHardware=$hardware)", bitmap.hasGainmap()) + } + } + + /** + * A feed image as the app loads it: over HTTP through [OkHttpFactory] (which re-homes the + * disk-cache file so the platform ImageDecoder is reachable), then again from the memory + * cache, then from the disk cache after the memory cache is gone. + */ + @OptIn(ExperimentalCoilApi::class) + @Test + fun coilCachesKeepTheGainmap() = + runBlocking { + val cacheDir = File(appContext.cacheDir, "hdr-coil-${UUID.randomUUID()}") + val bytes = hdrJpeg.readBytes() + ServerSocket(0, 50, InetAddress.getByName("127.0.0.1")).use { server -> + serveForever(server, bytes) + val memoryCache = MemoryCache.Builder().maxSizeBytes(64L * 1024 * 1024).build() + val loader = + ImageLoader + .Builder(appContext) + .memoryCache(memoryCache) + .diskCache(DiskCache.Builder().directory(cacheDir.toOkioPath()).build()) + .precision(Precision.INEXACT) + .components { + val client = OkHttpClient() + add(OkHttpFactory({ client }, DeDupeConcurrentRequestStrategy())) + }.build() + val url = "http://127.0.0.1:${server.localPort}/hdr.jpg" + val request = + ImageRequest + .Builder(appContext) + .data(url) + .size(400, 300) + .build() + + suspend fun load(expected: DataSource) { + val result = loader.execute(request) + if (result is ErrorResult) throw AssertionError("$expected load failed", result.throwable) + result as SuccessResult + assertEquals(expected, result.dataSource) + assertTrue("gain map lost on a $expected load", (result.image as BitmapImage).bitmap.hasGainmap()) + } + + load(DataSource.NETWORK) + load(DataSource.MEMORY_CACHE) + memoryCache.clear() + load(DataSource.DISK) + } + cacheDir.deleteRecursively() + } + + private suspend fun compress(file: File): File { + val compressed = MediaCompressor().compress(file.toUri(), "image/jpeg", CompressorQuality.MEDIUM, appContext) + assertEquals("the compressor fell back to the original", "image/jpeg", compressed.contentType) + val out = File(requireNotNull(compressed.uri.path)) + assertTrue("the compressor returned the original file", out.absolutePath != file.absolutePath) + return out + } + + private fun bounds(file: File) = BitmapFactory.Options().apply { inJustDecodeBounds = true }.also { BitmapFactory.decodeFile(file.absolutePath, it) } + + private fun decodesWithGainmap(file: File): Boolean = BitmapFactory.decodeFile(file.absolutePath)?.hasGainmap() == true + + private fun serveForever( + server: ServerSocket, + body: ByteArray, + ) = thread(isDaemon = true) { + while (!server.isClosed) { + val socket = runCatching { server.accept() }.getOrNull() ?: break + socket.use { + val input = it.getInputStream().bufferedReader() + while (input.readLine()?.isNotEmpty() == true) Unit + it.getOutputStream().apply { + write("HTTP/1.1 200 OK\r\nContent-Type: image/jpeg\r\nContent-Length: ${body.size}\r\nConnection: close\r\n\r\n".toByteArray()) + write(body) + flush() + } + } + } + } + + private fun ultraHdrBitmap(): Bitmap { + val base = createBitmap(4000, 3000).apply { eraseColor(Color.rgb(120, 140, 160)) } + val contents = createBitmap(1000, 750).apply { eraseColor(Color.rgb(200, 200, 200)) } + base.gainmap = + Gainmap(contents).apply { + setRatioMax(4f, 4f, 4f) + setDisplayRatioForFullHdr(4f) + } + return base + } +} diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/HdrWindowModeTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/HdrWindowModeTest.kt new file mode 100644 index 0000000000..2f4df413b4 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/HdrWindowModeTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import android.content.pm.ActivityInfo +import android.graphics.Gainmap +import android.os.Build +import android.view.Window +import android.view.WindowManager +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.window.Dialog +import androidx.core.graphics.createBitmap +import androidx.test.ext.junit.runners.AndroidJUnit4 +import coil3.asImage +import com.vitorpamplona.amethyst.commons.ui.components.getActivityWindow +import com.vitorpamplona.amethyst.commons.ui.components.getDialogWindow +import org.junit.Assert.assertEquals +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HdrWindowModeTest { + @get:Rule + val rule = createComposeRule() + + @Before + fun requireGainmaps() { + assumeTrue("Gain maps need API 34+", Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + } + + private fun hdrImage() = createBitmap(8, 8).apply { gainmap = Gainmap(createBitmap(2, 2)) }.asImage() + + private fun sdrImage() = createBitmap(8, 8).asImage() + + @Test + fun theWindowIsHdrOnlyWhileAGainmapImageIsShown() { + var window: Window? = null + var showFirst by mutableStateOf(true) + var showSecond by mutableStateOf(true) + val image = hdrImage() + + rule.setContent { + window = getActivityWindow() + if (showFirst) RequestHdrFor(image, HdrRequests.FEED_HEADROOM) + if (showSecond) RequestHdrFor(image, HdrRequests.FEED_HEADROOM) + } + rule.waitForIdle() + assertEquals(ActivityInfo.COLOR_MODE_HDR, window!!.colorMode) + + showFirst = false + rule.waitForIdle() + assertEquals("one image still wants HDR", ActivityInfo.COLOR_MODE_HDR, window!!.colorMode) + + showSecond = false + rule.waitForIdle() + assertEquals(ActivityInfo.COLOR_MODE_DEFAULT, window!!.colorMode) + } + + @Test + fun anSdrImageLeavesTheWindowAlone() { + var window: Window? = null + rule.setContent { + window = getActivityWindow() + RequestHdrFor(sdrImage(), HdrRequests.FEED_HEADROOM) + } + rule.waitForIdle() + assertEquals(ActivityInfo.COLOR_MODE_DEFAULT, window!!.colorMode) + } + + @Test + fun insideADialogTheDialogWindowGoesHdr() { + var activityWindow: Window? = null + var dialogWindow: Window? = null + val image = hdrImage() + + rule.setContent { + activityWindow = getActivityWindow() + Dialog(onDismissRequest = {}) { + dialogWindow = getDialogWindow() + RequestHdrFor(image, HdrRequests.UNCAPPED) + } + } + rule.waitForIdle() + assertEquals(ActivityInfo.COLOR_MODE_HDR, dialogWindow!!.colorMode) + assertEquals(ActivityInfo.COLOR_MODE_DEFAULT, activityWindow!!.colorMode) + } + + @Test + fun moreHdrImagesDoNotResendTheWindowAttributes() { + var window: Window? = null + var count by mutableStateOf(1) + val image = hdrImage() + var attributeChanges = 0 + + rule.setContent { + window = getActivityWindow() + repeat(count) { RequestHdrFor(image, HdrRequests.FEED_HEADROOM) } + } + rule.waitForIdle() + rule.runOnUiThread { + val original = window!!.callback + window!!.callback = + object : Window.Callback by original { + override fun onWindowAttributesChanged(attrs: WindowManager.LayoutParams?) { + attributeChanges++ + original.onWindowAttributesChanged(attrs) + } + } + } + + // Cards scrolling in and out while HDR is already on: nothing for the window to change. + count = 4 + rule.waitForIdle() + count = 2 + rule.waitForIdle() + + assertEquals(0, attributeChanges) + assertEquals(ActivityInfo.COLOR_MODE_HDR, window!!.colorMode) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/HdrWindowMode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/HdrWindowMode.kt new file mode 100644 index 0000000000..3457150f38 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/HdrWindowMode.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import android.content.pm.ActivityInfo +import android.os.Build +import android.view.Window +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import coil3.BitmapImage +import coil3.Image +import com.vitorpamplona.amethyst.commons.ui.components.getActivityWindow +import com.vitorpamplona.amethyst.commons.ui.components.getDialogWindow +import java.util.WeakHashMap + +/** + * The images on one window that currently want HDR, and how much headroom each asks for. + * + * An Ultra HDR photo (a JPEG with a gain map) only renders brighter than SDR white while its + * window is in [ActivityInfo.COLOR_MODE_HDR]; otherwise the platform silently draws the SDR base + * image. The window is shared by every card in a feed, so HDR stays on until the last image + * asking for it leaves composition. + */ +class HdrRequests { + private val tokens = mutableListOf() + + private class Request( + val headroom: Float, + ) + + val wantsHdr: Boolean get() = tokens.isNotEmpty() + + /** [UNCAPPED] if any request is uncapped, else the largest cap asked for. */ + val headroom: Float + get() = if (tokens.any { it.headroom == UNCAPPED }) UNCAPPED else tokens.maxOfOrNull { it.headroom } ?: UNCAPPED + + fun add(headroom: Float): Any = Request(headroom).also { tokens.add(it) } + + fun remove(token: Any) { + tokens.removeAll { it === token } + } + + companion object { + /** `Window.setDesiredHdrHeadroom`'s "no preference": the display's full HDR range. */ + const val UNCAPPED = 0f + + /** + * Feed cards share the screen with SDR text and chrome, which full HDR brightness makes + * look dim and grey. Cap them; the fullscreen viewer asks for [UNCAPPED]. + */ + const val FEED_HEADROOM = 2f + } +} + +private class HdrWindowState( + val originalColorMode: Int, +) { + val requests = HdrRequests() +} + +// Main-thread only: composition and disposal both run there. +private val windowStates = WeakHashMap() + +// Each setter dispatches the window attributes to the window manager even when the value is +// unchanged, and cards scroll in and out of a feed constantly: only write what actually changed. +private fun Window.applyHdr(state: HdrWindowState) { + val mode = if (state.requests.wantsHdr) ActivityInfo.COLOR_MODE_HDR else state.originalColorMode + if (colorMode != mode) colorMode = mode + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + val headroom = if (state.requests.wantsHdr) state.requests.headroom else HdrRequests.UNCAPPED + if (desiredHdrHeadroom != headroom) desiredHdrHeadroom = headroom + } +} + +fun Image.hasGainmap(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && (this as? BitmapImage)?.bitmap?.hasGainmap() == true + +/** + * Puts the hosting window (the dialog's own window inside a `Dialog`) into HDR mode while this + * is in composition and [image] carries a gain map, so an Ultra HDR photo shows its highlights. + */ +@Composable +fun RequestHdrFor( + image: Image, + headroom: Float, +) { + if (!remember(image) { image.hasGainmap() }) return + val window = getDialogWindow() ?: getActivityWindow() ?: return + + DisposableEffect(window, headroom) { + val state = windowStates.getOrPut(window) { HdrWindowState(window.colorMode) } + val token = state.requests.add(headroom) + window.applyHdr(state) + + onDispose { + state.requests.remove(token) + window.applyHdr(state) + if (!state.requests.wantsHdr) windowStates.remove(window) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 4d70f3f355..4bcce185aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -457,9 +457,11 @@ fun LocalImageView( is AsyncImagePainter.State.Success -> { SubcomposeAsyncImageContent(loadedImageModifier) + val image = (state as AsyncImagePainter.State.Success).result.image + RequestHdrFor(image, if (fullResolution) HdrRequests.UNCAPPED else HdrRequests.FEED_HEADROOM) + SideEffect { - val drawable = (state as AsyncImagePainter.State.Success).result.image - MediaAspectRatioCache.add(content.localJavaFile.toString(), drawable.width, drawable.height) + MediaAspectRatioCache.add(content.localJavaFile.toString(), image.width, image.height) } content.isVerified?.let { @@ -599,9 +601,11 @@ fun UrlImageView( ShowHashAnimated(content, controllerVisible, Modifier.align(Alignment.TopEnd)) + val image = (state as AsyncImagePainter.State.Success).result.image + RequestHdrFor(image, if (fullResolution) HdrRequests.UNCAPPED else HdrRequests.FEED_HEADROOM) + SideEffect { - val drawable = (state as AsyncImagePainter.State.Success).result.image - MediaAspectRatioCache.add(content.url, drawable.width, drawable.height) + MediaAspectRatioCache.add(content.url, image.width, image.height) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/HdrRequestsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/HdrRequestsTest.kt new file mode 100644 index 0000000000..c014b9787d --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/HdrRequestsTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HdrRequestsTest { + @Test + fun noRequestsMeansSdr() { + val requests = HdrRequests() + assertFalse(requests.wantsHdr) + } + + @Test + fun hdrStaysOnUntilTheLastRequestLeaves() { + val requests = HdrRequests() + val first = requests.add(2f) + val second = requests.add(2f) + + requests.remove(first) + assertTrue(requests.wantsHdr) + + requests.remove(second) + assertFalse(requests.wantsHdr) + } + + @Test + fun theLargestCappedHeadroomWins() { + val requests = HdrRequests() + requests.add(2f) + requests.add(3f) + assertEquals(3f, requests.headroom) + } + + @Test + fun anUncappedRequestLiftsTheCap() { + val requests = HdrRequests() + requests.add(2f) + val fullscreen = requests.add(HdrRequests.UNCAPPED) + assertEquals(HdrRequests.UNCAPPED, requests.headroom) + + requests.remove(fullscreen) + assertEquals(2f, requests.headroom) + } + + @Test + fun equalHeadroomsAreStillSeparateRequests() { + val requests = HdrRequests() + val first = requests.add(2f) + requests.add(2f) + + requests.remove(first) + requests.remove(first) + assertTrue("removing one token twice must not release the other", requests.wantsHdr) + } +}