package migrate_test import ( "context" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/mathiasbq/hyperguild/ingestion/internal/migrate" "github.com/mathiasbq/hyperguild/ingestion/internal/wing" ) // keywordClassifier picks a wing by a marker word in the text, and the scorer // agrees by the same marker, so a page "belongs" to the wing whose marker it holds. type keywordClassifier struct{} func (keywordClassifier) Classify(_ context.Context, text string, _ []string) (wing.Suggestion, error) { for _, w := range []string{"alpha", "beta"} { if strings.Contains(text, strings.ToUpper(w)) { return wing.Suggestion{Wing: w, Confidence: 0.95, Reason: "marker"}, nil } } return wing.Suggestion{}, nil } type keywordScorer struct{} func (keywordScorer) Score(text string) map[string]float64 { out := map[string]float64{} for _, w := range []string{"alpha", "beta"} { if strings.Contains(text, strings.ToUpper(w)) { out[w] = 1 } } return out } func resolverFor(string) *wing.Resolver { return &wing.Resolver{ Known: []string{"alpha", "beta", "client"}, Eligible: func(w string) bool { return w != "client" }, Classifier: keywordClassifier{}, Scorer: keywordScorer{}, Threshold: 0.8, } } func page(t *testing.T, dir, rel, body string) { t.Helper() full := filepath.Join(dir, rel) require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) require.NoError(t, os.WriteFile(full, []byte(body), 0o644)) } func src(title, marker string) string { return "---\ntitle: " + title + "\ntype: video\n---\n\n## Summary\n\n" + marker + " content.\n" } func entity(title string, sources ...string) string { var b strings.Builder b.WriteString("---\ntitle: " + title + "\ntype: tool\n---\n\n## Definition\n\nA thing.\n\n## Sources\n\n") for _, s := range sources { b.WriteString("- [[" + s + "|" + s + "]]\n") } return b.String() } func byFrom(items []migrate.Item) map[string]migrate.Item { m := map[string]migrate.Item{} for _, it := range items { m[it.From] = it } return m } func TestPlan_AResolvedSourceGoesToItsWingsSourcesHall(t *testing.T) { dir := t.TempDir() page(t, dir, "wiki/sources/talk-a.md", src("Talk A", "ALPHA")) items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) require.NoError(t, err) it := byFrom(items)["wiki/sources/talk-a.md"] assert.Equal(t, migrate.Auto, it.Status) assert.Equal(t, "wiki/alpha/sources/talk-a.md", it.To) } func TestPlan_AutoTunnelLogsAreJunkNotSources(t *testing.T) { dir := t.TempDir() page(t, dir, "wiki/sources/tunnel-candidates-2026-06-14.md", src("t", "ALPHA")) items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) require.NoError(t, err) it := byFrom(items)["wiki/sources/tunnel-candidates-2026-06-14.md"] assert.Equal(t, migrate.Exception, it.Status, "never auto-moved, whatever the classifier says") assert.Equal(t, "junk", it.Method) } func TestPlan_AnEntityFollowsTheMajorityWingOfItsSources(t *testing.T) { dir := t.TempDir() page(t, dir, "wiki/sources/a1.md", src("A1", "ALPHA")) page(t, dir, "wiki/sources/a2.md", src("A2", "ALPHA")) page(t, dir, "wiki/sources/b1.md", src("B1", "BETA")) page(t, dir, "wiki/entities/jev.md", entity("Jev", "a1", "a2", "b1")) page(t, dir, "wiki/concepts/factory.md", "---\ntitle: Factory\n---\n\n## Definition\n\nx\n\n## Sources\n\n- [[a1|A1]]\n") items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) require.NoError(t, err) m := byFrom(items) assert.Equal(t, "wiki/alpha/facts/jev.md", m["wiki/entities/jev.md"].To) assert.Equal(t, "entity", m["wiki/entities/jev.md"].Kind) assert.Equal(t, "wiki/alpha/facts/factory.md", m["wiki/concepts/factory.md"].To) assert.Equal(t, "concept", m["wiki/concepts/factory.md"].Kind) } func TestPlan_ATieBetweenWingsIsAnException(t *testing.T) { dir := t.TempDir() page(t, dir, "wiki/sources/a1.md", src("A1", "ALPHA")) page(t, dir, "wiki/sources/b1.md", src("B1", "BETA")) page(t, dir, "wiki/entities/jev.md", entity("Jev", "a1", "b1")) items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) require.NoError(t, err) it := byFrom(items)["wiki/entities/jev.md"] assert.Equal(t, migrate.Exception, it.Status) assert.Contains(t, it.Reason, "disagree") } func TestPlan_AnEntityWhoseSourcesWereNotPlacedIsAnException(t *testing.T) { dir := t.TempDir() page(t, dir, "wiki/sources/mystery.md", src("Mystery", "nothing recognisable")) page(t, dir, "wiki/entities/orphan.md", entity("Orphan", "mystery")) page(t, dir, "wiki/entities/nosources.md", "---\ntitle: No Sources\n---\n\n## Definition\n\nx\n") items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) require.NoError(t, err) m := byFrom(items) assert.Equal(t, migrate.Exception, m["wiki/entities/orphan.md"].Status) assert.Equal(t, migrate.Exception, m["wiki/entities/nosources.md"].Status) } func TestPlan_ADestinationThatAlreadyExistsIsAnException(t *testing.T) { dir := t.TempDir() page(t, dir, "wiki/sources/talk-a.md", src("Talk A", "ALPHA")) page(t, dir, "wiki/alpha/sources/talk-a.md", "someone else's page") items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) require.NoError(t, err) it := byFrom(items)["wiki/sources/talk-a.md"] assert.Equal(t, migrate.Exception, it.Status) assert.Contains(t, it.Reason, "already exists", "the occupied destination is named as the reason, not a generic slug clash") } func TestPlan_ASlugAlreadyUsedInAnotherWingIsAnException(t *testing.T) { // Graph identity is the slug alone; two pages with one slug would merge. dir := t.TempDir() page(t, dir, "wiki/sources/talk-a.md", src("Talk A", "ALPHA")) page(t, dir, "wiki/beta/facts/talk-a.md", "a beta page with the same slug") items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) require.NoError(t, err) it := byFrom(items)["wiki/sources/talk-a.md"] assert.Equal(t, migrate.Exception, it.Status) assert.Contains(t, it.Reason, "slug") } func TestApply_MovesOnlyAutoItemsAndStampsFrontmatterWithoutTouchingTheBody(t *testing.T) { dir := t.TempDir() body := src("Talk A", "ALPHA") + "\nSee [[jev|Jev]] and [[unknown-slug]].\n" page(t, dir, "wiki/sources/talk-a.md", body) page(t, dir, "wiki/sources/tunnel-candidates-2026-06-14.md", src("t", "ALPHA")) page(t, dir, "wiki/entities/jev.md", entity("Jev", "talk-a")) items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) require.NoError(t, err) n, err := migrate.Apply(dir, items) require.NoError(t, err) assert.Equal(t, 2, n) moved, err := os.ReadFile(filepath.Join(dir, "wiki/alpha/sources/talk-a.md")) require.NoError(t, err) assert.Contains(t, string(moved), "wing: alpha") assert.Contains(t, string(moved), "hall: sources") assert.Contains(t, string(moved), "See [[jev|Jev]] and [[unknown-slug]].", "links and body are byte-preserved") ent, err := os.ReadFile(filepath.Join(dir, "wiki/alpha/facts/jev.md")) require.NoError(t, err) assert.Contains(t, string(ent), "kind: entity") _, err = os.Stat(filepath.Join(dir, "wiki/sources/talk-a.md")) assert.ErrorIs(t, err, os.ErrNotExist, "the original is removed after the copy is written") _, err = os.Stat(filepath.Join(dir, "wiki/sources/tunnel-candidates-2026-06-14.md")) assert.NoError(t, err, "an exception is left exactly where it is") } func TestApply_NeverOverwritesAndKeepsTheSourceWhenTheDestinationAppearsLate(t *testing.T) { dir := t.TempDir() page(t, dir, "wiki/sources/talk-a.md", src("Talk A", "ALPHA")) items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) require.NoError(t, err) page(t, dir, "wiki/alpha/sources/talk-a.md", "appeared after the plan") n, err := migrate.Apply(dir, items) require.Error(t, err) assert.Zero(t, n) got, _ := os.ReadFile(filepath.Join(dir, "wiki/alpha/sources/talk-a.md")) assert.Equal(t, "appeared after the plan", string(got)) _, statErr := os.Stat(filepath.Join(dir, "wiki/sources/talk-a.md")) assert.NoError(t, statErr, "the source survives a failed write") } func TestPlan_IsEmptyOnceEverythingIsMigrated(t *testing.T) { dir := t.TempDir() page(t, dir, "wiki/sources/talk-a.md", src("Talk A", "ALPHA")) items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) require.NoError(t, err) _, err = migrate.Apply(dir, items) require.NoError(t, err) again, err := migrate.BuildPlan(context.Background(), dir, resolverFor) require.NoError(t, err) assert.Empty(t, again) } // An exception that blocked on a collision still carries a destination; Apply // must treat Status, not the presence of a path, as the decision. func TestApply_LeavesACollisionExceptionAloneEvenThoughItHasADestination(t *testing.T) { dir := t.TempDir() page(t, dir, "wiki/sources/talk-a.md", src("Talk A", "ALPHA")) page(t, dir, "wiki/alpha/sources/talk-a.md", "occupied") items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) require.NoError(t, err) require.Equal(t, migrate.Exception, byFrom(items)["wiki/sources/talk-a.md"].Status) n, err := migrate.Apply(dir, items) require.NoError(t, err) assert.Zero(t, n) _, statErr := os.Stat(filepath.Join(dir, "wiki/sources/talk-a.md")) assert.NoError(t, statErr) }