|
| 1 | +package trainings |
| 2 | + |
| 3 | +import ( |
| 4 | + "sort" |
| 5 | + "testing" |
| 6 | + |
| 7 | + "github.com/spf13/afero" |
| 8 | + "github.com/stretchr/testify/assert" |
| 9 | + "github.com/stretchr/testify/require" |
| 10 | + |
| 11 | + "github.com/ThreeDotsLabs/cli/trainings/genproto" |
| 12 | +) |
| 13 | + |
| 14 | +func TestMergeStartStateFiles(t *testing.T) { |
| 15 | + t.Run("first exercise (nil golden) returns scaffold as-is", func(t *testing.T) { |
| 16 | + scaffold := []*genproto.File{ |
| 17 | + {Path: "a.txt", Content: "A"}, |
| 18 | + {Path: "b.txt", Content: "B"}, |
| 19 | + } |
| 20 | + merged := mergeStartStateFiles(nil, scaffold) |
| 21 | + assertFilesByPath(t, merged, map[string]string{ |
| 22 | + "a.txt": "A", |
| 23 | + "b.txt": "B", |
| 24 | + }) |
| 25 | + }) |
| 26 | + |
| 27 | + t.Run("scaffold wins on path collision", func(t *testing.T) { |
| 28 | + golden := []*genproto.File{ |
| 29 | + {Path: "shared.txt", Content: "from golden"}, |
| 30 | + {Path: "golden-only.txt", Content: "G"}, |
| 31 | + } |
| 32 | + scaffold := []*genproto.File{ |
| 33 | + {Path: "shared.txt", Content: "from scaffold"}, // overrides golden |
| 34 | + {Path: "scaffold-only.txt", Content: "S"}, |
| 35 | + } |
| 36 | + merged := mergeStartStateFiles(golden, scaffold) |
| 37 | + assertFilesByPath(t, merged, map[string]string{ |
| 38 | + "shared.txt": "from scaffold", |
| 39 | + "golden-only.txt": "G", |
| 40 | + "scaffold-only.txt": "S", |
| 41 | + }) |
| 42 | + }) |
| 43 | + |
| 44 | + t.Run("regression: golden with filled-in placeholder is preserved when scaffold does not redeliver it", func(t *testing.T) { |
| 45 | + // This is the 0001_init_orders.up.sql scenario. |
| 46 | + // Earlier exercises scaffolded the file as empty; the user filled it in. |
| 47 | + // By a later exercise, the scaffold no longer includes that file — only |
| 48 | + // the prev-exercise golden does. The start state must preserve the |
| 49 | + // filled-in content. |
| 50 | + golden := []*genproto.File{ |
| 51 | + {Path: "migrations/0001_init.sql", Content: "CREATE TABLE ..."}, |
| 52 | + {Path: "common.go", Content: "package common"}, |
| 53 | + } |
| 54 | + scaffold := []*genproto.File{ |
| 55 | + {Path: "new_for_this_exercise.go", Content: "package new"}, |
| 56 | + } |
| 57 | + merged := mergeStartStateFiles(golden, scaffold) |
| 58 | + assertFilesByPath(t, merged, map[string]string{ |
| 59 | + "migrations/0001_init.sql": "CREATE TABLE ...", // survives from golden |
| 60 | + "common.go": "package common", |
| 61 | + "new_for_this_exercise.go": "package new", |
| 62 | + }) |
| 63 | + }) |
| 64 | +} |
| 65 | + |
| 66 | +func TestReplaceExerciseFiles_is1to1(t *testing.T) { |
| 67 | + // The invariant: after replaceExerciseFiles, exerciseDir contains exactly |
| 68 | + // the replacement files — any extras are deleted. This is load-bearing |
| 69 | + // for the "sync with example" and "replace on conflict" UX: user's project |
| 70 | + // must never silently diverge from what the caller asked for. |
| 71 | + fs := afero.NewMemMapFs() |
| 72 | + rootFs := afero.NewBasePathFs(fs, "/").(*afero.BasePathFs) |
| 73 | + |
| 74 | + // Pre-populate exerciseDir with some files that should be removed. |
| 75 | + require.NoError(t, afero.WriteFile(fs, "/ex/stale_placeholder.sql", []byte("-- todo"), 0644)) |
| 76 | + require.NoError(t, afero.WriteFile(fs, "/ex/user_scratch.txt", []byte("notes"), 0644)) |
| 77 | + require.NoError(t, afero.WriteFile(fs, "/ex/keep_me.go", []byte("old content"), 0644)) |
| 78 | + |
| 79 | + replacement := []*genproto.File{ |
| 80 | + {Path: "keep_me.go", Content: "new content"}, |
| 81 | + {Path: "fresh.go", Content: "fresh"}, |
| 82 | + } |
| 83 | + require.NoError(t, replaceExerciseFiles(rootFs, replacement, "ex")) |
| 84 | + |
| 85 | + // keep_me.go is overwritten |
| 86 | + got, err := afero.ReadFile(fs, "/ex/keep_me.go") |
| 87 | + require.NoError(t, err) |
| 88 | + assert.Equal(t, "new content", string(got)) |
| 89 | + |
| 90 | + // fresh.go is created |
| 91 | + got, err = afero.ReadFile(fs, "/ex/fresh.go") |
| 92 | + require.NoError(t, err) |
| 93 | + assert.Equal(t, "fresh", string(got)) |
| 94 | + |
| 95 | + // stale_placeholder.sql and user_scratch.txt are DELETED (not in replacement). |
| 96 | + // This is the 1:1 invariant the fix enforces. |
| 97 | + _, err = fs.Stat("/ex/stale_placeholder.sql") |
| 98 | + assert.True(t, err != nil, "stale placeholder should have been deleted") |
| 99 | + _, err = fs.Stat("/ex/user_scratch.txt") |
| 100 | + assert.True(t, err != nil, "user scratch should have been deleted") |
| 101 | +} |
| 102 | + |
| 103 | +func assertFilesByPath(t *testing.T, got []*genproto.File, want map[string]string) { |
| 104 | + t.Helper() |
| 105 | + byPath := map[string]string{} |
| 106 | + for _, f := range got { |
| 107 | + byPath[f.Path] = f.Content |
| 108 | + } |
| 109 | + paths := make([]string, 0, len(byPath)) |
| 110 | + for p := range byPath { |
| 111 | + paths = append(paths, p) |
| 112 | + } |
| 113 | + sort.Strings(paths) |
| 114 | + assert.Equal(t, want, byPath, "merged files mismatch; paths present: %v", paths) |
| 115 | +} |
0 commit comments