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:
shahondin1624
2026-03-17 08:35:42 +01:00
commit e386501b57
56 changed files with 5152 additions and 0 deletions

View File

@@ -0,0 +1,199 @@
package de.pfadfinder.songbook.parser
import de.pfadfinder.songbook.model.*
import java.io.File
object ChordProParser {
fun parse(input: String): Song {
val lines = input.lines()
var title: String? = null
val aliases = mutableListOf<String>()
var lyricist: String? = null
var composer: String? = null
var key: String? = null
val tags = mutableListOf<String>()
val notes = mutableListOf<String>()
val references = mutableMapOf<String, Int>()
var capo: Int? = null
val sections = mutableListOf<SongSection>()
// Current section being built
var currentType: SectionType? = null
var currentLabel: String? = null
var currentLines = mutableListOf<SongLine>()
fun flushSection() {
if (currentType != null) {
sections.add(SongSection(type = currentType!!, label = currentLabel, lines = currentLines.toList()))
currentType = null
currentLabel = null
currentLines = mutableListOf()
}
}
for (rawLine in lines) {
val line = rawLine.trimEnd()
// Skip comments
if (line.trimStart().startsWith("#")) continue
// Skip empty lines
if (line.isBlank()) continue
// Directive line
if (line.trimStart().startsWith("{") && line.trimEnd().endsWith("}")) {
val inner = line.trim().removePrefix("{").removeSuffix("}").trim()
val colonIndex = inner.indexOf(':')
val directive: String
val value: String?
if (colonIndex >= 0) {
directive = inner.substring(0, colonIndex).trim().lowercase()
value = inner.substring(colonIndex + 1).trim()
} else {
directive = inner.trim().lowercase()
value = null
}
when (directive) {
"title", "t" -> title = value
"alias" -> if (value != null) aliases.add(value)
"lyricist" -> lyricist = value
"composer" -> composer = value
"key" -> key = value
"tags" -> if (value != null) {
tags.addAll(value.split(",").map { it.trim() }.filter { it.isNotEmpty() })
}
"note" -> if (value != null) notes.add(value)
"capo" -> capo = value?.toIntOrNull()
"ref" -> if (value != null) {
parseReference(value)?.let { (bookId, page) ->
references[bookId] = page
}
}
"start_of_verse", "sov" -> {
flushSection()
currentType = SectionType.VERSE
currentLabel = value
}
"end_of_verse", "eov" -> {
flushSection()
}
"start_of_chorus", "soc" -> {
flushSection()
currentType = SectionType.CHORUS
currentLabel = value
}
"end_of_chorus", "eoc" -> {
flushSection()
}
"start_of_repeat", "sor" -> {
flushSection()
currentType = SectionType.REPEAT
currentLabel = value
}
"end_of_repeat", "eor" -> {
flushSection()
}
"chorus" -> {
flushSection()
sections.add(SongSection(type = SectionType.CHORUS))
}
"repeat" -> {
// Store repeat count as label on current section or create a new section
if (currentType != null) {
currentLabel = value
}
}
}
continue
}
// Text/chord line: if we're not inside a section, start an implicit VERSE
if (currentType == null) {
currentType = SectionType.VERSE
}
val songLine = parseChordLine(line)
currentLines.add(songLine)
}
// Flush any remaining section
flushSection()
return Song(
title = title ?: "",
aliases = aliases.toList(),
lyricist = lyricist,
composer = composer,
key = key,
tags = tags.toList(),
notes = notes.toList(),
references = references.toMap(),
capo = capo,
sections = sections.toList()
)
}
fun parseFile(file: File): Song = parse(file.readText())
internal fun parseChordLine(line: String): SongLine {
val segments = mutableListOf<LineSegment>()
var i = 0
val len = line.length
// Check if line starts with text before any chord
if (len > 0 && line[0] != '[') {
val nextBracket = line.indexOf('[')
if (nextBracket < 0) {
// No chords at all, entire line is text
segments.add(LineSegment(chord = null, text = line))
return SongLine(segments)
}
segments.add(LineSegment(chord = null, text = line.substring(0, nextBracket)))
i = nextBracket
}
while (i < len) {
if (line[i] == '[') {
val closeBracket = line.indexOf(']', i)
if (closeBracket < 0) {
// Malformed: treat rest as text
segments.add(LineSegment(chord = null, text = line.substring(i)))
break
}
val chord = line.substring(i + 1, closeBracket)
val textStart = closeBracket + 1
val nextBracket = line.indexOf('[', textStart)
val text = if (nextBracket < 0) {
line.substring(textStart)
} else {
line.substring(textStart, nextBracket)
}
segments.add(LineSegment(chord = chord, text = text))
i = if (nextBracket < 0) len else nextBracket
} else {
// Should not happen if logic is correct, but handle gracefully
val nextBracket = line.indexOf('[', i)
if (nextBracket < 0) {
segments.add(LineSegment(chord = null, text = line.substring(i)))
break
}
segments.add(LineSegment(chord = null, text = line.substring(i, nextBracket)))
i = nextBracket
}
}
return SongLine(segments)
}
internal fun parseReference(value: String): Pair<String, Int>? {
val parts = value.trim().split("\\s+".toRegex())
if (parts.size < 2) return null
val page = parts.last().toIntOrNull() ?: return null
val bookId = parts.dropLast(1).joinToString(" ")
return bookId to page
}
}

