|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "fmt" |
| 6 | + "os" |
| 7 | + "path/filepath" |
| 8 | + "testing" |
| 9 | + |
| 10 | + "github.com/DonovanMods/linux-mod-manager/internal/core" |
| 11 | + "github.com/DonovanMods/linux-mod-manager/internal/domain" |
| 12 | + "github.com/DonovanMods/linux-mod-manager/internal/source" |
| 13 | + "github.com/DonovanMods/linux-mod-manager/internal/storage/cache" |
| 14 | + "github.com/stretchr/testify/assert" |
| 15 | + "github.com/stretchr/testify/require" |
| 16 | +) |
| 17 | + |
| 18 | +// setupDoDeployCompileTest extends setupDoDeployTest into a DeployCompile |
| 19 | +// game (#255): ConvertPaks on, base pak present, merge-compiler source |
| 20 | +// registered under "fake-compiler", game registered (mergeCompilerForGame |
| 21 | +// resolves the game's configured sources). |
| 22 | +func setupDoDeployCompileTest(t *testing.T) (*core.Service, *domain.Game, *compilerInstallSource) { |
| 23 | + t.Helper() |
| 24 | + svc, game := setupDoDeployTest(t) |
| 25 | + game.DeployMode = domain.DeployCompile |
| 26 | + game.ConvertPaks = true |
| 27 | + game.InstallPath = t.TempDir() |
| 28 | + game.SourceIDs = map[string]string{"fake-compiler": "external-icarus-id"} |
| 29 | + require.NoError(t, svc.AddGame(game)) |
| 30 | + |
| 31 | + basePak := filepath.Join(game.InstallPath, "Icarus", "Content", "Data", "data.pak") |
| 32 | + require.NoError(t, os.MkdirAll(filepath.Dir(basePak), 0o755)) |
| 33 | + writeFakeBasePak(t, basePak) |
| 34 | + |
| 35 | + compiler := &compilerInstallSource{fakeInstallSource: newFakeInstallSource("fake-compiler")} |
| 36 | + svc.RegisterSource(compiler) |
| 37 | + return svc, game, compiler |
| 38 | +} |
| 39 | + |
| 40 | +// ensureDefaultProfile creates the "default" profile if a seed helper runs |
| 41 | +// before any seedDeployableMod call (which otherwise creates it). |
| 42 | +func ensureDefaultProfile(t *testing.T, svc *core.Service, game *domain.Game) { |
| 43 | + t.Helper() |
| 44 | + pm := svc.NewProfileManager() |
| 45 | + if _, err := pm.Get(game.ID, "default"); err != nil { |
| 46 | + require.ErrorIs(t, err, domain.ErrProfileNotFound) |
| 47 | + _, err := pm.Create(game.ID, "default") |
| 48 | + require.NoError(t, err) |
| 49 | + } |
| 50 | +} |
| 51 | + |
| 52 | +// seedCompileExmodzMod installs an enabled native merge-source mod: retained |
| 53 | +// source only, zero deployment members of its own (#197). |
| 54 | +func seedCompileExmodzMod(t *testing.T, svc *core.Service, game *domain.Game, modID, name, fileID string) { |
| 55 | + t.Helper() |
| 56 | + ensureDefaultProfile(t, svc, game) |
| 57 | + gameCache := svc.GetGameCache(game) |
| 58 | + require.NoError(t, gameCache.Store(game.ID, "fake-compiler", modID, "1.0", cache.RetainedSourceName(fileID), []byte(name+"-bytes"))) |
| 59 | + require.NoError(t, svc.SaveInstalledMod(&domain.InstalledMod{ |
| 60 | + Mod: domain.Mod{ID: modID, SourceID: "fake-compiler", Name: name, Version: "1.0", GameID: game.ID}, |
| 61 | + ProfileName: "default", |
| 62 | + Enabled: true, |
| 63 | + FileIDs: []string{fileID}, |
| 64 | + UpdatePolicy: domain.UpdateNotify, |
| 65 | + })) |
| 66 | + pm := svc.NewProfileManager() |
| 67 | + require.NoError(t, pm.UpsertMod(game.ID, "default", domain.ModReference{SourceID: "fake-compiler", ModID: modID, Version: "1.0", FileIDs: []string{fileID}})) |
| 68 | +} |
| 69 | + |
| 70 | +// seedCompilePakMod installs an enabled convert-eligible pak mod in the |
| 71 | +// shape #221 ingest produces: retained source plus a deployable raw copy |
| 72 | +// recorded as the manifest's sole member (raw-deploy default until a merge |
| 73 | +// flips it). fileID must classify as a convertible kind (suffix ".pak"). |
| 74 | +func seedCompilePakMod(t *testing.T, svc *core.Service, game *domain.Game, modID, name, fileID string) { |
| 75 | + t.Helper() |
| 76 | + ensureDefaultProfile(t, svc, game) |
| 77 | + gameCache := svc.GetGameCache(game) |
| 78 | + pakContent := []byte(name + "-pak-bytes") |
| 79 | + require.NoError(t, gameCache.Store(game.ID, "fake-compiler", modID, "1.0", cache.RetainedSourceName(fileID), pakContent)) |
| 80 | + member := modID + ".pak" |
| 81 | + require.NoError(t, gameCache.Store(game.ID, "fake-compiler", modID, "1.0", member, pakContent)) |
| 82 | + versionDir := gameCache.ModPath(game.ID, "fake-compiler", modID, "1.0") |
| 83 | + require.NoError(t, cache.MarkFileCompleteWithMembers(versionDir, fileID, []string{member})) |
| 84 | + require.NoError(t, svc.SaveInstalledMod(&domain.InstalledMod{ |
| 85 | + Mod: domain.Mod{ID: modID, SourceID: "fake-compiler", Name: name, Version: "1.0", GameID: game.ID}, |
| 86 | + ProfileName: "default", |
| 87 | + Enabled: true, |
| 88 | + FileIDs: []string{fileID}, |
| 89 | + UpdatePolicy: domain.UpdateNotify, |
| 90 | + })) |
| 91 | + pm := svc.NewProfileManager() |
| 92 | + require.NoError(t, pm.UpsertMod(game.ID, "default", domain.ModReference{SourceID: "fake-compiler", ModID: modID, Version: "1.0", FileIDs: []string{fileID}})) |
| 93 | +} |
| 94 | + |
| 95 | +// TestDoDeploy_Compile_LabelsMergedRawAndLooseAndPrintsFooter is #255's CLI |
| 96 | +// acceptance test: on a DeployCompile game the header stops claiming a |
| 97 | +// per-mod link method, merge participants are labeled "(merged)", an |
| 98 | +// opted-out pak deploying raw is labeled "(raw)", an ordinary loose-file |
| 99 | +// mod keeps its plain line, and a post-sync footer names the merged |
| 100 | +// artifact with its participant count, directly above "Deployed: N" (which |
| 101 | +// still counts merge participants). |
| 102 | +func TestDoDeploy_Compile_LabelsMergedRawAndLooseAndPrintsFooter(t *testing.T) { |
| 103 | + svc, game, _ := setupDoDeployCompileTest(t) |
| 104 | + seedCompileExmodzMod(t, svc, game, "bear-mount", "Bear Mount", "exmodz-file") |
| 105 | + seedCompilePakMod(t, svc, game, "raw-pak", "Raw Pak Mod", "raw.pak") |
| 106 | + require.NoError(t, svc.SetModConvertPaks("fake-compiler", "raw-pak", game.ID, "default", false)) |
| 107 | + seedDeployableMod(t, svc, game, "loose", "Loose Mod", "loose.esp") |
| 108 | + |
| 109 | + out := captureStdout(t, func() error { |
| 110 | + return doDeploy(context.Background(), svc, game, nil) |
| 111 | + }) |
| 112 | + |
| 113 | + assert.Contains(t, out, "Deploying 3 mod(s) — compile mode...\n\n", "the compile header must not claim a per-mod link method") |
| 114 | + assert.NotContains(t, out, "using symlink") |
| 115 | + assert.Contains(t, out, " ✓ Bear Mount (merged)\n") |
| 116 | + assert.Contains(t, out, " ✓ Raw Pak Mod (raw)\n") |
| 117 | + assert.Contains(t, out, " ✓ Loose Mod\n") |
| 118 | + assert.NotContains(t, out, "Loose Mod (", "a loose-file mod keeps its plain, unlabeled line") |
| 119 | + assert.Contains(t, out, "\nMerged 1 mod(s) → zzz_LMM_Merged_P.pak\nDeployed: 3\n", |
| 120 | + "the footer must name the merged artifact and sit directly above the summary") |
| 121 | + |
| 122 | + _, err := os.Lstat(filepath.Join(game.ModPath, "zzz_LMM_Merged_P.pak")) |
| 123 | + assert.NoError(t, err, "the merged artifact must actually be deployed") |
| 124 | + _, err = os.Lstat(filepath.Join(game.ModPath, "raw-pak.pak")) |
| 125 | + assert.NoError(t, err, "the opted-out pak must be deployed raw") |
| 126 | +} |
| 127 | + |
| 128 | +// pakFailCompilerSource wraps compilerInstallSource so a CLI test can script |
| 129 | +// per-ref pak-conversion failures, mirroring internal/source/icarus/merge.go's |
| 130 | +// real failure path (a "... - deploying raw" warning per skipped ref). |
| 131 | +type pakFailCompilerSource struct { |
| 132 | + *compilerInstallSource |
| 133 | + failRefs map[string]string |
| 134 | +} |
| 135 | + |
| 136 | +func (s *pakFailCompilerSource) MergeCompile(ctx context.Context, basePakPath string, sources []source.MergeSource, outputPath string) ([]string, []source.MergeFailure, error) { |
| 137 | + var out []byte |
| 138 | + var warnings []string |
| 139 | + var failed []source.MergeFailure |
| 140 | + for _, src := range sources { |
| 141 | + if reason, bad := s.failRefs[src.ModRef]; bad { |
| 142 | + failed = append(failed, source.MergeFailure{ModRef: src.ModRef, Reason: reason}) |
| 143 | + warnings = append(warnings, fmt.Sprintf("mod %s: pak conversion failed: %s - deploying raw", src.ModName, reason)) |
| 144 | + continue |
| 145 | + } |
| 146 | + data, err := os.ReadFile(src.SourcePath) |
| 147 | + if err != nil { |
| 148 | + return nil, nil, err |
| 149 | + } |
| 150 | + out = append(out, data...) |
| 151 | + } |
| 152 | + return warnings, failed, os.WriteFile(outputPath, out, 0o644) |
| 153 | +} |
| 154 | + |
| 155 | +var _ source.MergeCompiler = (*pakFailCompilerSource)(nil) |
| 156 | + |
| 157 | +// TestDoDeploy_Compile_ConversionFailure_FooterCorrectsOptimisticLabel covers |
| 158 | +// #255's accepted optimistic case end to end: an opted-in pak is labeled |
| 159 | +// "(merged)" inline (at ✓ time the merge hasn't run), its conversion then |
| 160 | +// fails during the post-loop sync - the existing warning carries the |
| 161 | +// correction, and the footer reports the raw fallback. |
| 162 | +func TestDoDeploy_Compile_ConversionFailure_FooterCorrectsOptimisticLabel(t *testing.T) { |
| 163 | + svc, game, compiler := setupDoDeployCompileTest(t) |
| 164 | + svc.RegisterSource(&pakFailCompilerSource{ |
| 165 | + compilerInstallSource: compiler, |
| 166 | + failRefs: map[string]string{"fake-compiler:flaky-pak": "irreconcilable"}, |
| 167 | + }) |
| 168 | + seedCompileExmodzMod(t, svc, game, "bear-mount", "Bear Mount", "exmodz-file") |
| 169 | + seedCompilePakMod(t, svc, game, "flaky-pak", "Flaky Pak", "flaky.pak") |
| 170 | + |
| 171 | + out := captureCombined(t, func() error { |
| 172 | + return doDeploy(context.Background(), svc, game, nil) |
| 173 | + }) |
| 174 | + |
| 175 | + assert.Contains(t, out, " ✓ Flaky Pak (merged)\n", "inline label is optimistic by design - the footer/warning correct it") |
| 176 | + assert.Contains(t, out, "pak conversion failed", "the existing conversion-failure warning must still print") |
| 177 | + assert.Contains(t, out, "\nMerged 1 mod(s) → zzz_LMM_Merged_P.pak (1 deployed raw)\nDeployed: 2\n", |
| 178 | + "the footer must report the raw fallback and still sit directly above the summary") |
| 179 | +} |
| 180 | + |
| 181 | +// TestDoDeploy_NonCompile_NoCompileReadout guards the gate #255 must not |
| 182 | +// move: a non-compile deploy's output is byte-identical to before - the |
| 183 | +// original header, no labels, no merge footer. |
| 184 | +func TestDoDeploy_NonCompile_NoCompileReadout(t *testing.T) { |
| 185 | + svc, game := setupDoDeployTest(t) |
| 186 | + seedDeployableMod(t, svc, game, "a", "Mod A", "a.esp") |
| 187 | + |
| 188 | + out := captureStdout(t, func() error { |
| 189 | + return doDeploy(context.Background(), svc, game, nil) |
| 190 | + }) |
| 191 | + |
| 192 | + assert.Contains(t, out, "Deploying 1 mod(s) using symlink...\n\n") |
| 193 | + assert.Contains(t, out, " ✓ Mod A\n") |
| 194 | + assert.Contains(t, out, "\nDeployed: 1\n") |
| 195 | + assert.NotContains(t, out, "compile mode") |
| 196 | + assert.NotContains(t, out, "(merged)") |
| 197 | + assert.NotContains(t, out, "Merged ") |
| 198 | +} |
0 commit comments