Skip to content

Commit 7b11636

Browse files
authored
Merge pull request #265 from DonovanMods/dyoung522/fix-259-update-batch-warnings
fix(tui): render success-emitted batch-update warnings in the update-results overlay (#259)
2 parents c77cb11 + 40eb285 commit 7b11636

7 files changed

Lines changed: 275 additions & 24 deletions

File tree

CHANGELOG.md

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

2121
### Fixed
2222

23+
- TUI: warnings emitted by successful updates in an apply-updates batch are
24+
now readable — they render as a trailing section (blank separator, one
25+
line per distinct warning) inside the same "update results" overlay that
26+
lists each update's ✓/✗ line, instead of being folded into an aggregate
27+
the overlay never showed. Identical warnings repeated across the batch
28+
(a merged-pak recompile re-emits the same profile-level asset-conflict
29+
diagnostics for every update that triggers it) are deduped on exact text,
30+
and the status line's one-row `(N warnings)` count matches the deduped
31+
section (#259).
2332
- Deploy output on a compile-mode game (Icarus) no longer presents merged
2433
mods as individual deployments (#255). The header drops the misleading
2534
`using <method>` claim, each mod's `` line is labeled by how its content

internal/tui/actions_provider.go

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -211,12 +211,15 @@ type ActionOutcome struct {
211211
// (mutations.go), the apply-updates batch's confirm-time body, populates
212212
// this today - one "✓ <name> <from> → <to>" line per successful update,
213213
// one "✗ <name>: <error>" line per failed one, in the SAME order the
214-
// batch was applied. Every other ActionProvider call leaves this nil,
215-
// same as ImportedProfile's own "" zero value above - app.go's
216-
// actionDoneMsg handler treats a nil/empty ResultLines as "nothing to
217-
// show" and opens no overlay for it. This is a TUI-side struct, not part
218-
// of the ActionProvider interface itself, so adding it required no
219-
// interface/method change on either provider (coreProvider/
214+
// batch was applied, plus (#259) one trailing section - a blank
215+
// separator, then one line per distinct success-emitted warning - when
216+
// any successful update carried Warnings (see applyUpdatesSequentially's
217+
// doc comment for why they must ride here). Every other ActionProvider
218+
// call leaves this nil, same as ImportedProfile's own "" zero value
219+
// above - app.go's actionDoneMsg handler treats a nil/empty ResultLines
220+
// as "nothing to show" and opens no overlay for it. This is a TUI-side
221+
// struct, not part of the ActionProvider interface itself, so adding it
222+
// required no interface/method change on either provider (coreProvider/
220223
// prototypeProvider): renderers besides the update batch's are free to
221224
// ignore it entirely.
222225
ResultLines []string
@@ -450,26 +453,36 @@ func (p *prototypeProvider) UninstallMod(_ context.Context, item ModItem) (Actio
450453
return ActionOutcome{Message: fmt.Sprintf("Uninstalled %q", item.Name)}, nil
451454
}
452455

456+
// prototypeMergeWarnings returns the canned merge-time diagnostics (#253),
457+
// surfaced on every prototype deploy AND every successful prototype update
458+
// (#259) so --prototype demo mode actually exercises both multi-warning
459+
// paths in actionDoneMsg (app.go): the auto-open warnings overlay (deploy)
460+
// and the update-results overlay's trailing warnings section (update batch -
461+
// applyUpdatesSequentially). Same rationale as prototypeAllSourcesWarning
462+
// (service.go): without these, no prototype mutation ever crosses
463+
// formatOutcomeStatus's "> 1" collapse threshold, leaving those states
464+
// unreachable in the one mode meant to demo every UI state. The strings
465+
// exist purely to exercise the rendering paths; they name assets no canned
466+
// mod actually bundles. Deliberately IDENTICAL on every call - like the real
467+
// profile-level merge diagnostics a per-update recompile re-emits verbatim -
468+
// so a multi-update prototype batch also demos the section's exact-text
469+
// dedupe (one section, not one copy per mod). Returns a fresh slice per
470+
// call so no caller ever aliases another outcome's Warnings.
471+
func prototypeMergeWarnings() []string {
472+
return []string{
473+
`asset "textures/armor/steel.dds" is bundled by both SkyUI and Ordinator - Ordinator wins (last-applied, per profile load order)`,
474+
`asset "textures/armor/steel_n.dds" is bundled by both SkyUI and Ordinator - Ordinator wins (last-applied, per profile load order)`,
475+
}
476+
}
477+
453478
func (p *prototypeProvider) DeployProfile(_ context.Context) (ActionOutcome, error) {
454479
deployed := 0
455480
for _, mod := range p.activeMods() {
456481
if mod.Status != "disabled" {
457482
deployed++
458483
}
459484
}
460-
// Canned merge-time diagnostics (#253), surfaced on every prototype
461-
// deploy so --prototype demo mode actually exercises the multi-warning
462-
// auto-open overlay path (actionDoneMsg, app.go) - the same rationale as
463-
// prototypeAllSourcesWarning (service.go): without these, no prototype
464-
// mutation ever crosses formatOutcomeStatus's "> 1" collapse threshold,
465-
// leaving the overlay unreachable in the one mode meant to demo every UI
466-
// state. Like that constant, the strings exist purely to exercise the
467-
// rendering path; they name assets no canned mod actually bundles.
468-
warnings := []string{
469-
`asset "textures/armor/steel.dds" is bundled by both SkyUI and Ordinator - Ordinator wins (last-applied, per profile load order)`,
470-
`asset "textures/armor/steel_n.dds" is bundled by both SkyUI and Ordinator - Ordinator wins (last-applied, per profile load order)`,
471-
}
472-
return ActionOutcome{Message: fmt.Sprintf("Deployed %d mod(s)", deployed), Warnings: warnings}, nil
485+
return ActionOutcome{Message: fmt.Sprintf("Deployed %d mod(s)", deployed), Warnings: prototypeMergeWarnings()}, nil
473486
}
474487

475488
// activeProfileName returns the canned Profiles entry currently marked
@@ -784,7 +797,9 @@ func (p *prototypeProvider) CheckUpdates(_ context.Context) (UpdatesView, error)
784797
// ApplyUpdate emits the brief's own fake progress sequence, then bumps the
785798
// matching InstalledMods entry's Version to u.ToVersion and clears its
786799
// AvailableVersion - so a repeated CheckUpdates no longer reports it,
787-
// mirroring a real update's "already up to date" outcome.
800+
// mirroring a real update's "already up to date" outcome. The canned merge
801+
// warnings demo #259's results-overlay warnings section - see
802+
// prototypeMergeWarnings' doc comment.
788803
func (p *prototypeProvider) ApplyUpdate(_ context.Context, u UpdateItem, progress func(ActionProgress)) (ActionOutcome, error) {
789804
idx := p.findInstalledIndex(u.Source, u.ID)
790805
if idx < 0 {
@@ -798,7 +813,7 @@ func (p *prototypeProvider) ApplyUpdate(_ context.Context, u UpdateItem, progres
798813
mods[idx].AvailableVersion = ""
799814
mods[idx].Status = "installed"
800815

801-
return ActionOutcome{Message: fmt.Sprintf("Updated %q to %s", u.Name, u.ToVersion)}, nil
816+
return ActionOutcome{Message: fmt.Sprintf("Updated %q to %s", u.Name, u.ToVersion), Warnings: prototypeMergeWarnings()}, nil
802817
}
803818

804819
// isValidUpdatePolicy reports whether policy is one of the three strings

internal/tui/actions_provider_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,35 @@ func TestPrototypeProviderActions_ApplyUpdate_UnknownModErrors(t *testing.T) {
532532
assert.Error(t, err)
533533
}
534534

535+
// TestPrototypeProviderActions_ApplyUpdate_CannedMultiWarningDemo guards
536+
// #259's demo-mode parity, mirroring
537+
// TestPrototypeProviderActions_DeployProfile_CannedMultiWarningDemo (#253): a
538+
// successful prototype update must return MORE than one canned merge warning
539+
// so --prototype exercises the update-results overlay's trailing warnings
540+
// section (applyUpdatesSequentially) - and the SAME canned text on every
541+
// update, so a multi-update batch demos the exact-text dedupe too (one
542+
// section, not one copy per mod - exactly how a real per-update recompile
543+
// repeats the profile-level merge diagnostics).
544+
func TestPrototypeProviderActions_ApplyUpdate_CannedMultiWarningDemo(t *testing.T) {
545+
t.Parallel()
546+
547+
actions := NewPrototypeProvider().(ActionProvider)
548+
549+
view, err := actions.CheckUpdates(context.Background())
550+
require.NoError(t, err)
551+
require.GreaterOrEqual(t, len(view.Updates), 2, "the canned set has two available updates (see prototype/data.go)")
552+
553+
first, err := actions.ApplyUpdate(context.Background(), view.Updates[0], nil)
554+
require.NoError(t, err)
555+
assert.Greater(t, len(first.Warnings), 1,
556+
"the prototype update must demo the multi-warning results section")
557+
558+
second, err := actions.ApplyUpdate(context.Background(), view.Updates[1], nil)
559+
require.NoError(t, err)
560+
assert.Equal(t, first.Warnings, second.Warnings,
561+
"identical canned text per update, so a batch demos exact-text dedupe")
562+
}
563+
535564
// TestPrototypeRollbackSwapsVersions covers Task 6's rollback demo: the
536565
// canned "skse-address-library" InstalledMods entry (see prototype/data.go's
537566
// PreviousVersion doc comment) has Version "11"/PreviousVersion "10" -
@@ -916,6 +945,12 @@ type recordingActions struct {
916945
// mid-batch update failure (one mod in a multi-update apply fails,
917946
// others succeed) without needing per-call outcome sequencing.
918947
ApplyUpdateErrByID map[string]error
948+
949+
// ApplyUpdateOutcomeByID, if set, overrides ApplyUpdateOutcome for a
950+
// specific UpdateItem.ID's successful return - lets a #259 test give
951+
// each update in a batch its own Warnings without per-call outcome
952+
// sequencing. ApplyUpdateErrByID still wins for an ID present in both.
953+
ApplyUpdateOutcomeByID map[string]ActionOutcome
919954
}
920955

921956
func (r *recordingActions) EnableMod(_ context.Context, item ModItem) (ActionOutcome, error) {
@@ -983,6 +1018,9 @@ func (r *recordingActions) ApplyUpdate(_ context.Context, u UpdateItem, progress
9831018
if err, ok := r.ApplyUpdateErrByID[u.ID]; ok {
9841019
return ActionOutcome{}, err
9851020
}
1021+
if out, ok := r.ApplyUpdateOutcomeByID[u.ID]; ok {
1022+
return out, nil
1023+
}
9861024
return r.ApplyUpdateOutcome, r.ApplyUpdateErr
9871025
}
9881026

internal/tui/actions_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,12 @@ func TestActionDoneMultiWarningOutcomeReplacesStaleReadOnlyOverlay(t *testing.T)
406406
// priority when an outcome carries BOTH ResultLines and 2+ Warnings (today
407407
// only the apply-updates batch can): the pre-existing "update results"
408408
// overlay wins, and the warnings overlay defers rather than clobbering the
409-
// batch's per-item record.
409+
// batch's per-item record. This deferral is lossless since #259: the batch
410+
// embeds its success-emitted warnings inside ResultLines as a trailing
411+
// section (applyUpdatesSequentially), so the winning overlay already carries
412+
// them - this test's hand-built outcome deliberately omits that section
413+
// because the priority decision is what's pinned here, not the lines'
414+
// content.
410415
func TestActionDoneResultLinesKeepPriorityOverWarningsOverlay(t *testing.T) {
411416
t.Parallel()
412417

internal/tui/app.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
620620
// gate on m.action.running - the Files overlay is the reachable
621621
// case), and deferring to it would silently re-lose the warnings
622622
// (Copilot PR #258 finding), so a stale overlay is replaced instead.
623+
// This deferral loses nothing (#259): the update batch embeds its
624+
// success-emitted warnings INSIDE ResultLines as a trailing section
625+
// (applyUpdatesSequentially, mutations.go), so the overlay that wins
626+
// already carries them.
623627
if len(msg.outcome.Warnings) > 1 && !openedResultsOverlay {
624628
m.overlay = &infoOverlay{title: "warnings", lines: msg.outcome.Warnings}
625629
}

internal/tui/mutations.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2297,10 +2297,29 @@ func (m Model) resolveChangelogPicked(msg changelogPickedMsg) (Model, tea.Cmd) {
22972297
// behavior just above: those mods never ran, which isn't the same thing as
22982298
// failing). app.go's actionDoneMsg handler renders these as a scrollable
22992299
// info overlay titled "update results" once the batch resolves.
2300+
//
2301+
// #259: warnings emitted by SUCCESSFUL updates are appended to ResultLines
2302+
// as one trailing section - a blank separator, then one line per distinct
2303+
// warning - because the "update results" overlay keeps priority over #253's
2304+
// warnings overlay (actionDoneMsg's openedResultsOverlay deferral), so any
2305+
// warning NOT inside ResultLines would be unreadable: a success's own entry
2306+
// is just its ✓ line. The section holds success-emitted warnings only - a
2307+
// failure's synthesized "<name>: <error>" warning already has its ✗ line in
2308+
// the same overlay and would render twice. These warnings are deduped on
2309+
// EXACT text (first occurrence wins, batch order) in both the section and
2310+
// the aggregate Warnings slice - keeping formatOutcomeStatus's "(N
2311+
// warnings)" count in step with the section - because they are mostly
2312+
// profile-LEVEL merge diagnostics ("asset X is bundled by both A and B"),
2313+
// re-emitted verbatim by every update that re-runs the merge, not facts
2314+
// about any one update; exact-match only, so distinct warnings that merely
2315+
// share a prefix never collapse into each other. Failure warnings are never
2316+
// deduped (their name prefix makes them distinct anyway).
23002317
func applyUpdatesSequentially(ctx context.Context, actions ActionProvider, updates []UpdateItem, progress func(ActionProgress)) (ActionOutcome, error) {
23012318
applied := 0
23022319
var warnings []string
23032320
var resultLines []string
2321+
var successWarnings []string
2322+
seenSuccessWarnings := map[string]bool{}
23042323
for _, u := range updates {
23052324
if ctx.Err() != nil {
23062325
break
@@ -2325,9 +2344,20 @@ func applyUpdatesSequentially(ctx context.Context, actions ActionProvider, updat
23252344
continue
23262345
}
23272346
applied++
2328-
warnings = append(warnings, outcome.Warnings...)
2347+
for _, w := range outcome.Warnings {
2348+
if seenSuccessWarnings[w] {
2349+
continue
2350+
}
2351+
seenSuccessWarnings[w] = true
2352+
warnings = append(warnings, w)
2353+
successWarnings = append(successWarnings, w)
2354+
}
23292355
resultLines = append(resultLines, fmt.Sprintf("✓ %s %s", u.Name, u.VersionLabel()))
23302356
}
2357+
if len(successWarnings) > 0 {
2358+
resultLines = append(resultLines, "")
2359+
resultLines = append(resultLines, successWarnings...)
2360+
}
23312361
return ActionOutcome{
23322362
Message: fmt.Sprintf("Applied %d update(s)", applied),
23332363
Warnings: warnings,

0 commit comments

Comments
 (0)