Sources are placed by the wing Resolver; concepts/entities inherit the strict majority wing of the sources that cite them. Collisions, junk (auto-tunnel logs) and anything unplaceable are Exceptions left in place. Apply writes the destination O_EXCL then removes the source, only for Auto items. Mutation-checked (junk guard, majority, slug identity, status gate, no-overwrite, destination-exists). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
244 lines
9.0 KiB
Go
244 lines
9.0 KiB
Go
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)
|
|
}
|