View File

@@ -0,0 +1,25 @@
package de.pfadfinder.songbook.parser
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import de.pfadfinder.songbook.model.BookConfig
import java.io.File
object ConfigParser {
private val mapper: ObjectMapper = ObjectMapper(YAMLFactory())
.registerKotlinModule()
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
fun parse(file: File): BookConfig {
return mapper.readValue(file, BookConfig::class.java)
}
fun parse(input: String): BookConfig {
return mapper.readValue(input, BookConfig::class.java)
}
}

View File

@@ -0,0 +1,55 @@
package de.pfadfinder.songbook.parser
import de.pfadfinder.songbook.model.BookConfig
import de.pfadfinder.songbook.model.Song
data class ValidationError(val file: String?, val line: Int?, val message: String)
object Validator {
fun validateSong(song: Song, fileName: String? = null): List<ValidationError> {
val errors = mutableListOf<ValidationError>()
if (song.title.isBlank()) {
errors.add(ValidationError(file = fileName, line = null, message = "Song must have a title"))
}
if (song.sections.isEmpty()) {
errors.add(ValidationError(file = fileName, line = null, message = "Song must have at least one section"))
}
return errors
}
fun validateSong(song: Song, config: BookConfig, fileName: String? = null): List<ValidationError> {
val errors = validateSong(song, fileName).toMutableList()
val knownBookIds = config.referenceBooks.map { it.id }.toSet()
for ((bookId, _) in song.references) {
if (bookId !in knownBookIds) {
errors.add(
ValidationError(
file = fileName,
line = null,
message = "Reference to unknown book '$bookId'. Known books: ${knownBookIds.joinToString(", ")}"
)
)
}
}
return errors
}
fun validateConfig(config: BookConfig): List<ValidationError> {
val errors = mutableListOf<ValidationError>()
with(config.layout.margins) {
if (top <= 0) errors.add(ValidationError(file = null, line = null, message = "Top margin must be greater than 0"))
if (bottom <= 0) errors.add(ValidationError(file = null, line = null, message = "Bottom margin must be greater than 0"))
if (inner <= 0) errors.add(ValidationError(file = null, line = null, message = "Inner margin must be greater than 0"))
if (outer <= 0) errors.add(ValidationError(file = null, line = null, message = "Outer margin must be greater than 0"))
}
return errors
}
}

View File

