From 346a2cb4f9b264174d80b7f7cd384452f3fc4b6e Mon Sep 17 00:00:00 2001 From: Daan Vanden Bosch Date: Sun, 8 Nov 2020 14:27:02 +0100 Subject: [PATCH] Added several unit tests and improved testing infra. --- .../core/disposable/TrackedDisposable.kt | 41 ++++++- lib/build.gradle.kts | 1 + .../lib/fileFormats/quest/QuestNpc.kt | 14 +++ .../phantasmal/lib/assembly/AssemblyTests.kt | 3 +- .../dataFlowAnalysis/ControlFlowGraphTests.kt | 3 +- .../dataFlowAnalysis/GetRegisterValueTests.kt | 3 +- .../dataFlowAnalysis/ValueSetTests.kt | 3 +- .../phantasmal/lib/buffer/BufferTests.kt | 3 +- .../lib/compression/prs/PrsCompressTests.kt | 3 +- .../lib/compression/prs/PrsDecompressTests.kt | 4 +- .../phantasmal/lib/cursor/CursorTests.kt | 3 +- .../lib/cursor/WritableCursorTests.kt | 6 +- .../fileFormats/AreaCollisionGeometryTests.kt | 28 +++++ .../lib/fileFormats/ninja/NinjaTests.kt | 4 +- .../lib/fileFormats/quest/BinTests.kt | 4 +- .../lib/fileFormats/quest/ByteCodeTests.kt | 3 +- .../lib/fileFormats/quest/DatTests.kt | 4 +- .../lib/fileFormats/quest/QstTests.kt | 6 +- .../lib/fileFormats/quest/QuestTests.kt | 4 +- .../world/phantasmal/lib/test/LibTestSuite.kt | 9 ++ .../world/phantasmal/lib/test/TestUtils.kt | 8 -- .../commonTest/resources/map_forest01c.rel | Bin 0 -> 72096 bytes .../world/phantasmal/lib/test/TestUtils.kt | 4 - .../world/phantasmal/lib/test/TestUtils.kt | 5 - .../phantasmal/observable/ObservableTests.kt | 4 +- .../observable/test/ObservableTestSuite.kt | 9 ++ .../observable/value/StaticValTests.kt | 4 +- .../observable/value/ValCreationTests.kt | 4 +- .../value/list/StaticListValTests.kt | 4 +- test-utils/build.gradle.kts | 8 ++ .../phantasmal/testUtils/AbstractTestSuite.kt | 28 +++++ .../world/phantasmal/testUtils/Assertions.kt | 12 +++ .../world/phantasmal/testUtils/AsyncTest.kt | 9 ++ .../world/phantasmal/testUtils/TestContext.kt | 11 ++ .../world/phantasmal/testUtils/TestSuite.kt | 26 ----- .../world/phantasmal/testUtils/AsyncTest.kt | 6 ++ .../world/phantasmal/testUtils/AsyncTest.kt | 9 ++ .../phantasmal/web/questEditor/QuestEditor.kt | 4 +- .../rendering/EntityManipulator.kt | 6 +- .../questEditor/widgets/QuestEditorWidget.kt | 7 +- .../world/phantasmal/web/viewer/Viewer.kt | 7 +- .../web/viewer/widgets/ViewerWidget.kt | 7 +- .../web/application/ApplicationTests.kt | 25 +---- .../PathAwareTabControllerTests.kt | 23 ++-- .../phantasmal/web/core/store/UiStoreTests.kt | 28 ++--- .../web/huntOptimizer/HuntOptimizerTests.kt | 33 ++---- .../web/questEditor/QuestEditorTests.kt | 28 +---- .../controllers/NpcCountsControllerTests.kt | 42 ++++++++ .../QuestEditorToolbarControllerTests.kt | 35 ++++++ .../controllers/QuestInfoControllerTests.kt | 36 +++++++ .../phantasmal/web/test/TestComponents.kt | 100 ++++++++++++++++++ .../world/phantasmal/web/test/TestModels.kt | 32 ++++++ .../phantasmal/web/test/WebTestContext.kt | 9 ++ .../world/phantasmal/web/test/WebTestSuite.kt | 8 ++ .../phantasmal/web/viewer/ViewerTests.kt | 15 +++ .../phantasmal/webui/test/WebuiTestSuite.kt | 9 ++ .../phantasmal/webui/widgets/WidgetTests.kt | 5 +- 57 files changed, 572 insertions(+), 187 deletions(-) create mode 100644 lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/AreaCollisionGeometryTests.kt create mode 100644 lib/src/commonTest/kotlin/world/phantasmal/lib/test/LibTestSuite.kt create mode 100644 lib/src/commonTest/resources/map_forest01c.rel create mode 100644 observable/src/commonTest/kotlin/world/phantasmal/observable/test/ObservableTestSuite.kt create mode 100644 test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/AbstractTestSuite.kt create mode 100644 test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/Assertions.kt create mode 100644 test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/AsyncTest.kt create mode 100644 test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/TestContext.kt delete mode 100644 test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/TestSuite.kt create mode 100644 test-utils/src/jsMain/kotlin/world/phantasmal/testUtils/AsyncTest.kt create mode 100644 test-utils/src/jvmMain/kotlin/world/phantasmal/testUtils/AsyncTest.kt create mode 100644 web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/NpcCountsControllerTests.kt create mode 100644 web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestEditorToolbarControllerTests.kt create mode 100644 web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestInfoControllerTests.kt create mode 100644 web/src/test/kotlin/world/phantasmal/web/test/TestComponents.kt create mode 100644 web/src/test/kotlin/world/phantasmal/web/test/TestModels.kt create mode 100644 web/src/test/kotlin/world/phantasmal/web/test/WebTestContext.kt create mode 100644 web/src/test/kotlin/world/phantasmal/web/test/WebTestSuite.kt create mode 100644 web/src/test/kotlin/world/phantasmal/web/viewer/ViewerTests.kt create mode 100644 webui/src/test/kotlin/world/phantasmal/webui/test/WebuiTestSuite.kt diff --git a/core/src/commonMain/kotlin/world/phantasmal/core/disposable/TrackedDisposable.kt b/core/src/commonMain/kotlin/world/phantasmal/core/disposable/TrackedDisposable.kt index 5a67495c..c669c16d 100644 --- a/core/src/commonMain/kotlin/world/phantasmal/core/disposable/TrackedDisposable.kt +++ b/core/src/commonMain/kotlin/world/phantasmal/core/disposable/TrackedDisposable.kt @@ -10,12 +10,18 @@ abstract class TrackedDisposable : Disposable { init { disposableCount++ + + if (trackPrecise) { + @Suppress("LeakingThis") + disposables.add(this) + } } final override fun dispose() { if (!disposed) { disposed = true disposableCount-- + disposables.remove(this) internalDispose() } } @@ -25,16 +31,45 @@ abstract class TrackedDisposable : Disposable { } companion object { + const val DISPOSABLE_PRINT_COUNT = 10 + + var disposables: MutableSet = mutableSetOf() + var trackPrecise = false var disposableCount: Int = 0 private set - fun checkNoLeaks(block: () -> Unit) { - val count = disposableCount + inline fun checkNoLeaks(trackPrecise: Boolean = false, block: () -> Unit) { + val initialCount = disposableCount + val initialTrackPrecise = this.trackPrecise + val initialDisposables = disposables + this.trackPrecise = trackPrecise + disposables = mutableSetOf() try { block() + checkLeaks(disposableCount - initialCount) } finally { - check(count == disposableCount) { "TrackedDisposables were leaked." } + this.trackPrecise = initialTrackPrecise + disposables = initialDisposables + } + } + + fun checkLeaks(leakCount: Int) { + buildString { + append("$leakCount TrackedDisposables were leaked") + + if (trackPrecise) { + append(": ") + disposables.take(DISPOSABLE_PRINT_COUNT).joinTo(this) { + it::class.simpleName ?: "Anonymous" + } + + if (disposables.size > DISPOSABLE_PRINT_COUNT) { + append(",..") + } + } + + append(".") } } } diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts index 92789a5f..2516b160 100644 --- a/lib/build.gradle.kts +++ b/lib/build.gradle.kts @@ -48,6 +48,7 @@ kotlin { dependencies { implementation(kotlin("test-common")) implementation(kotlin("test-annotations-common")) + implementation(project(":test-utils")) } } diff --git a/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/quest/QuestNpc.kt b/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/quest/QuestNpc.kt index eeec6512..b2888fd5 100644 --- a/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/quest/QuestNpc.kt +++ b/lib/src/commonMain/kotlin/world/phantasmal/lib/fileFormats/quest/QuestNpc.kt @@ -11,6 +11,20 @@ class QuestNpc( override var areaId: Int, val data: Buffer, ) : QuestEntity { + constructor( + type: NpcType, + episode: Episode, + areaId: Int, + wave: Int, + ) : this(episode, areaId, Buffer.withSize(NPC_BYTE_SIZE)) { + this.type = type + // TODO: Set default data. + // Set area_id after type, because you might want to overwrite the area_id that type has + // determined. + this.areaId = areaId + // TODO: Set wave properties. + } + var typeId: Short get() = data.getShort(0) set(value) { diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/AssemblyTests.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/AssemblyTests.kt index 576774d6..2eafde0a 100644 --- a/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/AssemblyTests.kt +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/AssemblyTests.kt @@ -1,11 +1,12 @@ package world.phantasmal.lib.assembly import world.phantasmal.core.Success +import world.phantasmal.lib.test.LibTestSuite import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -class AssemblyTests { +class AssemblyTests : LibTestSuite() { @Test fun assemble_basic_script() { val result = assemble(""" diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/dataFlowAnalysis/ControlFlowGraphTests.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/dataFlowAnalysis/ControlFlowGraphTests.kt index 0a47e656..f18f36b2 100644 --- a/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/dataFlowAnalysis/ControlFlowGraphTests.kt +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/dataFlowAnalysis/ControlFlowGraphTests.kt @@ -1,11 +1,12 @@ package world.phantasmal.lib.assembly.dataFlowAnalysis +import world.phantasmal.lib.test.LibTestSuite import world.phantasmal.lib.test.toInstructions import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -class ControlFlowGraphTests { +class ControlFlowGraphTests : LibTestSuite() { @Test fun single_instruction() { val im = toInstructions(""" diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/dataFlowAnalysis/GetRegisterValueTests.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/dataFlowAnalysis/GetRegisterValueTests.kt index 75a1e313..826f98ca 100644 --- a/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/dataFlowAnalysis/GetRegisterValueTests.kt +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/dataFlowAnalysis/GetRegisterValueTests.kt @@ -1,13 +1,14 @@ package world.phantasmal.lib.assembly.dataFlowAnalysis import world.phantasmal.lib.assembly.* +import world.phantasmal.lib.test.LibTestSuite import world.phantasmal.lib.test.toInstructions import kotlin.test.Test import kotlin.test.assertEquals private const val MAX_REGISTER_VALUES_SIZE: Long = 1L shl 32 -class GetRegisterValueTests { +class GetRegisterValueTests : LibTestSuite() { @Test fun when_no_instruction_sets_the_register_zero_is_returned() { val im = toInstructions(""" diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/dataFlowAnalysis/ValueSetTests.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/dataFlowAnalysis/ValueSetTests.kt index 7175b26f..b52bea3d 100644 --- a/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/dataFlowAnalysis/ValueSetTests.kt +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/assembly/dataFlowAnalysis/ValueSetTests.kt @@ -1,11 +1,12 @@ package world.phantasmal.lib.assembly.dataFlowAnalysis +import world.phantasmal.lib.test.LibTestSuite import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -class ValueSetTests { +class ValueSetTests : LibTestSuite() { @Test fun empty_set_has_size_0() { val vs = ValueSet.empty() diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/buffer/BufferTests.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/buffer/BufferTests.kt index c525c484..9eb203a5 100644 --- a/lib/src/commonTest/kotlin/world/phantasmal/lib/buffer/BufferTests.kt +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/buffer/BufferTests.kt @@ -1,11 +1,12 @@ package world.phantasmal.lib.buffer import world.phantasmal.lib.Endianness +import world.phantasmal.lib.test.LibTestSuite import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -class BufferTests { +class BufferTests : LibTestSuite() { @Test fun withCapacity() { withCapacity(Endianness.Little) diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/compression/prs/PrsCompressTests.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/compression/prs/PrsCompressTests.kt index ff7d7748..9791a5e9 100644 --- a/lib/src/commonTest/kotlin/world/phantasmal/lib/compression/prs/PrsCompressTests.kt +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/compression/prs/PrsCompressTests.kt @@ -2,11 +2,12 @@ package world.phantasmal.lib.compression.prs import world.phantasmal.lib.buffer.Buffer import world.phantasmal.lib.cursor.cursor +import world.phantasmal.lib.test.LibTestSuite import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals -class PrsCompressTests { +class PrsCompressTests : LibTestSuite() { @Test fun edge_case_0_bytes() { val compressed = prsCompress(Buffer.withSize(0).cursor()) diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/compression/prs/PrsDecompressTests.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/compression/prs/PrsDecompressTests.kt index ea570dfa..429e03de 100644 --- a/lib/src/commonTest/kotlin/world/phantasmal/lib/compression/prs/PrsDecompressTests.kt +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/compression/prs/PrsDecompressTests.kt @@ -3,13 +3,13 @@ package world.phantasmal.lib.compression.prs import world.phantasmal.lib.buffer.Buffer import world.phantasmal.lib.cursor.Cursor import world.phantasmal.lib.cursor.cursor -import world.phantasmal.lib.test.asyncTest +import world.phantasmal.lib.test.LibTestSuite import world.phantasmal.lib.test.readFile import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals -class PrsDecompressTests { +class PrsDecompressTests : LibTestSuite() { @Test fun edge_case_0_bytes() { testWithBuffer(Buffer.withSize(0)) diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/cursor/CursorTests.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/cursor/CursorTests.kt index a60fb48d..fcf3ced1 100644 --- a/lib/src/commonTest/kotlin/world/phantasmal/lib/cursor/CursorTests.kt +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/cursor/CursorTests.kt @@ -1,6 +1,7 @@ package world.phantasmal.lib.cursor import world.phantasmal.lib.Endianness +import world.phantasmal.lib.test.LibTestSuite import kotlin.test.Test import kotlin.test.assertEquals @@ -8,7 +9,7 @@ import kotlin.test.assertEquals * Test suite for all [Cursor] implementations. There is a subclass of this suite for every [Cursor] * implementation. */ -abstract class CursorTests { +abstract class CursorTests : LibTestSuite() { abstract fun createCursor( bytes: ByteArray, endianness: Endianness, diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/cursor/WritableCursorTests.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/cursor/WritableCursorTests.kt index e54dc818..7507da83 100644 --- a/lib/src/commonTest/kotlin/world/phantasmal/lib/cursor/WritableCursorTests.kt +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/cursor/WritableCursorTests.kt @@ -2,7 +2,7 @@ package world.phantasmal.lib.cursor import world.phantasmal.lib.Endianness import world.phantasmal.lib.buffer.Buffer -import kotlin.math.abs +import world.phantasmal.testUtils.assertCloseTo import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -144,8 +144,8 @@ abstract class WritableCursorTests : CursorTests() { // The read floats won't be exactly the same as the written floats in Kotlin JS, because // they're backed by numbers (64-bit floats). - assertTrue(abs(1337.9001f - cursor.float()) < 0.001) - assertTrue(abs(103.502f - cursor.float()) < 0.001) + assertCloseTo(1337.9001f, cursor.float(), epsilon = 0.001f) + assertCloseTo(103.502f, cursor.float(), epsilon = 0.001f) assertEquals(8, cursor.position) } diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/AreaCollisionGeometryTests.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/AreaCollisionGeometryTests.kt new file mode 100644 index 00000000..edb42c42 --- /dev/null +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/AreaCollisionGeometryTests.kt @@ -0,0 +1,28 @@ +package world.phantasmal.lib.fileFormats + +import world.phantasmal.lib.test.LibTestSuite +import world.phantasmal.lib.test.readFile +import world.phantasmal.testUtils.assertCloseTo +import kotlin.test.Test +import kotlin.test.assertEquals + +class AreaCollisionGeometryTests : LibTestSuite() { + @Test + fun parse_forest_1() = asyncTest { + val obj = parseAreaCollisionGeometry(readFile("/map_forest01c.rel")) + + assertEquals(69, obj.meshes.size) + assertEquals(11, obj.meshes[0].vertices.size) + assertCloseTo(-589.5195f, obj.meshes[0].vertices[0].x) + assertCloseTo(16.7166f, obj.meshes[0].vertices[0].y) + assertCloseTo(-218.6852f, obj.meshes[0].vertices[0].z) + assertEquals(12, obj.meshes[0].triangles.size) + assertEquals(0b100000001, obj.meshes[0].triangles[0].flags) + assertEquals(5, obj.meshes[0].triangles[0].index1) + assertEquals(0, obj.meshes[0].triangles[0].index2) + assertEquals(7, obj.meshes[0].triangles[0].index3) + assertCloseTo(0.0137f, obj.meshes[0].triangles[0].normal.x) + assertCloseTo(0.9994f, obj.meshes[0].triangles[0].normal.y) + assertCloseTo(-0.0307f, obj.meshes[0].triangles[0].normal.z) + } +} diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/ninja/NinjaTests.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/ninja/NinjaTests.kt index b5bd160a..6f1c16c2 100644 --- a/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/ninja/NinjaTests.kt +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/ninja/NinjaTests.kt @@ -1,13 +1,13 @@ package world.phantasmal.lib.fileFormats.ninja import world.phantasmal.core.Success -import world.phantasmal.lib.test.asyncTest +import world.phantasmal.lib.test.LibTestSuite import world.phantasmal.lib.test.readFile import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -class NinjaTests { +class NinjaTests : LibTestSuite() { @Test fun can_parse_rag_rappy_model() = asyncTest { val result = parseNj(readFile("/RagRappy.nj")) diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/BinTests.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/BinTests.kt index d90f724e..f0fa9b40 100644 --- a/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/BinTests.kt +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/BinTests.kt @@ -1,11 +1,11 @@ package world.phantasmal.lib.fileFormats.quest -import world.phantasmal.lib.test.asyncTest +import world.phantasmal.lib.test.LibTestSuite import world.phantasmal.lib.test.readFile import kotlin.test.Test import kotlin.test.assertEquals -class BinTests { +class BinTests : LibTestSuite() { @Test fun parse_quest_towards_the_future() = asyncTest { val bin = parseBin(readFile("/quest118_e_decompressed.bin")) diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/ByteCodeTests.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/ByteCodeTests.kt index 74c37159..96776a8b 100644 --- a/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/ByteCodeTests.kt +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/ByteCodeTests.kt @@ -5,11 +5,12 @@ import world.phantasmal.lib.assembly.InstructionSegment import world.phantasmal.lib.assembly.OP_BB_MAP_DESIGNATE import world.phantasmal.lib.assembly.OP_SET_EPISODE import world.phantasmal.lib.buffer.Buffer +import world.phantasmal.lib.test.LibTestSuite import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -class ByteCodeTests { +class ByteCodeTests : LibTestSuite() { @Test fun minimal() { val buffer = Buffer.fromByteArray(ubyteArrayOf( diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/DatTests.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/DatTests.kt index 7a24aae4..e19455f7 100644 --- a/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/DatTests.kt +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/DatTests.kt @@ -1,11 +1,11 @@ package world.phantasmal.lib.fileFormats.quest -import world.phantasmal.lib.test.asyncTest +import world.phantasmal.lib.test.LibTestSuite import world.phantasmal.lib.test.readFile import kotlin.test.Test import kotlin.test.assertEquals -class DatTests { +class DatTests : LibTestSuite() { @Test fun parse_quest_towards_the_future() = asyncTest { val dat = parseDat(readFile("/quest118_e_decompressed.dat")) diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/QstTests.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/QstTests.kt index dcd7345a..bf56f091 100644 --- a/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/QstTests.kt +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/QstTests.kt @@ -1,14 +1,14 @@ package world.phantasmal.lib.fileFormats.quest -import world.phantasmal.lib.test.asyncTest +import world.phantasmal.lib.test.LibTestSuite import world.phantasmal.lib.test.readFile import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -class QstTests { +class QstTests : LibTestSuite() { @Test - fun parse_a_GC_quest() = asyncTest{ + fun parse_a_GC_quest() = asyncTest { val cursor = readFile("/lost_heat_sword_gc.qst") val qst = parseQst(cursor).unwrap() diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/QuestTests.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/QuestTests.kt index f27a95fc..412d3fd9 100644 --- a/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/QuestTests.kt +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/fileFormats/quest/QuestTests.kt @@ -2,13 +2,13 @@ package world.phantasmal.lib.fileFormats.quest import world.phantasmal.core.Success import world.phantasmal.lib.assembly.* -import world.phantasmal.lib.test.asyncTest +import world.phantasmal.lib.test.LibTestSuite import world.phantasmal.lib.test.readFile import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -class QuestTests { +class QuestTests : LibTestSuite() { @Test fun parseBinDatToQuest_with_towards_the_future() = asyncTest { val result = parseBinDatToQuest(readFile("/quest118_e.bin"), readFile("/quest118_e.dat")) diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/test/LibTestSuite.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/test/LibTestSuite.kt new file mode 100644 index 00000000..e85236a3 --- /dev/null +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/test/LibTestSuite.kt @@ -0,0 +1,9 @@ +package world.phantasmal.lib.test + +import world.phantasmal.core.disposable.Disposer +import world.phantasmal.testUtils.AbstractTestSuite +import world.phantasmal.testUtils.TestContext + +abstract class LibTestSuite : AbstractTestSuite() { + override fun createContext(disposer: Disposer) = TestContext(disposer) +} diff --git a/lib/src/commonTest/kotlin/world/phantasmal/lib/test/TestUtils.kt b/lib/src/commonTest/kotlin/world/phantasmal/lib/test/TestUtils.kt index f2774881..7e52c784 100644 --- a/lib/src/commonTest/kotlin/world/phantasmal/lib/test/TestUtils.kt +++ b/lib/src/commonTest/kotlin/world/phantasmal/lib/test/TestUtils.kt @@ -6,14 +6,6 @@ import world.phantasmal.lib.assembly.assemble import world.phantasmal.lib.cursor.Cursor import kotlin.test.assertTrue -/** - * Ensure you return the value of this function in your test function. On Kotlin/JS this function - * actually returns a Promise. If this promise is not returned from the test function, the testing - * framework won't wait for its completion. This is a workaround for issue - * [https://youtrack.jetbrains.com/issue/KT-22228]. - */ -expect fun asyncTest(block: suspend () -> Unit) - expect suspend fun readFile(path: String): Cursor fun toInstructions(assembly: String): List { diff --git a/lib/src/commonTest/resources/map_forest01c.rel b/lib/src/commonTest/resources/map_forest01c.rel new file mode 100644 index 0000000000000000000000000000000000000000..92d9d34d7aaf1925972b8fc487c3272dac3f8f41 GIT binary patch literal 72096 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 literal 0 HcmV?d00001 diff --git a/lib/src/jsTest/kotlin/world/phantasmal/lib/test/TestUtils.kt b/lib/src/jsTest/kotlin/world/phantasmal/lib/test/TestUtils.kt index 98d5c292..d51a241e 100644 --- a/lib/src/jsTest/kotlin/world/phantasmal/lib/test/TestUtils.kt +++ b/lib/src/jsTest/kotlin/world/phantasmal/lib/test/TestUtils.kt @@ -1,15 +1,11 @@ package world.phantasmal.lib.test import kotlinx.browser.window -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.await -import kotlinx.coroutines.promise import world.phantasmal.lib.Endianness import world.phantasmal.lib.cursor.ArrayBufferCursor import world.phantasmal.lib.cursor.Cursor -actual fun asyncTest(block: suspend () -> Unit): dynamic = GlobalScope.promise { block() } - actual suspend fun readFile(path: String): Cursor { return window.fetch(path) .then { diff --git a/lib/src/jvmTest/kotlin/world/phantasmal/lib/test/TestUtils.kt b/lib/src/jvmTest/kotlin/world/phantasmal/lib/test/TestUtils.kt index b180192e..9380e76f 100644 --- a/lib/src/jvmTest/kotlin/world/phantasmal/lib/test/TestUtils.kt +++ b/lib/src/jvmTest/kotlin/world/phantasmal/lib/test/TestUtils.kt @@ -2,15 +2,10 @@ package world.phantasmal.lib.test -import kotlinx.coroutines.runBlocking import world.phantasmal.lib.buffer.Buffer import world.phantasmal.lib.cursor.Cursor import world.phantasmal.lib.cursor.cursor -actual fun asyncTest(block: suspend () -> Unit) { - runBlocking { block() } -} - actual suspend fun readFile(path: String): Cursor { val stream = {}::class.java.getResourceAsStream(path) ?: error("""Couldn't load resource "$path".""") diff --git a/observable/src/commonTest/kotlin/world/phantasmal/observable/ObservableTests.kt b/observable/src/commonTest/kotlin/world/phantasmal/observable/ObservableTests.kt index 01eab244..11c17f2d 100644 --- a/observable/src/commonTest/kotlin/world/phantasmal/observable/ObservableTests.kt +++ b/observable/src/commonTest/kotlin/world/phantasmal/observable/ObservableTests.kt @@ -1,6 +1,6 @@ package world.phantasmal.observable -import world.phantasmal.testUtils.TestSuite +import world.phantasmal.observable.test.ObservableTestSuite import kotlin.test.Test import kotlin.test.assertEquals @@ -10,7 +10,7 @@ typealias ObservableAndEmit = Pair, () -> Unit> * Test suite for all [Observable] implementations. There is a subclass of this suite for every * [Observable] implementation. */ -abstract class ObservableTests : TestSuite() { +abstract class ObservableTests : ObservableTestSuite() { protected abstract fun create(): ObservableAndEmit @Test diff --git a/observable/src/commonTest/kotlin/world/phantasmal/observable/test/ObservableTestSuite.kt b/observable/src/commonTest/kotlin/world/phantasmal/observable/test/ObservableTestSuite.kt new file mode 100644 index 00000000..09d6f199 --- /dev/null +++ b/observable/src/commonTest/kotlin/world/phantasmal/observable/test/ObservableTestSuite.kt @@ -0,0 +1,9 @@ +package world.phantasmal.observable.test + +import world.phantasmal.core.disposable.Disposer +import world.phantasmal.testUtils.AbstractTestSuite +import world.phantasmal.testUtils.TestContext + +abstract class ObservableTestSuite : AbstractTestSuite() { + override fun createContext(disposer: Disposer) = TestContext(disposer) +} diff --git a/observable/src/commonTest/kotlin/world/phantasmal/observable/value/StaticValTests.kt b/observable/src/commonTest/kotlin/world/phantasmal/observable/value/StaticValTests.kt index 1ee9c19a..087dc7a6 100644 --- a/observable/src/commonTest/kotlin/world/phantasmal/observable/value/StaticValTests.kt +++ b/observable/src/commonTest/kotlin/world/phantasmal/observable/value/StaticValTests.kt @@ -1,10 +1,10 @@ package world.phantasmal.observable.value -import world.phantasmal.testUtils.TestSuite +import world.phantasmal.observable.test.ObservableTestSuite import kotlin.test.Test import kotlin.test.assertEquals -class StaticValTests : TestSuite() { +class StaticValTests : ObservableTestSuite() { @Test fun observing_StaticVal_should_never_create_leaks() = test { val static = StaticVal("test value") diff --git a/observable/src/commonTest/kotlin/world/phantasmal/observable/value/ValCreationTests.kt b/observable/src/commonTest/kotlin/world/phantasmal/observable/value/ValCreationTests.kt index 7841d97c..9b3abfbf 100644 --- a/observable/src/commonTest/kotlin/world/phantasmal/observable/value/ValCreationTests.kt +++ b/observable/src/commonTest/kotlin/world/phantasmal/observable/value/ValCreationTests.kt @@ -1,9 +1,9 @@ package world.phantasmal.observable.value -import world.phantasmal.testUtils.TestSuite +import world.phantasmal.observable.test.ObservableTestSuite import kotlin.test.* -class ValCreationTests : TestSuite() { +class ValCreationTests : ObservableTestSuite() { @Test fun test_value() = test { assertEquals(7, value(7).value) diff --git a/observable/src/commonTest/kotlin/world/phantasmal/observable/value/list/StaticListValTests.kt b/observable/src/commonTest/kotlin/world/phantasmal/observable/value/list/StaticListValTests.kt index a7b95002..50358b53 100644 --- a/observable/src/commonTest/kotlin/world/phantasmal/observable/value/list/StaticListValTests.kt +++ b/observable/src/commonTest/kotlin/world/phantasmal/observable/value/list/StaticListValTests.kt @@ -1,10 +1,10 @@ package world.phantasmal.observable.value.list -import world.phantasmal.testUtils.TestSuite +import world.phantasmal.observable.test.ObservableTestSuite import kotlin.test.Test import kotlin.test.assertEquals -class StaticListValTests : TestSuite() { +class StaticListValTests : ObservableTestSuite() { @Test fun observing_StaticListVal_should_never_create_leaks() = test { val static = StaticListVal(listOf(1, 2, 3)) diff --git a/test-utils/build.gradle.kts b/test-utils/build.gradle.kts index ac807f02..fe5c84df 100644 --- a/test-utils/build.gradle.kts +++ b/test-utils/build.gradle.kts @@ -9,6 +9,8 @@ kotlin { browser {} } + jvm() + sourceSets { commonMain { dependencies { @@ -24,5 +26,11 @@ kotlin { api(kotlin("test-js")) } } + + named("jvmMain") { + dependencies { + api(kotlin("test")) + } + } } } diff --git a/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/AbstractTestSuite.kt b/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/AbstractTestSuite.kt new file mode 100644 index 00000000..2f415dc9 --- /dev/null +++ b/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/AbstractTestSuite.kt @@ -0,0 +1,28 @@ +package world.phantasmal.testUtils + +import world.phantasmal.core.disposable.Disposer +import world.phantasmal.core.disposable.TrackedDisposable + +abstract class AbstractTestSuite { + fun test(testBlock: Ctx.() -> Unit) { + TrackedDisposable.checkNoLeaks(trackPrecise = true) { + val disposer = Disposer() + + testBlock(createContext(disposer)) + + disposer.dispose() + } + } + + fun asyncTest(testBlock: suspend Ctx.() -> Unit) = world.phantasmal.testUtils.asyncTest { + TrackedDisposable.checkNoLeaks(trackPrecise = true) { + val disposer = Disposer() + + testBlock(createContext(disposer)) + + disposer.dispose() + } + } + + protected abstract fun createContext(disposer: Disposer): Ctx +} diff --git a/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/Assertions.kt b/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/Assertions.kt new file mode 100644 index 00000000..676656fa --- /dev/null +++ b/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/Assertions.kt @@ -0,0 +1,12 @@ +package world.phantasmal.testUtils + +import kotlin.math.abs +import kotlin.test.assertTrue + +fun assertCloseTo(expected: Double, actual: Double, epsilon: Double = 0.001) { + assertTrue(abs(expected - actual) <= epsilon) +} + +fun assertCloseTo(expected: Float, actual: Float, epsilon: Float = 0.001f) { + assertTrue(abs(expected - actual) <= epsilon) +} diff --git a/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/AsyncTest.kt b/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/AsyncTest.kt new file mode 100644 index 00000000..8f43f8bb --- /dev/null +++ b/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/AsyncTest.kt @@ -0,0 +1,9 @@ +package world.phantasmal.testUtils + +/** + * Ensure you return the value of this function in your test function. On Kotlin/JS this function + * actually returns a Promise. If this promise is not returned from the test function, the testing + * framework won't wait for its completion. This is a workaround for issue + * [https://youtrack.jetbrains.com/issue/KT-22228]. + */ +expect fun asyncTest(block: suspend () -> Unit) diff --git a/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/TestContext.kt b/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/TestContext.kt new file mode 100644 index 00000000..6ff678a9 --- /dev/null +++ b/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/TestContext.kt @@ -0,0 +1,11 @@ +package world.phantasmal.testUtils + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import world.phantasmal.core.disposable.Disposer + +open class TestContext(val disposer: Disposer) { + val scope: CoroutineScope = object : CoroutineScope { + override val coroutineContext = Job() + } +} diff --git a/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/TestSuite.kt b/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/TestSuite.kt deleted file mode 100644 index 935d6462..00000000 --- a/test-utils/src/commonMain/kotlin/world/phantasmal/testUtils/TestSuite.kt +++ /dev/null @@ -1,26 +0,0 @@ -package world.phantasmal.testUtils - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import world.phantasmal.core.disposable.Disposer -import world.phantasmal.core.disposable.TrackedDisposable -import kotlin.test.assertEquals - -abstract class TestSuite { - fun test(block: TestContext.() -> Unit) { - val initialDisposableCount = TrackedDisposable.disposableCount - val disposer = Disposer() - - block(TestContext(disposer)) - - disposer.dispose() - val leakCount = TrackedDisposable.disposableCount - initialDisposableCount - assertEquals(0, leakCount, "TrackedDisposables were leaked") - } - - class TestContext(val disposer: Disposer) { - val scope: CoroutineScope = object : CoroutineScope { - override val coroutineContext = Job() - } - } -} diff --git a/test-utils/src/jsMain/kotlin/world/phantasmal/testUtils/AsyncTest.kt b/test-utils/src/jsMain/kotlin/world/phantasmal/testUtils/AsyncTest.kt new file mode 100644 index 00000000..6c88177e --- /dev/null +++ b/test-utils/src/jsMain/kotlin/world/phantasmal/testUtils/AsyncTest.kt @@ -0,0 +1,6 @@ +package world.phantasmal.testUtils + +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.promise + +actual fun asyncTest(block: suspend () -> Unit): dynamic = GlobalScope.promise { block() } diff --git a/test-utils/src/jvmMain/kotlin/world/phantasmal/testUtils/AsyncTest.kt b/test-utils/src/jvmMain/kotlin/world/phantasmal/testUtils/AsyncTest.kt new file mode 100644 index 00000000..31a077f1 --- /dev/null +++ b/test-utils/src/jvmMain/kotlin/world/phantasmal/testUtils/AsyncTest.kt @@ -0,0 +1,9 @@ +@file:JvmName("AsyncTestJvm") + +package world.phantasmal.testUtils + +import kotlinx.coroutines.runBlocking + +actual fun asyncTest(block: suspend () -> Unit) { + runBlocking { block() } +} diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/QuestEditor.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/QuestEditor.kt index 09293862..430c9960 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/QuestEditor.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/QuestEditor.kt @@ -13,8 +13,8 @@ import world.phantasmal.web.questEditor.controllers.QuestInfoController import world.phantasmal.web.questEditor.loading.AreaAssetLoader import world.phantasmal.web.questEditor.loading.EntityAssetLoader import world.phantasmal.web.questEditor.loading.QuestLoader -import world.phantasmal.web.questEditor.rendering.QuestEditorMeshManager import world.phantasmal.web.questEditor.rendering.EntityManipulator +import world.phantasmal.web.questEditor.rendering.QuestEditorMeshManager import world.phantasmal.web.questEditor.rendering.QuestRenderer import world.phantasmal.web.questEditor.stores.AreaStore import world.phantasmal.web.questEditor.stores.QuestEditorStore @@ -63,7 +63,7 @@ class QuestEditor( // Main Widget return QuestEditorWidget( scope, - QuestEditorToolbar(scope, toolbarController), + { s -> QuestEditorToolbar(s, toolbarController) }, { s -> QuestInfoWidget(s, questInfoController) }, { s -> NpcCountsWidget(s, npcCountsController) }, { s -> QuestEditorRendererWidget(s, canvas, renderer) } diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityManipulator.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityManipulator.kt index 443ddb1e..c7ca3c2d 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityManipulator.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityManipulator.kt @@ -39,17 +39,13 @@ class EntityManipulator( init { state = IdleState(questEditorStore, renderer, enabled) - observe(questEditorStore.selectedEntity, ::selectedEntityChanged) + observe(questEditorStore.selectedEntity) { state.cancel() } addDisposables( disposableListener(renderer.canvas, "pointerdown", ::onPointerDown) ) } - private fun selectedEntityChanged(entity: QuestEntityModel<*, *>?) { - state.cancel() - } - private fun onPointerDown(e: PointerEvent) { processPointerEvent(e) diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorWidget.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorWidget.kt index 54a88477..1eb65395 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorWidget.kt @@ -17,9 +17,12 @@ private class TestWidget(scope: CoroutineScope) : Widget(scope) { } } +/** + * Takes ownership of the widgets created by the given createWidget functions. + */ class QuestEditorWidget( scope: CoroutineScope, - private val toolbar: Widget, + private val createToolbar: (CoroutineScope) -> Widget, private val createQuestInfoWidget: (CoroutineScope) -> Widget, private val createNpcCountsWidget: (CoroutineScope) -> Widget, private val createQuestRendererWidget: (CoroutineScope) -> Widget, @@ -28,7 +31,7 @@ class QuestEditorWidget( div { className = "pw-quest-editor-quest-editor" - addChild(toolbar) + addChild(createToolbar(scope)) addChild(DockWidget( scope, item = DockedRow( diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/Viewer.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/Viewer.kt index 38447275..13a6b984 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/Viewer.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/Viewer.kt @@ -31,6 +31,11 @@ class Viewer( val renderer = addDisposable(MeshRenderer(viewerStore, canvas, createEngine(canvas))) // Main Widget - return ViewerWidget(scope, ViewerToolbar(scope, viewerToolbarController), canvas, renderer) + return ViewerWidget( + scope, + { s -> ViewerToolbar(s, viewerToolbarController) }, + canvas, + renderer + ) } } diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerWidget.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerWidget.kt index 3b5a8f49..332d3461 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerWidget.kt @@ -8,9 +8,12 @@ import world.phantasmal.web.core.widgets.RendererWidget import world.phantasmal.webui.dom.div import world.phantasmal.webui.widgets.Widget +/** + * Takes ownership of the widget returned by [createToolbar]. + */ class ViewerWidget( scope: CoroutineScope, - private val toolbar: Widget, + private val createToolbar: (CoroutineScope) -> Widget, private val canvas: HTMLCanvasElement, private val renderer: Renderer, ) : Widget(scope) { @@ -18,7 +21,7 @@ class ViewerWidget( div { className = "pw-viewer-viewer" - addChild(toolbar) + addChild(createToolbar(scope)) div { className = "pw-viewer-viewer-container" diff --git a/web/src/test/kotlin/world/phantasmal/web/application/ApplicationTests.kt b/web/src/test/kotlin/world/phantasmal/web/application/ApplicationTests.kt index db6726ca..3b8dcec9 100644 --- a/web/src/test/kotlin/world/phantasmal/web/application/ApplicationTests.kt +++ b/web/src/test/kotlin/world/phantasmal/web/application/ApplicationTests.kt @@ -1,41 +1,26 @@ package world.phantasmal.web.application -import io.ktor.client.* -import io.ktor.client.features.json.* -import io.ktor.client.features.json.serializer.* import kotlinx.browser.document -import kotlinx.coroutines.cancel import world.phantasmal.core.disposable.Disposer -import world.phantasmal.core.disposable.disposable import world.phantasmal.core.disposable.use -import world.phantasmal.testUtils.TestSuite -import world.phantasmal.web.core.loading.AssetLoader -import world.phantasmal.web.core.PwTool +import world.phantasmal.web.core.PwToolType import world.phantasmal.web.externals.babylon.Engine import world.phantasmal.web.test.TestApplicationUrl +import world.phantasmal.web.test.WebTestSuite import kotlin.test.Test -class ApplicationTests : TestSuite() { +class ApplicationTests : WebTestSuite() { @Test fun initialization_and_shutdown_should_succeed_without_throwing() = test { - (listOf(null) + PwTool.values().toList()).forEach { tool -> + (listOf(null) + PwToolType.values().toList()).forEach { tool -> Disposer().use { disposer -> - val httpClient = HttpClient { - install(JsonFeature) { - serializer = KotlinxSerializer(kotlinx.serialization.json.Json { - ignoreUnknownKeys = true - }) - } - } - disposer.add(disposable { httpClient.cancel() }) - val appUrl = TestApplicationUrl(if (tool == null) "" else "/${tool.slug}") disposer.add( Application( scope, rootElement = document.body!!, - assetLoader = AssetLoader(basePath = "", httpClient), + assetLoader = components.assetLoader, applicationUrl = appUrl, createEngine = { Engine(it) } ) diff --git a/web/src/test/kotlin/world/phantasmal/web/core/controllers/PathAwareTabControllerTests.kt b/web/src/test/kotlin/world/phantasmal/web/core/controllers/PathAwareTabControllerTests.kt index 38a52487..4ad744a8 100644 --- a/web/src/test/kotlin/world/phantasmal/web/core/controllers/PathAwareTabControllerTests.kt +++ b/web/src/test/kotlin/world/phantasmal/web/core/controllers/PathAwareTabControllerTests.kt @@ -1,14 +1,15 @@ package world.phantasmal.web.core.controllers -import world.phantasmal.testUtils.TestSuite -import world.phantasmal.web.core.PwTool +import world.phantasmal.testUtils.TestContext +import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.stores.UiStore import world.phantasmal.web.test.TestApplicationUrl +import world.phantasmal.web.test.WebTestSuite import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -class PathAwareTabControllerTests : TestSuite() { +class PathAwareTabControllerTests : WebTestSuite() { @Test fun activeTab_is_initialized_correctly() = test { setup { ctrl, appUrl -> @@ -23,7 +24,7 @@ class PathAwareTabControllerTests : TestSuite() { setup { ctrl, appUrl -> ctrl.setActiveTab(ctrl.tabs[2]) - assertEquals("/${PwTool.HuntOptimizer.slug}/c", appUrl.url.value) + assertEquals("/${PwToolType.HuntOptimizer.slug}/c", appUrl.url.value) assertEquals(1, appUrl.historyEntries) assertFalse(appUrl.canGoForward) } @@ -32,7 +33,7 @@ class PathAwareTabControllerTests : TestSuite() { @Test fun activeTab_changes_when_applicationUrl_changes() = test { setup { ctrl, applicationUrl -> - applicationUrl.pushUrl("/${PwTool.HuntOptimizer.slug}/c") + applicationUrl.pushUrl("/${PwToolType.HuntOptimizer.slug}/c") assertEquals("/c", ctrl.activeTab.value?.path) } @@ -44,7 +45,7 @@ class PathAwareTabControllerTests : TestSuite() { val uiStore = disposer.add(UiStore(scope, appUrl)) disposer.add( - PathAwareTabController(scope, uiStore, PwTool.HuntOptimizer, listOf( + PathAwareTabController(uiStore, PwToolType.HuntOptimizer, listOf( PathAwareTab("A", "/a"), PathAwareTab("B", "/b"), PathAwareTab("C", "/c"), @@ -55,11 +56,11 @@ class PathAwareTabControllerTests : TestSuite() { assertFalse(appUrl.canGoForward) assertEquals("/${uiStore.defaultTool.slug}", appUrl.url.value) - uiStore.setCurrentTool(PwTool.HuntOptimizer) + uiStore.setCurrentTool(PwToolType.HuntOptimizer) assertEquals(1, appUrl.historyEntries) assertFalse(appUrl.canGoForward) - assertEquals("/${PwTool.HuntOptimizer.slug}", appUrl.url.value) + assertEquals("/${PwToolType.HuntOptimizer.slug}", appUrl.url.value) appUrl.back() @@ -69,12 +70,12 @@ class PathAwareTabControllerTests : TestSuite() { private fun TestContext.setup( block: (PathAwareTabController, applicationUrl: TestApplicationUrl) -> Unit, ) { - val applicationUrl = TestApplicationUrl("/${PwTool.HuntOptimizer.slug}/b") + val applicationUrl = TestApplicationUrl("/${PwToolType.HuntOptimizer.slug}/b") val uiStore = disposer.add(UiStore(scope, applicationUrl)) - uiStore.setCurrentTool(PwTool.HuntOptimizer) + uiStore.setCurrentTool(PwToolType.HuntOptimizer) val ctrl = disposer.add( - PathAwareTabController(scope, uiStore, PwTool.HuntOptimizer, listOf( + PathAwareTabController(uiStore, PwToolType.HuntOptimizer, listOf( PathAwareTab("A", "/a"), PathAwareTab("B", "/b"), PathAwareTab("C", "/c"), diff --git a/web/src/test/kotlin/world/phantasmal/web/core/store/UiStoreTests.kt b/web/src/test/kotlin/world/phantasmal/web/core/store/UiStoreTests.kt index 64a9a5ca..df05018f 100644 --- a/web/src/test/kotlin/world/phantasmal/web/core/store/UiStoreTests.kt +++ b/web/src/test/kotlin/world/phantasmal/web/core/store/UiStoreTests.kt @@ -1,20 +1,20 @@ package world.phantasmal.web.core.store -import world.phantasmal.testUtils.TestSuite -import world.phantasmal.web.core.PwTool +import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.stores.UiStore import world.phantasmal.web.test.TestApplicationUrl +import world.phantasmal.web.test.WebTestSuite import kotlin.test.Test import kotlin.test.assertEquals -class UiStoreTests : TestSuite() { +class UiStoreTests : WebTestSuite() { @Test fun applicationUrl_is_initialized_correctly() = test { val applicationUrl = TestApplicationUrl("/") val uiStore = disposer.add(UiStore(scope, applicationUrl)) - assertEquals(PwTool.Viewer, uiStore.currentTool.value) - assertEquals("/${PwTool.Viewer.slug}", applicationUrl.url.value) + assertEquals(PwToolType.Viewer, uiStore.currentTool.value) + assertEquals("/${PwToolType.Viewer.slug}", applicationUrl.url.value) } @Test @@ -22,7 +22,7 @@ class UiStoreTests : TestSuite() { val applicationUrl = TestApplicationUrl("/") val uiStore = disposer.add(UiStore(scope, applicationUrl)) - PwTool.values().forEach { tool -> + PwToolType.values().forEach { tool -> uiStore.setCurrentTool(tool) assertEquals(tool, uiStore.currentTool.value) @@ -35,13 +35,13 @@ class UiStoreTests : TestSuite() { val applicationUrl = TestApplicationUrl("/") val uiStore = disposer.add(UiStore(scope, applicationUrl)) - assertEquals(PwTool.Viewer, uiStore.currentTool.value) - assertEquals("/${PwTool.Viewer.slug}", applicationUrl.url.value) + assertEquals(PwToolType.Viewer, uiStore.currentTool.value) + assertEquals("/${PwToolType.Viewer.slug}", applicationUrl.url.value) listOf("/models", "/textures", "/animations").forEach { prefix -> uiStore.setPathPrefix(prefix, replace = false) - assertEquals("/${PwTool.Viewer.slug}${prefix}", applicationUrl.url.value) + assertEquals("/${PwToolType.Viewer.slug}${prefix}", applicationUrl.url.value) } } @@ -50,7 +50,7 @@ class UiStoreTests : TestSuite() { val applicationUrl = TestApplicationUrl("/") val uiStore = disposer.add(UiStore(scope, applicationUrl)) - PwTool.values().forEach { tool -> + PwToolType.values().forEach { tool -> listOf("/a", "/b", "/c").forEach { path -> applicationUrl.url.value = "/${tool.slug}$path" @@ -67,13 +67,13 @@ class UiStoreTests : TestSuite() { assertEquals("/${uiStore.defaultTool.slug}", appUrl.url.value) - uiStore.setCurrentTool(PwTool.HuntOptimizer) + uiStore.setCurrentTool(PwToolType.HuntOptimizer) - assertEquals("/${PwTool.HuntOptimizer.slug}", appUrl.url.value) + assertEquals("/${PwToolType.HuntOptimizer.slug}", appUrl.url.value) uiStore.setPathPrefix("/prefix", replace = true) - assertEquals("/${PwTool.HuntOptimizer.slug}/prefix", appUrl.url.value) + assertEquals("/${PwToolType.HuntOptimizer.slug}/prefix", appUrl.url.value) appUrl.back() @@ -81,6 +81,6 @@ class UiStoreTests : TestSuite() { appUrl.forward() - assertEquals("/${PwTool.HuntOptimizer.slug}/prefix", appUrl.url.value) + assertEquals("/${PwToolType.HuntOptimizer.slug}/prefix", appUrl.url.value) } } diff --git a/web/src/test/kotlin/world/phantasmal/web/huntOptimizer/HuntOptimizerTests.kt b/web/src/test/kotlin/world/phantasmal/web/huntOptimizer/HuntOptimizerTests.kt index 87286034..f4fbcab7 100644 --- a/web/src/test/kotlin/world/phantasmal/web/huntOptimizer/HuntOptimizerTests.kt +++ b/web/src/test/kotlin/world/phantasmal/web/huntOptimizer/HuntOptimizerTests.kt @@ -1,37 +1,18 @@ package world.phantasmal.web.huntOptimizer -import io.ktor.client.* -import io.ktor.client.features.json.* -import io.ktor.client.features.json.serializer.* -import kotlinx.coroutines.cancel -import world.phantasmal.core.disposable.disposable -import world.phantasmal.testUtils.TestSuite -import world.phantasmal.web.core.loading.AssetLoader -import world.phantasmal.web.core.PwTool +import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.stores.UiStore import world.phantasmal.web.test.TestApplicationUrl +import world.phantasmal.web.test.WebTestSuite import kotlin.test.Test -class HuntOptimizerTests : TestSuite() { +class HuntOptimizerTests : WebTestSuite() { @Test fun initialization_and_shutdown_should_succeed_without_throwing() = test { - val httpClient = HttpClient { - install(JsonFeature) { - serializer = KotlinxSerializer(kotlinx.serialization.json.Json { - ignoreUnknownKeys = true - }) - } - } - disposer.add(disposable { httpClient.cancel() }) + val uiStore = + disposer.add(UiStore(scope, TestApplicationUrl("/${PwToolType.HuntOptimizer}"))) - val uiStore = disposer.add(UiStore(scope, TestApplicationUrl("/${PwTool.HuntOptimizer}"))) - - disposer.add( - HuntOptimizer( - scope, - AssetLoader(basePath = "", httpClient), - uiStore - ) - ) + val huntOptimizer = disposer.add(HuntOptimizer(components.assetLoader, uiStore)) + disposer.add(huntOptimizer.initialize(scope)) } } diff --git a/web/src/test/kotlin/world/phantasmal/web/questEditor/QuestEditorTests.kt b/web/src/test/kotlin/world/phantasmal/web/questEditor/QuestEditorTests.kt index b190184e..d5839477 100644 --- a/web/src/test/kotlin/world/phantasmal/web/questEditor/QuestEditorTests.kt +++ b/web/src/test/kotlin/world/phantasmal/web/questEditor/QuestEditorTests.kt @@ -1,33 +1,15 @@ package world.phantasmal.web.questEditor -import io.ktor.client.* -import io.ktor.client.features.json.* -import io.ktor.client.features.json.serializer.* -import kotlinx.coroutines.cancel -import world.phantasmal.core.disposable.disposable -import world.phantasmal.testUtils.TestSuite -import world.phantasmal.web.core.loading.AssetLoader import world.phantasmal.web.externals.babylon.Engine +import world.phantasmal.web.test.WebTestSuite import kotlin.test.Test -class QuestEditorTests : TestSuite() { +class QuestEditorTests : WebTestSuite() { @Test fun initialization_and_shutdown_should_succeed_without_throwing() = test { - val httpClient = HttpClient { - install(JsonFeature) { - serializer = KotlinxSerializer(kotlinx.serialization.json.Json { - ignoreUnknownKeys = true - }) - } - } - disposer.add(disposable { httpClient.cancel() }) - - disposer.add( - QuestEditor( - scope, - AssetLoader(basePath = "", httpClient), - createEngine = { Engine(it) } - ) + val questEditor = disposer.add( + QuestEditor(components.assetLoader, createEngine = { Engine(it) }) ) + disposer.add(questEditor.initialize(scope)) } } diff --git a/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/NpcCountsControllerTests.kt b/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/NpcCountsControllerTests.kt new file mode 100644 index 00000000..c62faa56 --- /dev/null +++ b/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/NpcCountsControllerTests.kt @@ -0,0 +1,42 @@ +package world.phantasmal.web.questEditor.controllers + +import world.phantasmal.lib.fileFormats.quest.Episode +import world.phantasmal.lib.fileFormats.quest.NpcType +import world.phantasmal.web.test.WebTestSuite +import world.phantasmal.web.test.createQuestModel +import world.phantasmal.web.test.createQuestNpcModel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NpcCountsControllerTests : WebTestSuite() { + @Test + fun exposes_correct_model_before_and_after_a_quest_is_loaded() = test { + val store = components.questEditorStore + val ctrl = disposer.add(NpcCountsController(store)) + + assertTrue(ctrl.unavailable.value) + + store.setCurrentQuest(createQuestModel( + episode = Episode.I, + npcs = listOf( + createQuestNpcModel(NpcType.Scientist, Episode.I), + createQuestNpcModel(NpcType.Nurse, Episode.I), + createQuestNpcModel(NpcType.Nurse, Episode.I), + createQuestNpcModel(NpcType.Principal, Episode.I), + createQuestNpcModel(NpcType.Nurse, Episode.I), + createQuestNpcModel(NpcType.Scientist, Episode.I), + ) + )) + + assertFalse(ctrl.unavailable.value) + assertEquals(3, ctrl.npcCounts.value.size) + assertEquals("Principal", ctrl.npcCounts.value[0].name) + assertEquals("1", ctrl.npcCounts.value[0].count) + assertEquals("Scientist", ctrl.npcCounts.value[1].name) + assertEquals("2", ctrl.npcCounts.value[1].count) + assertEquals("Nurse", ctrl.npcCounts.value[2].name) + assertEquals("3", ctrl.npcCounts.value[2].count) + } +} diff --git a/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestEditorToolbarControllerTests.kt b/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestEditorToolbarControllerTests.kt new file mode 100644 index 00000000..b9aeb561 --- /dev/null +++ b/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestEditorToolbarControllerTests.kt @@ -0,0 +1,35 @@ +package world.phantasmal.web.questEditor.controllers + +import org.w3c.files.File +import world.phantasmal.core.Failure +import world.phantasmal.core.Severity +import world.phantasmal.web.test.WebTestSuite +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class QuestEditorToolbarControllerTests : WebTestSuite() { + @Test + fun a_failure_is_exposed_when_openFiles_fails() = asyncTest { + val ctrl = disposer.add(QuestEditorToolbarController( + components.questLoader, + components.areaStore, + components.questEditorStore + )) + + assertNull(ctrl.result.value) + + ctrl.openFiles(listOf(File(arrayOf(), "unknown.extension"))) + + val result = ctrl.result.value + + assertTrue(result is Failure) + assertEquals(1, result.problems.size) + assertEquals(Severity.Error, result.problems.first().severity) + assertEquals( + "Please select a .qst file or one .bin and one .dat file.", + result.problems.first().uiMessage + ) + } +} diff --git a/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestInfoControllerTests.kt b/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestInfoControllerTests.kt new file mode 100644 index 00000000..7e82e510 --- /dev/null +++ b/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestInfoControllerTests.kt @@ -0,0 +1,36 @@ +package world.phantasmal.web.questEditor.controllers + +import world.phantasmal.lib.fileFormats.quest.Episode +import world.phantasmal.web.test.WebTestSuite +import world.phantasmal.web.test.createQuestModel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class QuestInfoControllerTests : WebTestSuite() { + @Test + fun exposes_correct_model_before_and_after_a_quest_is_loaded() = test { + val store = components.questEditorStore + val ctrl = disposer.add(QuestInfoController(store)) + + assertTrue(ctrl.unavailable.value) + assertTrue(ctrl.disabled.value) + + store.setCurrentQuest(createQuestModel( + id = 25, + name = "A Quest", + shortDescription = "A short description.", + longDescription = "A long description.", + episode = Episode.II + )) + + assertFalse(ctrl.unavailable.value) + assertFalse(ctrl.disabled.value) + assertEquals("II", ctrl.episode.value) + assertEquals(25, ctrl.id.value) + assertEquals("A Quest", ctrl.name.value) + assertEquals("A short description.", ctrl.shortDescription.value) + assertEquals("A long description.", ctrl.longDescription.value) + } +} diff --git a/web/src/test/kotlin/world/phantasmal/web/test/TestComponents.kt b/web/src/test/kotlin/world/phantasmal/web/test/TestComponents.kt new file mode 100644 index 00000000..66e23fbe --- /dev/null +++ b/web/src/test/kotlin/world/phantasmal/web/test/TestComponents.kt @@ -0,0 +1,100 @@ +package world.phantasmal.web.test + +import io.ktor.client.* +import io.ktor.client.features.json.* +import io.ktor.client.features.json.serializer.* +import kotlinx.coroutines.cancel +import world.phantasmal.core.disposable.Disposable +import world.phantasmal.core.disposable.disposable +import world.phantasmal.testUtils.TestContext +import world.phantasmal.web.core.loading.AssetLoader +import world.phantasmal.web.externals.babylon.Engine +import world.phantasmal.web.externals.babylon.Scene +import world.phantasmal.web.questEditor.loading.AreaAssetLoader +import world.phantasmal.web.questEditor.loading.QuestLoader +import world.phantasmal.web.questEditor.stores.AreaStore +import world.phantasmal.web.questEditor.stores.QuestEditorStore +import kotlin.reflect.KProperty + +/** + * Assigning a disposable to any of the properties in this class will add the assigned value to + * [ctx]'s disposer. + */ +class TestComponents(private val ctx: TestContext) { + var httpClient: HttpClient by default { + HttpClient { + install(JsonFeature) { + serializer = KotlinxSerializer(kotlinx.serialization.json.Json { + ignoreUnknownKeys = true + }) + } + }.also { + ctx.disposer.add(disposable { it.cancel() }) + } + } + + // Babylon.js + + var scene: Scene by default { Scene(Engine(null)) } + + // Asset Loaders + + var assetLoader: AssetLoader by default { AssetLoader(basePath = "", httpClient) } + + var areaAssetLoader: AreaAssetLoader by default { + AreaAssetLoader(ctx.scope, assetLoader, scene) + } + + var questLoader: QuestLoader by default { QuestLoader(ctx.scope, assetLoader) } + + // Stores + + var areaStore: AreaStore by default { AreaStore(ctx.scope, areaAssetLoader) } + + var questEditorStore: QuestEditorStore by default { + QuestEditorStore(ctx.scope, areaStore) + } + + private fun default(defaultValue: () -> T) = LazyDefault { + val value = defaultValue() + + if (value is Disposable) { + ctx.disposer.add(value) + } + + value + } + + private inner class LazyDefault(private val defaultValue: () -> T) { + private var initialized = false + private var value: T? = null + + operator fun getValue(thisRef: Any?, prop: KProperty<*>): T { + if (!initialized) { + val value = defaultValue() + + if (value is Disposable) { + ctx.disposer.add(value) + } + + this.value = value + initialized = true + } + + return value.unsafeCast() + } + + operator fun setValue(thisRef: Any?, prop: KProperty<*>, value: T) { + require(initialized) { + "Property ${prop.name} is already initialized." + } + + if (value is Disposable) { + ctx.disposer.add(value) + } + + this.value = value + initialized = true + } + } +} diff --git a/web/src/test/kotlin/world/phantasmal/web/test/TestModels.kt b/web/src/test/kotlin/world/phantasmal/web/test/TestModels.kt new file mode 100644 index 00000000..1444c5d0 --- /dev/null +++ b/web/src/test/kotlin/world/phantasmal/web/test/TestModels.kt @@ -0,0 +1,32 @@ +package world.phantasmal.web.test + +import world.phantasmal.lib.fileFormats.quest.Episode +import world.phantasmal.lib.fileFormats.quest.NpcType +import world.phantasmal.lib.fileFormats.quest.QuestNpc +import world.phantasmal.web.questEditor.models.QuestModel +import world.phantasmal.web.questEditor.models.QuestNpcModel +import world.phantasmal.web.questEditor.models.QuestObjectModel + +fun createQuestModel( + id: Int = 1, + name: String = "Test", + shortDescription: String = name, + longDescription: String = name, + episode: Episode = Episode.I, + npcs: List = emptyList(), + objects: List = emptyList(), +): QuestModel = + QuestModel( + id, + language = 1, + name, + shortDescription, + longDescription, + episode, + emptyMap(), + npcs.toMutableList(), + objects.toMutableList(), + ) { _, _, _ -> null } + +fun createQuestNpcModel(type: NpcType, episode: Episode): QuestNpcModel = + QuestNpcModel(QuestNpc(type, episode, areaId = 0, wave = 0), wave = null) diff --git a/web/src/test/kotlin/world/phantasmal/web/test/WebTestContext.kt b/web/src/test/kotlin/world/phantasmal/web/test/WebTestContext.kt new file mode 100644 index 00000000..4aa75725 --- /dev/null +++ b/web/src/test/kotlin/world/phantasmal/web/test/WebTestContext.kt @@ -0,0 +1,9 @@ +package world.phantasmal.web.test + +import world.phantasmal.core.disposable.Disposer +import world.phantasmal.testUtils.TestContext + +open class WebTestContext(disposer: Disposer) : TestContext(disposer) { + @Suppress("LeakingThis") + val components = TestComponents(this) +} diff --git a/web/src/test/kotlin/world/phantasmal/web/test/WebTestSuite.kt b/web/src/test/kotlin/world/phantasmal/web/test/WebTestSuite.kt new file mode 100644 index 00000000..0d1a63db --- /dev/null +++ b/web/src/test/kotlin/world/phantasmal/web/test/WebTestSuite.kt @@ -0,0 +1,8 @@ +package world.phantasmal.web.test + +import world.phantasmal.core.disposable.Disposer +import world.phantasmal.testUtils.AbstractTestSuite + +abstract class WebTestSuite : AbstractTestSuite() { + override fun createContext(disposer: Disposer) = WebTestContext(disposer) +} diff --git a/web/src/test/kotlin/world/phantasmal/web/viewer/ViewerTests.kt b/web/src/test/kotlin/world/phantasmal/web/viewer/ViewerTests.kt new file mode 100644 index 00000000..072ed593 --- /dev/null +++ b/web/src/test/kotlin/world/phantasmal/web/viewer/ViewerTests.kt @@ -0,0 +1,15 @@ +package world.phantasmal.web.viewer + +import world.phantasmal.web.externals.babylon.Engine +import world.phantasmal.web.test.WebTestSuite +import kotlin.test.Test + +class ViewerTests : WebTestSuite() { + @Test + fun initialization_and_shutdown_should_succeed_without_throwing() = test { + val viewer = disposer.add( + Viewer(createEngine = { Engine(it) }) + ) + disposer.add(viewer.initialize(scope)) + } +} diff --git a/webui/src/test/kotlin/world/phantasmal/webui/test/WebuiTestSuite.kt b/webui/src/test/kotlin/world/phantasmal/webui/test/WebuiTestSuite.kt new file mode 100644 index 00000000..e0deb97b --- /dev/null +++ b/webui/src/test/kotlin/world/phantasmal/webui/test/WebuiTestSuite.kt @@ -0,0 +1,9 @@ +package world.phantasmal.webui.test + +import world.phantasmal.core.disposable.Disposer +import world.phantasmal.testUtils.AbstractTestSuite +import world.phantasmal.testUtils.TestContext + +abstract class WebuiTestSuite : AbstractTestSuite() { + override fun createContext(disposer: Disposer) = TestContext(disposer) +} diff --git a/webui/src/test/kotlin/world/phantasmal/webui/widgets/WidgetTests.kt b/webui/src/test/kotlin/world/phantasmal/webui/widgets/WidgetTests.kt index d3e4665c..fc3d5af9 100644 --- a/webui/src/test/kotlin/world/phantasmal/webui/widgets/WidgetTests.kt +++ b/webui/src/test/kotlin/world/phantasmal/webui/widgets/WidgetTests.kt @@ -6,14 +6,13 @@ import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.mutableVal import world.phantasmal.observable.value.trueVal -import world.phantasmal.testUtils.TestSuite import world.phantasmal.webui.dom.div +import world.phantasmal.webui.test.WebuiTestSuite import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -import kotlin.test.fail -class WidgetTests : TestSuite() { +class WidgetTests : WebuiTestSuite() { @Test fun ancestorHidden_and_selfOrAncestorHidden_should_update_when_hidden_changes() = test { val parentHidden = mutableVal(false)