From 0f908b4bfc1a00af1191184beeed8d47e8542f12 Mon Sep 17 00:00:00 2001 From: stillya Date: Sun, 9 Aug 2026 03:24:41 +0500 Subject: [PATCH] feat(effect): add sleeping effect in indexing mode --- src/main/kotlin/dev/stillya/vpet/Animated.kt | 2 + .../stillya/vpet/AnimatedStatusBarWidget.kt | 10 ++ .../stillya/vpet/AnimationEventListener.kt | 2 + .../kotlin/dev/stillya/vpet/IconRenderer.kt | 2 + .../vpet/graphics/DefaultIconRenderer.kt | 65 ++++++++++--- .../dev/stillya/vpet/graphics/Effect.kt | 2 + .../vpet/graphics/effect/SleepEffect.kt | 64 +++++++++++++ .../vpet/listener/IndexingEventListener.kt | 15 +++ .../dev/stillya/vpet/pet/PetAnimated.kt | 28 ++++++ .../vpet/service/AnimationEventService.kt | 23 ++++- src/main/resources/META-INF/plugin.xml | 4 + .../vpet/graphics/DefaultIconRendererTest.kt | 95 +++++++++++++++++++ .../vpet/graphics/effect/SleepEffectTest.kt | 88 +++++++++++++++++ .../vpet/pet/PetAnimatedIntegrationTest.kt | 79 +++++++++++++++ 14 files changed, 464 insertions(+), 15 deletions(-) create mode 100644 src/main/kotlin/dev/stillya/vpet/graphics/effect/SleepEffect.kt create mode 100644 src/main/kotlin/dev/stillya/vpet/listener/IndexingEventListener.kt create mode 100644 src/test/kotlin/dev/stillya/vpet/graphics/DefaultIconRendererTest.kt create mode 100644 src/test/kotlin/dev/stillya/vpet/graphics/effect/SleepEffectTest.kt diff --git a/src/main/kotlin/dev/stillya/vpet/Animated.kt b/src/main/kotlin/dev/stillya/vpet/Animated.kt index 7b06fe7..190ddf2 100644 --- a/src/main/kotlin/dev/stillya/vpet/Animated.kt +++ b/src/main/kotlin/dev/stillya/vpet/Animated.kt @@ -9,6 +9,8 @@ interface Animated { fun onOccasion() fun onStartObserving() fun onCursorMove(isOnLeftSide: Boolean) + fun onIndexingStart() + fun onIndexingFinish() data class Params( val atlasPath: String, diff --git a/src/main/kotlin/dev/stillya/vpet/AnimatedStatusBarWidget.kt b/src/main/kotlin/dev/stillya/vpet/AnimatedStatusBarWidget.kt index f4f85f6..6a968e2 100644 --- a/src/main/kotlin/dev/stillya/vpet/AnimatedStatusBarWidget.kt +++ b/src/main/kotlin/dev/stillya/vpet/AnimatedStatusBarWidget.kt @@ -1,8 +1,11 @@ package dev.stillya.vpet import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.service +import com.intellij.openapi.project.DumbService import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Computable import com.intellij.openapi.wm.IconWidgetPresentation import com.intellij.openapi.wm.StatusBar import com.intellij.openapi.wm.StatusBarWidgetFactory @@ -152,6 +155,13 @@ class AnimatedStatusBarWidget( ) curFrames = iconRenderer.render() + + // Project listeners don't fire retroactively, so catch an already running indexing pass + val indexing = ApplicationManager.getApplication() + .runReadAction(Computable { DumbService.isDumb(project) }) + if (indexing) { + animation.onIndexingStart() + } } private fun startCursorTracking() { diff --git a/src/main/kotlin/dev/stillya/vpet/AnimationEventListener.kt b/src/main/kotlin/dev/stillya/vpet/AnimationEventListener.kt index 4cf2183..ce89ff5 100644 --- a/src/main/kotlin/dev/stillya/vpet/AnimationEventListener.kt +++ b/src/main/kotlin/dev/stillya/vpet/AnimationEventListener.kt @@ -10,6 +10,8 @@ interface AnimationEventListener { FAIL, SUCCESS, PROGRESS, + INDEXING_START, + INDEXING_FINISH, } companion object { diff --git a/src/main/kotlin/dev/stillya/vpet/IconRenderer.kt b/src/main/kotlin/dev/stillya/vpet/IconRenderer.kt index c0b9e78..9b2a6ef 100644 --- a/src/main/kotlin/dev/stillya/vpet/IconRenderer.kt +++ b/src/main/kotlin/dev/stillya/vpet/IconRenderer.kt @@ -13,4 +13,6 @@ interface IconRenderer { ): AnimationContext fun setFlipped(flipped: Boolean) + fun lockFrame() + fun unlockFrame() } \ No newline at end of file diff --git a/src/main/kotlin/dev/stillya/vpet/graphics/DefaultIconRenderer.kt b/src/main/kotlin/dev/stillya/vpet/graphics/DefaultIconRenderer.kt index 91034c4..c710ea6 100644 --- a/src/main/kotlin/dev/stillya/vpet/graphics/DefaultIconRenderer.kt +++ b/src/main/kotlin/dev/stillya/vpet/graphics/DefaultIconRenderer.kt @@ -6,6 +6,7 @@ import dev.stillya.vpet.IconRenderer import dev.stillya.vpet.animation.Animation import dev.stillya.vpet.animation.AnimationState import dev.stillya.vpet.animation.INFINITE +import dev.stillya.vpet.graphics.effect.SleepEffect import dev.stillya.vpet.graphics.effect.SnowflakeEffect import dev.stillya.vpet.settings.VPetSettings import java.awt.Image @@ -29,11 +30,15 @@ class DefaultIconRenderer(project: Project) : IconRenderer { @Volatile private var isFlipped: Boolean = false + + @Volatile + private var locked: Boolean = false private val currentLoopCount: AtomicInteger = AtomicInteger(0) private val scaleValue: Double = 1.2 private val verticalOffset: Int = -8 - private var effect: Effect? = null + private var snowEffect: Effect? = null + private var sleepEffect: Effect? = null private val epochManager = AnimationEpochManager() private val renderCache = mutableMapOf>() @@ -58,6 +63,29 @@ class DefaultIconRenderer(project: Project) : IconRenderer { } override fun render(): List { + val frames = if (locked) lockedFrame() else renderInternal() + if (activeEffect == EffectKind.NONE) return frames + + // An effect animates inside paintIcon, and the widget's icon flow goes through + // distinctUntilChanged, so only a distinct instance triggers the next repaint + return frames.map { object : Icon by it {} } + } + + private fun lockedFrame(): List { + val frame = currentAnimation?.let { doRender(it).firstOrNull() } + ?: renderInternal().firstOrNull() + return frame?.let { listOf(it) } ?: emptyList() + } + + override fun lockFrame() { + locked = true + } + + override fun unlockFrame() { + locked = false + } + + private fun renderInternal(): List { currentAnimation?.let { current -> if (!validateAnimation(current)) { log.trace("Animation '${current.name}' no longer valid, finding next") @@ -198,18 +226,33 @@ class DefaultIconRenderer(project: Project) : IconRenderer { x: Int, y: Int ) { - if (settings.xmasModeEnabled) { - if (effect == null) { - effect = SnowflakeEffect(scaledWidth, scaledHeight) - } - val g2d = g.create() as java.awt.Graphics2D - g2d.translate(x, y) - val animState = currentAnimation?.state ?: AnimationState.IDLE - effect?.apply(g2d, animState) - g2d.dispose() - } + val effect = resolveEffect(scaledWidth, scaledHeight) + if (effect != null && !effect.overSprite) paintEffect(effect, g, x, y) super.paintIcon(c, g, x, y + verticalOffset) + if (effect != null && effect.overSprite) paintEffect(effect, g, x, y) } } } + + private enum class EffectKind { NONE, SLEEP, SNOW } + + private val activeEffect: EffectKind + get() = when { + locked -> EffectKind.SLEEP + settings.xmasModeEnabled -> EffectKind.SNOW + else -> EffectKind.NONE + } + + private fun resolveEffect(width: Int, height: Int): Effect? = when (activeEffect) { + EffectKind.SLEEP -> sleepEffect ?: SleepEffect(width, height).also { sleepEffect = it } + EffectKind.SNOW -> snowEffect ?: SnowflakeEffect(width, height).also { snowEffect = it } + EffectKind.NONE -> null + } + + private fun paintEffect(effect: Effect, g: java.awt.Graphics, x: Int, y: Int) { + val g2d = g.create() as java.awt.Graphics2D + g2d.translate(x, y) + effect.apply(g2d, currentAnimation?.state ?: AnimationState.IDLE) + g2d.dispose() + } } \ No newline at end of file diff --git a/src/main/kotlin/dev/stillya/vpet/graphics/Effect.kt b/src/main/kotlin/dev/stillya/vpet/graphics/Effect.kt index db6832a..3e02edf 100644 --- a/src/main/kotlin/dev/stillya/vpet/graphics/Effect.kt +++ b/src/main/kotlin/dev/stillya/vpet/graphics/Effect.kt @@ -5,4 +5,6 @@ import java.awt.Graphics2D interface Effect { fun apply(g: Graphics2D, state: AnimationState) + + val overSprite: Boolean get() = false } diff --git a/src/main/kotlin/dev/stillya/vpet/graphics/effect/SleepEffect.kt b/src/main/kotlin/dev/stillya/vpet/graphics/effect/SleepEffect.kt new file mode 100644 index 0000000..5b69d8d --- /dev/null +++ b/src/main/kotlin/dev/stillya/vpet/graphics/effect/SleepEffect.kt @@ -0,0 +1,64 @@ +package dev.stillya.vpet.graphics.effect + +import com.intellij.ui.JBColor +import dev.stillya.vpet.animation.AnimationState +import dev.stillya.vpet.graphics.Effect +import java.awt.Color +import java.awt.Font +import java.awt.Graphics2D +import java.awt.RenderingHints +import kotlin.math.roundToInt + +class SleepEffect( + private val width: Int, + private val height: Int, + private val clock: () -> Long = System::currentTimeMillis +) : Effect { + override val overSprite = true + + private val startMs = clock() + + companion object { + internal const val CYCLE_MS = 2700L + private const val COUNT = 3 + + private const val BASE_X = 0.48f + private const val DRIFT_X = 0.24f + private const val BASE_Y = 0.68f + private const val TOP_Y = 0.18f + private const val MIN_SIZE = 0.16f + private const val MAX_SIZE = 0.26f + + private const val MAX_ALPHA = 230f + private const val FADE_IN = 0.2f + private const val FADE_OUT = 0.45f + + private val Z_LIGHT = Color(0x33, 0x33, 0x4D) + private val Z_DARK = Color(0xE0, 0xE0, 0xF0) + } + + override fun apply(g: Graphics2D, state: AnimationState) { + val elapsed = ((clock() - startMs) % CYCLE_MS) / CYCLE_MS.toFloat() + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON) + + for (i in 0 until COUNT) { + drawZ(g, (elapsed + i.toFloat() / COUNT) % 1f) + } + } + + private fun drawZ(g: Graphics2D, phase: Float) { + val fade = minOf(phase / FADE_IN, (1f - phase) / FADE_OUT, 1f) + if (fade <= 0f) return + + val alpha = (MAX_ALPHA * fade).roundToInt() + g.color = JBColor(withAlpha(Z_LIGHT, alpha), withAlpha(Z_DARK, alpha)) + g.font = Font(Font.SANS_SERIF, Font.BOLD, (height * (MIN_SIZE + (MAX_SIZE - MIN_SIZE) * phase)).roundToInt()) + g.drawString( + "z", + (width * (BASE_X + DRIFT_X * phase)).roundToInt(), + (height * (BASE_Y - (BASE_Y - TOP_Y) * phase)).roundToInt() + ) + } + + private fun withAlpha(color: Color, alpha: Int) = Color(color.red, color.green, color.blue, alpha) +} diff --git a/src/main/kotlin/dev/stillya/vpet/listener/IndexingEventListener.kt b/src/main/kotlin/dev/stillya/vpet/listener/IndexingEventListener.kt new file mode 100644 index 0000000..99feff0 --- /dev/null +++ b/src/main/kotlin/dev/stillya/vpet/listener/IndexingEventListener.kt @@ -0,0 +1,15 @@ +package dev.stillya.vpet.listener + +import com.intellij.openapi.project.DumbService +import com.intellij.openapi.project.Project +import dev.stillya.vpet.AnimationEventListener + +class IndexingEventListener(private val project: Project) : DumbService.DumbModeListener { + + override fun enteredDumbMode() = publish(AnimationEventListener.AnimationEvent.INDEXING_START) + + override fun exitDumbMode() = publish(AnimationEventListener.AnimationEvent.INDEXING_FINISH) + + private fun publish(event: AnimationEventListener.AnimationEvent) = + project.messageBus.syncPublisher(AnimationEventListener.TOPIC).onEvent(event) +} diff --git a/src/main/kotlin/dev/stillya/vpet/pet/PetAnimated.kt b/src/main/kotlin/dev/stillya/vpet/pet/PetAnimated.kt index 1e3830a..b13629e 100644 --- a/src/main/kotlin/dev/stillya/vpet/pet/PetAnimated.kt +++ b/src/main/kotlin/dev/stillya/vpet/pet/PetAnimated.kt @@ -51,6 +51,7 @@ class PetAnimated( private var animationPlayer: AnimationPlayer = AnimationPlayer(bridges) private var isObserving = AtomicBoolean(false) + private var isSleeping = AtomicBoolean(false) private var cachedAnimationKey: Pair? = null private var cachedAnimation: Animation? = null @@ -81,6 +82,8 @@ class PetAnimated( ?: throw IllegalArgumentException("Atlas not found") image = loadImage(params.imgPath) + wakeUp() + val context = renderer.createAnimationContext(AnimationTrigger.IDLE_BEHAVIOR) log.trace("Starting initial transition to IDLE state") val idleSequence = transitionMatrix.transitionTo(currentState, AnimationState.IDLE) @@ -170,6 +173,7 @@ class PetAnimated( override fun onFail() { log.trace("BUILD FAILED - Transitioning to FAILED") + wakeUp() exitObservingMode() val sequence = transitionMatrix.transitionTo(currentState, AnimationState.FAILED) if (sequence.first.steps.isNotEmpty()) { @@ -182,6 +186,7 @@ class PetAnimated( override fun onSuccess() { log.trace("BUILD SUCCESS - Transitioning to CELEBRATING") + wakeUp() exitObservingMode() val sequence = transitionMatrix.transitionTo(currentState, AnimationState.CELEBRATING) if (sequence.first.steps.isNotEmpty()) { @@ -194,6 +199,7 @@ class PetAnimated( override fun onProgress() { log.trace("BUILD START - Transitioning to RUNNING") + wakeUp() exitObservingMode() val sequence = transitionMatrix.transitionTo(currentState, AnimationState.RUNNING) if (sequence.first.steps.isNotEmpty()) { @@ -206,6 +212,7 @@ class PetAnimated( override fun onCompleted() { log.trace("BUILD COMPLETED - Transitioning to CELEBRATING") + wakeUp() exitObservingMode() val sequence = transitionMatrix.transitionTo(currentState, AnimationState.CELEBRATING) if (sequence.first.steps.isNotEmpty()) { @@ -218,6 +225,7 @@ class PetAnimated( override fun onOccasion() { log.trace("USER CLICK - Transitioning to OCCASION") + wakeUp() exitObservingMode() val sequence = transitionMatrix.transitionTo(currentState, AnimationState.OCCASION) if (sequence.first.steps.isNotEmpty()) { @@ -228,6 +236,22 @@ class PetAnimated( } } + override fun onIndexingStart() { + if (isSleeping.compareAndSet(false, true)) { + log.trace("INDEXING STARTED - locking frame") + renderer.lockFrame() + } + } + + override fun onIndexingFinish() = wakeUp() + + private fun wakeUp() { + if (isSleeping.compareAndSet(true, false)) { + log.trace("Waking up - unlocking frame") + renderer.unlockFrame() + } + } + private fun exitObservingMode() { if (isObserving.compareAndSet(true, false)) { log.trace("Exiting OBSERVING mode") @@ -238,6 +262,10 @@ class PetAnimated( } override fun onStartObserving() { + if (isSleeping.get()) { + return + } + if (currentState == AnimationState.IDLE && isObserving.compareAndSet(false, true)) { log.trace("INACTIVITY - Starting OBSERVING mode") observingStartTimeMs = System.currentTimeMillis() diff --git a/src/main/kotlin/dev/stillya/vpet/service/AnimationEventService.kt b/src/main/kotlin/dev/stillya/vpet/service/AnimationEventService.kt index 5c19468..4a48caa 100644 --- a/src/main/kotlin/dev/stillya/vpet/service/AnimationEventService.kt +++ b/src/main/kotlin/dev/stillya/vpet/service/AnimationEventService.kt @@ -11,13 +11,28 @@ class AnimationEventService(private val project: Project) : AnimationEventListen get() = project.service() override fun onEvent(event: AnimationEventListener.AnimationEvent) { - ActivityTracker.getInstance(project).notifyActivity() when (event) { - AnimationEventListener.AnimationEvent.FAIL -> animated.onFail() - AnimationEventListener.AnimationEvent.SUCCESS -> animated.onSuccess() - AnimationEventListener.AnimationEvent.PROGRESS -> animated.onProgress() + AnimationEventListener.AnimationEvent.FAIL -> { + notifyActivity() + animated.onFail() + } + + AnimationEventListener.AnimationEvent.SUCCESS -> { + notifyActivity() + animated.onSuccess() + } + + AnimationEventListener.AnimationEvent.PROGRESS -> { + notifyActivity() + animated.onProgress() + } + + AnimationEventListener.AnimationEvent.INDEXING_START -> animated.onIndexingStart() + AnimationEventListener.AnimationEvent.INDEXING_FINISH -> animated.onIndexingFinish() } } + + private fun notifyActivity() = ActivityTracker.getInstance(project).notifyActivity() } @Service(Service.Level.PROJECT) diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index ceb57b1..14ff9fa 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -63,6 +63,10 @@ + + + diff --git a/src/test/kotlin/dev/stillya/vpet/graphics/DefaultIconRendererTest.kt b/src/test/kotlin/dev/stillya/vpet/graphics/DefaultIconRendererTest.kt new file mode 100644 index 0000000..4164e0d --- /dev/null +++ b/src/test/kotlin/dev/stillya/vpet/graphics/DefaultIconRendererTest.kt @@ -0,0 +1,95 @@ +package dev.stillya.vpet.graphics + +import com.intellij.testFramework.LightPlatform4TestCase +import dev.stillya.vpet.animation.Animation +import dev.stillya.vpet.animation.AnimationState +import dev.stillya.vpet.animation.INFINITE +import dev.stillya.vpet.config.AsepriteJsonAtlasLoader +import dev.stillya.vpet.config.SpriteSheetAtlas +import org.junit.Test +import java.awt.Image +import javax.imageio.ImageIO + +class DefaultIconRendererTest : LightPlatform4TestCase() { + + private companion object { + const val WALK = "Walk" // 8 frames + const val DMG = "Dmg" // 2 frames + } + + private lateinit var renderer: DefaultIconRenderer + private lateinit var atlas: SpriteSheetAtlas + private lateinit var image: Image + + override fun setUp() { + super.setUp() + renderer = DefaultIconRenderer(project) + atlas = AsepriteJsonAtlasLoader().load("/META-INF/spritesheets/cat/atlas.json")!! + image = javaClass.getResourceAsStream("/META-INF/spritesheets/cat/sprite.png")!! + .use { ImageIO.read(it) } + } + + private fun animation(tag: String, loop: Int = 1) = Animation( + name = tag, + sheet = atlas.create(image, tag), + loop = loop, + onFinish = {}, + state = AnimationState.IDLE + ) + + @Test + fun testLockCollapsesToASingleFrame() { + renderer.enqueue(animation(WALK)) + assertEquals(8, renderer.render().size) + + renderer.lockFrame() + + assertEquals(1, renderer.render().size) + } + + @Test + fun testLockedFramesAreDistinctInstances() { + renderer.enqueue(animation(WALK)) + renderer.render() + renderer.lockFrame() + + val first = renderer.render().single() + val second = renderer.render().single() + + assertNotSame("Locked frames must be fresh instances", first, second) + assertFalse("Locked frames must not be equal", first == second) + } + + @Test + fun testWithoutAnyEffectFramesAreReusedAsIs() { + renderer.enqueue(animation(WALK, loop = INFINITE)) + val first = renderer.render() + val second = renderer.render() + + first.forEachIndexed { i, icon -> + assertSame("No effect is active, frames should come straight from the cache", icon, second[i]) + } + } + + @Test + fun testLockDoesNotAdvanceTheAnimationQueue() { + renderer.enqueue(animation(WALK, loop = 5)) + renderer.enqueue(animation(DMG)) + assertEquals(8, renderer.render().size) + + renderer.lockFrame() + repeat(20) { assertEquals(1, renderer.render().size) } + renderer.unlockFrame() + + repeat(4) { assertEquals("Loop $it was consumed while locked", 8, renderer.render().size) } + assertEquals("Only then move on to the queued animation", 2, renderer.render().size) + } + + @Test + fun testLockBeforeAnyAnimationIsRendered() { + renderer.lockFrame() + renderer.enqueue(animation(WALK)) + + assertEquals(1, renderer.render().size) + } +} diff --git a/src/test/kotlin/dev/stillya/vpet/graphics/effect/SleepEffectTest.kt b/src/test/kotlin/dev/stillya/vpet/graphics/effect/SleepEffectTest.kt new file mode 100644 index 0000000..5f39db6 --- /dev/null +++ b/src/test/kotlin/dev/stillya/vpet/graphics/effect/SleepEffectTest.kt @@ -0,0 +1,88 @@ +package dev.stillya.vpet.graphics.effect + +import dev.stillya.vpet.animation.AnimationState +import dev.stillya.vpet.AnimatedStatusBarWidget +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.awt.image.BufferedImage + +class SleepEffectTest { + + private companion object { + const val SIZE = 38 + const val PAD = SIZE + const val FRAME_MS = AnimatedStatusBarWidget.FRAME_RATE_MS + const val CYCLE_MS = SleepEffect.CYCLE_MS + } + + private var now = 1_000_000L + + private fun newEffect() = SleepEffect(SIZE, SIZE) { now } + + private fun renderPadded(effect: SleepEffect): BufferedImage { + val image = BufferedImage(SIZE + PAD * 2, SIZE + PAD * 2, BufferedImage.TYPE_INT_ARGB) + val g = image.createGraphics() + g.translate(PAD, PAD) + effect.apply(g, AnimationState.IDLE) + g.dispose() + return image + } + + private fun renderPixels(effect: SleepEffect): IntArray = + renderPadded(effect).getRGB(PAD, PAD, SIZE, SIZE, null, 0, SIZE) + + @Test + fun testDrawsOnlyInsideTheIconBox() { + val effect = newEffect() + var visiblePixels = 0 + + repeat(9) { + val image = renderPadded(effect) + for (y in 0 until image.height) { + for (x in 0 until image.width) { + if (image.getRGB(x, y) ushr 24 == 0) continue + val insideBox = x in PAD until PAD + SIZE && y in PAD until PAD + SIZE + assertTrue("Glyph drawn outside the icon box at ($x, $y)", insideBox) + visiblePixels++ + } + } + now += CYCLE_MS / 9 + } + + assertTrue("Effect should draw at least one visible glyph", visiblePixels > 0) + } + + @Test + fun testAdvancesEveryFrame() { + val effect = newEffect() + val first = renderPixels(effect) + + now += FRAME_MS + val second = renderPixels(effect) + + assertFalse("Phase should advance between two frames", first.contentEquals(second)) + } + + @Test + fun testIsDrivenByTheClockNotByRepaintCount() { + val effect = newEffect() + val first = renderPixels(effect) + repeat(5) { renderPixels(effect) } + val afterExtraRepaints = renderPixels(effect) + + assertArrayEquals("Extra repaints must not advance the phase", first, afterExtraRepaints) + } + + @Test + fun testLoopsAfterFullCycle() { + val effect = newEffect() + val first = renderPixels(effect) + + now += CYCLE_MS + val afterCycle = renderPixels(effect) + + assertArrayEquals("Effect should repeat after a full cycle", first, afterCycle) + } +} diff --git a/src/test/kotlin/dev/stillya/vpet/pet/PetAnimatedIntegrationTest.kt b/src/test/kotlin/dev/stillya/vpet/pet/PetAnimatedIntegrationTest.kt index 7bdf8b6..d4625f6 100644 --- a/src/test/kotlin/dev/stillya/vpet/pet/PetAnimatedIntegrationTest.kt +++ b/src/test/kotlin/dev/stillya/vpet/pet/PetAnimatedIntegrationTest.kt @@ -276,15 +276,85 @@ class PetAnimatedIntegrationTest : LightPlatform4TestCase() { assertTrue("Should flip when cursor moves to different sides", flippedLeft || flippedRight) } + + @Test + fun testIndexingLocksAndUnlocksTheFrame() { + petAnimated.onIndexingStart() + assertTrue("Frame should be locked while indexing", rendererSpy.isLocked) + + petAnimated.onIndexingFinish() + assertFalse("Frame should be unlocked once indexing is done", rendererSpy.isLocked) + } + + @Test + fun testIndexingDoesNotEnqueueAnimations() { + rendererSpy.clear() + + petAnimated.onIndexingStart() + + assertEquals( + "Sleeping must not touch the animation queue", + 0, + rendererSpy.enqueuedAnimations.size + ) + } + + @Test + fun testBuildWakesUpTheSleepingPet() { + petAnimated.onIndexingStart() + rendererSpy.clear() + + petAnimated.onProgress() + + assertFalse("Build should wake the pet", rendererSpy.isLocked) + assertTrue( + "Woken pet should play the running animation", + rendererSpy.collectChain().any { it.name == "Run" } + ) + } + + @Test + fun testIndexingFinishAfterBuildAlreadyWokeUpIsNoOp() { + petAnimated.onIndexingStart() + petAnimated.onProgress() + rendererSpy.clear() + + petAnimated.onIndexingFinish() + + assertFalse(rendererSpy.isLocked) + assertEquals( + "A late exitDumbMode must not disturb the running animation", + 0, + rendererSpy.enqueuedAnimations.size + ) + } + + @Test + fun testObservingIsSuppressedWhileSleeping() { + petAnimated.onIndexingStart() + rendererSpy.clear() + + petAnimated.onStartObserving() + + assertEquals( + "Observing would clear the locked frame and flip the sprite", + 0, + rendererSpy.enqueuedAnimations.size + ) + } } class IconRendererSpy(project: Project) : IconRenderer { val enqueuedAnimations = mutableListOf() private var flippedState: Boolean = false + private var lockedState: Boolean = false val isFlipped: Boolean get() = flippedState + val isLocked: Boolean + get() = lockedState + override fun enqueue(animation: Animation) { enqueuedAnimations.add(animation) } @@ -303,9 +373,18 @@ class IconRendererSpy(project: Project) : IconRenderer { flippedState = flipped } + override fun lockFrame() { + lockedState = true + } + + override fun unlockFrame() { + lockedState = false + } + fun clear() { enqueuedAnimations.clear() flippedState = false + lockedState = false } fun collectChain(): List {