@@ -0,0 +1,488 @@
package de.pfadfinder.songbook.parser
import de.pfadfinder.songbook.model.SectionType
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import kotlin.test.Test
class ChordProParserTest {
@Test
fun `parse complete song`() {
val input = """
# This is a comment
{title: Wonderwall}
{alias: Wonderwall (Oasis)}
{lyricist: Noel Gallagher}
{composer: Noel Gallagher}
{key: F#m}
{tags: pop, rock, 90s}
{note: Play with capo on 2nd fret}
{ref: mundorgel 42}
{capo: 2}
{start_of_verse: Verse 1}
[Em7]Today is [G]gonna be the day
That they're [Dsus4]gonna throw it back to [A7sus4]you
{end_of_verse}
{start_of_chorus}
[C]And all the [D]roads we have to [Em]walk are winding
{end_of_chorus}
{chorus}
""".trimIndent()
val song = ChordProParser.parse(input)
song.title shouldBe "Wonderwall"
song.aliases shouldHaveSize 1
song.aliases[0] shouldBe "Wonderwall (Oasis)"
song.lyricist shouldBe "Noel Gallagher"
song.composer shouldBe "Noel Gallagher"
song.key shouldBe "F#m"
song.tags shouldBe listOf("pop", "rock", "90s")
song.notes shouldHaveSize 1
song.notes[0] shouldBe "Play with capo on 2nd fret"
song.references shouldBe mapOf("mundorgel" to 42)
song.capo shouldBe 2
song.sections shouldHaveSize 3
// Verse 1
val verse = song.sections[0]
verse.type shouldBe SectionType.VERSE
verse.label shouldBe "Verse 1"
verse.lines shouldHaveSize 2
// First line of verse
val firstLine = verse.lines[0]
firstLine.segments shouldHaveSize 2
firstLine.segments[0].chord shouldBe "Em7"
firstLine.segments[0].text shouldBe "Today is "
firstLine.segments[1].chord shouldBe "G"
firstLine.segments[1].text shouldBe "gonna be the day"
// Chorus
val chorus = song.sections[1]
chorus.type shouldBe SectionType.CHORUS
chorus.label.shouldBeNull()
chorus.lines shouldHaveSize 1
// Empty chorus reference
val chorusRef = song.sections[2]
chorusRef.type shouldBe SectionType.CHORUS
chorusRef.lines.shouldBeEmpty()
}
@Test
fun `parse title directive`() {
val input = "{title: My Song}"
val song = ChordProParser.parse(input)
song.title shouldBe "My Song"
}
@Test
fun `parse short title directive`() {
val input = "{t: My Song}"
val song = ChordProParser.parse(input)
song.title shouldBe "My Song"
}
@Test
fun `parse missing title results in empty string`() {
val input = """
{start_of_verse}
Hello world
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.title shouldBe ""
}
@Test
fun `comments are skipped`() {
val input = """
{title: Test}
# This is a comment
{start_of_verse}
Hello world
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.title shouldBe "Test"
song.sections shouldHaveSize 1
song.sections[0].lines shouldHaveSize 1
song.sections[0].lines[0].segments[0].text shouldBe "Hello world"
}
@Test
fun `parse chord line with no chords`() {
val line = ChordProParser.parseChordLine("Just plain text")
line.segments shouldHaveSize 1
line.segments[0].chord.shouldBeNull()
line.segments[0].text shouldBe "Just plain text"
}
@Test
fun `parse chord line starting with chord`() {
val line = ChordProParser.parseChordLine("[Am]Hello [C]World")
line.segments shouldHaveSize 2
line.segments[0].chord shouldBe "Am"
line.segments[0].text shouldBe "Hello "
line.segments[1].chord shouldBe "C"
line.segments[1].text shouldBe "World"
}
@Test
fun `parse chord line starting with text`() {
val line = ChordProParser.parseChordLine("Hello [Am]World")
line.segments shouldHaveSize 2
line.segments[0].chord.shouldBeNull()
line.segments[0].text shouldBe "Hello "
line.segments[1].chord shouldBe "Am"
line.segments[1].text shouldBe "World"
}
@Test
fun `parse chord line with chord at end`() {
val line = ChordProParser.parseChordLine("[Am]Hello [C]")
line.segments shouldHaveSize 2
line.segments[0].chord shouldBe "Am"
line.segments[0].text shouldBe "Hello "
line.segments[1].chord shouldBe "C"
line.segments[1].text shouldBe ""
}
@Test
fun `parse chord line with only chord`() {
val line = ChordProParser.parseChordLine("[Am]")
line.segments shouldHaveSize 1
line.segments[0].chord shouldBe "Am"
line.segments[0].text shouldBe ""
}
@Test
fun `parse multiple aliases`() {
val input = """
{title: Song}
{alias: Alias One}
{alias: Alias Two}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.aliases shouldBe listOf("Alias One", "Alias Two")
}
@Test
fun `parse reference with multi-word book name`() {
val ref = ChordProParser.parseReference("My Big Songbook 123")
ref.shouldNotBeNull()
ref.first shouldBe "My Big Songbook"
ref.second shouldBe 123
}
@Test
fun `parse reference with single word book name`() {
val ref = ChordProParser.parseReference("mundorgel 42")
ref.shouldNotBeNull()
ref.first shouldBe "mundorgel"
ref.second shouldBe 42
}
@Test
fun `parse reference with invalid page returns null`() {
val ref = ChordProParser.parseReference("mundorgel abc")
ref.shouldBeNull()
}
@Test
fun `parse reference with only one token returns null`() {
val ref = ChordProParser.parseReference("mundorgel")
ref.shouldBeNull()
}
@Test
fun `parse tags directive`() {
val input = """
{title: Song}
{tags: folk, german, campfire}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.tags shouldBe listOf("folk", "german", "campfire")
}
@Test
fun `parse tags with extra whitespace`() {
val input = """
{title: Song}
{tags: folk , german , campfire }
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.tags shouldBe listOf("folk", "german", "campfire")
}
@Test
fun `parse chorus directive creates empty section`() {
val input = """
{title: Song}
{start_of_chorus}
[C]La la [G]la
{end_of_chorus}
{chorus}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 2
song.sections[0].type shouldBe SectionType.CHORUS
song.sections[0].lines shouldHaveSize 1
song.sections[1].type shouldBe SectionType.CHORUS
song.sections[1].lines.shouldBeEmpty()
}
@Test
fun `parse capo directive`() {
val input = """
{title: Song}
{capo: 3}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.capo shouldBe 3
}
@Test
fun `parse capo with invalid value results in null`() {
val input = """
{title: Song}
{capo: abc}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.capo.shouldBeNull()
}
@Test
fun `parse repeat section`() {
val input = """
{title: Song}
{start_of_repeat: 2x}
[Am]La la la
{end_of_repeat}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 1
song.sections[0].type shouldBe SectionType.REPEAT
song.sections[0].label shouldBe "2x"
song.sections[0].lines shouldHaveSize 1
}
@Test
fun `implicit verse for lines outside sections`() {
val input = """
{title: Song}
[Am]Hello [C]World
Just text
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 1
song.sections[0].type shouldBe SectionType.VERSE
song.sections[0].lines shouldHaveSize 2
}
@Test
fun `multiple sections parsed correctly`() {
val input = """
{title: Song}
{start_of_verse: 1}
Line one
{end_of_verse}
{start_of_verse: 2}
Line two
{end_of_verse}
{start_of_chorus}
Chorus line
{end_of_chorus}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 3
song.sections[0].type shouldBe SectionType.VERSE
song.sections[0].label shouldBe "1"
song.sections[1].type shouldBe SectionType.VERSE
song.sections[1].label shouldBe "2"
song.sections[2].type shouldBe SectionType.CHORUS
}
@Test
fun `parse multiple notes`() {
val input = """
{title: Song}
{note: First note}
{note: Second note}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.notes shouldBe listOf("First note", "Second note")
}
@Test
fun `parse multiple references`() {
val input = """
{title: Song}
{ref: mundorgel 42}
{ref: pfadfinderlied 17}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.references shouldBe mapOf("mundorgel" to 42, "pfadfinderlied" to 17)
}
@Test
fun `parse key directive`() {
val input = """
{title: Song}
{key: Am}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.key shouldBe "Am"
}
@Test
fun `empty input produces song with empty title and no sections`() {
val song = ChordProParser.parse("")
song.title shouldBe ""
song.sections.shouldBeEmpty()
}
@Test
fun `malformed chord bracket treated as text`() {
val line = ChordProParser.parseChordLine("[Am broken text")
line.segments shouldHaveSize 1
line.segments[0].chord.shouldBeNull()
line.segments[0].text shouldBe "[Am broken text"
}
@Test
fun `repeat directive sets label on current section`() {
val input = """
{title: Song}
{start_of_verse}
Line one
{repeat: 3}
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 1
song.sections[0].label shouldBe "3"
}
@Test
fun `parse short directives sov eov soc eoc`() {
val input = """
{title: Song}
{sov: V1}
Line one
{eov}
{soc}
Chorus
{eoc}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 2
song.sections[0].type shouldBe SectionType.VERSE
song.sections[0].label shouldBe "V1"
song.sections[1].type shouldBe SectionType.CHORUS
}
@Test
fun `parse short directives sor eor`() {
val input = """
{title: Song}
{sor: 2x}
Repeat line
{eor}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 1
song.sections[0].type shouldBe SectionType.REPEAT
song.sections[0].label shouldBe "2x"
}
@Test
fun `section without explicit end is flushed at end of input`() {
val input = """
{title: Song}
{start_of_verse}
Line one
Line two
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 1
song.sections[0].lines shouldHaveSize 2
}
@Test
fun `section flushed when new section starts without end directive`() {
val input = """
{title: Song}
{start_of_verse: 1}
Line one
{start_of_verse: 2}
Line two
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 2
song.sections[0].label shouldBe "1"
song.sections[0].lines shouldHaveSize 1
song.sections[1].label shouldBe "2"
song.sections[1].lines shouldHaveSize 1
}
@Test
fun `lyricist and composer directives`() {
val input = """
{title: Song}
{lyricist: John Doe}
{composer: Jane Smith}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.lyricist shouldBe "John Doe"
song.composer shouldBe "Jane Smith"
}
@Test
fun `parse consecutive chords with no text between`() {
val line = ChordProParser.parseChordLine("[Am][C][G]End")
line.segments shouldHaveSize 3
line.segments[0].chord shouldBe "Am"
line.segments[0].text shouldBe ""
line.segments[1].chord shouldBe "C"
line.segments[1].text shouldBe ""
line.segments[2].chord shouldBe "G"
line.segments[2].text shouldBe "End"
}
}

