diff --git a/cmd/harness_test.go b/cmd/harness_test.go index 28ca43d..d2816e6 100644 --- a/cmd/harness_test.go +++ b/cmd/harness_test.go @@ -170,12 +170,13 @@ func rawResponse(status int, contentType string, body []byte) *http.Response { // webScript scripts the non-Spotify web boundaries: the lyrics providers, // the search providers and artwork downloads. type webScript struct { - lyricsErr bool // lrclib and genius both fail - noLyrics bool // lrclib answers with empty lyrics - artworkErr bool // the artwork URL answers 404 - providerErr bool // youtube and qobuz both fail - noResults bool // providers answer successfully but find nothing - qobuzEmpty bool // qobuz finds nothing, youtube carries the search + lyricsErr bool // lrclib and genius both fail + noLyrics bool // lrclib answers with empty lyrics + lyricsLeadBlank bool // lrclib answers lyrics with leading blank lines + artworkErr bool // the artwork URL answers 404 + providerErr bool // youtube and qobuz both fail + noResults bool // providers answer successfully but find nothing + qobuzEmpty bool // qobuz finds nothing, youtube carries the search } // installWeb installs the Spotify router plus the scripted web fixtures @@ -221,6 +222,9 @@ func lrclibResponse(web webScript) (*http.Response, error) { if web.noLyrics { return jsonResponse(200, `{"plainLyrics":"","syncedLyrics":""}`), nil } + if web.lyricsLeadBlank { + return jsonResponse(200, "{\"plainLyrics\":\"\\n\\nsome lyrics\",\"syncedLyrics\":\"\"}"), nil + } return jsonResponse(200, `{"plainLyrics":"some lyrics","syncedLyrics":""}`), nil } diff --git a/cmd/show.go b/cmd/show.go index 2435199..64a8e75 100644 --- a/cmd/show.go +++ b/cmd/show.go @@ -50,7 +50,7 @@ func cmdShow() *cobra.Command { return d + "s" }(tag.Duration())) fmt.Fprintln(table, "Upstream URL\t", sys.Fallback(tag.UpstreamURL(), fallback)) - fmt.Fprintln(table, "Lyrics\t", sys.Fallback(sys.Excerpt(sys.FirstLine(tag.UnsynchronizedLyrics()), 64), fallback)) + fmt.Fprintln(table, "Lyrics\t", sys.Fallback(sys.Excerpt(sys.FirstContentLine(tag.UnsynchronizedLyrics()), 64), fallback)) fmt.Fprintln(table, "Artwork\t", func(mimeType string, data []byte) string { if len(data) > 0 { return fmt.Sprintf("%s (%s)", mimeType, sys.HumanizeBytes(len(data))) diff --git a/cmd/show_test.go b/cmd/show_test.go index c44b35f..3537836 100644 --- a/cmd/show_test.go +++ b/cmd/show_test.go @@ -1,8 +1,10 @@ package cmd import ( + "io" "os" "path/filepath" + "strings" "testing" "github.com/bogem/id3v2/v2" @@ -11,6 +13,23 @@ import ( "github.com/stretchr/testify/assert" ) +// captureStdout returns what fn writes to the standard output. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + reader, writer, err := os.Pipe() + assert.Nil(t, err) + previous := os.Stdout + os.Stdout = writer + t.Cleanup(func() { os.Stdout = previous }) + + fn() + + assert.Nil(t, writer.Close()) + contents, err := io.ReadAll(reader) + assert.Nil(t, err) + return string(contents) +} + // writeShowTrack creates a real audio file carrying a full ID3 tag. func writeShowTrack(t *testing.T, path string, picture bool) { t.Helper() @@ -61,3 +80,28 @@ func TestCmdShowPictureFallback(t *testing.T) { // testing: a tag without a picture falls back to a text notice assert.Nil(t, sys.ErrOnly(testExecute(cmdShow(), path))) } + +func TestCmdShowLyricsLeadingBlankLines(t *testing.T) { + path := filepath.Join(t.TempDir(), "track.mp3") + writeShowTrack(t, path, false) + + tag, err := id3.Open(path, id3v2.Options{Parse: true}) + assert.Nil(t, err) + tag.SetLyrics("Title", "\n\nfirst lyric line\nsecond lyric line") + assert.Nil(t, tag.Save()) + assert.Nil(t, tag.Close()) + + // testing: a stored frame that starts with blank lines reports + // its first content line on the Lyrics row, not the fallback + output := captureStdout(t, func() { + assert.Nil(t, sys.ErrOnly(testExecute(cmdShow(), path))) + }) + for _, line := range strings.Split(output, "\n") { + if strings.Contains(line, "Lyrics") { + assert.Contains(t, line, "first lyric line") + assert.NotContains(t, line, fallback) + return + } + } + t.Fatal("no Lyrics row in the show output") +} diff --git a/cmd/sync.go b/cmd/sync.go index 34a6355..d23ab84 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -608,7 +608,7 @@ func routineCollectLyrics(track *entity.Track) func(context.Context, chan error) } tui.Lot("compose").Wipe() track.Lyrics = lyrics - tui.Printf("lyrics for %s by %s: %s", track.Title, track.Artist(), sys.Fallback(sys.Excerpt(sys.FirstLine(lyrics), 64), "not found")) + tui.Printf("lyrics for %s by %s: %s", track.Title, track.Artist(), sys.Fallback(sys.Excerpt(sys.FirstContentLine(lyrics), 64), "not found")) } } diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 22f366d..1b08fa4 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -104,6 +104,18 @@ func TestCmdSync(t *testing.T) { assert.Contains(t, string(mix), "Artist - Title.mp3") } +func TestCmdSyncLyricsLeadingBlankLines(t *testing.T) { + musicDir, _ := syncEnv(t, nil, webScript{qobuzEmpty: true, lyricsLeadBlank: true}) + + // testing: lyrics that arrive with leading blank lines print + // their first content line, not the not-found fallback + output := captureStdout(t, func() { + assert.Nil(t, sys.ErrOnly(testExecute(cmdSync(), "--plain", "-o", musicDir, "-l"))) + }) + assert.Contains(t, output, "lyrics for Title by Artist: some lyrics") + assert.NotContains(t, output, "lyrics for Title by Artist: not found") +} + func TestCmdSyncInvalidEnvironment(t *testing.T) { resetState(t) seedSession(t) diff --git a/lyrics/composer.go b/lyrics/composer.go index f10c1b1..89c3be2 100644 --- a/lyrics/composer.go +++ b/lyrics/composer.go @@ -1,12 +1,14 @@ package lyrics import ( + "bytes" "context" "os" "path/filepath" "regexp" "strings" "sync" + "unicode" "github.com/arunsworld/nursery" "github.com/streambinder/pango/entity" @@ -103,10 +105,22 @@ func chooseComposition(first, latter []byte) []byte { } } +// normalize strips the leading whitespace of a lyrics text, so the +// text starts at its first content line whatever composer produced +// it: a page parser can emit line breaks before the first text node +func normalize(data []byte) []byte { + return bytes.TrimLeftFunc(data, unicode.IsSpace) +} + // not found entries return no error func Search(track *entity.Track) (string, error) { - if bytes, err := os.ReadFile(track.Path().Lyrics()); err == nil { - return string(bytes), nil + if cached, err := os.ReadFile(track.Path().Lyrics()); err == nil { + // an entry of an earlier version can carry leading blank + // lines: normalize the entry on read, and count an entry + // that holds no content as a miss + if text := normalize(cached); len(text) > 0 { + return string(text), nil + } } var ( @@ -152,6 +166,7 @@ func Search(track *entity.Track) (string, error) { return "", failures[0] } + result = normalize(result) if len(result) == 0 { return "", nil } @@ -207,5 +222,5 @@ func Get(url string) (string, error) { return "", failures[0] } - return string(result), nil + return string(normalize(result)), nil } diff --git a/lyrics/composer_test.go b/lyrics/composer_test.go index 105b49f..0727908 100644 --- a/lyrics/composer_test.go +++ b/lyrics/composer_test.go @@ -228,3 +228,51 @@ func TestGetPartialFailure(t *testing.T) { assert.Nil(t, err) assert.Equal(t, "lyrics", lyrics) } + +func TestSearchNormalizesLeadingBlankLines(t *testing.T) { + uncached := uncachedTrack(t, "test-normalize") + setComposers(t, stubComposer{searchResult: []byte("\n\n[Verse 1]\nlyrics")}) + + // testing: the result starts at its first content line, and the + // cache file holds the same normalized text + lyrics, err := Search(uncached) + assert.Nil(t, err) + assert.Equal(t, "[Verse 1]\nlyrics", lyrics) + + cached, err := os.ReadFile(uncached.Path().Lyrics()) + assert.Nil(t, err) + assert.Equal(t, "[Verse 1]\nlyrics", string(cached)) +} + +func TestSearchNormalizesCached(t *testing.T) { + cached := uncachedTrack(t, "test-cached-normalize") + assert.Nil(t, os.MkdirAll(filepath.Dir(cached.Path().Lyrics()), 0o755)) + assert.Nil(t, os.WriteFile(cached.Path().Lyrics(), []byte("\nlyrics"), 0o600)) + setComposers(t, stubComposer{searchErr: errors.New("must not be called")}) + + // testing: a cache entry of an earlier version is normalized on read + lyrics, err := Search(cached) + assert.Nil(t, err) + assert.Equal(t, "lyrics", lyrics) +} + +func TestSearchBlankCacheIsMiss(t *testing.T) { + cached := uncachedTrack(t, "test-cached-blank") + assert.Nil(t, os.MkdirAll(filepath.Dir(cached.Path().Lyrics()), 0o755)) + assert.Nil(t, os.WriteFile(cached.Path().Lyrics(), []byte("\n\n"), 0o600)) + setComposers(t, stubComposer{searchResult: []byte("lyrics")}) + + // testing: an entry that holds only blank lines counts as a miss + lyrics, err := Search(cached) + assert.Nil(t, err) + assert.Equal(t, "lyrics", lyrics) +} + +func TestGetNormalizesLeadingBlankLines(t *testing.T) { + setComposers(t, stubComposer{getResult: []byte("\nlyrics")}) + + // testing + lyrics, err := Get("http://localhost") + assert.Nil(t, err) + assert.Equal(t, "lyrics", lyrics) +} diff --git a/specs/001-lyrics-reporting/plan.md b/specs/001-lyrics-reporting/plan.md new file mode 100644 index 0000000..2629f84 --- /dev/null +++ b/specs/001-lyrics-reporting/plan.md @@ -0,0 +1,96 @@ +# Implementation Plan: Truthful Lyrics Reporting + +**Branch**: `feat/lyrics-reporting` | **Date**: 2026-10-26 | +**Spec**: `specs/001-lyrics-reporting/spec.md` + +**Input**: Feature specification from +`/specs/001-lyrics-reporting/spec.md` + +## Summary + +The lyrics boundary learns one normalization, and the two lyrics +displays learn one projection. A helper in `lyrics/composer.go` +strips leading whitespace from a lyrics text. `Search` applies +the helper to the merged result before the cache write and the +return, to a cache entry on read, and `Get` applies the helper to +its result. A new `sys.FirstContentLine` returns the first line +whose trimmed content is not empty, and the collect log line in +`cmd/sync.go` and the Lyrics row in `cmd/show.go` excerpt through +that projection instead of `sys.FirstLine`. Nothing else changes: +the composer merge rules, the failure accounting, and the frame +format stay as they are. + +## Technical Context + +**Language/Version**: Go, version from `go.mod` + +**Primary Dependencies**: the standard library only; no new +dependency + +**Storage**: the lyrics cache file under the XDG cache directory, +one file per track + +**Testing**: table and stub tests with the established suite; +command tests run against scripted transports and a fake tool PATH + +**Constraints**: per-package coverage at 90 percent; unit tests +stub every network dependency; the exported surface of the +`lyrics` package stays compatible + +## Constitution Check + +- Graceful Enrichment: the change keeps the degradation contract. + Collection still fails only when every composer fails. +- Honest Reporting: the change implements the rule directly. The + fallback of each display now depends on the value, not on a + projection that can be empty for a present value. +- Library Surface: `Search` and `Get` keep their signatures. + Consumers receive a cleaner text at their next pin bump; no + breaking change. +- Specification Cycle: this document and the task list complete + the plan and tasks steps of cycle 001. + +## Decisions + +- **Normalization point**: one helper at the lyrics boundary, not + one fix per composer. A new composer can emit the same leading + blank lines tomorrow; the boundary rule covers every source, + the legacy cache, and both entry points in one place. +- **Normalization form**: strip the leading whitespace run. A + synchronized text starts with a timestamp bracket and carries no + leading whitespace, so the rule leaves synchronized texts + untouched. Lyrics lines carry no significant leading whitespace. +- **Legacy cache**: normalize on read instead of migrating files. + A cache entry that normalizes to empty held no content, so the + read falls through to the composers. +- **Display projection**: add `sys.FirstContentLine` next to + `sys.FirstLine`. `FirstLine` keeps its contract for its other + semantic; the two lyrics displays switch to the new projection. +- **No change in the companion tools**: the tools import the + `lyrics` package and inherit the normalization when they bump + the module pin. Their own excerpts already read the whole text. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-lyrics-reporting/ +├── spec.md +├── plan.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +lyrics/composer.go # normalization helper, applied in Search and Get +lyrics/composer_test.go # normalization, cache, and legacy cache cases +sys/string.go # FirstContentLine next to FirstLine +sys/string_test.go # projection cases +cmd/sync.go # collect log line uses the new projection +cmd/show.go # Lyrics row uses the new projection +cmd/show_test.go # stored frame with leading blank lines +cmd/sync_test.go # end-to-end lyrics line through a scripted source +cmd/harness_test.go # the web script gains a leading-blank lyrics answer +``` diff --git a/specs/001-lyrics-reporting/spec.md b/specs/001-lyrics-reporting/spec.md new file mode 100644 index 0000000..e8c437d --- /dev/null +++ b/specs/001-lyrics-reporting/spec.md @@ -0,0 +1,172 @@ +# Feature Specification: Truthful Lyrics Reporting + +**Feature Branch**: `feat/lyrics-reporting` + +**Created**: 2026-10-26 + +**Status**: Draft. The owner ordered this fix on 2026-10-26, after +the investigation below, and ordered the SDD adoption first. The +owner ratifies this text at the review of the pull request. + +**Input**: Report by the owner, 2026-10-26: on one track, the +synchronization printed the lyrics as not found, a companion tool +printed the lyrics for the same track minutes later, and the `show` +command still reported the lyrics frame as unset after both runs. + +## Hypothesis Investigation + +**Hypothesis**: A synchronization reports the lyrics of a track as +missing while the lyrics sources hold them, and the failure is in +the search or in the persistence of the lyrics frame. + +**Evidence** (verified on 2026-10-26, on live sources, on the +machine of the owner, and by reproduction): + +1. The lyrics cache file for the track existed after the run, with + the full lyrics text, and the synchronization itself had + written the file during the run. `lyrics.Search` writes the + cache only on a hit, so the search had found the text. +2. The cached text begins with a blank line. The page parser of one + lyrics composer emits a line break before the first text node, + so every text from that composer starts with an empty first + line. +3. Both reporting sites reduce the value to its first line through + `sys.FirstLine`: the collect log line in `cmd/sync.go` and the + Lyrics row in `cmd/show.go`. The first line of the text is + empty, and both sites fall back on an empty excerpt: a "not + found" log line in the synchronization, an unset row in `show`. +4. The companion tool consumes the same `lyrics.Search` result and + excerpts the whole text, so the tool displayed the lyrics. The + tool also saved the same lyrics frame again, which is why the + installed file kept its size across its runs while its + timestamps moved: the persistence had worked in both tools. +5. Reproduction: a file that carries a lyrics frame whose text + begins with a blank line reads the full text back through the + ID3 layer, and `show` reports the frame as unset. + +**Assumed cause check**: The cause is not a source outage: the +sources held the text and the run cached it. The cause is not a +persistence failure: a byte scan of the installed file finds the +lyrics sections, and the frame reads back in full. The cause is +not a search failure. The cause is an unnormalized lyrics text at +the lyrics boundary, plus a lossy projection that both displays +use to decide whether the value exists. The constitution now +states the rule in the section Honest Reporting. + +**Outcome**: Specify a smaller correction than the report implied. +No change to the search and no change to the persistence. The +lyrics boundary returns text without leading blank lines, and both +displays read the first non-empty line. + +## User Scenarios and Testing + +### User Story 1 - A synchronization reports a lyrics hit truthfully (Priority: P1) + +The owner runs a synchronization. The collect stage finds the +lyrics of a track. The run output shows an excerpt of the lyrics. + +**Why this priority**: The run output is the primary signal of the +tool. A false negative hides every real success and sends the +owner to debug a healthy stage. + +**Independent Test**: Run a synchronization against a scripted +lyrics source that answers a text with a leading blank line. The +printed lyrics line carries the first content line, not the +not-found fallback. + +**Acceptance Scenarios**: + +1. **Given** a lyrics source that returns a text starting with + blank lines, **When** the collect stage processes the track, + **Then** the run output shows the first non-empty line of the + text. +2. **Given** a lyrics source that returns no text, **When** the + collect stage processes the track, **Then** the run output + shows the not-found fallback. + +### User Story 2 - Show reports a stored lyrics frame truthfully (Priority: P1) + +The owner runs `show` on an installed track. The track carries a +lyrics frame. The Lyrics row shows an excerpt of the stored text. + +**Why this priority**: Stored frames from runs before the fix keep +the leading blank line. Normalizing new results alone cannot repair +the reporting for the existing library. + +**Independent Test**: Write a fixture file whose lyrics frame +starts with blank lines and run `show`. The Lyrics row carries the +first non-empty line, not the unset fallback. + +**Acceptance Scenarios**: + +1. **Given** a stored lyrics frame that starts with blank lines, + **When** the owner runs `show`, **Then** the Lyrics row shows + the first non-empty line of the frame. +2. **Given** a stored track without a lyrics frame, **When** the + owner runs `show`, **Then** the Lyrics row shows the unset + fallback. + +### User Story 3 - Lyrics consumers receive a clean text (Priority: P2) + +A tool that imports the `lyrics` package calls `Search` or `Get`. +The returned text starts at its first content line, and the cache +file on disk holds the same text. + +**Why this priority**: Consumers excerpt the returned text and +write it into files. A shared normalization at the boundary +repairs every consumer at once. + +**Independent Test**: Stub a composer that returns a text starting +with blank lines. `Search` and `Get` return the text without the +leading blank lines, and the cache file holds the normalized text. + +**Acceptance Scenarios**: + +1. **Given** a composer that returns a text starting with blank + lines, **When** a consumer calls `Search`, **Then** the result + and the cache file start at the first content line. +2. **Given** a cache file written by an earlier version, with + leading blank lines, **When** a consumer calls `Search`, + **Then** the result starts at the first content line. +3. **Given** a cache file that holds only blank lines, **When** a + consumer calls `Search`, **Then** the cache counts as a miss and + the composers run. + +## Requirements + +### Functional Requirements + +- **FR-001**: `lyrics.Search` and `lyrics.Get` return a lyrics + text with no leading blank lines. +- **FR-002**: The lyrics cache stores the normalized text, and a + cache read normalizes a legacy entry. A cache entry that holds + only blank lines counts as a miss. +- **FR-003**: The collect log line and the `show` Lyrics row + excerpt the first non-empty line of the value. The fallback text + appears only for an actually empty value. +- **FR-004**: The lyrics choice rules stay unchanged: a + synchronized text wins over a plain text, the longer text wins + otherwise, and collection fails only when every composer fails. +- **FR-005**: The stored lyrics frame format stays unchanged, and + no other stage changes behavior. + +### Key Entities + +- **Lyrics text**: the plain or synchronized text that a composer + returns for a track, held in the cache, in the track object, and + in the lyrics frame of the installed file. + +## Success Criteria + +### Measurable Outcomes + +- **SC-001**: Unit tests prove that a composer result with leading + blank lines yields a normalized result and a normalized cache + file from `Search`, and a normalized result from `Get`. +- **SC-002**: A command test proves that `show` prints the first + content line for a stored frame with leading blank lines. +- **SC-003**: A command test proves that a full synchronization + whose lyrics source answers with leading blank lines prints the + first content line, not the not-found fallback. +- **SC-004**: The full test suite passes with the per-package + coverage gate at 90 percent, locally and in CI. diff --git a/specs/001-lyrics-reporting/tasks.md b/specs/001-lyrics-reporting/tasks.md new file mode 100644 index 0000000..dd43fa4 --- /dev/null +++ b/specs/001-lyrics-reporting/tasks.md @@ -0,0 +1,73 @@ +# Tasks: Truthful Lyrics Reporting + +**Input**: Design documents from `/specs/001-lyrics-reporting/` + +**Prerequisites**: plan.md (required), spec.md (required) + +**Tests**: The constitution requires unit tests next to the code +and a 90 percent coverage gate. The task list includes the tests. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to +- Include exact file paths in descriptions + +## Phase 1: Artifacts + +**Purpose**: Close the specify and plan steps of cycle 001. + +- [x] T001 Record the hypothesis investigation and the + requirements in `specs/001-lyrics-reporting/spec.md` +- [x] T002 Record the normalization point and the projection + decision in `specs/001-lyrics-reporting/plan.md` + +## Phase 2: Lyrics Normalization (Story 3) + +**Purpose**: The boundary returns a text that starts at its first +content line. Story 1 depends on this phase through `Search`. + +- [x] T003 [US3] Add the normalization helper to + `lyrics/composer.go` and apply the helper to the merged + result in `Search`, to the cache entry on read, and to the + result in `Get` +- [x] T004 [US3] Cover the normalization in + `lyrics/composer_test.go`: a composer result with leading + blank lines, the cache file content, a legacy cache entry, + a blank-only cache entry, and a `Get` result + +## Phase 3: Display Projection (Stories 1 and 2) + +**Purpose**: Both displays excerpt the first non-empty line, so +the fallback fires only for an empty value. + +- [x] T005 [P] [US1] Add `sys.FirstContentLine` next to + `sys.FirstLine` in `sys/string.go`, with cases in + `sys/string_test.go` +- [x] T006 [US1] Switch the collect log line in `cmd/sync.go` to + the new projection +- [x] T007 [US2] Switch the Lyrics row in `cmd/show.go` to the + new projection +- [x] T008 [US2] Cover the stored-frame case in + `cmd/show_test.go`: a fixture whose lyrics frame starts + with blank lines prints the first content line +- [x] T009 [US1] Cover the run output in `cmd/sync_test.go`: the + web script in `cmd/harness_test.go` gains a lyrics answer + with leading blank lines, and the printed lyrics line + carries the first content line + +## Phase 4: Verification + +**Purpose**: Prove the cycle against the constitution gates. + +- [x] T010 Run gofumpt, golangci-lint, the full test suite with + the race detector, and the coverage gate locally +- [x] T011 Open the pull request stacked on the onboarding branch + and reach a green pipeline + +## Phase 5: Converge + +**Purpose**: Close the cycle as a historical record. + +- [x] T012 Mark the task outcomes and the success criteria in + this file at the end of the implementation diff --git a/sys/string.go b/sys/string.go index ffbab31..446372c 100644 --- a/sys/string.go +++ b/sys/string.go @@ -128,3 +128,12 @@ func LegalizeFilename(filename string) string { func FirstLine(text string) string { return strings.Split(text, "\n")[0] } + +func FirstContentLine(text string) string { + for _, line := range strings.Split(text, "\n") { + if len(strings.TrimSpace(line)) > 0 { + return line + } + } + return "" +} diff --git a/sys/string_test.go b/sys/string_test.go index 64e4a08..47dc836 100644 --- a/sys/string_test.go +++ b/sys/string_test.go @@ -19,6 +19,7 @@ func BenchmarkString(b *testing.B) { TestContainsEach(&testing.T{}) TestLegalizeFilename(&testing.T{}) TestFirstLine(&testing.T{}) + TestFirstContentLine(&testing.T{}) } } @@ -91,3 +92,12 @@ func TestFirstLine(t *testing.T) { assert.Equal(t, "line1", FirstLine("line1\nline2")) assert.Equal(t, "line", FirstLine("line")) } + +func TestFirstContentLine(t *testing.T) { + assert.Equal(t, "line1", FirstContentLine("line1\nline2")) + assert.Equal(t, "line1", FirstContentLine("\n\nline1\nline2")) + assert.Equal(t, "line1", FirstContentLine(" \nline1")) + assert.Equal(t, "line1", FirstContentLine("\r\nline1")) + assert.Equal(t, "", FirstContentLine("")) + assert.Equal(t, "", FirstContentLine("\n \n")) +}