Initial implementation of songbook toolset
Kotlin/JVM multi-module project for generating a scout songbook PDF from ChordPro-format text files. Includes ChordPro parser, layout engine with greedy spread packing for double-page songs, OpenPDF renderer, CLI (Clikt), Compose Desktop GUI, and 5 sample songs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
package de.pfadfinder.songbook.app
|
||||
|
||||
import de.pfadfinder.songbook.model.*
|
||||
import de.pfadfinder.songbook.parser.*
|
||||
import de.pfadfinder.songbook.layout.*
|
||||
import de.pfadfinder.songbook.renderer.pdf.*
|
||||
import mu.KotlinLogging
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
data class BuildResult(
|
||||
val success: Boolean,
|
||||
val outputFile: File? = null,
|
||||
val errors: List<ValidationError> = emptyList(),
|
||||
val songCount: Int = 0,
|
||||
val pageCount: Int = 0
|
||||
)
|
||||
|
||||
class SongbookPipeline(private val projectDir: File) {
|
||||
|
||||
fun build(): BuildResult {
|
||||
// 1. Parse config
|
||||
val configFile = File(projectDir, "songbook.yaml")
|
||||
if (!configFile.exists()) {
|
||||
return BuildResult(false, errors = listOf(ValidationError(configFile.name, null, "songbook.yaml not found")))
|
||||
}
|
||||
logger.info { "Parsing config: ${configFile.absolutePath}" }
|
||||
val config = ConfigParser.parse(configFile)
|
||||
|
||||
// Validate config
|
||||
val configErrors = Validator.validateConfig(config)
|
||||
if (configErrors.isNotEmpty()) {
|
||||
return BuildResult(false, errors = configErrors)
|
||||
}
|
||||
|
||||
// 2. Parse songs
|
||||
val songsDir = File(projectDir, config.songs.directory)
|
||||
if (!songsDir.exists() || !songsDir.isDirectory) {
|
||||
return BuildResult(false, errors = listOf(ValidationError(config.songs.directory, null, "Songs directory not found")))
|
||||
}
|
||||
|
||||
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
|
||||
?.sortedBy { it.name }
|
||||
?: emptyList()
|
||||
|
||||
if (songFiles.isEmpty()) {
|
||||
return BuildResult(false, errors = listOf(ValidationError(config.songs.directory, null, "No song files found")))
|
||||
}
|
||||
|
||||
logger.info { "Found ${songFiles.size} song files" }
|
||||
|
||||
val songs = mutableListOf<Song>()
|
||||
val allErrors = mutableListOf<ValidationError>()
|
||||
|
||||
for (file in songFiles) {
|
||||
try {
|
||||
val song = ChordProParser.parseFile(file)
|
||||
val songErrors = Validator.validateSong(song, file.name)
|
||||
if (songErrors.isNotEmpty()) {
|
||||
allErrors.addAll(songErrors)
|
||||
} else {
|
||||
songs.add(song)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
allErrors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
if (allErrors.isNotEmpty()) {
|
||||
return BuildResult(false, errors = allErrors)
|
||||
}
|
||||
|
||||
// Sort songs
|
||||
val sortedSongs = when (config.songs.order) {
|
||||
"alphabetical" -> songs.sortedBy { it.title.lowercase() }
|
||||
else -> songs // manual order = file order
|
||||
}
|
||||
|
||||
logger.info { "Parsed ${sortedSongs.size} songs" }
|
||||
|
||||
// 3. Measure songs
|
||||
val fontMetrics = PdfFontMetrics()
|
||||
val measurementEngine = MeasurementEngine(fontMetrics, config)
|
||||
val measuredSongs = sortedSongs.map { measurementEngine.measure(it) }
|
||||
|
||||
// 4. Generate TOC and paginate
|
||||
val tocGenerator = TocGenerator(config)
|
||||
val tocPages = tocGenerator.estimateTocPages(sortedSongs)
|
||||
|
||||
val paginationEngine = PaginationEngine(config)
|
||||
val pages = paginationEngine.paginate(measuredSongs, tocPages)
|
||||
|
||||
val tocEntries = tocGenerator.generate(pages, tocPages)
|
||||
|
||||
val layoutResult = LayoutResult(
|
||||
tocPages = tocPages,
|
||||
pages = pages,
|
||||
tocEntries = tocEntries
|
||||
)
|
||||
|
||||
logger.info { "Layout: ${tocPages} TOC pages, ${pages.size} content pages" }
|
||||
|
||||
// 5. Render PDF
|
||||
val outputDir = File(projectDir, config.output.directory)
|
||||
outputDir.mkdirs()
|
||||
val outputFile = File(outputDir, config.output.filename)
|
||||
|
||||
logger.info { "Rendering PDF: ${outputFile.absolutePath}" }
|
||||
|
||||
val renderer = PdfBookRenderer()
|
||||
FileOutputStream(outputFile).use { fos ->
|
||||
renderer.render(layoutResult, config, fos)
|
||||
}
|
||||
|
||||
logger.info { "Build complete: ${sortedSongs.size} songs, ${pages.size + tocPages} pages" }
|
||||
|
||||
return BuildResult(
|
||||
success = true,
|
||||
outputFile = outputFile,
|
||||
songCount = sortedSongs.size,
|
||||
pageCount = pages.size + tocPages
|
||||
)
|
||||
}
|
||||
|
||||
fun validate(): List<ValidationError> {
|
||||
val configFile = File(projectDir, "songbook.yaml")
|
||||
if (!configFile.exists()) {
|
||||
return listOf(ValidationError(configFile.name, null, "songbook.yaml not found"))
|
||||
}
|
||||
|
||||
val config = ConfigParser.parse(configFile)
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
errors.addAll(Validator.validateConfig(config))
|
||||
|
||||
val songsDir = File(projectDir, config.songs.directory)
|
||||
if (!songsDir.exists()) {
|
||||
errors.add(ValidationError(config.songs.directory, null, "Songs directory not found"))
|
||||
return errors
|
||||
}
|
||||
|
||||
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
|
||||
?.sortedBy { it.name }
|
||||
?: emptyList()
|
||||
|
||||
for (file in songFiles) {
|
||||
try {
|
||||
val song = ChordProParser.parseFile(file)
|
||||
errors.addAll(Validator.validateSong(song, file.name))
|
||||
} catch (e: Exception) {
|
||||
errors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
package de.pfadfinder.songbook.app
|
||||
|
||||
import io.kotest.matchers.booleans.shouldBeFalse
|
||||
import io.kotest.matchers.booleans.shouldBeTrue
|
||||
import io.kotest.matchers.collections.shouldBeEmpty
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.collections.shouldNotBeEmpty
|
||||
import io.kotest.matchers.ints.shouldBeGreaterThan
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
|
||||
class SongbookPipelineTest {
|
||||
|
||||
private fun createTempProject(): File {
|
||||
val dir = kotlin.io.path.createTempDirectory("songbook-test").toFile()
|
||||
dir.deleteOnExit()
|
||||
return dir
|
||||
}
|
||||
|
||||
private fun writeConfig(projectDir: File, config: String = defaultConfig()) {
|
||||
File(projectDir, "songbook.yaml").writeText(config)
|
||||
}
|
||||
|
||||
private fun defaultConfig(
|
||||
songsDir: String = "./songs",
|
||||
outputDir: String = "./output",
|
||||
outputFilename: String = "liederbuch.pdf",
|
||||
order: String = "alphabetical"
|
||||
): String = """
|
||||
book:
|
||||
title: "Test Liederbuch"
|
||||
format: "A5"
|
||||
songs:
|
||||
directory: "$songsDir"
|
||||
order: "$order"
|
||||
fonts:
|
||||
lyrics:
|
||||
family: "Helvetica"
|
||||
size: 10
|
||||
chords:
|
||||
family: "Helvetica"
|
||||
size: 9
|
||||
title:
|
||||
family: "Helvetica"
|
||||
size: 14
|
||||
metadata:
|
||||
family: "Helvetica"
|
||||
size: 8
|
||||
toc:
|
||||
family: "Helvetica"
|
||||
size: 9
|
||||
layout:
|
||||
margins:
|
||||
top: 15
|
||||
bottom: 15
|
||||
inner: 20
|
||||
outer: 12
|
||||
images:
|
||||
directory: "./images"
|
||||
output:
|
||||
directory: "$outputDir"
|
||||
filename: "$outputFilename"
|
||||
""".trimIndent()
|
||||
|
||||
private fun writeSongFile(songsDir: File, filename: String, content: String) {
|
||||
songsDir.mkdirs()
|
||||
File(songsDir, filename).writeText(content)
|
||||
}
|
||||
|
||||
private fun sampleSong(title: String = "Test Song"): String = """
|
||||
{title: $title}
|
||||
{start_of_verse}
|
||||
[Am]Hello [C]world
|
||||
This is a test
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
|
||||
// --- build() tests ---
|
||||
|
||||
@Test
|
||||
fun `build returns error when songbook yaml is missing`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeFalse()
|
||||
result.errors shouldHaveSize 1
|
||||
result.errors[0].message shouldContain "songbook.yaml not found"
|
||||
result.outputFile.shouldBeNull()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build returns error when songs directory does not exist`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir, defaultConfig(songsDir = "./nonexistent"))
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeFalse()
|
||||
result.errors shouldHaveSize 1
|
||||
result.errors[0].message shouldContain "Songs directory not found"
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build returns error when songs directory is empty`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
File(projectDir, "songs").mkdirs()
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeFalse()
|
||||
result.errors shouldHaveSize 1
|
||||
result.errors[0].message shouldContain "No song files found"
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build returns error for invalid config with zero margins`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
val config = """
|
||||
book:
|
||||
title: "Test"
|
||||
layout:
|
||||
margins:
|
||||
top: 0
|
||||
bottom: 15
|
||||
inner: 20
|
||||
outer: 12
|
||||
""".trimIndent()
|
||||
writeConfig(projectDir, config)
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeFalse()
|
||||
result.errors.shouldNotBeEmpty()
|
||||
result.errors.any { it.message.contains("margin", ignoreCase = true) }.shouldBeTrue()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build returns error for song with missing title`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "bad_song.chopro", """
|
||||
{start_of_verse}
|
||||
[Am]Hello world
|
||||
{end_of_verse}
|
||||
""".trimIndent())
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeFalse()
|
||||
result.errors.shouldNotBeEmpty()
|
||||
result.errors.any { it.message.contains("title", ignoreCase = true) }.shouldBeTrue()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build returns error for song with no sections`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "empty_song.chopro", "{title: Empty Song}")
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeFalse()
|
||||
result.errors.shouldNotBeEmpty()
|
||||
result.errors.any { it.message.contains("section", ignoreCase = true) }.shouldBeTrue()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build succeeds with valid project`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "song1.chopro", sampleSong("Alpha Song"))
|
||||
writeSongFile(songsDir, "song2.chopro", sampleSong("Beta Song"))
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
result.errors.shouldBeEmpty()
|
||||
result.outputFile.shouldNotBeNull()
|
||||
result.outputFile!!.exists().shouldBeTrue()
|
||||
result.songCount shouldBe 2
|
||||
result.pageCount shouldBeGreaterThan 0
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build creates output directory if it does not exist`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir, defaultConfig(outputDir = "./out/build"))
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "song1.chopro", sampleSong())
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
File(projectDir, "out/build").exists().shouldBeTrue()
|
||||
result.outputFile!!.exists().shouldBeTrue()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with alphabetical order sorts songs by title`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir, defaultConfig(order = "alphabetical"))
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "z_first.chopro", sampleSong("Zebra Song"))
|
||||
writeSongFile(songsDir, "a_second.chopro", sampleSong("Alpha Song"))
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
result.songCount shouldBe 2
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with manual order preserves file order`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir, defaultConfig(order = "manual"))
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "02_second.chopro", sampleSong("Second Song"))
|
||||
writeSongFile(songsDir, "01_first.chopro", sampleSong("First Song"))
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
result.songCount shouldBe 2
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build recognizes cho extension`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "song1.cho", sampleSong("Cho Song"))
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
result.songCount shouldBe 1
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build recognizes crd extension`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "song1.crd", sampleSong("Crd Song"))
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
result.songCount shouldBe 1
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build ignores non-song files in songs directory`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "song1.chopro", sampleSong("Real Song"))
|
||||
writeSongFile(songsDir, "readme.txt", "Not a song")
|
||||
writeSongFile(songsDir, "notes.md", "# Notes")
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
result.songCount shouldBe 1
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build output file has correct name`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir, defaultConfig(outputFilename = "my-book.pdf"))
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "song1.chopro", sampleSong())
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
result.outputFile!!.name shouldBe "my-book.pdf"
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build pageCount includes toc pages`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "song1.chopro", sampleSong())
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
// At least 1 content page + TOC pages (minimum 2 for even count)
|
||||
result.pageCount shouldBeGreaterThan 1
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
// --- validate() tests ---
|
||||
|
||||
@Test
|
||||
fun `validate returns error when songbook yaml is missing`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val errors = pipeline.validate()
|
||||
|
||||
errors shouldHaveSize 1
|
||||
errors[0].message shouldContain "songbook.yaml not found"
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate returns error when songs directory does not exist`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir, defaultConfig(songsDir = "./nonexistent"))
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val errors = pipeline.validate()
|
||||
|
||||
errors.shouldNotBeEmpty()
|
||||
errors.any { it.message.contains("Songs directory not found") }.shouldBeTrue()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate returns empty list for valid project`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "song1.chopro", sampleSong())
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val errors = pipeline.validate()
|
||||
|
||||
errors.shouldBeEmpty()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate reports config errors`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
val config = """
|
||||
layout:
|
||||
margins:
|
||||
top: 0
|
||||
bottom: 0
|
||||
inner: 0
|
||||
outer: 0
|
||||
""".trimIndent()
|
||||
writeConfig(projectDir, config)
|
||||
// Still need songs dir to exist for full validate
|
||||
File(projectDir, "./songs").mkdirs()
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val errors = pipeline.validate()
|
||||
|
||||
errors shouldHaveSize 4
|
||||
errors.all { it.message.contains("margin", ignoreCase = true) }.shouldBeTrue()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate reports song validation errors`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "bad_song.chopro", "{title: }")
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val errors = pipeline.validate()
|
||||
|
||||
errors.shouldNotBeEmpty()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate reports errors for multiple invalid songs`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "bad1.chopro", "{title: Good Title}") // no sections
|
||||
writeSongFile(songsDir, "bad2.chopro", "{title: Another Title}") // no sections
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val errors = pipeline.validate()
|
||||
|
||||
errors.shouldNotBeEmpty()
|
||||
errors.size shouldBeGreaterThan 1
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate with empty songs directory returns no song errors`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
File(projectDir, "songs").mkdirs()
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val errors = pipeline.validate()
|
||||
|
||||
// No errors because there are no song files to validate
|
||||
errors.shouldBeEmpty()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
// --- BuildResult data class tests ---
|
||||
|
||||
@Test
|
||||
fun `BuildResult defaults are correct`() {
|
||||
val result = BuildResult(success = false)
|
||||
|
||||
result.success.shouldBeFalse()
|
||||
result.outputFile.shouldBeNull()
|
||||
result.errors.shouldBeEmpty()
|
||||
result.songCount shouldBe 0
|
||||
result.pageCount shouldBe 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BuildResult with all fields set`() {
|
||||
val file = File("/tmp/test.pdf")
|
||||
val errors = listOf(de.pfadfinder.songbook.parser.ValidationError("test", 1, "error"))
|
||||
val result = BuildResult(
|
||||
success = true,
|
||||
outputFile = file,
|
||||
errors = errors,
|
||||
songCount = 5,
|
||||
pageCount = 10
|
||||
)
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
result.outputFile shouldBe file
|
||||
result.errors shouldHaveSize 1
|
||||
result.songCount shouldBe 5
|
||||
result.pageCount shouldBe 10
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user