diff --git a/build.gradle.kts b/build.gradle.kts index 701cea3c..8db80c8f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -151,9 +151,9 @@ registerTestSubset( koverReport { defaults { verify { - // Guards against coverage regressions; raise the bound as coverage grows (baseline: 59% on 2026-07-09). + // Guards against coverage regressions; raise the bound as coverage grows (baseline: 61% on 2026-07-10). rule("Minimal line coverage") { - minBound(56) + minBound(59) } } } diff --git a/docs/testing.md b/docs/testing.md index b2adae38..c0b4c36e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -108,6 +108,8 @@ Notes: - Screen-level dialogs wrapped in an AWT `DialogWindow` (e.g. `PluginDialog`, `ColorPickerDialog`) do not mount in `runComposeUiTest`; test their inner content composable or drive the flow through the public state holder instead. +A real `AppState` can be built in tests via `testutil.TestAppState.create(scope)`, which injects a `testutil.FakeIpcState` (the `AppState` constructor takes an IPC-state factory so no local port is bound) and collaborators that avoid network/audio. Prefer this over reflection for app-level glue and dialog-state tests; see `ui/AppStateConstructionTest.kt`. + State-holder classes (`ui/ProjectStore.kt`, `AppErrorState`, dialog states, ...) are plain classes over Compose `mutableStateOf` and are tested without rendering — see `ui/ProjectStoreTest.kt`, which drives the real implementations against fixture projects. For classes that require an `AppState` (which cannot be constructed in diff --git a/src/jvmMain/kotlin/com/sdercolin/vlabeler/io/ImportProject.kt b/src/jvmMain/kotlin/com/sdercolin/vlabeler/io/ImportProject.kt index 38e940b0..46c1ba7c 100644 --- a/src/jvmMain/kotlin/com/sdercolin/vlabeler/io/ImportProject.kt +++ b/src/jvmMain/kotlin/com/sdercolin/vlabeler/io/ImportProject.kt @@ -51,7 +51,14 @@ data class ImportedModule( } } -fun importModulesFromProject(projectText: String): List = runCatching { +/** + * Parses the importable modules from a project file's text. + * + * Throws if the text is not a structurally valid project (e.g. malformed JSON or a missing `labelerConf`), so that + * the caller can distinguish a parse failure from a valid project that simply has no importable entries (which + * returns an empty list). Individual malformed modules or entries are skipped. + */ +fun importModulesFromProject(projectText: String): List { val root = json.parseToJsonElement(projectText) val modules = mutableListOf() @@ -85,12 +92,7 @@ fun importModulesFromProject(projectText: String): List = runCat } } - require(modules.isNotEmpty()) - - modules.distinctBy { it.name } -}.getOrElse { - Log.error(it) - emptyList() + return modules.distinctBy { it.name } } private fun parseModule(element: JsonElement, continuous: Boolean, extension: String): ImportedModule? = runCatching { diff --git a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ipc/IpcState.kt b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ipc/IpcState.kt index e138209e..8cf7836d 100644 --- a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ipc/IpcState.kt +++ b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ipc/IpcState.kt @@ -12,9 +12,16 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach /** - * State for Inter-Process Communication. + * State for Inter-Process Communication. Tests provide a fake implementation so that no local port is bound. */ -class IpcState(private val appState: AppState) { +interface IpcState { + fun close() +} + +/** + * The real [IpcState], backed by a ZeroMQ server bound to a fixed local port (see [IpcServer]). + */ +class IpcStateImpl(private val appState: AppState) : IpcState { private val server = IpcServer(appState.mainScope) @@ -41,7 +48,7 @@ class IpcState(private val appState: AppState) { response(response) } - fun close() { + override fun close() { server.close() } } diff --git a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/AppDialogState.kt b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/AppDialogState.kt index 0908a34d..a1104358 100644 --- a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/AppDialogState.kt +++ b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/AppDialogState.kt @@ -174,7 +174,10 @@ interface AppDialogState { importEntriesDialogArgs != null || macroPluginShownInDialog != null || macroPluginReport != null || - customizableItemManagerTypeShownInDialog != null || + ( + customizableItemManagerTypeShownInDialog != null && + customizableItemManagerTypeShownInDialog != CustomizableItem.Type.MacroPlugin + ) || embeddedDialog != null } @@ -610,15 +613,26 @@ class AppDialogStateImpl( isShowingOpenProjectDialog = false isShowingSaveAsProjectDialog = false isShowingExportDialog = false - isShowingSampleListDialog = false + isShowingImportDialog = false isShowingPreferencesDialog = false preferencesDialogArgs = null + isShowingSampleListDialog = false isShowingSampleDirectoryRedirectDialog = false + isShowingPrerenderDialog = false + isShowingEntrySampleSyncDialog = false + isShowingAboutDialog = false + isShowingLicenseDialog = false + isShowingQuickLaunchManagerDialog = false + isShowingTrackingSettingsDialog = false + quickEditArgs = null + isShowingFileNameNormalizerDialog = false + isShowingVideo = false + updaterDialogContent = null + importEntriesDialogArgs = null + reloadLabelDialogArgs = null macroPluginShownInDialog = null macroPluginReport = null customizableItemManagerTypeShownInDialog = null - isShowingQuickLaunchManagerDialog = false - isShowingVideo = false closeEmbeddedDialog() } } diff --git a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/AppState.kt b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/AppState.kt index 1bb60dde..92123312 100644 --- a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/AppState.kt +++ b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/AppState.kt @@ -21,6 +21,7 @@ import com.sdercolin.vlabeler.io.loadProject import com.sdercolin.vlabeler.io.openCreatedProject import com.sdercolin.vlabeler.io.saveProjectFile import com.sdercolin.vlabeler.ipc.IpcState +import com.sdercolin.vlabeler.ipc.IpcStateImpl import com.sdercolin.vlabeler.ipc.request.OpenOrCreateRequest import com.sdercolin.vlabeler.model.AppConf import com.sdercolin.vlabeler.model.AppRecord @@ -103,6 +104,10 @@ class AppState( snackbarState: AppSnackbarState = AppSnackbarStateImpl(snackbarHostState), dialogState: AppDialogState = AppDialogStateImpl(unsavedChangesState, projectStore, snackbarState), updaterState: AppUpdaterState = AppUpdaterStateImpl(appConf, snackbarState, dialogState, appRecordStore, mainScope), + // Factory for the IPC state. It needs the fully-constructed AppState, so it is passed as a factory rather than a + // value. The default builds the real IPC server (which binds a fixed local port); tests pass a factory that + // returns a fake [IpcState] so no port is bound. + ipcStateFactory: (AppState) -> IpcState = { IpcStateImpl(it) }, ) : AppErrorState by errorState, AppViewState by viewState, AppScreenState by screenState, @@ -135,7 +140,7 @@ class AppState( errorState, ) { toggleVideoPopup(false) } - private val ipcState: IpcState = IpcState(this) + private val ipcState: IpcState = ipcStateFactory(this) val trackingState = TrackingState(appRecordStore, mainScope) fun validate() { diff --git a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/project/ProjectSettingDialogState.kt b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/project/ProjectSettingDialogState.kt index f8cd2eb3..c8681c1f 100644 --- a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/project/ProjectSettingDialogState.kt +++ b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/project/ProjectSettingDialogState.kt @@ -80,7 +80,8 @@ class ProjectSettingDialogState( val isOutputFileValid: Boolean get() { return try { - val outputFile = outputFile ?: return true + // an empty output file means "no output file", the same as null (see createNewProject) + val outputFile = outputFile?.ifEmpty { null } ?: return true val parent = outputFile.toFile().parentFile ?: return false return parent.isDirectory } catch (e: Exception) { diff --git a/src/jvmTest/kotlin/io/ExportProjectTest.kt b/src/jvmTest/kotlin/io/ExportProjectTest.kt new file mode 100644 index 00000000..1bd26d28 --- /dev/null +++ b/src/jvmTest/kotlin/io/ExportProjectTest.kt @@ -0,0 +1,99 @@ +package io + +import com.sdercolin.vlabeler.env.Log +import com.sdercolin.vlabeler.io.exportProject +import com.sdercolin.vlabeler.io.exportProjectModule +import com.sdercolin.vlabeler.io.singleModuleToRawLabels +import com.sdercolin.vlabeler.model.Project +import com.sdercolin.vlabeler.ui.AppState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import testutil.TestEnv +import testutil.TestFixtures +import testutil.TestLabelers +import testutil.createTestProject +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Tests for [ProjectStore.exportProject] and [ProjectStore.exportProjectModule]: the raw label file written by the + * labeler writer, and the creation of missing output directories. + */ +class ExportProjectTest { + + private lateinit var tempDir: File + private lateinit var sampleDir: File + private lateinit var scope: CoroutineScope + private lateinit var appState: AppState + + @BeforeTest + fun setup() { + Log.muted = true + TestEnv.ensureLogDirectory() + scope = CoroutineScope(SupervisorJob()) + tempDir = createTempDirectory("vlabeler-test").toFile() + sampleDir = TestFixtures.deploy( + "oto", + tempDir.resolve("oto"), + wavFiles = listOf("_a_ka.wav"), + ) + appState = testutil.TestAppState.create(scope = scope) + } + + @AfterTest + fun teardown() { + scope.cancel() + Log.muted = false + tempDir.deleteRecursively() + } + + private fun createProject(): Project = createTestProject( + labeler = TestLabelers.utauOto, + sampleDirectory = sampleDir, + inputFilePath = sampleDir.resolve("oto.ini").absolutePath, + ) + + @Test + fun testExportProjectModuleWritesRawLabels() { + val project = createProject() + val output = tempDir.resolve("out/exported.ini") + + runBlocking { appState.exportProjectModule(project, moduleIndex = 0, outputFile = output) } + + // parent directory is created on demand + assertTrue(output.parentFile.isDirectory) + val expected = project.singleModuleToRawLabels(0) + assertEquals(expected, output.readText()) + assertTrue(output.readText().isNotBlank()) + } + + @Test + fun testExportProjectWritesToModuleRawFile() { + val project = createProject() + val rawFile = requireNotNull(project.modules[0].getRawFile(project)) + + runBlocking { appState.exportProject(project) } + + val expected = project.singleModuleToRawLabels(0) + assertEquals(expected, rawFile.readText()) + } + + @Test + fun testExportProjectSkipsModulesWithoutRawFile() { + // a module whose rawFilePath is null must be skipped without error + val project = createProject() + val withoutRaw = project.copy( + modules = project.modules.map { it.copy(rawFilePath = null) }, + ) + + // no exception thrown and nothing written + runBlocking { appState.exportProject(withoutRaw) } + } +} diff --git a/src/jvmTest/kotlin/io/ImportProjectEdgeCasesTest.kt b/src/jvmTest/kotlin/io/ImportProjectEdgeCasesTest.kt index 1ebcddb4..595458ca 100644 --- a/src/jvmTest/kotlin/io/ImportProjectEdgeCasesTest.kt +++ b/src/jvmTest/kotlin/io/ImportProjectEdgeCasesTest.kt @@ -47,12 +47,13 @@ class ImportProjectEdgeCasesTest { """.trimIndent() @Test - fun testInvalidJsonReturnsEmptyList() { - assertEquals(emptyList(), importModulesFromProject("not a json")) + fun testInvalidJsonThrows() { + // malformed input is a structural failure, so the caller can surface it instead of silently importing nothing + assertFailsWith { importModulesFromProject("not a json") } } @Test - fun testMissingLabelerConfReturnsEmptyList() { + fun testMissingLabelerConfThrows() { val json = """ { "modules": [ @@ -64,7 +65,7 @@ class ImportProjectEdgeCasesTest { } """.trimIndent() - assertEquals(emptyList(), importModulesFromProject(json)) + assertFailsWith { importModulesFromProject(json) } } @Test diff --git a/src/jvmTest/kotlin/io/LoadProjectTest.kt b/src/jvmTest/kotlin/io/LoadProjectTest.kt new file mode 100644 index 00000000..1bcf3f5c --- /dev/null +++ b/src/jvmTest/kotlin/io/LoadProjectTest.kt @@ -0,0 +1,293 @@ +package io + +import com.sdercolin.vlabeler.env.Log +import com.sdercolin.vlabeler.exception.ProjectParseException +import com.sdercolin.vlabeler.exception.RequiredLabelerNotFoundException +import com.sdercolin.vlabeler.io.awaitLoadProject +import com.sdercolin.vlabeler.model.LabelerConf +import com.sdercolin.vlabeler.model.Project +import com.sdercolin.vlabeler.ui.AppState +import com.sdercolin.vlabeler.ui.string.Strings +import com.sdercolin.vlabeler.ui.string.stringStatic +import com.sdercolin.vlabeler.util.CustomLabelerDir +import com.sdercolin.vlabeler.util.stringifyJson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import testutil.TestEnv +import testutil.TestFixtures +import testutil.TestLabelers +import testutil.createTestProject +import java.io.File +import java.util.Collections +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for [awaitLoadProject]: the happy path, the labeler-version reconciliation branches (newer/equal/older than the + * project's), the [RequiredLabelerNotFoundException] and labeler auto-install branches, the parse-error and + * missing-file branches, and the auto-saved path. + */ +class LoadProjectTest { + + private lateinit var tempDir: File + private lateinit var sampleDir: File + private lateinit var scope: CoroutineScope + + @BeforeTest + fun setup() { + Log.muted = true + TestEnv.ensureLogDirectory() + scope = CoroutineScope(SupervisorJob()) + tempDir = createTempDirectory("vlabeler-test").toFile() + sampleDir = TestFixtures.deploy( + "oto", + tempDir.resolve("oto"), + wavFiles = listOf("_a_ka.wav"), + ) + } + + @AfterTest + fun teardown() { + scope.cancel() + Log.muted = false + tempDir.deleteRecursively() + } + + private fun createProject(): Project = createTestProject( + labeler = TestLabelers.utauOto, + sampleDirectory = sampleDir, + inputFilePath = sampleDir.resolve("oto.ini").absolutePath, + ) + + private fun write(project: Project, file: File = project.projectFile): File { + file.parentFile.mkdirs() + file.writeText(project.stringifyJson()) + return file + } + + /** + * Runs [awaitLoadProject] while consuming any snackbars it raises (they use [SnackbarDuration.Indefinite] and would + * otherwise suspend forever without a UI host consuming them). Returns the messages that were shown, in order. + */ + private fun load(file: File, appState: AppState, autoSaved: Boolean = false): List = runBlocking { + val messages = Collections.synchronizedList(mutableListOf()) + val dismisser = launch(Dispatchers.Default) { + var last: Any? = null + while (isActive) { + val data = appState.snackbarHostState.currentSnackbarData + if (data != null && data !== last) { + messages.add(data.message) + last = data + data.dismiss() + } else { + delay(2) + } + } + } + try { + withContext(Dispatchers.IO) { awaitLoadProject(file, appState, autoSaved) } + } finally { + dismisser.cancel() + } + messages.toList() + } + + @Test + fun testLoadWithSameLabelerVersion() { + val project = createProject() + val file = write(project) + val appState = TestAppState() + + val messages = load(file, appState) + + assertEquals(emptyList(), messages) + assertNull(appState.error) + assertTrue(appState.hasProject) + assertEquals(project.projectName, appState.project?.projectName) + assertFalse(appState.isBusy) + // the existing labeler (equal version) is used as-is + assertEquals(TestLabelers.utauOto.name, appState.project?.labelerConf?.name) + assertEquals(TestLabelers.utauOto.version, appState.project?.labelerConf?.version) + } + + @Test + fun testLoadWithNewerExistingLabeler() { + // the project stores an older labeler version; the newer available labeler wins with no snackbar + val project = createProject() + val onDisk = project.copy(originalLabelerConf = project.originalLabelerConf.copy(version = 1)) + val file = write(onDisk) + val appState = TestAppState(availableLabelerConfs = listOf(TestLabelers.utauOto)) + + val messages = load(file, appState) + + assertEquals(emptyList(), messages) + assertNull(appState.error) + assertTrue(appState.hasProject) + // the newer available labeler (version 30) replaces the older one stored in the project (version 1) + assertEquals(TestLabelers.utauOto.version, appState.project?.labelerConf?.version) + } + + @Test + fun testLoadWithOlderExistingLabelerInstallsUpdate() { + // the available labeler is older than the project's -> the project's labeler is installed and a warning shown + val olderAvailable = TestLabelers.utauOto.copy(version = 1) + val project = createProject() + val file = write(project) + val appState = TestAppState(availableLabelerConfs = listOf(olderAvailable)) + + val messages = load(file, appState) + + assertEquals(1, messages.size) + assertTrue(messages.single().isNotBlank()) + assertNull(appState.error) + assertTrue(appState.hasProject) + // the project keeps its own (newer) labeler + assertEquals(TestLabelers.utauOto.version, appState.project?.labelerConf?.version) + } + + @Test + fun testLoadInstallsMissingLabelerWithoutResources() { + // the labeler is unknown but ships no resource files -> it is auto-installed and a warning is shown + val project = createProject() + val renamed = project.copy( + originalLabelerConf = project.originalLabelerConf.copy(name = "custom-oto-test"), + ) + val file = write(renamed) + val appState = TestAppState(availableLabelerConfs = listOf(TestLabelers.nnsvsSinger)) + + val messages = load(file, appState) + + assertEquals(1, messages.size) + assertTrue(messages.single().isNotBlank()) + assertNull(appState.error) + assertTrue(appState.hasProject) + assertEquals("custom-oto-test", appState.project?.labelerConf?.name) + // the labeler was written into the custom labeler directory + assertTrue(CustomLabelerDir.resolve("custom-oto-test-labeler").isDirectory) + } + + @Test + fun testLoadThrowsWhenMissingLabelerHasResources() { + // the labeler is unknown and requires resource files -> it cannot be auto-installed + val project = createProject() + val onDisk = project.copy( + originalLabelerConf = project.originalLabelerConf.copy( + name = "missing-oto-test", + resourceFiles = listOf("resource.bin"), + ), + ) + val file = write(onDisk) + val appState = TestAppState(availableLabelerConfs = listOf(TestLabelers.nnsvsSinger)) + + assertFailsWith { load(file, appState) } + assertFalse(appState.hasProject) + } + + @Test + fun testLoadThrowsWhenOlderLabelerHasResources() { + // the available labeler is older AND the project's labeler requires resource files -> cannot install + val olderAvailable = TestLabelers.utauOto.copy(version = 1) + val project = createProject() + val onDisk = project.copy( + originalLabelerConf = project.originalLabelerConf.copy(resourceFiles = listOf("resource.bin")), + ) + val file = write(onDisk) + val appState = TestAppState(availableLabelerConfs = listOf(olderAvailable)) + + assertFailsWith { load(file, appState) } + assertFalse(appState.hasProject) + } + + @Test + fun testLoadShowsErrorOnInvalidProjectFile() { + val file = tempDir.resolve("broken.lbp") + file.writeText("{ not a valid project }") + val appState = TestAppState() + + val messages = load(file, appState) + + assertEquals(emptyList(), messages) + assertFalse(appState.hasProject) + assertIs(appState.error) + assertFalse(appState.isBusy) + } + + @Test + fun testLoadShowsSnackbarWhenFileMissing() { + val file = tempDir.resolve("does-not-exist.lbp") + val appState = TestAppState() + + val messages = load(file, appState) + + assertEquals(listOf(stringStatic(Strings.StarterRecentDeleted)), messages) + assertFalse(appState.hasProject) + } + + @Test + fun testLoadResetsCacheDirectoryWhenParentMissing() { + // the stored cache directory's parent no longer exists -> it is reset to the default and a warning is shown + val project = createProject() + val onDisk = project.copy( + cacheDirectoryPath = tempDir.resolve("gone").resolve("caches").absolutePath, + ) + val file = write(onDisk) + val appState = TestAppState() + + val messages = load(file, appState) + + assertEquals(1, messages.size) + assertTrue(messages.single().isNotBlank()) + assertTrue(appState.hasProject) + val expectedCache = Project.getDefaultCacheDirectory(project.workingDirectoryPath, project.projectName) + assertEquals(expectedCache, appState.project?.cacheDirectoryPath) + } + + @Test + fun testLoadRedirectsProjectPathWhenFileMoved() { + // the project file is loaded from a path different from the one recorded inside it -> paths are redirected + val project = createProject() + val movedFile = tempDir.resolve("moved").resolve("renamed-project.lbp") + write(project, movedFile) + val appState = TestAppState() + + val messages = load(movedFile, appState) + + assertEquals(emptyList(), messages) + assertTrue(appState.hasProject) + assertEquals("renamed-project", appState.project?.projectName) + assertEquals(movedFile.parentFile.absolutePath, appState.project?.workingDirectory?.absolutePath) + } + + @Test + fun testLoadAutoSavedProject() { + val project = createProject() + val file = write(project) + val appState = TestAppState() + + val messages = load(file, appState, autoSaved = true) + + assertEquals(emptyList(), messages) + assertNull(appState.error) + assertTrue(appState.hasProject) + assertEquals(project.projectName, appState.project?.projectName) + } + + private fun TestAppState( + availableLabelerConfs: List = listOf(TestLabelers.utauOto, TestLabelers.nnsvsSinger), + ): AppState = testutil.TestAppState.create(scope = scope, availableLabelerConfs = availableLabelerConfs) +} diff --git a/src/jvmTest/kotlin/io/OpenCreatedProjectTest.kt b/src/jvmTest/kotlin/io/OpenCreatedProjectTest.kt new file mode 100644 index 00000000..8a448b58 --- /dev/null +++ b/src/jvmTest/kotlin/io/OpenCreatedProjectTest.kt @@ -0,0 +1,152 @@ +package io + +import com.sdercolin.vlabeler.env.Log +import com.sdercolin.vlabeler.exception.ProjectImportException +import com.sdercolin.vlabeler.io.awaitOpenCreatedProject +import com.sdercolin.vlabeler.io.importProjectFile +import com.sdercolin.vlabeler.io.openCreatedProject +import com.sdercolin.vlabeler.model.Project +import com.sdercolin.vlabeler.ui.AppState +import com.sdercolin.vlabeler.util.stringifyJson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import testutil.TestEnv +import testutil.TestFixtures +import testutil.TestLabelers +import testutil.createTestProject +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for [awaitOpenCreatedProject]/[openCreatedProject] (opening a freshly created project) and [importProjectFile] + * (importing entries from another project file). + */ +class OpenCreatedProjectTest { + + private lateinit var tempDir: File + private lateinit var sampleDir: File + private lateinit var scope: CoroutineScope + private lateinit var appState: AppState + + @BeforeTest + fun setup() { + Log.muted = true + TestEnv.ensureLogDirectory() + scope = CoroutineScope(SupervisorJob()) + tempDir = createTempDirectory("vlabeler-test").toFile() + sampleDir = TestFixtures.deploy( + "oto", + tempDir.resolve("oto"), + wavFiles = listOf("_a_ka.wav"), + ) + appState = testutil.TestAppState.create(scope = scope) + } + + @AfterTest + fun teardown() { + scope.cancel() + Log.muted = false + tempDir.deleteRecursively() + } + + private fun createProject(workingDirectory: File = tempDir.resolve("working")): Project = createTestProject( + labeler = TestLabelers.utauOto, + sampleDirectory = sampleDir, + workingDirectory = workingDirectory, + inputFilePath = sampleDir.resolve("oto.ini").absolutePath, + ) + + @Test + fun testAwaitOpenCreatedProjectSavesAndOpens() { + val project = createProject() + + runBlocking { awaitOpenCreatedProject(project, appState) } + + assertTrue(appState.hasProject) + assertEquals(project.projectName, appState.project?.projectName) + // the project file was written to disk + assertTrue(project.projectFile.isFile) + // it was registered as a recent project + assertTrue(appState.appRecordFlow.value.recentProjects.contains(project.projectFile.absolutePath)) + } + + @Test + fun testOpenCreatedProjectAsync() { + val project = createProject(workingDirectory = tempDir.resolve("working-async")) + + runBlocking { + openCreatedProject(this, project, appState) + withTimeout(15_000) { + while (!appState.hasProject) delay(5) + } + } + + assertEquals(project.projectName, appState.project?.projectName) + assertTrue(project.projectFile.isFile) + } + + @Test + fun testImportProjectFileOpensDialog() { + val project = createProject() + val file = tempDir.resolve("to-import.lbp") + file.writeText(project.stringifyJson()) + + runBlocking { + importProjectFile(this, file, appState) + withTimeout(15_000) { + while (appState.importEntriesDialogArgs == null) delay(5) + } + } + + val args = assertNotNull(appState.importEntriesDialogArgs) + assertTrue(args.importedModules.isNotEmpty()) + assertFalse(appState.isBusy) + } + + @Test + fun testImportProjectFileShowsErrorOnUnreadableFile() { + val file = tempDir.resolve("does-not-exist.lbp") + + runBlocking { + importProjectFile(this, file, appState) + withTimeout(15_000) { + while (appState.error == null) delay(5) + } + } + + assertIs(appState.error) + assertFalse(appState.isBusy) + } + + @Test + fun testImportProjectFileShowsErrorOnMalformedFile() { + // a readable but structurally invalid project file surfaces an import error instead of silently opening the + // import dialog with no modules + val file = tempDir.resolve("malformed.lbp") + file.writeText("not a valid project") + + runBlocking { + importProjectFile(this, file, appState) + withTimeout(15_000) { + while (appState.error == null) delay(5) + } + } + + assertIs(appState.error) + assertNull(appState.importEntriesDialogArgs) + assertFalse(appState.isBusy) + } +} diff --git a/src/jvmTest/kotlin/testutil/FakeIpcState.kt b/src/jvmTest/kotlin/testutil/FakeIpcState.kt new file mode 100644 index 00000000..7046d484 --- /dev/null +++ b/src/jvmTest/kotlin/testutil/FakeIpcState.kt @@ -0,0 +1,17 @@ +package testutil + +import com.sdercolin.vlabeler.ipc.IpcState + +/** + * A no-op [IpcState] for tests: it binds no port and does nothing on close. Records whether [close] was called so + * that tests exercising the app's shutdown path can assert it. + */ +class FakeIpcState : IpcState { + + var closed: Boolean = false + private set + + override fun close() { + closed = true + } +} diff --git a/src/jvmTest/kotlin/testutil/TestAppState.kt b/src/jvmTest/kotlin/testutil/TestAppState.kt new file mode 100644 index 00000000..2523b0ef --- /dev/null +++ b/src/jvmTest/kotlin/testutil/TestAppState.kt @@ -0,0 +1,50 @@ +package testutil + +import androidx.compose.material.SnackbarHostState +import androidx.compose.runtime.mutableStateOf +import com.sdercolin.vlabeler.env.KeyboardViewModel +import com.sdercolin.vlabeler.model.AppConf +import com.sdercolin.vlabeler.model.Arguments +import com.sdercolin.vlabeler.model.LabelerConf +import com.sdercolin.vlabeler.model.Plugin +import com.sdercolin.vlabeler.tracking.TrackingService +import com.sdercolin.vlabeler.ui.AppRecordStore +import com.sdercolin.vlabeler.ui.AppState +import com.sdercolin.vlabeler.ui.editor.ScrollFitViewModel +import kotlinx.coroutines.CoroutineScope + +/** + * Builds a real [AppState] for tests through its actual constructor (no reflection). A [FakeIpcState] is injected so + * no port is bound, and the collaborators do not touch the network or audio devices. The application directory is + * already redirected to a build directory by the test task (see `VLABELER_APP_DIR` in `build.gradle.kts`). + * + * @param scope the coroutine scope for the app; pass a cancelled scope to keep all background flows inert, or an + * unconfined scope to run launched work synchronously. + */ +object TestAppState { + + fun create( + scope: CoroutineScope, + appConf: AppConf = AppConf(), + availableLabelerConfs: List = listOf(TestLabelers.utauOto, TestLabelers.nnsvsSinger), + plugins: List = emptyList(), + launchArguments: Arguments = Arguments(), + ): AppState { + TestEnv.ensureLogDirectory() + val appConfState = mutableStateOf(appConf) + val appRecordStore = AppRecordStore(com.sdercolin.vlabeler.model.AppRecord(), scope) + return AppState( + mainScope = scope, + keyboardViewModel = KeyboardViewModel(scope, appConf.keymaps), + scrollFitViewModel = ScrollFitViewModel(scope), + appRecordStore = appRecordStore, + trackingService = TrackingService(appRecordStore, scope), + snackbarHostState = SnackbarHostState(), + appConf = appConfState, + availableLabelerConfs = availableLabelerConfs, + plugins = plugins, + launchArguments = launchArguments, + ipcStateFactory = { FakeIpcState() }, + ) + } +} diff --git a/src/jvmTest/kotlin/ui/AppDialogStateTest.kt b/src/jvmTest/kotlin/ui/AppDialogStateTest.kt new file mode 100644 index 00000000..05d50c93 --- /dev/null +++ b/src/jvmTest/kotlin/ui/AppDialogStateTest.kt @@ -0,0 +1,393 @@ +package ui + +import com.sdercolin.vlabeler.env.Log +import com.sdercolin.vlabeler.env.Version +import com.sdercolin.vlabeler.model.Plugin +import com.sdercolin.vlabeler.repository.update.model.Update +import com.sdercolin.vlabeler.ui.AppState +import com.sdercolin.vlabeler.ui.Screen +import com.sdercolin.vlabeler.ui.dialog.AskIfSaveDialogPurpose +import com.sdercolin.vlabeler.ui.dialog.AskIfSaveDialogResult +import com.sdercolin.vlabeler.ui.dialog.EmbeddedDialogRequest +import com.sdercolin.vlabeler.ui.dialog.EmbeddedDialogResult +import com.sdercolin.vlabeler.ui.dialog.ReloadLabelDialogArgs +import com.sdercolin.vlabeler.ui.dialog.customization.CustomizableItem +import com.sdercolin.vlabeler.ui.dialog.importentries.ImportEntriesDialogArgs +import com.sdercolin.vlabeler.ui.dialog.preferences.PreferencesEditorState +import com.sdercolin.vlabeler.ui.string.toLocalized +import com.sdercolin.vlabeler.util.ParamMap +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import testutil.TestAppState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Tests for the app-level dialog/glue logic implemented by `AppDialogStateImpl` and exercised through a real + * [AppState] built with [TestAppState.create] (IPC disabled). Covers the open/close/queue of embedded and + * standalone dialogs, dialog-stacking (opening a project/export/etc. dialog closes the others), the + * [AppState.awaitEmbeddedDialog] request/result flow, and the [AppState.closeAllDialogs] reset. + * + * The app is built with an unconfined [mainScope] so that suspend `await...` flows run synchronously up to their + * suspension point and can be resolved deterministically by invoking the captured result callback. + */ +class AppDialogStateTest { + + private lateinit var scope: CoroutineScope + private lateinit var appState: AppState + + @BeforeTest + fun setup() { + Log.muted = true + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + appState = TestAppState.create(scope) + } + + @AfterTest + fun teardown() { + scope.cancel() + Log.muted = false + } + + /** Invokes the star-projected result callback with an untyped result. */ + @Suppress("UNCHECKED_CAST") + private fun EmbeddedDialogRequest<*>.resolve(result: EmbeddedDialogResult<*>?) { + (onResult as (EmbeddedDialogResult<*>?) -> Unit)(result) + } + + private fun macroPlugin(name: String) = Plugin( + name = name, + type = Plugin.Type.Macro, + author = "tester", + supportedLabelFileExtension = "*", + scriptFiles = emptyList(), + ) + + /* region simple standalone dialog toggles */ + + @Test + fun `simple dialog toggles set and clear their flags`() { + assertFalse(appState.isShowingProjectSettingDialog) + appState.openProjectSettingDialog() + assertTrue(appState.isShowingProjectSettingDialog) + appState.closeProjectSettingDialog() + assertFalse(appState.isShowingProjectSettingDialog) + + appState.openSampleListDialog() + assertTrue(appState.isShowingSampleListDialog) + appState.closeSampleListDialog() + assertFalse(appState.isShowingSampleListDialog) + + appState.openSampleDirectoryRedirectDialog() + assertTrue(appState.isShowingSampleDirectoryRedirectDialog) + appState.closeSampleDirectoryRedirectDialog() + assertFalse(appState.isShowingSampleDirectoryRedirectDialog) + + appState.openPrerenderDialog() + assertTrue(appState.isShowingPrerenderDialog) + appState.closePrerenderDialog() + assertFalse(appState.isShowingPrerenderDialog) + + appState.openEntrySampleSyncDialog() + assertTrue(appState.isShowingEntrySampleSyncDialog) + appState.closeEntrySampleSyncDialog() + assertFalse(appState.isShowingEntrySampleSyncDialog) + + appState.openAboutDialog() + assertTrue(appState.isShowingAboutDialog) + appState.closeAboutDialog() + assertFalse(appState.isShowingAboutDialog) + + appState.openLicenseDialog() + assertTrue(appState.isShowingLicenseDialog) + appState.closeLicenseDialog() + assertFalse(appState.isShowingLicenseDialog) + + appState.openQuickLaunchManagerDialog() + assertTrue(appState.isShowingQuickLaunchManagerDialog) + appState.closeQuickLaunchManagerDialog() + assertFalse(appState.isShowingQuickLaunchManagerDialog) + + appState.openTrackingSettingsDialog() + assertTrue(appState.isShowingTrackingSettingsDialog) + appState.closeTrackingSettingsDialog() + assertFalse(appState.isShowingTrackingSettingsDialog) + + appState.openFileNameNormalizerDialog() + assertTrue(appState.isShowingFileNameNormalizerDialog) + appState.closeFileNameNormalizerDialog() + assertFalse(appState.isShowingFileNameNormalizerDialog) + } + + /* endregion */ + + /* region dialog stacking: opening one closes the previously opened ones */ + + @Test + fun `opening a project or export dialog closes the previously opened dialogs`() { + appState.openProjectSettingDialog() + assertTrue(appState.isShowingProjectSettingDialog) + + appState.openOpenProjectDialog() + assertTrue(appState.isShowingOpenProjectDialog) + // opening the open-project dialog closes the other closable dialogs first + assertFalse(appState.isShowingProjectSettingDialog) + + appState.openSaveAsProjectDialog() + assertTrue(appState.isShowingSaveAsProjectDialog) + assertFalse(appState.isShowingOpenProjectDialog) + + appState.openExportDialog() + assertTrue(appState.isShowingExportDialog) + assertFalse(appState.isShowingSaveAsProjectDialog) + + appState.openImportDialog() + assertTrue(appState.isShowingImportDialog) + assertFalse(appState.isShowingExportDialog) + + appState.closeImportDialog() + assertFalse(appState.isShowingImportDialog) + } + + @Test + fun `preferences dialog carries and clears its launch args`() { + val args = PreferencesEditorState.LaunchArgs.Keymap("search") + appState.openProjectSettingDialog() + + appState.openPreferencesDialog(args) + assertTrue(appState.isShowingPreferencesDialog) + assertSame(args, appState.preferencesDialogArgs) + // opening preferences also closed the project setting dialog + assertFalse(appState.isShowingProjectSettingDialog) + + appState.closePreferencesDialog() + assertFalse(appState.isShowingPreferencesDialog) + assertNull(appState.preferencesDialogArgs) + } + + /* endregion */ + + /* region dialogs holding args */ + + @Test + fun `updater dialog holds and clears the update content`() { + val update = Update(Version(2, 0, 0), "2024-01-01", "https://example.com/asset", emptyList()) + appState.openUpdaterDialog(update) + assertSame(update, appState.updaterDialogContent) + appState.closeUpdaterDialog() + assertNull(appState.updaterDialogContent) + } + + @Test + fun `import entries dialog holds and clears its args`() { + val args = ImportEntriesDialogArgs(emptyList()) + appState.openImportEntriesDialog(args) + assertSame(args, appState.importEntriesDialogArgs) + appState.closeImportEntriesDialog() + assertNull(appState.importEntriesDialogArgs) + } + + @Test + fun `reload label dialog holds and clears its args`() { + val args = ReloadLabelDialogArgs(emptyList()) + appState.openReloadLabelDialog(args) + assertSame(args, appState.reloadLabelDialogArgs) + appState.closeReloadLabelDialog() + assertNull(appState.reloadLabelDialogArgs) + } + + @Test + fun `customizable item manager dialog toggles the shown type and closes others`() { + appState.openProjectSettingDialog() + + appState.openCustomizableItemManagerDialog(CustomizableItem.Type.Labeler) + assertEquals(CustomizableItem.Type.Labeler, appState.customizableItemManagerTypeShownInDialog) + // opening the manager closed the project setting dialog + assertFalse(appState.isShowingProjectSettingDialog) + + appState.closeCustomizableItemManagerDialog() + assertNull(appState.customizableItemManagerTypeShownInDialog) + } + + @Test + fun `macro plugin dialog from slot stores args, updates params, and closes`() { + val plugin = macroPlugin("macro-1") + val params = ParamMap(mapOf("a" to 1)) + appState.openMacroPluginDialogFromSlot(plugin, params, slot = 3) + + var args = assertNotNull(appState.macroPluginShownInDialog) + assertSame(plugin, args.plugin) + assertSame(params, args.paramMap) + assertEquals(3, args.slot) + + val newParams = ParamMap(mapOf("a" to 2)) + appState.updateMacroPluginDialogInputParams(newParams) + args = assertNotNull(appState.macroPluginShownInDialog) + assertSame(newParams, args.paramMap) + // the plugin and slot are preserved when only the params change + assertSame(plugin, args.plugin) + assertEquals(3, args.slot) + + appState.closeMacroPluginDialog() + assertNull(appState.macroPluginShownInDialog) + } + + @Test + fun `macro plugin report is shown and dismissed`() { + val report = "done".toLocalized() + appState.showMacroPluginReport(report) + assertSame(report, appState.macroPluginReport) + appState.closeMacroPluginReport() + assertNull(appState.macroPluginReport) + } + + @Test + fun `pending action after saved is stored and cleared`() { + val action = AppState.PendingActionAfterSaved.Exit + appState.putPendingActionAfterSaved(action) + assertSame(action, appState.pendingActionAfterSaved) + appState.clearPendingActionAfterSaved() + assertNull(appState.pendingActionAfterSaved) + } + + /* endregion */ + + /* region embedded dialog request / result */ + + @Test + fun `openEmbeddedDialog exposes a request whose null result closes the dialog without dispatching`() { + appState.openEmbeddedDialog(AskIfSaveDialogPurpose.IsCreatingNew) + val request = assertNotNull(appState.embeddedDialog) + assertIs(request.args) + + request.resolve(null) + assertNull(appState.embeddedDialog) + // a cancelled dialog leaves the app on the starter screen (no action dispatched) + assertIs(appState.screen) + } + + @Test + fun `openEmbeddedDialog result is dispatched to handleDialogResult`() { + appState.openEmbeddedDialog(AskIfSaveDialogPurpose.IsCreatingNew) + val request = assertNotNull(appState.embeddedDialog) + + // declining to save with a "creating new" pending action opens the project creator + request.resolve(AskIfSaveDialogResult(save = false, AppState.PendingActionAfterSaved.CreatingNew)) + + assertNull(appState.embeddedDialog) + assertIs(appState.screen) + } + + @Test + fun `awaitEmbeddedDialog resolves with the callback result`() { + var completed = false + var result: EmbeddedDialogResult<*>? = null + scope.launch { + result = appState.awaitEmbeddedDialog(AskIfSaveDialogPurpose.IsExiting) + completed = true + } + // the unconfined coroutine has run up to the dialog suspension point + val request = assertNotNull(appState.embeddedDialog) + assertFalse(completed) + + val expected = AskIfSaveDialogResult(save = true, AppState.PendingActionAfterSaved.Exit) + request.resolve(expected) + + assertTrue(completed) + assertSame(expected, result) + assertNull(appState.embeddedDialog) + } + + @Test + fun `closeEmbeddedDialog cancels a pending awaitEmbeddedDialog`() { + var thrown: Throwable? = null + var result: EmbeddedDialogResult<*>? = null + scope.launch { + try { + result = appState.awaitEmbeddedDialog(AskIfSaveDialogPurpose.IsExiting) + } catch (e: CancellationException) { + thrown = e + } + } + assertNotNull(appState.embeddedDialog) + + appState.closeEmbeddedDialog() + + assertIs(thrown) + assertNull(result) + assertNull(appState.embeddedDialog) + } + + /* endregion */ + + /* region close-all and any-dialog-opening */ + + @Test + fun `closeAllDialogs clears the dialogs it owns`() { + // openImportDialog itself calls closeAllDialogs, so open it first, then the flag-only dialogs + appState.openImportDialog() + appState.openProjectSettingDialog() + appState.openSampleListDialog() + appState.openSampleDirectoryRedirectDialog() + appState.openQuickLaunchManagerDialog() + appState.showMacroPluginReport("report".toLocalized()) + appState.openEmbeddedDialog(AskIfSaveDialogPurpose.IsExiting) + // dialogs that closeAllDialogs previously did not clear + appState.openAboutDialog() + appState.openLicenseDialog() + appState.openTrackingSettingsDialog() + assertTrue(appState.anyDialogOpening()) + + appState.closeAllDialogs() + + assertFalse(appState.isShowingProjectSettingDialog) + assertFalse(appState.isShowingSampleListDialog) + assertFalse(appState.isShowingSampleDirectoryRedirectDialog) + assertFalse(appState.isShowingQuickLaunchManagerDialog) + assertNull(appState.macroPluginReport) + assertNull(appState.embeddedDialog) + assertFalse(appState.isShowingAboutDialog) + assertFalse(appState.isShowingLicenseDialog) + assertFalse(appState.isShowingImportDialog) + assertFalse(appState.isShowingTrackingSettingsDialog) + // closeAllDialogs must leave no dialog open + assertFalse(appState.anyDialogOpening()) + } + + @Test + fun `anyDialogOpening reflects open dialogs`() { + assertFalse(appState.anyDialogOpening()) + assertFalse(appState.anyDialogOpeningExceptMacroPluginManager()) + + appState.openAboutDialog() + assertTrue(appState.anyDialogOpening()) + assertTrue(appState.anyDialogOpeningExceptMacroPluginManager()) + + appState.closeAboutDialog() + assertFalse(appState.anyDialogOpening()) + + // the macro-plugin customizable item manager is counted by anyDialogOpening() but excluded from + // anyDialogOpeningExceptMacroPluginManager() + appState.openCustomizableItemManagerDialog(CustomizableItem.Type.MacroPlugin) + assertTrue(appState.anyDialogOpening()) + assertFalse(appState.anyDialogOpeningExceptMacroPluginManager()) + + // a non-macro-plugin manager IS counted by both + appState.openCustomizableItemManagerDialog(CustomizableItem.Type.Labeler) + assertTrue(appState.anyDialogOpeningExceptMacroPluginManager()) + } + + /* endregion */ +} diff --git a/src/jvmTest/kotlin/ui/AppStateConstructionTest.kt b/src/jvmTest/kotlin/ui/AppStateConstructionTest.kt new file mode 100644 index 00000000..8bdf08ca --- /dev/null +++ b/src/jvmTest/kotlin/ui/AppStateConstructionTest.kt @@ -0,0 +1,43 @@ +package ui + +import com.sdercolin.vlabeler.env.Log +import com.sdercolin.vlabeler.ui.Screen +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import testutil.TestAppState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Verifies that a real [com.sdercolin.vlabeler.ui.AppState] can be constructed in tests (IPC disabled), which is the + * basis for the state-glue and dialog-state tests that use [TestAppState]. + */ +class AppStateConstructionTest { + + private lateinit var scope: CoroutineScope + + @BeforeTest + fun setup() { + Log.muted = true + scope = CoroutineScope(SupervisorJob()) + } + + @AfterTest + fun teardown() { + scope.cancel() + Log.muted = false + } + + @Test + fun testConstructsWithoutBindingIpcPort() { + val appState = TestAppState.create(scope = CoroutineScope(Job())) + // fresh app starts on the starter screen with no project + assertTrue(appState.screen == Screen.Starter) + assertFalse(appState.hasProject) + } +} diff --git a/src/jvmTest/kotlin/ui/AppStateGlueTest.kt b/src/jvmTest/kotlin/ui/AppStateGlueTest.kt new file mode 100644 index 00000000..be89ec52 --- /dev/null +++ b/src/jvmTest/kotlin/ui/AppStateGlueTest.kt @@ -0,0 +1,282 @@ +package ui + +import com.sdercolin.vlabeler.env.Log +import com.sdercolin.vlabeler.ipc.IpcState +import com.sdercolin.vlabeler.model.Plugin +import com.sdercolin.vlabeler.model.Project +import com.sdercolin.vlabeler.repository.ChartRepository +import com.sdercolin.vlabeler.repository.ConvertedAudioRepository +import com.sdercolin.vlabeler.repository.SampleInfoRepository +import com.sdercolin.vlabeler.ui.AppErrorState +import com.sdercolin.vlabeler.ui.AppState +import com.sdercolin.vlabeler.ui.Screen +import com.sdercolin.vlabeler.ui.dialog.AskIfSaveDialogPurpose +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import testutil.FakeIpcState +import testutil.TestAppState +import testutil.TestFixtures +import testutil.TestLabelers +import testutil.createTestProject +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Tests for the app-level glue in [AppState] that is not covered by `EditorStateTest`: screen switching, project + * open/close wiring, plugin/labeler filtering against the disabled lists in the app record, error passthrough, and + * the shutdown path. A real [AppState] is built with [TestAppState.create] (IPC disabled) and an unconfined + * [mainScope] so that launched work runs synchronously up to its first suspension point. + */ +class AppStateGlueTest { + + private lateinit var scope: CoroutineScope + private val loadedProjects = mutableSetOf() + + @BeforeTest + fun setup() { + Log.muted = true + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + } + + @AfterTest + fun teardown() { + scope.cancel() + loadedProjects.forEach { + ChartRepository.clear(it) + SampleInfoRepository.clear(it) + ConvertedAudioRepository.clear(it) + } + loadedProjects.clear() + Log.muted = false + } + + private fun create(plugins: List = emptyList()) = TestAppState.create(scope = scope, plugins = plugins) + + private fun openProject(appState: AppState) { + appState.openEditor(otoProject) + loadedProjects += otoProject + } + + private fun macroPlugin(name: String) = plugin(name, Plugin.Type.Macro) + private fun templatePlugin(name: String) = plugin(name, Plugin.Type.Template) + private fun plugin(name: String, type: Plugin.Type) = Plugin( + name = name, + type = type, + author = "tester", + supportedLabelFileExtension = "*", + scriptFiles = emptyList(), + ) + + /* region screen switching and project open/close */ + + @Test + fun `a fresh app has no project and starts on the starter screen`() { + val appState = create() + assertIs(appState.screen) + assertFalse(appState.hasProject) + assertNull(appState.project) + assertFalse(appState.shouldExit) + assertFailsWith { appState.requireProject() } + } + + @Test + fun `requestOpenProjectCreator switches to the creator screen and closes open dialogs`() { + val appState = create() + appState.openProjectSettingDialog() + + appState.requestOpenProjectCreator() + + assertIs(appState.screen) + // switching the screen closes the project setting dialog + assertFalse(appState.isShowingProjectSettingDialog) + } + + @Test + fun `requestOpenProjectCreator asks to save first when there are unsaved changes`() { + val appState = create() + appState.projectContentChanged() + assertTrue(appState.hasUnsavedChanges) + + appState.requestOpenProjectCreator() + + // instead of switching immediately, the ask-if-save dialog is queued and the screen is unchanged + val request = assertNotNull(appState.embeddedDialog) + assertIs(request.args) + assertIs(appState.screen) + } + + @Test + fun `closeProjectCreator resets to the starter screen`() { + val appState = create() + appState.requestOpenProjectCreator() + assertIs(appState.screen) + + appState.closeProjectCreator() + assertIs(appState.screen) + } + + @Test + fun `openEditor loads the project and switches to the editor screen`() { + val appState = create() + openProject(appState) + + assertTrue(appState.hasProject) + val editorScreen = assertIs(appState.screen) + assertEquals("test-project", appState.requireProject().projectName) + assertEquals("test-project", editorScreen.state.project.projectName) + // the editor exposed by the screen state is the one created for the loaded project + assertSame(editorScreen.state, appState.editor) + } + + @Test + fun `requestCloseProject resets the project and screen when there are no unsaved changes`() { + val appState = create() + openProject(appState) + assertTrue(appState.hasProject) + + appState.requestCloseProject() + + assertFalse(appState.hasProject) + assertNull(appState.project) + assertIs(appState.screen) + } + + /* endregion */ + + /* region plugin / labeler filtering */ + + @Test + fun `getPlugins and getActivePlugins filter by type and disabled names`() { + val template = templatePlugin("t1") + val macroEnabled = macroPlugin("m1") + val macroDisabled = macroPlugin("m2") + val appState = create(plugins = listOf(template, macroEnabled, macroDisabled)) + + assertEquals(listOf(template), appState.getPlugins(Plugin.Type.Template)) + assertEquals(listOf(macroEnabled, macroDisabled), appState.getPlugins(Plugin.Type.Macro)) + + // all plugins are active until disabled in the app record + assertEquals(listOf(macroEnabled, macroDisabled), appState.getActivePlugins(Plugin.Type.Macro)) + + appState.appRecordStore.update { setPluginDisabled("m2", true) } + assertEquals(listOf(macroEnabled), appState.getActivePlugins(Plugin.Type.Macro)) + // getPlugins ignores the disabled list + assertEquals(listOf(macroEnabled, macroDisabled), appState.getPlugins(Plugin.Type.Macro)) + } + + @Test + fun `activeLabelerConfs excludes labelers disabled in the app record`() { + val appState = create() + val available = appState.availableLabelerConfs + assertEquals(2, available.size) + assertEquals(available, appState.activeLabelerConfs) + + val disabledName = available.first().name + appState.appRecordStore.update { setLabelerDisabled(disabledName, true) } + + assertEquals(available.drop(1), appState.activeLabelerConfs) + // the available list itself is unchanged + assertEquals(available, appState.availableLabelerConfs) + } + + /* endregion */ + + /* region error passthrough and shutdown */ + + @Test + fun `showError stores the error and pending action and clearError resets them`() { + val appState = create() + val error = IllegalStateException("boom") + + appState.showError(error, AppErrorState.ErrorPendingAction.ExitProject) + assertSame(error, appState.error) + assertEquals(AppErrorState.ErrorPendingAction.ExitProject, appState.errorPendingAction) + + appState.clearError() + assertNull(appState.error) + assertNull(appState.errorPendingAction) + } + + @Test + fun `handleErrorPendingAction ExitProject resets the project`() { + val appState = create() + openProject(appState) + assertTrue(appState.hasProject) + + appState.handleErrorPendingAction(AppErrorState.ErrorPendingAction.ExitProject) + + assertFalse(appState.hasProject) + assertIs(appState.screen) + } + + @Test + fun `handleErrorPendingAction Exit triggers the shutdown flag`() { + val appState = create() + appState.handleErrorPendingAction(AppErrorState.ErrorPendingAction.Exit) + assertTrue(appState.shouldExit) + } + + @Test + fun `handleErrorPendingAction null is a no-op`() { + val appState = create() + appState.handleErrorPendingAction(null) + assertFalse(appState.shouldExit) + assertIs(appState.screen) + } + + @Test + fun `exit closes the ipc state and flags shouldExit`() { + val appState = create() + assertFalse(appState.shouldExit) + + appState.exit() + + assertTrue(appState.shouldExit) + val ipcState = readIpcState(appState) + val fake = assertIs(ipcState) + assertTrue(fake.closed) + } + + private fun readIpcState(appState: AppState): IpcState { + val field = AppState::class.java.getDeclaredField("ipcState") + field.isAccessible = true + return field.get(appState) as IpcState + } + + /* endregion */ + + companion object { + + private val sharedTempDir: File by lazy { + createTempDirectory("vlabeler-glue-test").toFile().also { dir -> + Runtime.getRuntime().addShutdownHook(Thread { dir.deleteRecursively() }) + } + } + + private val otoProject: Project by lazy { + val sampleDir = TestFixtures.deploy( + "oto", + sharedTempDir.resolve("oto"), + wavFiles = listOf("_a_ka.wav"), + ) + createTestProject( + labeler = TestLabelers.utauOto, + sampleDirectory = sampleDir, + inputFilePath = sampleDir.resolve("oto.ini").absolutePath, + ) + } + } +} diff --git a/src/jvmTest/kotlin/ui/ImportEntriesDialogStateTest.kt b/src/jvmTest/kotlin/ui/ImportEntriesDialogStateTest.kt new file mode 100644 index 00000000..64ad59de --- /dev/null +++ b/src/jvmTest/kotlin/ui/ImportEntriesDialogStateTest.kt @@ -0,0 +1,220 @@ +package ui + +import com.sdercolin.vlabeler.env.Log +import com.sdercolin.vlabeler.io.ImportedModule +import com.sdercolin.vlabeler.model.Entry +import com.sdercolin.vlabeler.model.LabelerConf +import com.sdercolin.vlabeler.model.Project +import com.sdercolin.vlabeler.ui.AppState +import com.sdercolin.vlabeler.ui.dialog.importentries.ImportEntriesDialogState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import testutil.TestAppState +import testutil.TestFixtures +import testutil.TestLabelers +import testutil.createTestProject +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for [ImportEntriesDialogState], focusing on how imported modules are mapped to the existing modules of a real + * project ("A3", "C4" from the UTAU singer fixture) and how compatibility and name matching drive the default + * selection. + */ +class ImportEntriesDialogStateTest { + + private lateinit var scope: CoroutineScope + private lateinit var tempDir: File + private lateinit var appState: AppState + + @BeforeTest + fun setup() { + Log.muted = true + scope = CoroutineScope(SupervisorJob()) + tempDir = createTempDirectory("vlabeler-test").toFile() + val sampleDir = TestFixtures.deploy( + "utau-singer", + tempDir.resolve("utau-singer"), + wavFiles = listOf("C4/_a_ka.wav", "C4/_i_ki.wav", "A3/_a_ka.wav"), + ) + val project = createTestProject( + labeler = TestLabelers.utauSinger, + sampleDirectory = sampleDir, + ) + appState = TestAppState.create(scope = scope, availableLabelerConfs = listOf(TestLabelers.utauSinger)) + appState.openEditor(project) + } + + @AfterTest + fun teardown() { + scope.cancel() + Log.muted = false + tempDir.deleteRecursively() + } + + private val labeler: LabelerConf get() = appState.requireProject().labelerConf + + private fun sampleEntry(name: String): Entry = + appState.requireProject().modules.first { it.name == "C4" }.entries.first().copy(name = name) + + private fun compatibleModule(name: String, entries: List = listOf(sampleEntry("imported"))) = + ImportedModule( + name = name, + entries = entries, + pointSize = labeler.fields.size, + extraSize = labeler.extraFields.size, + continuous = labeler.continuous, + extension = labeler.extension, + ) + + private fun createState( + modules: List, + onFinish: () -> Unit = {}, + ) = ImportEntriesDialogState(onFinish, appState, modules) + + @Test + fun testExistingModuleNamesAndContinuousDefaults() { + val state = createState(listOf(compatibleModule("C4"))) + + assertEquals(listOf("A3", "C4"), state.existingModuleNames.sorted()) + // the UTAU oto labeler is not continuous, so replacing content is optional and off by default + assertFalse(state.forceReplaceContent) + assertFalse(state.replaceContent) + } + + @Test + fun testMatchingModuleNameIsAutoSelected() { + val state = createState(listOf(compatibleModule("C4"))) + val item = state.items.single() + + assertTrue(item.compatible) + assertTrue(item.selected) + assertEquals("C4", item.targetName) + assertTrue(item.isValid) + assertTrue(state.isValid) + } + + @Test + fun testMismatchingModuleNameIsNotSelected() { + val state = createState(listOf(compatibleModule("Unknown"))) + val item = state.items.single() + + assertTrue(item.compatible) + assertFalse(item.selected) + assertNull(item.targetName) + // an unselected item is still valid (it simply will not be imported) + assertTrue(item.isValid) + assertTrue(state.isValid) + } + + @Test + fun testIncompatibleModuleIsNotSelected() { + val incompatible = ImportedModule( + name = "C4", + entries = listOf(sampleEntry("imported")), + pointSize = labeler.fields.size + 1, + extraSize = labeler.extraFields.size, + continuous = labeler.continuous, + extension = labeler.extension, + ) + val state = createState(listOf(incompatible)) + val item = state.items.single() + + assertFalse(item.compatible) + assertFalse(item.selected) + } + + @Test + fun testSelectTargetAndToggle() { + val state = createState(listOf(compatibleModule("Unknown"))) + val item = state.items.single() + + item.selectTarget("C4") + assertTrue(item.selected) + assertEquals("C4", item.targetName) + + item.toggleSelected(false) + assertFalse(item.selected) + assertNull(item.targetName) + + // selected but with no target resolves to an invalid state + item.toggleSelected(true) + assertTrue(item.selected) + assertNull(item.targetName) + assertFalse(item.isValid) + assertFalse(state.isValid) + } + + @Test + fun testSubmitImportsSelectedEntriesIntoTargetModule() { + val before = appState.requireProject().modules.first { it.name == "C4" }.entries.size + var finished = false + val state = createState( + listOf(compatibleModule("C4", entries = listOf(sampleEntry("imported-1")))), + ) { finished = true } + + state.submit() + + assertTrue(finished) + val c4 = appState.requireProject().modules.first { it.name == "C4" } + assertEquals(before + 1, c4.entries.size) + assertTrue(c4.entries.any { it.name == "imported-1" }) + } + + @Test + fun testUnselectedModulesAreNotImported() { + val before = appState.requireProject().modules.first { it.name == "C4" }.entries.size + // name does not match any existing module, so it is not selected by default + val state = createState(listOf(compatibleModule("Unknown", entries = listOf(sampleEntry("skipped"))))) + + state.submit() + + val c4 = appState.requireProject().modules.first { it.name == "C4" } + assertEquals(before, c4.entries.size) + } + + @Test + fun testForceReplaceContentForContinuousLabeler() { + val nnsvsTempDir = createTempDirectory("vlabeler-test").toFile() + try { + val sampleDir = TestFixtures.deploy( + "nnsvs-singer", + nnsvsTempDir.resolve("nnsvs-singer"), + wavFiles = listOf("wav/doremi.wav", "wav/legato.wav"), + wavDurationMs = 1500, + ) + val project: Project = createTestProject( + labeler = TestLabelers.nnsvsSinger, + sampleDirectory = sampleDir, + ) + val nnsvsAppState = TestAppState.create( + scope = scope, + availableLabelerConfs = listOf(TestLabelers.nnsvsSinger), + ) + nnsvsAppState.openEditor(project) + val nnsvsLabeler = project.labelerConf + val module = ImportedModule( + name = "doremi", + entries = listOf(project.modules.first { it.name == "doremi" }.entries.first()), + pointSize = nnsvsLabeler.fields.size, + extraSize = nnsvsLabeler.extraFields.size, + continuous = nnsvsLabeler.continuous, + extension = nnsvsLabeler.extension, + ) + val state = ImportEntriesDialogState({}, nnsvsAppState, listOf(module)) + + assertTrue(state.forceReplaceContent) + assertTrue(state.replaceContent) + } finally { + nnsvsTempDir.deleteRecursively() + } + } +} diff --git a/src/jvmTest/kotlin/ui/ProjectSettingDialogStateTest.kt b/src/jvmTest/kotlin/ui/ProjectSettingDialogStateTest.kt new file mode 100644 index 00000000..f48ab1fe --- /dev/null +++ b/src/jvmTest/kotlin/ui/ProjectSettingDialogStateTest.kt @@ -0,0 +1,161 @@ +package ui + +import com.sdercolin.vlabeler.env.Log +import com.sdercolin.vlabeler.ui.AppState +import com.sdercolin.vlabeler.ui.dialog.project.ProjectSettingDialogState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import testutil.TestAppState +import testutil.TestFixtures +import testutil.TestLabelers +import testutil.createTestProject +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Tests for [ProjectSettingDialogState], built on a real [AppState] holding a single-module UTAU "oto" project (so the + * output file is editable and auto-export can be changed). + */ +class ProjectSettingDialogStateTest { + + private lateinit var scope: CoroutineScope + private lateinit var tempDir: File + private lateinit var appState: AppState + + @BeforeTest + fun setup() { + Log.muted = true + scope = CoroutineScope(SupervisorJob()) + tempDir = createTempDirectory("vlabeler-test").toFile() + val voicebank = TestFixtures.deploy( + "oto", + tempDir.resolve("voice"), + wavFiles = listOf("_a_ka.wav"), + ) + val project = createTestProject( + labeler = TestLabelers.utauOto, + sampleDirectory = voicebank, + inputFilePath = voicebank.resolve("oto.ini").absolutePath, + ) + appState = TestAppState.create(scope = scope, availableLabelerConfs = listOf(TestLabelers.utauOto)) + appState.openEditor(project) + } + + @AfterTest + fun teardown() { + scope.cancel() + Log.muted = false + tempDir.deleteRecursively() + } + + private fun createState(onFinish: () -> Unit = {}) = ProjectSettingDialogState(appState, onFinish) + + @Test + fun testInitialValuesFromProject() { + val project = appState.requireProject() + val state = createState() + + assertEquals(project.encoding, state.encoding) + assertEquals(project.rootSampleDirectoryPath, state.rootDirectory) + assertEquals(project.cacheDirectory.absolutePath, state.cacheDirectory) + assertEquals(project.autoExport, state.autoExport) + } + + @Test + fun testSingleModuleOtoProjectHasEditableOutputFile() { + val state = createState() + + assertTrue(state.isOutputFileEditable) + assertEquals( + appState.requireProject().currentModule.getRawFile(appState.requireProject())?.absolutePath, + state.outputFile, + ) + // oto labeler defines a defaultInputFilePath, so auto-export can always be toggled + assertTrue(state.canChangeAutoExport) + } + + @Test + fun testRootDirectoryValidation() { + val state = createState() + + // the project's sample directory exists, so the initial value is valid + assertTrue(state.isRootDirectoryValid) + + state.updateRootDirectory(tempDir.absolutePath) + assertTrue(state.isRootDirectoryValid) + + state.updateRootDirectory(tempDir.resolve("does-not-exist").absolutePath) + assertFalse(state.isRootDirectoryValid) + } + + @Test + fun testCacheDirectoryValidation() { + val state = createState() + + // default cache directory has an existing parent and is not itself a file + assertTrue(state.isCacheDirectoryValid) + + state.updateCacheDirectory(tempDir.resolve("new-cache").absolutePath) + assertTrue(state.isCacheDirectoryValid) + + val existingFile = tempDir.resolve("a-file.txt").apply { writeText("x") } + state.updateCacheDirectory(existingFile.absolutePath) + assertFalse(state.isCacheDirectoryValid) + } + + @Test + fun testOutputFileValidationAndErrorState() { + val state = createState() + + assertTrue(state.isOutputFileValid) + assertFalse(state.isError) + + state.updateOutputFile(tempDir.resolve("output.ini").absolutePath) + assertTrue(state.isOutputFileValid) + assertFalse(state.isError) + + state.updateOutputFile(tempDir.resolve("missing-parent").resolve("output.ini").absolutePath) + assertFalse(state.isOutputFileValid) + assertTrue(state.isError) + + // an empty output file means "no output file" and must be treated as valid, like null + state.updateOutputFile("") + assertTrue(state.isOutputFileValid) + assertFalse(state.isError) + } + + @Test + fun testSubmitUpdatesProjectAndFinishes() { + var finished = false + val state = createState { finished = true } + state.encoding = "Shift-JIS" + state.autoExport = true + + state.submit() + + assertTrue(finished) + assertEquals("Shift-JIS", appState.requireProject().encoding) + assertTrue(appState.requireProject().autoExport) + } + + @Test + fun testCancelFinishesWithoutChangingProject() { + val before = appState.requireProject() + var finished = false + val state = createState { finished = true } + state.encoding = "UTF-16" + + state.cancel() + + assertTrue(finished) + // cancel does not commit the edited encoding + assertEquals(before, appState.requireProject()) + } +} diff --git a/src/jvmTest/kotlin/ui/SampleListDialogStateTest.kt b/src/jvmTest/kotlin/ui/SampleListDialogStateTest.kt new file mode 100644 index 00000000..24c93dde --- /dev/null +++ b/src/jvmTest/kotlin/ui/SampleListDialogStateTest.kt @@ -0,0 +1,212 @@ +package ui + +import com.sdercolin.vlabeler.env.Log +import com.sdercolin.vlabeler.ui.AppState +import com.sdercolin.vlabeler.ui.dialog.sample.SampleListDialogState +import com.sdercolin.vlabeler.ui.editor.EditorState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import testutil.TestAppState +import testutil.TestFixtures +import testutil.TestLabelers +import testutil.TestWav +import testutil.createTestProject +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for [SampleListDialogState] using a real multi-module UTAU singer project (modules "A3" and "C4"). Missing and + * excluded samples are simulated by editing the deployed sample directory on disk. + */ +class SampleListDialogStateTest { + + private lateinit var scope: CoroutineScope + private lateinit var tempDir: File + private lateinit var sampleDir: File + private lateinit var appState: AppState + private lateinit var editorState: EditorState + + @BeforeTest + fun setup() { + Log.muted = true + scope = CoroutineScope(SupervisorJob()) + tempDir = createTempDirectory("vlabeler-test").toFile() + sampleDir = TestFixtures.deploy( + "utau-singer", + tempDir.resolve("utau-singer"), + wavFiles = listOf("C4/_a_ka.wav", "C4/_i_ki.wav", "A3/_a_ka.wav"), + ) + val project = createTestProject( + labeler = TestLabelers.utauSinger, + sampleDirectory = sampleDir, + ) + appState = TestAppState.create(scope = scope, availableLabelerConfs = listOf(TestLabelers.utauSinger)) + appState.openEditor(project) + editorState = requireNotNull(appState.editor) + } + + @AfterTest + fun teardown() { + scope.cancel() + Log.muted = false + tempDir.deleteRecursively() + } + + private val c4Dir: File get() = sampleDir.resolve("C4") + + private fun createDialog() = SampleListDialogState(editorState) + + @Test + fun testModuleListing() { + val dialog = createDialog() + + assertEquals(listOf("A3", "C4"), dialog.allModuleNames.sorted()) + } + + @Test + fun testIncludedSamplesAllValidWhenFilesPresent() { + val dialog = createDialog() + dialog.selectModule("C4") + + assertEquals(listOf("_a_ka.wav", "_i_ki.wav"), dialog.includedSampleItems.map { it.name }.sorted()) + assertTrue(dialog.includedSampleItems.all { it.valid }) + assertTrue(dialog.includedSampleItems.all { it.entryCount == 2 }) + assertEquals(emptyList(), dialog.excludedSampleItems) + } + + @Test + fun testMissingSampleDetected() { + // the entries still reference _i_ki.wav, but the file is no longer on disk + assertTrue(c4Dir.resolve("_i_ki.wav").delete()) + + val dialog = createDialog() + dialog.selectModule("C4") + + val included = dialog.includedSampleItems.associateBy { it.name } + assertEquals(true, included.getValue("_a_ka.wav").valid) + assertEquals(false, included.getValue("_i_ki.wav").valid) + } + + @Test + fun testExcludedSampleDetected() { + // a sample file that no entry references + TestWav.write(c4Dir.resolve("_extra.wav")) + + val dialog = createDialog() + dialog.selectModule("C4") + + assertEquals(listOf("_extra.wav"), dialog.excludedSampleItems.map { it.name }) + assertTrue(dialog.includedSampleItems.none { it.name == "_extra.wav" }) + } + + @Test + fun testCreateDefaultEntriesForAllExcludedSamples() { + TestWav.write(c4Dir.resolve("_extra.wav")) + + val dialog = createDialog() + dialog.selectModule("C4") + assertEquals(listOf("_extra.wav"), dialog.excludedSampleItems.map { it.name }) + + dialog.createDefaultEntriesForAllExcludedSamples() + + assertEquals(emptyList(), dialog.excludedSampleItems) + val extra = dialog.includedSampleItems.first { it.name == "_extra.wav" } + assertEquals(1, extra.entryCount) + assertTrue(extra.valid) + } + + @Test + fun testCreateDefaultEntryForSelectedSample() { + TestWav.write(c4Dir.resolve("_extra.wav")) + + val dialog = createDialog() + dialog.selectModule("C4") + dialog.selectSample("_extra.wav") + + dialog.createDefaultEntry() + + val extra = dialog.includedSampleItems.first { it.name == "_extra.wav" } + assertEquals(1, extra.entryCount) + } + + @Test + fun testSelectSampleAndEntry() { + val dialog = createDialog() + dialog.selectModule("C4") + + dialog.selectSample("_a_ka.wav") + assertEquals("_a_ka.wav", dialog.selectedSampleName) + assertEquals(2, dialog.entryItems.size) + assertNull(dialog.selectedEntryIndex) + + val targetIndex = dialog.entryItems.first().entry.index + dialog.selectEntry(targetIndex) + assertEquals(targetIndex, dialog.selectedEntryIndex) + + // jumping moves the project's current entry in the C4 module + dialog.jumpToSelectedEntry() + val c4 = appState.requireProject().modules.first { it.name == "C4" } + assertEquals(targetIndex, c4.currentIndex) + } + + @Test + fun testSelectModuleClearsSelections() { + val dialog = createDialog() + dialog.selectModule("C4") + dialog.selectSample("_a_ka.wav") + dialog.selectEntry(dialog.entryItems.first().entry.index) + + dialog.selectModule("A3") + + assertNull(dialog.selectedSampleName) + assertNull(dialog.selectedEntryIndex) + assertEquals(emptyList(), dialog.entryItems) + } + + @Test + fun testSampleDirectoryHelpers() { + val dialog = createDialog() + dialog.selectModule("C4") + + assertEquals(c4Dir.absolutePath, dialog.sampleDirectory.absolutePath) + assertTrue(dialog.isSampleDirectoryExisting()) + assertEquals(c4Dir.absolutePath, dialog.getInitialSampleDirectoryForRedirection()) + } + + @Test + fun testRedirectionDialogFlagAndNoOpResults() { + val dialog = createDialog() + dialog.selectModule("C4") + val before = dialog.sampleDirectory.absolutePath + + dialog.requestRedirectSampleDirectory() + assertTrue(dialog.isShowingSampleDirectoryRedirectDialog) + + // cancelling (null result) closes the dialog and changes nothing + dialog.handleRedirectionDialogResult(null, null) + assertFalse(dialog.isShowingSampleDirectoryRedirectDialog) + assertEquals(before, dialog.sampleDirectory.absolutePath) + } + + @Test + fun testRedirectionToExistingDirectory() { + val newDir = tempDir.resolve("redirected").apply { mkdirs() } + TestWav.write(newDir.resolve("_a_ka.wav")) + + val dialog = createDialog() + dialog.selectModule("C4") + + dialog.handleRedirectionDialogResult(tempDir.absolutePath, "redirected") + + assertFalse(dialog.isShowingSampleDirectoryRedirectDialog) + assertEquals(newDir.canonicalPath, dialog.sampleDirectory.canonicalPath) + } +} diff --git a/src/jvmTest/kotlin/ui/UpdaterDialogStateTest.kt b/src/jvmTest/kotlin/ui/UpdaterDialogStateTest.kt new file mode 100644 index 00000000..1f29b45a --- /dev/null +++ b/src/jvmTest/kotlin/ui/UpdaterDialogStateTest.kt @@ -0,0 +1,204 @@ +package ui + +import com.sdercolin.vlabeler.env.Log +import com.sdercolin.vlabeler.env.Version +import com.sdercolin.vlabeler.env.appVersion +import com.sdercolin.vlabeler.model.AppRecord +import com.sdercolin.vlabeler.repository.update.model.Asset +import com.sdercolin.vlabeler.repository.update.model.Release +import com.sdercolin.vlabeler.repository.update.model.Update +import com.sdercolin.vlabeler.repository.update.model.UpdateChannel +import com.sdercolin.vlabeler.ui.AppRecordStore +import com.sdercolin.vlabeler.ui.dialog.updater.UpdaterDialogState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import testutil.TestEnv +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for [UpdaterDialogState] and the update-selection logic in [Update.from]. No real download or network call is + * made: the dialog is driven with an in-memory [Update], and version comparison is exercised through [Update.from] + * with in-memory release data (assets for every platform are included so the resolved asset does not depend on the + * host OS). + * + * [UpdaterDialogState.getDiffSummary] is `@Composable` and cannot be invoked outside a composition, so it is not + * covered here. + */ +class UpdaterDialogStateTest { + + private lateinit var scope: CoroutineScope + private lateinit var dialogScope: CoroutineScope + private lateinit var tempDir: File + private lateinit var appRecordStore: AppRecordStore + + private val update = Update( + version = Version(9, 9, 9), + date = "2024-01-01", + assetUrl = "https://example.com/downloads/vlabeler-9.9.9-mac-arm64.dmg", + diff = listOf( + Update.Summary(Version(9, 9, 9), "https://example.com/releases/9.9.9", "2024-01-01"), + ), + ) + + @BeforeTest + fun setup() { + Log.muted = true + TestEnv.ensureLogDirectory() + scope = CoroutineScope(SupervisorJob()) + // Unconfined so that the coroutine launched by cancel()/cancelIgnored() runs synchronously + dialogScope = CoroutineScope(Dispatchers.Unconfined) + tempDir = createTempDirectory("vlabeler-test").toFile() + appRecordStore = AppRecordStore(AppRecord(updateDownloadDirectory = tempDir.absolutePath), scope) + } + + @AfterTest + fun teardown() { + scope.cancel() + dialogScope.cancel() + Log.muted = false + tempDir.deleteRecursively() + } + + private fun createState(onError: (Throwable) -> Unit = {}, onFinish: () -> Unit = {}) = + UpdaterDialogState(update, dialogScope, appRecordStore, onError, onFinish) + + @Test + fun testInitialState() { + val state = createState() + + assertFalse(state.isDownloading) + assertEquals(0f, state.progress) + assertFalse(state.isShowingChoosingDownloadPositionDialog) + assertEquals(tempDir.absolutePath, state.downloadDirectory.absolutePath) + assertTrue(state.isDownloadPositionValid) + } + + @Test + fun testDownloadPositionValidityFollowsDirectory() { + val state = createState() + + state.downloadDirectory = tempDir.resolve("missing") + assertFalse(state.isDownloadPositionValid) + // the new directory is persisted to the app record + assertEquals(tempDir.resolve("missing").absolutePath, appRecordStore.value.updateDownloadDirectory) + + state.downloadDirectory = tempDir + assertTrue(state.isDownloadPositionValid) + } + + @Test + fun testChooseDownloadPositionUpdatesDirectoryAndRecord() { + val newDir = tempDir.resolve("downloads").apply { mkdirs() } + val state = createState() + + state.openChooseDownloadPositionDialog() + assertTrue(state.isShowingChoosingDownloadPositionDialog) + + state.handleChoosingDownloadPositionDialogResult(tempDir.absolutePath, "downloads") + + assertFalse(state.isShowingChoosingDownloadPositionDialog) + assertEquals(newDir.absolutePath, state.downloadDirectory.absolutePath) + assertEquals(newDir.absolutePath, appRecordStore.value.updateDownloadDirectory) + } + + @Test + fun testChooseDownloadPositionNullResultDoesNotChangeDirectory() { + val state = createState() + val before = state.downloadDirectory.absolutePath + state.openChooseDownloadPositionDialog() + + state.handleChoosingDownloadPositionDialogResult(null, null) + + assertFalse(state.isShowingChoosingDownloadPositionDialog) + assertEquals(before, state.downloadDirectory.absolutePath) + } + + @Test + fun testCancelInvokesFinish() { + var finished = false + val state = createState(onFinish = { finished = true }) + + state.cancel() + + assertTrue(finished) + } + + @Test + fun testCancelIgnoredMarksVersionIgnored() { + var finished = false + val state = createState(onFinish = { finished = true }) + + state.cancelIgnored() + + assertTrue(appRecordStore.value.isUpdateIgnored(update.version)) + assertTrue(finished) + } + + // ---- Update.from: which updates are shown for a given current-vs-available version set ---- + + private fun assetsFor(version: Version) = listOf( + Asset("https://example.com/vlabeler-$version-win64.zip", "vlabeler-$version-win64.zip"), + Asset("https://example.com/vlabeler-$version-mac-arm64.dmg", "vlabeler-$version-mac-arm64.dmg"), + Asset("https://example.com/vlabeler-$version-mac-x64.dmg", "vlabeler-$version-mac-x64.dmg"), + Asset("https://example.com/vlabeler-$version-amd64.deb", "vlabeler-$version-amd64.deb"), + ) + + private fun releaseOf(version: Version, prerelease: Boolean = false, draft: Boolean = false) = Release( + htmlUrl = "https://example.com/releases/$version", + tagName = version.toString(), + publishedAt = "2024-01-01T00:00:00Z", + prerelease = prerelease, + draft = draft, + assets = assetsFor(version), + ) + + private val older = Version(0, 0, 0) + private val newer1 = appVersion.copy(major = appVersion.major + 1, stage = null, stageVersion = null) + private val newer2 = appVersion.copy(major = appVersion.major + 2, stage = null, stageVersion = null) + + @Test + fun testUpdateFromReturnsNewestWithDiffOfNewerVersions() { + val releases = listOf(releaseOf(newer1), releaseOf(older), releaseOf(newer2)) + + val result = Update.from(releases, UpdateChannel.Stable) + + assertNotNull(result) + assertEquals(newer2, result.version) + // diff lists the newer versions from newest to oldest, excluding the already-installed/older ones + assertEquals(listOf(newer2, newer1), result.diff.map { it.version }) + } + + @Test + fun testUpdateFromReturnsNullWhenNothingIsNewer() { + val result = Update.from(listOf(releaseOf(older)), UpdateChannel.Stable) + + assertNull(result) + } + + @Test + fun testUpdateFromReturnsNullForNoReleases() { + assertNull(Update.from(emptyList(), UpdateChannel.Stable)) + } + + @Test + fun testUpdateFromExcludesDraftReleases() { + val releases = listOf(releaseOf(newer1, draft = true), releaseOf(newer2)) + + val result = Update.from(releases, UpdateChannel.Stable) + + assertNotNull(result) + assertEquals(newer2, result.version) + assertEquals(listOf(newer2), result.diff.map { it.version }) + } +}