Skip to content

Commit c77cb11

Browse files
authored
Merge pull request #264 from DonovanMods/dyoung522/fix-255-compile-deploy-output
fix(deploy): label merged/raw mods and name the merged artifact in deploy output (#255)
2 parents 34ba9d0 + 98fe168 commit c77cb11

8 files changed

Lines changed: 734 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020

2121
### Fixed
2222

23+
- Deploy output on a compile-mode game (Icarus) no longer presents merged
24+
mods as individual deployments (#255). The header drops the misleading
25+
`using <method>` claim, each mod's `` line is labeled by how its content
26+
actually reaches the game directory — `(merged)` for merge participants,
27+
`(raw)` for a conversion-opted-out pak, unlabeled for ordinary loose-file
28+
mods — and a post-sync footer finally names the one artifact that really
29+
deployed (`Merged N mod(s) → zzz_LMM_Merged_P.pak`, with a
30+
`(N deployed raw)` count when conversions fell back). The TUI's deploy
31+
status line reports the same readout (`Deployed N mod(s) — merged N → …`).
32+
`Deployed: N` still counts merge participants, and non-compile deploy
33+
output is unchanged, byte for byte.
2334
- TUI: a mutation that completes with two or more warnings now auto-opens a
2435
scrollable overlay listing every warning in full, instead of collapsing
2536
them to an unreadable `(N warnings)` status suffix — on merged-pak games

cmd/lmm/deploy.go

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,15 +124,31 @@ func doDeploy(ctx context.Context, service *core.Service, game *domain.Game, arg
124124
// per-mod progress events at all when there is nothing to deploy, which
125125
// is exactly the "No mods to deploy" case the pre-extraction CLI checked
126126
// via len(modsToDeploy) before it had been folded into the flow.
127+
//
128+
// On a DeployCompile game the header drops "using <method>" (#255): the
129+
// listed mods are mostly carried by one merged artifact deployed after
130+
// the loop, so claiming a per-mod link method up front asserted
131+
// something untrue about most of the lines below it.
132+
compileMode := game.DeployMode == domain.DeployCompile
127133
deployHeaderPrinted := false
128134
printDeployHeaderOnce := func(total int) {
129135
if deployHeaderPrinted {
130136
return
131137
}
132138
deployHeaderPrinted = true
133-
fmt.Printf("Deploying %d mod(s) using %s...\n\n", total, methodName)
139+
if compileMode {
140+
fmt.Printf("Deploying %d mod(s) — compile mode...\n\n", total)
141+
} else {
142+
fmt.Printf("Deploying %d mod(s) using %s...\n\n", total, methodName)
143+
}
134144
}
135145

146+
// mergeFooterPrinted: a DeployMergeSynced footer was printed (#255), so
147+
// the summary below skips its own leading blank line - the footer
148+
// already separates the per-mod block and "Deployed: N" reads as part
149+
// of the same closing readout.
150+
mergeFooterPrinted := false
151+
136152
// progress prints every diagnostic and per-mod status line at its exact
137153
// point of occurrence, driven entirely by core.DeployProfile's progress
138154
// events - including diagnostics that also land in result.Warnings/
@@ -166,6 +182,20 @@ func doDeploy(ctx context.Context, service *core.Service, game *domain.Game, arg
166182
// --purge pass; handled so a future change can't accidentally
167183
// route them into printDeployHeaderOnce below.
168184
return
185+
case core.DeployMergeSynced:
186+
// #255: the post-sync footer naming the merged artifact. Fires
187+
// only after the deploy loop (some per-mod event has already
188+
// printed the header), and its Total counts the mods the merged
189+
// artifact actually carries (raw fallbacks excluded - they ride
190+
// RawFallbacks), not the deploy total - so it must not fall
191+
// through to printDeployHeaderOnce below.
192+
fmt.Printf("\nMerged %d mod(s) → %s", p.Total, p.Detail)
193+
if p.RawFallbacks > 0 {
194+
fmt.Printf(" (%d deployed raw)", p.RawFallbacks)
195+
}
196+
fmt.Println()
197+
mergeFooterPrinted = true
198+
return
169199
}
170200

171201
printDeployHeaderOnce(p.Total)
@@ -185,7 +215,19 @@ func doDeploy(ctx context.Context, service *core.Service, game *domain.Game, arg
185215
case core.DeploySkipped:
186216
fmt.Printf(" %s %s - %s\n", colorRed("✗"), p.ModName, p.Detail)
187217
case core.DeployDeployed:
188-
fmt.Printf(" %s %s\n", colorGreen("✓"), p.ModName)
218+
// #255: on a compile game, label how the mod's content actually
219+
// reaches the game dir - "(merged)" rides the merged artifact
220+
// (optimistic for a pak whose conversion then fails; the
221+
// conversion warning + footer carry the correction), "(raw)" is
222+
// a ConvertPaks-opted-out pak deploying itself.
223+
switch p.ModClass {
224+
case core.DeployModMerged:
225+
fmt.Printf(" %s %s (merged)\n", colorGreen("✓"), p.ModName)
226+
case core.DeployModRaw:
227+
fmt.Printf(" %s %s (raw)\n", colorGreen("✓"), p.ModName)
228+
default:
229+
fmt.Printf(" %s %s\n", colorGreen("✓"), p.ModName)
230+
}
189231
case core.DeployNote:
190232
if verbose {
191233
fmt.Printf(" %s\n", p.Detail)
@@ -212,7 +254,11 @@ func doDeploy(ctx context.Context, service *core.Service, game *domain.Game, arg
212254
return nil
213255
}
214256

215-
fmt.Printf("\nDeployed: %d", result.Deployed)
257+
if mergeFooterPrinted {
258+
fmt.Printf("Deployed: %d", result.Deployed)
259+
} else {
260+
fmt.Printf("\nDeployed: %d", result.Deployed)
261+
}
216262
if failed := len(result.Skipped); failed > 0 {
217263
fmt.Printf(", Failed: %d", failed)
218264
}

cmd/lmm/deploy_compile_test.go

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
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

Comments
 (0)