View File

@@ -0,0 +1,182 @@
package de.pfadfinder.songbook.parser
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import kotlin.test.Test
class ConfigParserTest {
private val sampleYaml = """
book:
title: "Pfadfinder Liederbuch"
subtitle: "Ausgabe 2024"
edition: "3. Auflage"
format: A5
songs:
directory: "./songs"
order: alphabetical
fonts:
lyrics: { family: "Garamond", file: "./fonts/Garamond.ttf", size: 10 }
chords: { family: "Garamond", file: "./fonts/Garamond-Bold.ttf", size: 9, color: "#333333" }
title: { family: "Garamond", file: "./fonts/Garamond-Bold.ttf", size: 14 }
metadata: { family: "Garamond", file: "./fonts/Garamond-Italic.ttf", size: 8 }
toc: { family: "Garamond", file: "./fonts/Garamond.ttf", size: 9 }
layout:
margins: { top: 15, bottom: 15, inner: 20, outer: 12 }
chord_line_spacing: 3
verse_spacing: 4
page_number_position: bottom-outer
images:
directory: "./images"
reference_books:
- id: mundorgel
name: "Mundorgel"
abbreviation: "MO"
- id: pfadfinderlied
name: "Das Pfadfinderlied"
abbreviation: "PL"
output:
directory: "./output"
filename: "liederbuch.pdf"
""".trimIndent()
@Test
fun `parse full config from yaml string`() {
val config = ConfigParser.parse(sampleYaml)
// Book meta
config.book.title shouldBe "Pfadfinder Liederbuch"
config.book.subtitle shouldBe "Ausgabe 2024"
config.book.edition shouldBe "3. Auflage"
config.book.format shouldBe "A5"
// Songs config
config.songs.directory shouldBe "./songs"
config.songs.order shouldBe "alphabetical"
// Fonts
config.fonts.lyrics.family shouldBe "Garamond"
config.fonts.lyrics.file shouldBe "./fonts/Garamond.ttf"
config.fonts.lyrics.size shouldBe 10f
config.fonts.lyrics.color shouldBe "#000000" // default
config.fonts.chords.family shouldBe "Garamond"
config.fonts.chords.file shouldBe "./fonts/Garamond-Bold.ttf"
config.fonts.chords.size shouldBe 9f
config.fonts.chords.color shouldBe "#333333"
config.fonts.title.family shouldBe "Garamond"
config.fonts.title.size shouldBe 14f
config.fonts.metadata.family shouldBe "Garamond"
config.fonts.metadata.size shouldBe 8f
config.fonts.toc.family shouldBe "Garamond"
config.fonts.toc.size shouldBe 9f
// Layout
config.layout.margins.top shouldBe 15f
config.layout.margins.bottom shouldBe 15f
config.layout.margins.inner shouldBe 20f
config.layout.margins.outer shouldBe 12f
config.layout.chordLineSpacing shouldBe 3f
config.layout.verseSpacing shouldBe 4f
config.layout.pageNumberPosition shouldBe "bottom-outer"
// Images
config.images.directory shouldBe "./images"
// Reference books
config.referenceBooks shouldHaveSize 2
config.referenceBooks[0].id shouldBe "mundorgel"
config.referenceBooks[0].name shouldBe "Mundorgel"
config.referenceBooks[0].abbreviation shouldBe "MO"
config.referenceBooks[1].id shouldBe "pfadfinderlied"
config.referenceBooks[1].name shouldBe "Das Pfadfinderlied"
config.referenceBooks[1].abbreviation shouldBe "PL"
// Output
config.output.directory shouldBe "./output"
config.output.filename shouldBe "liederbuch.pdf"
}
@Test
fun `parse minimal config uses defaults`() {
val yaml = """
book:
title: "Minimal"
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.book.title shouldBe "Minimal"
config.book.format shouldBe "A5" // default
config.songs.directory shouldBe "./songs" // default
config.fonts.lyrics.family shouldBe "Helvetica" // default
config.layout.margins.top shouldBe 15f // default
config.output.filename shouldBe "liederbuch.pdf" // default
}
@Test
fun `parse config with only book section`() {
val yaml = """
book:
title: "Test"
subtitle: "Sub"
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.book.title shouldBe "Test"
config.book.subtitle shouldBe "Sub"
config.book.edition shouldBe null
}
@Test
fun `parse config with reference books`() {
val yaml = """
book:
title: "Test"
reference_books:
- id: mo
name: "Mundorgel"
abbreviation: "MO"
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.referenceBooks shouldHaveSize 1
config.referenceBooks[0].id shouldBe "mo"
}
@Test
fun `parse config with custom layout margins`() {
val yaml = """
book:
title: "Test"
layout:
margins:
top: 25
bottom: 20
inner: 30
outer: 15
chord_line_spacing: 5
verse_spacing: 6
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.layout.margins.top shouldBe 25f
config.layout.margins.bottom shouldBe 20f
config.layout.margins.inner shouldBe 30f
config.layout.margins.outer shouldBe 15f
config.layout.chordLineSpacing shouldBe 5f
config.layout.verseSpacing shouldBe 6f
}
@Test
fun `parse config ignores unknown properties`() {
val yaml = """
book:
title: "Test"
unknown_field: "value"
some_extra_section:
key: value
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.book.title shouldBe "Test"
}
}

View File

@@ -0,0 +1,209 @@
package de.pfadfinder.songbook.parser
import de.pfadfinder.songbook.model.*
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.string.shouldContain
import kotlin.test.Test
class ValidatorTest {
@Test
fun `valid song produces no errors`() {
val song = Song(
title = "Test Song",
sections = listOf(
SongSection(type = SectionType.VERSE, lines = listOf(
SongLine(segments = listOf(LineSegment(text = "Hello")))
))
)
)
val errors = Validator.validateSong(song)
errors.shouldBeEmpty()
}
@Test
fun `missing title produces error`() {
val song = Song(
title = "",
sections = listOf(
SongSection(type = SectionType.VERSE, lines = listOf(
SongLine(segments = listOf(LineSegment(text = "Hello")))
))
)
)
val errors = Validator.validateSong(song)
errors shouldHaveSize 1
errors[0].message shouldContain "title"
}
@Test
fun `blank title produces error`() {
val song = Song(
title = " ",
sections = listOf(
SongSection(type = SectionType.VERSE, lines = listOf(
SongLine(segments = listOf(LineSegment(text = "Hello")))
))
)
)
val errors = Validator.validateSong(song)
errors shouldHaveSize 1
errors[0].message shouldContain "title"
}
@Test
fun `empty sections produces error`() {
val song = Song(
title = "Test",
sections = emptyList()
)
val errors = Validator.validateSong(song)
errors shouldHaveSize 1
errors[0].message shouldContain "section"
}
@Test
fun `missing title and empty sections produces two errors`() {
val song = Song(title = "", sections = emptyList())
val errors = Validator.validateSong(song)
errors shouldHaveSize 2
}
@Test
fun `fileName is included in error`() {
val song = Song(title = "", sections = emptyList())
val errors = Validator.validateSong(song, "test.chopro")
errors.forEach { it.file shouldContain "test.chopro" }
}
@Test
fun `valid song with known references produces no errors`() {
val config = BookConfig(
referenceBooks = listOf(
ReferenceBook(id = "mundorgel", name = "Mundorgel", abbreviation = "MO")
)
)
val song = Song(
title = "Test",
references = mapOf("mundorgel" to 42),
sections = listOf(
SongSection(type = SectionType.VERSE, lines = listOf(
SongLine(segments = listOf(LineSegment(text = "Hello")))
))
)
)
val errors = Validator.validateSong(song, config)
errors.shouldBeEmpty()
}
@Test
fun `unknown reference book produces error`() {
val config = BookConfig(
referenceBooks = listOf(
ReferenceBook(id = "mundorgel", name = "Mundorgel", abbreviation = "MO")
)
)
val song = Song(
title = "Test",
references = mapOf("unknown_book" to 42),
sections = listOf(
SongSection(type = SectionType.VERSE, lines = listOf(
SongLine(segments = listOf(LineSegment(text = "Hello")))
))
)
)
val errors = Validator.validateSong(song, config)
errors shouldHaveSize 1
errors[0].message shouldContain "unknown_book"
}
@Test
fun `multiple unknown references produce multiple errors`() {
val config = BookConfig(referenceBooks = emptyList())
val song = Song(
title = "Test",
references = mapOf("book1" to 1, "book2" to 2),
sections = listOf(
SongSection(type = SectionType.VERSE, lines = listOf(
SongLine(segments = listOf(LineSegment(text = "Hello")))
))
)
)
val errors = Validator.validateSong(song, config)
errors shouldHaveSize 2
}
@Test
fun `valid config produces no errors`() {
val config = BookConfig()
val errors = Validator.validateConfig(config)
errors.shouldBeEmpty()
}
@Test
fun `zero top margin produces error`() {
val config = BookConfig(
layout = LayoutConfig(margins = Margins(top = 0f))
)
val errors = Validator.validateConfig(config)
errors shouldHaveSize 1
errors[0].message shouldContain "Top margin"
}
@Test
fun `negative bottom margin produces error`() {
val config = BookConfig(
layout = LayoutConfig(margins = Margins(bottom = -5f))
)
val errors = Validator.validateConfig(config)
errors shouldHaveSize 1
errors[0].message shouldContain "Bottom margin"
}
@Test
fun `negative inner margin produces error`() {
val config = BookConfig(
layout = LayoutConfig(margins = Margins(inner = -1f))
)
val errors = Validator.validateConfig(config)
errors shouldHaveSize 1
errors[0].message shouldContain "Inner margin"
}
@Test
fun `zero outer margin produces error`() {
val config = BookConfig(
layout = LayoutConfig(margins = Margins(outer = 0f))
)
val errors = Validator.validateConfig(config)
errors shouldHaveSize 1
errors[0].message shouldContain "Outer margin"
}
@Test
fun `all margins zero produces four errors`() {
val config = BookConfig(
layout = LayoutConfig(margins = Margins(top = 0f, bottom = 0f, inner = 0f, outer = 0f))
)
val errors = Validator.validateConfig(config)
errors shouldHaveSize 4
}
@Test
fun `unknown reference with fileName in error`() {
val config = BookConfig(referenceBooks = emptyList())
val song = Song(
title = "Test",
references = mapOf("book1" to 1),
sections = listOf(
SongSection(type = SectionType.VERSE, lines = listOf(
SongLine(segments = listOf(LineSegment(text = "Hello")))
))
)
)
val errors = Validator.validateSong(song, config, "myfile.chopro")
errors shouldHaveSize 1
errors[0].file shouldContain "myfile.chopro"
}
}