Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 9 additions & 7 deletions src/jvmMain/kotlin/com/sdercolin/vlabeler/io/ImportProject.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,14 @@ data class ImportedModule(
}
}

fun importModulesFromProject(projectText: String): List<ImportedModule> = 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<ImportedModule> {
val root = json.parseToJsonElement(projectText)

val modules = mutableListOf<ImportedModule>()
Expand Down Expand Up @@ -85,12 +92,7 @@ fun importModulesFromProject(projectText: String): List<ImportedModule> = 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 {
Expand Down
13 changes: 10 additions & 3 deletions src/jvmMain/kotlin/com/sdercolin/vlabeler/ipc/IpcState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -41,7 +48,7 @@ class IpcState(private val appState: AppState) {
response(response)
}

fun close() {
override fun close() {
server.close()
}
}
22 changes: 18 additions & 4 deletions src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/AppDialogState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,10 @@ interface AppDialogState {
importEntriesDialogArgs != null ||
macroPluginShownInDialog != null ||
macroPluginReport != null ||
customizableItemManagerTypeShownInDialog != null ||
(
customizableItemManagerTypeShownInDialog != null &&
customizableItemManagerTypeShownInDialog != CustomizableItem.Type.MacroPlugin
) ||
embeddedDialog != null
}

Expand Down Expand Up @@ -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()
}
}
7 changes: 6 additions & 1 deletion src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/AppState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) },
Comment thread
sdercolin marked this conversation as resolved.
) : AppErrorState by errorState,
AppViewState by viewState,
AppScreenState by screenState,
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
99 changes: 99 additions & 0 deletions src/jvmTest/kotlin/io/ExportProjectTest.kt
Original file line number Diff line number Diff line change
@@ -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) }
}
}
9 changes: 5 additions & 4 deletions src/jvmTest/kotlin/io/ImportProjectEdgeCasesTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Exception> { importModulesFromProject("not a json") }
}

@Test
fun testMissingLabelerConfReturnsEmptyList() {
fun testMissingLabelerConfThrows() {
val json = """
{
"modules": [
Expand All @@ -64,7 +65,7 @@ class ImportProjectEdgeCasesTest {
}
""".trimIndent()

assertEquals(emptyList(), importModulesFromProject(json))
assertFailsWith<Exception> { importModulesFromProject(json) }
}

@Test
Expand Down
Loading
Loading