mirror of
https://github.com/Darkrock-Studios/hammer-editor.git
synced 2026-08-05 23:59:46 +00:00
A previous refactor narrowed the TOML loader catches to IOException +
SerializationException, dropping the IllegalArgumentException branch that readJsonOrNull already has. tomlkt doesn't route every decode failure through SerializationException: a stale stats.toml (old schema, e.g. a non-integer key in Map<Int, Int>) throws a raw NumberFormatException. That uncaught exception killed the parallelMap worker for the affected project, nulled its result slot, and filterNotNull() dropped it from the project selection list — while sync, a different path, still showed it. Add a readTomlOrNull helper mirroring readJsonOrNull that absorbs the full set tomlkt can throw on bad input (SerializationException, IllegalArgumentException incl. NumberFormatException, IllegalStateException from parser errors, IOException), with an onError callback so callers keep their site-specific logging. Schema-version checks run after decode, so they can't rescue a decode that throws first. Migrate every TOML read site to it: ProjectStatisticsCacheReader, StatisticsDatasource, ProjectDataDatasource, ProjectsListComponent, ReferenceIndexDatasource, WritingActivityDatasource, and SceneMetadataDatasource (which previously had no error handling at all). Stale caches are now treated as a miss and recalculated. Add ReadTomlOrNullTest covering each exception family and a ProjectStatisticsCacheReaderTest reproducing the original crash.
This commit is contained in:
parent
59f5172c93
commit
f779996fee
12 changed files with 283 additions and 94 deletions
|
|
@ -67,6 +67,40 @@ inline fun <reified T : Any> FileSystem.readToml(path: Path, toml: Toml, clazz:
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and decodes [T] from a TOML file, returning null on any read or decode
|
||||
* failure rather than throwing. [onError] is invoked with the failure (default
|
||||
* no-op) so callers can log site-specific context including the exception.
|
||||
*
|
||||
* tomlkt does not funnel every decode failure through [SerializationException]:
|
||||
* stale or hand-edited files can throw [IllegalArgumentException] (numeric
|
||||
* coercion via NumberFormatException, type-mismatch casts, bad booleans) or
|
||||
* [IllegalStateException] (parser errors such as a malformed date-time). All are
|
||||
* treated as a missing/unusable file.
|
||||
*/
|
||||
@Suppress("SwallowedException")
|
||||
inline fun <reified T : Any> FileSystem.readTomlOrNull(
|
||||
path: Path,
|
||||
toml: Toml,
|
||||
onError: (Throwable) -> Unit = {},
|
||||
): T? {
|
||||
return try {
|
||||
readToml<T>(path, toml)
|
||||
} catch (e: IOException) {
|
||||
onError(e)
|
||||
null
|
||||
} catch (e: SerializationException) {
|
||||
onError(e)
|
||||
null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
onError(e)
|
||||
null
|
||||
} catch (e: IllegalStateException) {
|
||||
onError(e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T> FileSystem.writeToml(path: Path, toml: Toml, obj: T) {
|
||||
write(path) {
|
||||
val jsonStr = toml.encodeToString<T>(obj)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import com.arkivanov.decompose.ComponentContext
|
|||
import com.arkivanov.decompose.value.Value
|
||||
import com.arkivanov.decompose.value.getAndUpdate
|
||||
import com.darkrockstudios.apps.hammer.*
|
||||
import com.darkrockstudios.apps.hammer.base.http.readToml
|
||||
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
|
||||
import com.darkrockstudios.apps.hammer.common.components.ComponentToaster
|
||||
import com.darkrockstudios.apps.hammer.common.components.ComponentToasterImpl
|
||||
import com.darkrockstudios.apps.hammer.common.components.SavableComponent
|
||||
|
|
@ -35,7 +35,6 @@ import io.github.aakira.napier.Napier
|
|||
import korlibs.datastructure.iterators.parallelMap
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.SerializationException
|
||||
import net.peanuuutz.tomlkt.Toml
|
||||
import okio.FileSystem
|
||||
import okio.IOException
|
||||
|
|
@ -164,13 +163,10 @@ class ProjectsListComponent(
|
|||
loadProjectsJob = scope.launch {
|
||||
val projects = projectsRepository.getProjects(projectsDir)
|
||||
val projectData = projects.parallelMap { projectDef ->
|
||||
// The project can be deleted concurrently (e.g. another window, or a refresh
|
||||
// racing a delete) between listing it and reading its metadata. loadMetadata
|
||||
// then tries to recreate the file in a directory that no longer exists and
|
||||
// throws - skip the vanished project rather than failing the whole load.
|
||||
// The project can be deleted concurrently
|
||||
val metadata = try {
|
||||
projectMetadataDatasource.loadMetadata(projectDef)
|
||||
} catch (e: IOException) {
|
||||
} catch (_: IOException) {
|
||||
null
|
||||
}
|
||||
if (metadata != null) {
|
||||
|
|
@ -181,7 +177,7 @@ class ProjectsListComponent(
|
|||
totalWords = statisticsCacheReader.loadTotalWords(projectDef),
|
||||
)
|
||||
} else {
|
||||
Napier.w { "Failed to load metadata for project: ${projectDef.name}" }
|
||||
Napier.d { "Failed to load metadata for project: ${projectDef.name}" }
|
||||
null
|
||||
}
|
||||
}.filterNotNull().sortedByDescending { it.metadata.info.lastAccessed }
|
||||
|
|
@ -200,15 +196,9 @@ class ProjectsListComponent(
|
|||
*/
|
||||
private fun loadStoredProjectData(projectDef: ProjectDef): StoredData {
|
||||
val path = projectDef.path.toOkioPath() / ProjectDataDatasource.FILENAME
|
||||
return try {
|
||||
fileSystem.readToml<StoredProjectData>(path, toml).data
|
||||
} catch (e: IOException) {
|
||||
Napier.w("Failed to read stored project data for ${projectDef.name}, using defaults", e)
|
||||
StoredData()
|
||||
} catch (e: SerializationException) {
|
||||
Napier.w("Failed to read stored project data for ${projectDef.name}, using defaults", e)
|
||||
StoredData()
|
||||
}
|
||||
return fileSystem.readTomlOrNull<StoredProjectData>(path, toml) { e ->
|
||||
Napier.d("Failed to read stored project data for ${projectDef.name}, using defaults", e)
|
||||
}?.data ?: StoredData()
|
||||
}
|
||||
|
||||
private fun updateLastAccessed(projectDef: ProjectDef) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package com.darkrockstudios.apps.hammer.common.data.projectdata
|
||||
|
||||
import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectData
|
||||
import com.darkrockstudios.apps.hammer.base.http.readToml
|
||||
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
|
||||
import com.darkrockstudios.apps.hammer.base.http.writeToml
|
||||
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
|
||||
import com.darkrockstudios.apps.hammer.common.data.ProjectScoped
|
||||
|
|
@ -13,10 +13,8 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import net.peanuuutz.tomlkt.Toml
|
||||
import okio.FileSystem
|
||||
import okio.IOException
|
||||
import okio.Path
|
||||
|
||||
/**
|
||||
|
|
@ -38,15 +36,9 @@ class ProjectDataDatasource(
|
|||
suspend fun load(): StoredProjectData = withContext(dispatcherIo) {
|
||||
val path = getPath()
|
||||
if (!fileSystem.exists(path)) return@withContext StoredProjectData()
|
||||
try {
|
||||
fileSystem.readToml<StoredProjectData>(path, toml)
|
||||
} catch (e: IOException) {
|
||||
fileSystem.readTomlOrNull<StoredProjectData>(path, toml) { e ->
|
||||
Napier.e("Failed to load project_data.toml: $path", e)
|
||||
StoredProjectData()
|
||||
} catch (e: SerializationException) {
|
||||
Napier.e("Failed to load project_data.toml: $path", e)
|
||||
StoredProjectData()
|
||||
}
|
||||
} ?: StoredProjectData()
|
||||
}
|
||||
|
||||
suspend fun save(stored: StoredProjectData): Unit = withContext(dispatcherIo) {
|
||||
|
|
@ -77,10 +69,7 @@ suspend fun loadStoredProjectData(
|
|||
toml: Toml,
|
||||
): StoredProjectData = withContext(Dispatchers.IO) {
|
||||
val path = projectDef.path.toOkioPath() / ProjectDataDatasource.FILENAME
|
||||
try {
|
||||
fileSystem.readToml<StoredProjectData>(path, toml)
|
||||
} catch (e: Exception) {
|
||||
fileSystem.readTomlOrNull<StoredProjectData>(path, toml) { e ->
|
||||
Napier.d("No project_data.toml at $path (${e.message})")
|
||||
StoredProjectData()
|
||||
}
|
||||
} ?: StoredProjectData()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.darkrockstudios.apps.hammer.common.data.projectmetadata
|
||||
|
||||
import com.darkrockstudios.apps.hammer.base.ProjectId
|
||||
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
|
||||
import com.darkrockstudios.apps.hammer.common.components.storyeditor.metadata.Info
|
||||
import com.darkrockstudios.apps.hammer.common.components.storyeditor.metadata.ProjectMetadata
|
||||
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
|
||||
|
|
@ -8,11 +9,9 @@ import com.darkrockstudios.apps.hammer.common.fileio.HPath
|
|||
import com.darkrockstudios.apps.hammer.common.fileio.okio.toHPath
|
||||
import com.darkrockstudios.apps.hammer.common.fileio.okio.toOkioPath
|
||||
import io.github.aakira.napier.Napier
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import net.peanuuutz.tomlkt.Toml
|
||||
import okio.FileSystem
|
||||
import okio.IOException
|
||||
import kotlin.time.Clock
|
||||
|
||||
class ProjectMetadataDatasource(
|
||||
|
|
@ -26,21 +25,14 @@ class ProjectMetadataDatasource(
|
|||
fun loadMetadata(projectDef: ProjectDef): ProjectMetadata {
|
||||
val path = getMetadataPath(projectDef).toOkioPath()
|
||||
|
||||
val metadata = try {
|
||||
val metadataText = fileSystem.read(path) {
|
||||
readUtf8()
|
||||
}
|
||||
toml.decodeFromString(metadataText)
|
||||
} catch (e: IOException) {
|
||||
Napier.e("Failed to load project metadata: ${path.toHPath().path}")
|
||||
|
||||
// Delete any old corrupt file if we got here
|
||||
return fileSystem.readTomlOrNull<ProjectMetadata>(path, toml) { e ->
|
||||
Napier.e("Failed to load project metadata: ${path.toHPath().path}", e)
|
||||
} ?: run {
|
||||
// Missing or corrupt (tomlkt throws beyond SerializationException): drop the
|
||||
// bad file and start fresh so migrators re-run instead of crashing the load.
|
||||
fileSystem.delete(path, false)
|
||||
|
||||
createNewMetadata(projectDef)
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
fun saveMetadata(metadata: ProjectMetadata, projectDef: ProjectDef) {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
package com.darkrockstudios.apps.hammer.common.data.projectstatistics
|
||||
|
||||
import com.darkrockstudios.apps.hammer.base.http.readToml
|
||||
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
|
||||
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
|
||||
import io.github.aakira.napier.Napier
|
||||
import kotlinx.serialization.SerializationException
|
||||
import net.peanuuutz.tomlkt.Toml
|
||||
import okio.FileSystem
|
||||
import okio.IOException
|
||||
|
||||
/** Reads the cached stats file directly, without opening a ProjectDefScope. */
|
||||
class ProjectStatisticsCacheReader(
|
||||
|
|
@ -18,15 +16,9 @@ class ProjectStatisticsCacheReader(
|
|||
val file = StatisticsCachePaths.statsFile(projectDef)
|
||||
if (!fileSystem.exists(file)) return null
|
||||
|
||||
val stats = try {
|
||||
fileSystem.readToml<ProjectStatistics>(file, toml)
|
||||
} catch (e: IOException) {
|
||||
val stats = fileSystem.readTomlOrNull<ProjectStatistics>(file, toml) { e ->
|
||||
Napier.d("Failed to read statistics cache for ${projectDef.name}", e)
|
||||
return null
|
||||
} catch (e: SerializationException) {
|
||||
Napier.d("Failed to read statistics cache for ${projectDef.name}", e)
|
||||
return null
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
if (stats.schemaVersion != ProjectStatistics.CURRENT_SCHEMA_VERSION) return null
|
||||
return stats
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
package com.darkrockstudios.apps.hammer.common.data.projectstatistics
|
||||
|
||||
import com.darkrockstudios.apps.hammer.base.http.readToml
|
||||
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
|
||||
import com.darkrockstudios.apps.hammer.base.http.writeToml
|
||||
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
|
||||
import com.darkrockstudios.apps.hammer.common.data.ProjectScoped
|
||||
|
|
@ -8,10 +8,8 @@ import com.darkrockstudios.apps.hammer.common.dependencyinjection.ProjectDefScop
|
|||
import com.darkrockstudios.apps.hammer.common.dependencyinjection.injectIoDispatcher
|
||||
import io.github.aakira.napier.Napier
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
import net.peanuuutz.tomlkt.Toml
|
||||
import okio.FileSystem
|
||||
import okio.IOException
|
||||
|
||||
class StatisticsDatasource(
|
||||
private val fileSystem: FileSystem,
|
||||
|
|
@ -25,14 +23,8 @@ class StatisticsDatasource(
|
|||
suspend fun loadStatistics(): ProjectStatistics? = withContext(dispatcherIo) {
|
||||
val file = StatisticsCachePaths.statsFile(projectDef)
|
||||
return@withContext if (fileSystem.exists(file)) {
|
||||
try {
|
||||
fileSystem.readToml(file, toml)
|
||||
} catch (e: IOException) {
|
||||
fileSystem.readTomlOrNull<ProjectStatistics>(file, toml) { e ->
|
||||
Napier.e("Failed to load statistics cache", e)
|
||||
null
|
||||
} catch (e: SerializationException) {
|
||||
Napier.e("Failed to load statistics cache", e)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
package com.darkrockstudios.apps.hammer.common.data.references
|
||||
|
||||
import com.darkrockstudios.apps.hammer.base.http.readToml
|
||||
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
|
||||
import com.darkrockstudios.apps.hammer.base.http.writeToml
|
||||
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
|
||||
import com.darkrockstudios.apps.hammer.common.data.ProjectScoped
|
||||
|
|
@ -9,10 +9,8 @@ import com.darkrockstudios.apps.hammer.common.dependencyinjection.injectIoDispat
|
|||
import com.darkrockstudios.apps.hammer.common.getCacheDirectory
|
||||
import io.github.aakira.napier.Napier
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
import net.peanuuutz.tomlkt.Toml
|
||||
import okio.FileSystem
|
||||
import okio.IOException
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toPath
|
||||
|
||||
|
|
@ -28,14 +26,8 @@ class ReferenceIndexDatasource(
|
|||
suspend fun loadIndex(): ReferenceIndex? = withContext(dispatcherIo) {
|
||||
val file = getIndexPath()
|
||||
return@withContext if (fileSystem.exists(file)) {
|
||||
try {
|
||||
fileSystem.readToml(file, toml)
|
||||
} catch (e: IOException) {
|
||||
fileSystem.readTomlOrNull<ReferenceIndex>(file, toml) { e ->
|
||||
Napier.e("Failed to load reference index cache", e)
|
||||
null
|
||||
} catch (e: SerializationException) {
|
||||
Napier.e("Failed to load reference index cache", e)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
package com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.scenemetadata
|
||||
|
||||
import com.darkrockstudios.apps.hammer.base.http.readToml
|
||||
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
|
||||
import com.darkrockstudios.apps.hammer.base.http.writeToml
|
||||
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
|
||||
import com.darkrockstudios.apps.hammer.common.data.ProjectScoped
|
||||
|
|
@ -27,7 +27,9 @@ class SceneMetadataDatasource(
|
|||
suspend fun loadMetadata(sceneId: Int): SceneMetadata? = withContext(dispatcherIo) {
|
||||
val file = getMetadataPath(sceneId).toOkioPath()
|
||||
return@withContext if (fileSystem.exists(file)) {
|
||||
fileSystem.readToml(file, toml)
|
||||
fileSystem.readTomlOrNull<SceneMetadata>(file, toml) { e ->
|
||||
Napier.e("Failed to load scene metadata for SceneId: $sceneId", e)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
package com.darkrockstudios.apps.hammer.common.data.writingactivity
|
||||
|
||||
import com.darkrockstudios.apps.hammer.base.http.readToml
|
||||
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
|
||||
import com.darkrockstudios.apps.hammer.base.http.writeToml
|
||||
import com.darkrockstudios.apps.hammer.base.http.writingactivity.DeviceLog
|
||||
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
|
||||
|
|
@ -11,10 +11,8 @@ import com.darkrockstudios.apps.hammer.common.dependencyinjection.injectIoDispat
|
|||
import com.darkrockstudios.apps.hammer.common.fileio.okio.toOkioPath
|
||||
import io.github.aakira.napier.Napier
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
import net.peanuuutz.tomlkt.Toml
|
||||
import okio.FileSystem
|
||||
import okio.IOException
|
||||
import okio.Path
|
||||
|
||||
/**
|
||||
|
|
@ -44,14 +42,8 @@ class WritingActivityDatasource(
|
|||
suspend fun loadDeviceLog(deviceId: String): DeviceLog? = withContext(dispatcherIo) {
|
||||
val path = getDeviceLogPath(deviceId)
|
||||
if (!fileSystem.exists(path)) return@withContext null
|
||||
try {
|
||||
fileSystem.readToml(path, toml)
|
||||
} catch (e: IOException) {
|
||||
fileSystem.readTomlOrNull<DeviceLog>(path, toml) { e ->
|
||||
Napier.e("Failed to load writing activity log: $path", e)
|
||||
null
|
||||
} catch (e: SerializationException) {
|
||||
Napier.e("Failed to load writing activity log: $path", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -62,11 +54,8 @@ class WritingActivityDatasource(
|
|||
.filter { it.name.endsWith(FILE_SUFFIX) }
|
||||
.mapNotNull { path ->
|
||||
val deviceId = path.name.removeSuffix(FILE_SUFFIX)
|
||||
val log: DeviceLog? = try {
|
||||
fileSystem.readToml(path, toml)
|
||||
} catch (e: Exception) {
|
||||
val log = fileSystem.readTomlOrNull<DeviceLog>(path, toml) { e ->
|
||||
Napier.e("Failed to load writing activity log: $path", e)
|
||||
null
|
||||
}
|
||||
log?.let { deviceId to it }
|
||||
}.toMap()
|
||||
|
|
|
|||
147
common/src/desktopTest/kotlin/base/http/ReadTomlOrNullTest.kt
Normal file
147
common/src/desktopTest/kotlin/base/http/ReadTomlOrNullTest.kt
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package base.http
|
||||
|
||||
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
|
||||
import com.darkrockstudios.apps.hammer.base.http.writeToml
|
||||
import com.darkrockstudios.apps.hammer.common.dependencyinjection.createTomlSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import okio.Path.Companion.toPath
|
||||
import okio.fakefilesystem.FakeFileSystem
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.*
|
||||
|
||||
/**
|
||||
* tomlkt does not route every malformed-input failure through SerializationException.
|
||||
* These pin the full set [readTomlOrNull] must absorb so a stale or hand-edited cache
|
||||
* file can never crash a caller: numeric coercion (NumberFormatException), bad booleans
|
||||
* and type-mismatch casts (IllegalArgumentException), parser errors (IllegalStateException),
|
||||
* tomlkt decode errors (SerializationException), and missing/unreadable files (IOException).
|
||||
*/
|
||||
class ReadTomlOrNullTest {
|
||||
|
||||
@Serializable
|
||||
private data class Sample(
|
||||
val count: Int,
|
||||
val enabled: Boolean = false,
|
||||
val byChapter: Map<Int, Int> = emptyMap(),
|
||||
)
|
||||
|
||||
private val toml = createTomlSerializer()
|
||||
private val fileSystem = FakeFileSystem()
|
||||
private val path = "/sample.toml".toPath()
|
||||
|
||||
private fun write(content: String) {
|
||||
fileSystem.write(path) { writeUtf8(content.trimIndent()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid toml decodes and does not report an error`() {
|
||||
var errored = false
|
||||
fileSystem.writeToml(path, toml, Sample(count = 3, enabled = true))
|
||||
|
||||
val result = fileSystem.readTomlOrNull<Sample>(path, toml) { errored = true }
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals(3, result.count)
|
||||
assertTrue(result.enabled)
|
||||
assertFalse(errored)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing file returns null and reports the error`() {
|
||||
var reported: Throwable? = null
|
||||
|
||||
val result = fileSystem.readTomlOrNull<Sample>(path, toml) { reported = it }
|
||||
|
||||
assertNull(result)
|
||||
assertNotNull(reported)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-numeric int value returns null`() {
|
||||
write("""count = "Title"""")
|
||||
|
||||
val reported = capture()
|
||||
|
||||
assertNull(reported.result)
|
||||
assertNotNull(reported.error) // NumberFormatException
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-numeric map key returns null`() {
|
||||
// The production crash: a Map<Int, Int> with a non-integer key.
|
||||
write(
|
||||
"""
|
||||
count = 0
|
||||
|
||||
[byChapter]
|
||||
Title = 5
|
||||
"""
|
||||
)
|
||||
|
||||
val reported = capture()
|
||||
|
||||
assertNull(reported.result)
|
||||
assertNotNull(reported.error) // NumberFormatException
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-boolean value returns null`() {
|
||||
write(
|
||||
"""
|
||||
count = 0
|
||||
enabled = "maybe"
|
||||
"""
|
||||
)
|
||||
|
||||
val reported = capture()
|
||||
|
||||
assertNull(reported.result)
|
||||
assertNotNull(reported.error) // IllegalArgumentException from requireNotNull
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `type mismatch returns null`() {
|
||||
// count is an Int field but the file holds a table.
|
||||
write(
|
||||
"""
|
||||
[count]
|
||||
nested = 1
|
||||
"""
|
||||
)
|
||||
|
||||
val reported = capture()
|
||||
|
||||
assertNull(reported.result)
|
||||
assertNotNull(reported.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed date-time literal returns null`() {
|
||||
// An unquoted, invalid date-time token fails in tomlkt's parser with
|
||||
// IllegalStateException rather than SerializationException.
|
||||
write("count = 2020-99-99T25:61:61Z")
|
||||
|
||||
val reported = capture()
|
||||
|
||||
assertNull(reported.result)
|
||||
assertNotNull(reported.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed syntax returns null`() {
|
||||
write("count = = 3")
|
||||
|
||||
val reported = capture()
|
||||
|
||||
assertNull(reported.result)
|
||||
assertNotNull(reported.error) // SerializationException (UnexpectedTokenException)
|
||||
}
|
||||
|
||||
private class Captured(val result: Sample?, val error: Throwable?)
|
||||
|
||||
private fun capture(): Captured {
|
||||
var error: Throwable? = null
|
||||
val result = fileSystem.readTomlOrNull<Sample>(path, toml) { error = it }
|
||||
return Captured(result, error)
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +56,28 @@ class ProjectMetadataDatasourceTest : BaseTest() {
|
|||
assertEquals(expectedMetadata, metadata)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Corrupt project metadata recovers instead of throwing`() {
|
||||
createProject(fileSystem, PROJECT_1_NAME)
|
||||
val path = projectMetadataDatasource.getMetadataPath(getProject1Def()).toOkioPath()
|
||||
// dataVersion is an Int; a non-numeric value throws NumberFormatException on decode,
|
||||
// which is not a SerializationException. Loading must recover, not crash the project load.
|
||||
fileSystem.write(path) {
|
||||
writeUtf8(
|
||||
"""
|
||||
[info]
|
||||
created = "2022-12-30T07:08:02.691261600Z"
|
||||
dataVersion = "not-a-number"
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
val metadata = projectMetadataDatasource.loadMetadata(getProject1Def())
|
||||
|
||||
// Recreated fresh (dataVersion 0 so migrators re-run) rather than throwing.
|
||||
assertEquals(0, metadata.info.dataVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Get Project Metadata Path`() {
|
||||
createProject(fileSystem, PROJECT_1_NAME)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
package repositories.projectstatistics
|
||||
|
||||
import com.darkrockstudios.apps.hammer.common.data.projectstatistics.ProjectStatisticsCacheReader
|
||||
import com.darkrockstudios.apps.hammer.common.data.projectstatistics.StatisticsCachePaths
|
||||
import com.darkrockstudios.apps.hammer.common.dependencyinjection.createTomlSerializer
|
||||
import getProject1Def
|
||||
import okio.fakefilesystem.FakeFileSystem
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* A stale or hand-edited stats cache can hold values that tomlkt fails to coerce
|
||||
* (e.g. a non-integer key in a `Map<Int, Int>`), which surfaces as a
|
||||
* NumberFormatException - an IllegalArgumentException, not a SerializationException.
|
||||
* The reader must treat that as a cache miss rather than letting it escape and
|
||||
* crash the project-list load.
|
||||
*/
|
||||
class ProjectStatisticsCacheReaderTest {
|
||||
|
||||
@Test
|
||||
fun `malformed stats cache returns null instead of throwing`() {
|
||||
val fileSystem = FakeFileSystem()
|
||||
val toml = createTomlSerializer()
|
||||
val projectDef = getProject1Def()
|
||||
|
||||
val statsFile = StatisticsCachePaths.statsFile(projectDef)
|
||||
fileSystem.createDirectories(statsFile.parent!!)
|
||||
fileSystem.write(statsFile) {
|
||||
// "Title" is not a valid Int key for wordsByChapter: Map<Int, Int>.
|
||||
writeUtf8(
|
||||
"""
|
||||
numberOfScenes = 0
|
||||
totalWords = 0
|
||||
lastCalculated = "1970-01-01T00:00:00Z"
|
||||
|
||||
[wordsByChapter]
|
||||
Title = 5
|
||||
|
||||
[encyclopediaEntriesByType]
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
val reader = ProjectStatisticsCacheReader(fileSystem, toml)
|
||||
|
||||
assertNull(reader.loadStatistics(projectDef))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue