Review of PR #132 found a real bug in the first-occurrence-wins fix: routing every one of b's occurrences of a normalized heading to a's single first occurrence means a self-merge (or a revision pass echoing a page back unchanged, exactly what #106 can produce) pairs b's SECOND "## NOTES" against a's FIRST "## Notes" -- different content, same normalized key -- and MergeReport wrongly reports a disagreement where none exists. Inert in #130 (fed the vestigial Merge wrapper, which discards conflicts) and only became visible once correctly wired into MergeReport, the function production actually calls. Fixed by pairing occurrences positionally: a's on-disk sections sharing a normalized heading are collected into an ordered list per key: b's 1st occurrence of that key pairs with a's 1st, b's 2nd with a's 2nd, and so on. b having MORE occurrences than a appends the extra as new content and registers it, so a third occurrence in b pairs with that newly appended one rather than creating yet another duplicate. Flip-tested: forcing every pairing back to position 0 (the prior fix's actual behavior) turns the new self-merge test red; the positional version stays green, and the original PR #130 regression test (fresh content must route to the first occurrence when b has only one occurrence) still passes unchanged -- the two fixes aren't in tension, positional pairing subsumes first-occurrence-wins as the pos==0 case. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
509 lines
21 KiB
Go
509 lines
21 KiB
Go
// ingestion/internal/wiki/merge_test.go
|
|
package wiki
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func TestMerge_BulletSectionsUnion(t *testing.T) {
|
|
a := Page{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Related Concepts\n\n- [[bar|Bar]]\n"}
|
|
b := Page{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Related Concepts\n\n- [[bar|Bar]]\n- [[baz|Baz]]\n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Contains(t, got.Content, "[[bar|Bar]]")
|
|
assert.Contains(t, got.Content, "[[baz|Baz]]")
|
|
assert.Equal(t, 1, strings.Count(got.Content, "[[bar|Bar]]"))
|
|
}
|
|
|
|
func TestMerge_AppendSections(t *testing.T) {
|
|
a := Page{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Evolving Notes\n\nFirst note.\n"}
|
|
b := Page{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Evolving Notes\n\nSecond note.\n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Contains(t, got.Content, "First note.")
|
|
assert.Contains(t, got.Content, "Second note.")
|
|
}
|
|
|
|
func TestMerge_KeepFirstForOtherSections(t *testing.T) {
|
|
a := Page{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Definition\n\nFirst definition.\n"}
|
|
b := Page{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Definition\n\nSecond definition.\n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Contains(t, got.Content, "First definition.")
|
|
assert.NotContains(t, got.Content, "Second definition.")
|
|
}
|
|
|
|
func TestMerge_NewSectionFromB(t *testing.T) {
|
|
a := Page{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Definition\n\nA thing.\n"}
|
|
b := Page{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Why It Matters\n\nBecause reasons.\n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Contains(t, got.Content, "A thing.")
|
|
assert.Contains(t, got.Content, "Because reasons.")
|
|
}
|
|
|
|
func TestMerge_KeepsFrontmatterFromA(t *testing.T) {
|
|
a := Page{Path: "p.md", Content: "---\ntitle: A\nlast_updated: 2026-01-01\n---\n\n## Definition\n\nA.\n"}
|
|
b := Page{Path: "p.md", Content: "---\ntitle: B\nlast_updated: 2026-06-01\n---\n\n## Definition\n\nB.\n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Contains(t, got.Content, "title: A")
|
|
assert.NotContains(t, got.Content, "title: B")
|
|
}
|
|
|
|
// --- hyperguild#103 review: a page with content outside any `## ` section ---
|
|
// must never lose that content on merge. parseSections used to only capture
|
|
// text between `## ` headings — anything before the first heading (a common,
|
|
// ordinary shape for a hand-written note: an intro paragraph before the first
|
|
// section) or, worse, an entire page with no `## ` headings at all (a legacy
|
|
// note, a stub, any hand-edit that doesn't follow the section convention)
|
|
// vanished silently. Merge would then fill the page entirely from the fresh
|
|
// extraction — the opposite of "never silently rewrite what a human put
|
|
// there," the guarantee this whole file exists to provide.
|
|
|
|
func TestMerge_PreservesOnDiskContentWithNoSectionHeadings(t *testing.T) {
|
|
a := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\nJust some free text a human wrote. No section headings at all.\n"}
|
|
b := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\n## Definition\n\nFresh extraction's take.\n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Contains(t, got.Content, "Just some free text a human wrote",
|
|
"on-disk content with no ## headings must survive the merge, not be replaced wholesale by the fresh extraction")
|
|
}
|
|
|
|
func TestMerge_DegenerateEmptyHeadingDoesNotCollideWithPreamble(t *testing.T) {
|
|
// Second review pass on #103: representing the preamble as a section with
|
|
// heading=="" collides with a literal `## ` (marker, no title text) —
|
|
// strings.TrimPrefix("## ", "## ") is also "". `a` has only a preamble
|
|
// (no degenerate heading of its own); `b` introduces a genuinely new
|
|
// degenerate `## ` section. Because both key to "", Merge's idx thinks
|
|
// b's section "already exists" in a (it's really just a's unrelated
|
|
// preamble) and never adds it — b's section vanishes from the output
|
|
// entirely, not merged, not kept, not appended.
|
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\nAn intro paragraph.\n\n## Definition\n\nA thing.\n"}
|
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## \n\nA genuinely new degenerate section's body.\n\n## Definition\n\nA thing.\n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Contains(t, got.Content, "An intro paragraph.", "a's own preamble must survive")
|
|
assert.Contains(t, got.Content, "A genuinely new degenerate section's body.",
|
|
"b's degenerate section is new content, not a collision with a's unrelated preamble — it must not be silently dropped")
|
|
}
|
|
|
|
func TestMerge_PreservesPreambleBeforeFirstHeading(t *testing.T) {
|
|
a := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\nAn intro paragraph before the first heading.\n\n## Definition\n\nA thing.\n"}
|
|
b := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\n## Definition\n\nA thing.\n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Contains(t, got.Content, "An intro paragraph before the first heading",
|
|
"text before the first ## heading is ordinary page content, not something the parser is entitled to drop")
|
|
}
|
|
|
|
// --- hyperguild#103 review: dedup must be whitespace-insensitive -----------
|
|
// Page content is produced by an LLM per run, so byte-identical re-extraction
|
|
// of "the same" logical line is not guaranteed — different bullet spacing or
|
|
// trailing whitespace on an otherwise-identical line must still dedup, or
|
|
// idempotency only holds when the extractor happens to reproduce whitespace
|
|
// exactly, which is not a real guarantee.
|
|
|
|
func TestMerge_AppendSectionDedupsAcrossWhitespaceVariants(t *testing.T) {
|
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Open Questions\n\n- Does this converge?\n"}
|
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Open Questions\n\n- Does this converge? \n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Equal(t, 1, strings.Count(got.Content, "Does this converge?"),
|
|
"same logical line with different whitespace must dedup, not duplicate")
|
|
}
|
|
|
|
func TestMerge_BulletSectionDedupsAcrossWhitespaceVariants(t *testing.T) {
|
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Related Concepts\n\n- [[bar|Bar]]\n"}
|
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Related Concepts\n\n- [[bar|Bar]]\n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Equal(t, 1, strings.Count(got.Content, "[[bar|Bar]]"),
|
|
"same logical bullet with different whitespace must dedup, not duplicate")
|
|
}
|
|
|
|
// --- brain#21/B1: merging must be safe to run again -------------------------
|
|
|
|
func TestMerge_AppendSectionDoesNotDuplicateContentAlreadyPresent(t *testing.T) {
|
|
// Compile-on-ingest merges a freshly extracted page into the one already
|
|
// on disk. Bullet sections union, so they are naturally idempotent — but
|
|
// append sections concatenate, so re-ingesting an unchanged source would
|
|
// grow "Open Questions" on every run until the page is unreadable.
|
|
page := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: X
|
|
---
|
|
|
|
## Open Questions
|
|
|
|
- Does this converge?
|
|
`}
|
|
|
|
once := Merge(page, page)
|
|
twice := Merge(once, page)
|
|
|
|
assert.Equal(t, 1, strings.Count(once.Content, "Does this converge?"),
|
|
"merging a page with itself must not duplicate an append section")
|
|
assert.Equal(t, once.Content, twice.Content,
|
|
"merge must be idempotent — a third pass may not keep growing the page")
|
|
}
|
|
|
|
func TestMerge_AppendSectionStillAccumulatesGenuinelyNewContent(t *testing.T) {
|
|
// The dedup must not turn append sections into keep-a-only sections:
|
|
// new material is exactly what compile-on-ingest exists to add.
|
|
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: X
|
|
---
|
|
|
|
## Open Questions
|
|
|
|
- Does this converge?
|
|
`}
|
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: X
|
|
---
|
|
|
|
## Open Questions
|
|
|
|
- What happens under load?
|
|
`}
|
|
|
|
got := Merge(existing, fresh).Content
|
|
assert.Contains(t, got, "Does this converge?", "existing question must survive")
|
|
assert.Contains(t, got, "What happens under load?", "new question must be added")
|
|
}
|
|
|
|
func TestMerge_GeneratorOwnedFrontmatterFieldsAreRefreshed(t *testing.T) {
|
|
// #103 made on-disk frontmatter win wholesale, which is what protects
|
|
// human-set fields from a regenerating writer. The cost was that fields the
|
|
// generator legitimately owns — when it last ran, what it last saw — froze
|
|
// at their first-ingest values. An explicit owned-set is the boundary:
|
|
// named fields the generator may refresh, everything else human-owned.
|
|
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: 'X'
|
|
last_updated: 2026-01-01
|
|
source_hash: aaaa
|
|
half_life: durable
|
|
claim_provenance: asserted
|
|
---
|
|
|
|
## Summary
|
|
|
|
Original.
|
|
`}
|
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: 'Renamed by the model'
|
|
last_updated: 2026-08-22
|
|
source_hash: bbbb
|
|
---
|
|
|
|
## Summary
|
|
|
|
Rewritten by the model.
|
|
`}
|
|
|
|
got := Merge(existing, fresh).Content
|
|
|
|
assert.Contains(t, got, "last_updated: 2026-08-22", "generator owns last_updated")
|
|
assert.Contains(t, got, "source_hash: bbbb", "generator owns source_hash")
|
|
assert.Contains(t, got, "title: 'X'", "a human-set title must survive regeneration")
|
|
assert.Contains(t, got, "half_life: durable", "the claim envelope must survive regeneration")
|
|
assert.Contains(t, got, "claim_provenance: asserted")
|
|
assert.Contains(t, got, "Original.", "an existing section still wins over the model's rewrite")
|
|
assert.NotContains(t, got, "Renamed by the model")
|
|
}
|
|
|
|
func TestMerge_GeneratorOwnedFieldsAreAppendedWhenAPageNeverHadThemBefore(t *testing.T) {
|
|
// Found in review of PR #126: refreshOwned only replaced a generator-owned
|
|
// line that already existed in a's frontmatter — it never appended one a
|
|
// didn't have. Every page ingested before this feature shipped has no
|
|
// source_hash line at all, so on its first re-ingest after deploy, the
|
|
// fresh hash was silently dropped and the skip-on-unchanged feature this
|
|
// PR exists to build would never engage for the entire pre-existing
|
|
// corpus, permanently, with nothing telling anyone.
|
|
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: 'X'
|
|
---
|
|
|
|
## Summary
|
|
|
|
Original.
|
|
`}
|
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: 'Renamed by the model'
|
|
last_updated: 2026-08-22
|
|
source_hash: bbbb
|
|
---
|
|
|
|
## Summary
|
|
|
|
Rewritten by the model.
|
|
`}
|
|
|
|
got := Merge(existing, fresh).Content
|
|
|
|
// Exact frontmatter block, not Contains: the second review round found
|
|
// that a substring check doesn't prove the fields landed inside a valid
|
|
// fence, in the right order, or that the fence itself wasn't corrupted —
|
|
// two independent mutations (closeIdx targeting the wrong fence, reversed
|
|
// sort) both survived a Contains-only version of this test.
|
|
want := "---\ntitle: 'X'\nlast_updated: 2026-08-22\nsource_hash: bbbb\n---\n\n## Summary\n\nOriginal.\n"
|
|
assert.Equal(t, want, got)
|
|
}
|
|
|
|
func TestMerge_GeneratorOwnedFieldsCreateAFrontmatterBlockWhenThePageHadNoneAtAll(t *testing.T) {
|
|
// Second review round on PR #126: when a has no frontmatter fence at all,
|
|
// the append fallback spliced raw, unfenced "key: value" lines straight
|
|
// into the body -- worse than the original bug, because the next
|
|
// parseSections call wouldn't recognize them as frontmatter (they don't
|
|
// start with `---`), so they'd be absorbed as ordinary preamble text and
|
|
// re-duplicate on every subsequent merge instead of updating in place.
|
|
existing := Page{Path: "wiki/sources/x.md", Content: "## Summary\n\nOriginal.\n"}
|
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
|
last_updated: 2026-08-22
|
|
source_hash: bbbb
|
|
---
|
|
|
|
## Summary
|
|
|
|
Rewritten by the model.
|
|
`}
|
|
|
|
got := Merge(existing, fresh).Content
|
|
|
|
want := "---\nlast_updated: 2026-08-22\nsource_hash: bbbb\n---\n\n## Summary\n\nOriginal.\n"
|
|
assert.Equal(t, want, got)
|
|
}
|
|
|
|
func TestMerge_GeneratorOwnedFieldsMixedReplaceAndAppend(t *testing.T) {
|
|
// a already has last_updated (replace in place) but not source_hash
|
|
// (append) -- the two code paths in the same call, together.
|
|
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: 'X'
|
|
last_updated: 2026-01-01
|
|
---
|
|
|
|
## Summary
|
|
|
|
Original.
|
|
`}
|
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
|
last_updated: 2026-08-22
|
|
source_hash: bbbb
|
|
---
|
|
|
|
## Summary
|
|
|
|
Rewritten by the model.
|
|
`}
|
|
|
|
got := Merge(existing, fresh).Content
|
|
|
|
want := "---\ntitle: 'X'\nlast_updated: 2026-08-22\nsource_hash: bbbb\n---\n\n## Summary\n\nOriginal.\n"
|
|
assert.Equal(t, want, got)
|
|
}
|
|
|
|
// --- hyperguild#106: heading casing drift must not fork a section ----------
|
|
// Page-aware extraction asks the model to revise the page it's given, but
|
|
// nothing guarantees the model reproduces a heading's exact on-disk casing
|
|
// ("Key Claims" vs "Key claims"). Without normalizing the match key, that
|
|
// drift silently creates a near-duplicate section instead of updating the
|
|
// original — the failure mode #106 exists to close.
|
|
|
|
func TestMerge_HeadingCaseDriftMatchesSameSection(t *testing.T) {
|
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Key Claims\n\n- Appetite beats estimation.\n"}
|
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Key claims\n\n- New pattern holds under load.\n"}
|
|
|
|
got := Merge(a, b).Content
|
|
|
|
assert.Equal(t, 1, strings.Count(got, "## Key"),
|
|
"casing drift in the heading must not create a second section")
|
|
assert.Contains(t, got, "Appetite beats estimation.")
|
|
assert.Contains(t, got, "New pattern holds under load.",
|
|
"the differently-cased section's content must still be unioned in")
|
|
assert.Contains(t, got, "## Key Claims",
|
|
"the on-disk heading's original casing must be preserved in the output")
|
|
assert.NotContains(t, got, "## Key claims",
|
|
"the fresh extraction's heading casing must not overwrite the on-disk display text")
|
|
}
|
|
|
|
func TestMerge_HeadingWhitespaceDriftMatchesSameSection(t *testing.T) {
|
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Open Questions \n\n- Does appetite scale?\n"}
|
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Open Questions\n\n- What about remote teams?\n"}
|
|
|
|
got := Merge(a, b).Content
|
|
|
|
assert.Equal(t, 1, strings.Count(got, "## Open Questions"),
|
|
"surrounding whitespace drift in the heading must not create a second section")
|
|
assert.Contains(t, got, "Does appetite scale?")
|
|
assert.Contains(t, got, "What about remote teams?")
|
|
}
|
|
|
|
func TestMerge_PreExistingCaseDuplicateHeadingsRouteToTheFirstOccurrence(t *testing.T) {
|
|
// Found in review of PR #130: normalizeHeading's idx map is built by
|
|
// iterating secsA with idx[normalizeHeading(s.heading)] = i -- last
|
|
// write wins. Before heading normalization existed, two on-disk
|
|
// sections differing only by case (real, pre-existing corpus drift --
|
|
// the exact pattern #106 itself cites) never collided, since idx keyed
|
|
// on the exact string. After normalization they collide, and every
|
|
// fresh update silently, deterministically routes to whichever
|
|
// duplicate happens to appear LAST in the file -- the first one is
|
|
// frozen forever, never receiving updates again, with nothing to say
|
|
// so. A Contains-only check doesn't catch this: nothing is literally
|
|
// deleted, a bullet just lands under the wrong of two still-present
|
|
// headings. Assert it lands under the FIRST occurrence instead —
|
|
// deterministic and matches "earliest is canonical," same reasoning
|
|
// as every other on-disk-wins default in this file.
|
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Key Claims\n\n- claim one\n\n## KEY CLAIMS\n\n- claim two\n"}
|
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Key Claims\n\n- claim one\n- fresh claim\n"}
|
|
|
|
got := Merge(a, b).Content
|
|
|
|
assert.Contains(t, got, "## Key Claims\n\n- claim one\n- fresh claim\n",
|
|
"the fresh bullet must land in the first-occurrence section, not silently route to the last duplicate")
|
|
assert.Contains(t, got, "## KEY CLAIMS\n\n- claim two\n",
|
|
"the second duplicate is untouched by this update, not merged into or overwritten")
|
|
}
|
|
|
|
func TestMergeReport_SelfMergeOfPreExistingDuplicateHeadingsReportsNoConflict(t *testing.T) {
|
|
// Found in review of PR #132: first-occurrence-wins routes ALL of b's
|
|
// occurrences of a normalized heading to a's FIRST occurrence, not to
|
|
// their own positional counterpart. So merging a page against itself
|
|
// (or an echoed-back copy, exactly what #106's revision prompt can
|
|
// produce) pairs b's second "## NOTES" against a's first "## Notes" —
|
|
// different content, same normalized key — and MergeReport wrongly
|
|
// reports a disagreement where none exists. Fixed by pairing
|
|
// positionally: b's Nth occurrence of a normalized heading matches a's
|
|
// Nth occurrence, not always the first.
|
|
page := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Notes\n\nFirst note.\n\n## NOTES\n\nSecond note.\n"}
|
|
|
|
_, conflicts := MergeReport(page, page)
|
|
assert.Empty(t, conflicts, "merging a page with pre-existing duplicate headings against itself must report no conflicts")
|
|
}
|
|
|
|
func TestMergeReport_DuplicateHeadingsPairPositionallyNotAllToFirst(t *testing.T) {
|
|
// Companion case: b has genuinely different content in its second
|
|
// occurrence of the normalized heading — that must compare against a's
|
|
// second occurrence (and report if they disagree), not against a's
|
|
// first (which would either false-positive if a's first differs, or
|
|
// false-negative-mask a real disagreement in the second pair).
|
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Notes\n\nFirst note.\n\n## NOTES\n\nSecond note.\n"}
|
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Notes\n\nFirst note.\n\n## NOTES\n\nSecond note, revised.\n"}
|
|
|
|
_, conflicts := MergeReport(a, b)
|
|
assert.Equal(t, []string{"NOTES"}, conflicts,
|
|
"only the second occurrence, which actually disagrees, is reported")
|
|
}
|
|
|
|
func TestRefreshOwned_CommentLinesSurviveUnmangled(t *testing.T) {
|
|
// Third review round on PR #126: parseFrontmatterBlock dropped the
|
|
// sibling api package's comment guard (strings.HasPrefix(trimmed, "#")).
|
|
// Without it, a comment line containing a colon gets cut at the colon
|
|
// and re-rendered with collapsed whitespace instead of surviving
|
|
// verbatim.
|
|
a := "---\n#see also: https://x/y\ntitle: 'X'\n---\n"
|
|
b := "---\nsource_hash: bbbb\n---\n"
|
|
|
|
got := refreshOwned(a, b)
|
|
assert.Equal(t, "---\n#see also: https://x/y\ntitle: 'X'\nsource_hash: bbbb\n---\n", got)
|
|
}
|
|
|
|
func TestRefreshOwned_IndentedListItemsWithColonsAreNotMisparsedAsKeys(t *testing.T) {
|
|
// Same round: an indented YAML block-list item that happens to contain a
|
|
// colon (e.g. a title with a subtitle) is a continuation line, never a
|
|
// top-level key, regardless of what strings.Cut finds in it. Misparsing
|
|
// it as a key/value pair and re-rendering strips its indentation,
|
|
// de-indenting it out of the list it belongs to -- and refreshOwned
|
|
// re-renders this block on essentially every non-skipped ingest, so this
|
|
// is the normal path, not an edge case.
|
|
a := "---\naliases:\n - 'Chapter 3: The Reckoning'\ntitle: 'X'\n---\n"
|
|
b := "---\nsource_hash: bbbb\n---\n"
|
|
|
|
got := refreshOwned(a, b)
|
|
assert.Equal(t, "---\naliases:\n - 'Chapter 3: The Reckoning'\ntitle: 'X'\nsource_hash: bbbb\n---\n", got)
|
|
}
|
|
|
|
func TestMerge_ReportsSectionsWhereTheFreshVersionWasDiscarded(t *testing.T) {
|
|
// The asymmetry that makes ingest safe (on-disk wins) also throws away a
|
|
// real signal: the source now says something different about a section we
|
|
// already have. Today that disagreement is silently discarded, so nobody
|
|
// can tell "the source is unchanged" from "the source changed its mind and
|
|
// we ignored it".
|
|
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: 'X'
|
|
---
|
|
|
|
## Summary
|
|
|
|
Appetite is fixed, scope is variable.
|
|
|
|
## Method
|
|
|
|
Six-week cycles.
|
|
`}
|
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: 'X'
|
|
---
|
|
|
|
## Summary
|
|
|
|
Appetite is negotiable and scope is fixed.
|
|
|
|
## Method
|
|
|
|
Six-week cycles.
|
|
`}
|
|
|
|
got, conflicts := MergeReport(existing, fresh)
|
|
|
|
assert.Contains(t, got.Content, "Appetite is fixed", "on-disk version still wins")
|
|
assert.NotContains(t, got.Content, "negotiable")
|
|
assert.Equal(t, []string{"Summary"}, conflicts,
|
|
"only the section that actually disagreed is reported")
|
|
}
|
|
|
|
func TestMerge_ReportsNothingWhenTheFreshVersionAgrees(t *testing.T) {
|
|
page := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: 'X'
|
|
---
|
|
|
|
## Summary
|
|
|
|
Appetite is fixed, scope is variable.
|
|
`}
|
|
|
|
_, conflicts := MergeReport(page, page)
|
|
assert.Empty(t, conflicts, "identical content is not a disagreement")
|
|
}
|
|
|
|
func TestMerge_ReportsNothingForInternalWhitespaceVariantsOnly(t *testing.T) {
|
|
// Found in review of PR #129: the conflict comparison used raw
|
|
// strings.TrimSpace, which only tolerates LEADING/TRAILING whitespace —
|
|
// unlike appendNew/mergeBullets, which already canonicalize internal
|
|
// whitespace runs too (#103: a re-extraction's spacing isn't guaranteed
|
|
// byte-stable). A mid-line double-space from re-extraction would
|
|
// false-positive as a "disagreement" here even though every other
|
|
// whitespace-sensitive comparison in this file already tolerates it.
|
|
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: 'X'
|
|
---
|
|
|
|
## Summary
|
|
|
|
Appetite is fixed, scope is variable.
|
|
`}
|
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: 'X'
|
|
---
|
|
|
|
## Summary
|
|
|
|
Appetite is fixed, scope is variable.
|
|
`}
|
|
|
|
_, conflicts := MergeReport(existing, fresh)
|
|
assert.Empty(t, conflicts, "internal whitespace differences alone are not a disagreement")
|
|
}
|