From 27342b097d5cb498868c2ba5559077a72d8b2c90 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:28:35 -0400 Subject: [PATCH 1/2] fix(awol): drop ambiguous LOA count from summary line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smoke-test feedback: "N total · M LOA" misreads as "M LOAs accounted" when M is actually the count of flagged troopers currently on an active LOA — "0 LOA" looked like LOA subtraction hadn't run. Drop the count entirely; the ⚪ rows and the footer ("AWOL days subtracts valid LOA days.") already convey LOA involvement. Summary is now "N flagged" on both healthy and degraded paths; the loud degraded footer still differentiates degraded mode, so "LOA unknown" in the summary was redundant. Simplify awolSummaryLine to take only the flagged count. Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/awol_test.go | 53 ++++++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/commands/awol_test.go b/commands/awol_test.go index d2bf4d4..9dc8ccf 100644 --- a/commands/awol_test.go +++ b/commands/awol_test.go @@ -149,7 +149,7 @@ func TestRunAwol_SmallResultRendersEmbedChunks(t *testing.T) { if embed.Title != "AWOL — 1-7" { t.Fatalf("title = %q, want %q", embed.Title, "AWOL — 1-7") } - if !strings.Contains(desc, "2 total · 0 LOA") { + if !strings.Contains(desc, "2 flagged") { t.Fatalf("summary line missing.\nGot:\n%s", desc) } if embed.Footer == nil || embed.Footer.Text != awolReportFooter { @@ -275,25 +275,26 @@ func lineContaining(s, sub string) string { // // This is a genuine regression guard for the prefix-aware budget // (`chunkBudget := discordEmbedDescriptionLimit - len(descPrefix)`). To trip the -// pre-fix bug a chunk must land in the danger band (4096 − prefixLen, 4096] = -// (4077, 4096], where the raw chunk fits the bare 4096 budget but overflows once -// the prefix is prepended. The earlier version stepped *over* that band with -// coarse ~139-byte lines and so passed even against the un-fixed budget. +// pre-fix bug a chunk must land in the danger band (4096 − prefixLen, 4096], where +// the raw chunk fits the bare 4096 budget but overflows once the prefix is +// prepended. The summary prefix is now "N flagged\n\n"; for N=45 that is +// "45 flagged\n\n" = 12 bytes, so the danger band is (4084, 4096]. Sizing targets +// that band exactly so the test still exercises the prefix reservation. // // Sizing math (all bytes): -// - Each no-LOA row renders as "🔴 [U%02d_x](https://7cav.us/rosters/profile/) — 68d AWOL · last post 75d\n". -// With pad="x", 2-digit user index, 3-digit milpac id, and the 68/75 day +// - Each no-LOA row renders as "🔴 [U%02d_<12x>](https://7cav.us/rosters/profile/) — 68d AWOL · last post 75d\n". +// With a 12-char pad, 2-digit user index, 3-digit milpac id, and the 68/75 day // figures this calc produces for a 2026-03-01 post at awolRefDate -// (DaysAWOL=68, raw=75 — both 2 digits), every row is exactly 80 bytes. -// - Summary prefix "51 total · 0 LOA\n\n" = 19 bytes → danger band (4077, 4096]. -// - 51 rows = 4080 raw bytes. The buggy budget (4096) packs all 51 into one -// chunk; description = 19 + 4080 = 4099 > 4096 → FAILS pre-fix. -// - The fixed budget (4096 − 19 = 4077) flushes after 50 rows = 4000 bytes; -// description = 19 + 4000 = 4019 ≤ 4096 → PASSES. (2 chunks ≤ maxEmbedsPerMsg +// (DaysAWOL=68, raw=75 — both 2 digits), every row is exactly 91 bytes. +// - Summary prefix "45 flagged\n\n" = 12 bytes → danger band (4084, 4096]. +// - 45 rows = 4095 raw bytes. The buggy budget (4096) packs all 45 into one +// chunk; description = 12 + 4095 = 4107 > 4096 → FAILS pre-fix. +// - The fixed budget (4096 − 12 = 4084) flushes after 44 rows = 4004 bytes; +// description = 12 + 4004 = 4016 ≤ 4096 → PASSES. (2 chunks ≤ maxEmbedsPerMsg // so we stay on the embed path, not the file fallback.) const ( - embedLimitTestUsers = 51 - embedLimitTestPad = 1 // → 80-byte rows; see sizing math above + embedLimitTestUsers = 45 + embedLimitTestPad = 12 // → 91-byte rows; see sizing math above ) func TestRunAwol_EmbedDescriptionWithinDiscordLimit(t *testing.T) { @@ -502,8 +503,8 @@ func TestRunAwol_ActiveLOAStillAWOL(t *testing.T) { if !strings.Contains(line, "12d AWOL · last post 25d") { t.Fatalf("expected '12d AWOL · last post 25d'.\nGot line: %q", line) } - if !strings.Contains(desc, "1 total · 1 LOA") { - t.Fatalf("summary should report 1 LOA.\nGot:\n%s", desc) + if !strings.Contains(desc, "1 flagged") { + t.Fatalf("summary should report 1 flagged.\nGot:\n%s", desc) } } @@ -543,13 +544,9 @@ func TestRunAwol_ExpiredLOASubtractedNotTagged(t *testing.T) { if strings.Index(desc, "Vasquez.A") > strings.Index(desc, "Tanner.K") { t.Fatalf("no-LOA Vasquez.A should outrank LOA-subtracted Tanner.K.\nGot:\n%s", desc) } - if !strings.Contains(desc, "1 LOA") && !strings.Contains(desc, "0 LOA") { + if !strings.Contains(desc, "2 flagged") { t.Fatalf("summary line missing.\nGot:\n%s", desc) } - // No active LOA → 0 LOA. - if !strings.Contains(desc, "· 0 LOA") { - t.Fatalf("expired LOA must not count toward active-LOA tally.\nGot:\n%s", desc) - } } // TestRunAwol_FullyCoveredMemberNotListed pins that a trooper whose entire gap is @@ -583,7 +580,7 @@ func TestRunAwol_FullyCoveredMemberNotListed(t *testing.T) { if !strings.Contains(desc, "Bare.B") { t.Fatalf("uncovered member must be listed.\nGot:\n%s", desc) } - if !strings.Contains(desc, "1 total") { + if !strings.Contains(desc, "1 flagged") { t.Fatalf("summary should count only listed members.\nGot:\n%s", desc) } } @@ -631,12 +628,12 @@ func TestRunAwol_UnhealthyCacheRendersRawFallback(t *testing.T) { if strings.Contains(desc, "LOA]") || strings.Contains(desc, "threads/4242") || strings.Contains(desc, "⚪") { t.Fatalf("no LOA decoration on degraded path.\nGot:\n%s", desc) } - // Summary must not assert a concrete LOA count. - if strings.Contains(desc, "· 0 LOA") || strings.Contains(desc, "· 1 LOA") { - t.Fatalf("degraded summary must not report a concrete LOA count.\nGot:\n%s", desc) + // Summary is just the flagged count — no LOA token on either path. + if !strings.Contains(desc, "2 flagged") { + t.Fatalf("degraded summary should report the flagged count.\nGot:\n%s", desc) } - if !strings.Contains(desc, "LOA unknown") { - t.Fatalf("degraded summary should mark LOA unknown.\nGot:\n%s", desc) + if strings.Contains(desc, "LOA unknown") || strings.Contains(desc, " LOA\n") || strings.Contains(desc, "· 0 LOA") || strings.Contains(desc, "· 1 LOA") { + t.Fatalf("degraded summary must not carry an LOA count/unknown token.\nGot:\n%s", desc) } // Footer must say the adjustment was SKIPPED, not merely "stale column". if embed.Footer == nil || !strings.Contains(embed.Footer.Text, "SKIPPED") { From 167a758734da7fa89918ad1d6de52697f2f2172c Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:32:47 -0400 Subject: [PATCH 2/2] fix(awol): actually drop LOA count from summary (recover lost edit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit pushed the test changes but not the awol.go change — a stray `git checkout` during local verification reverted awol.go before commit, so the built binary still rendered "N total · M LOA". This re-applies the source change: awolSummaryLine now takes only the flagged count and renders "N flagged"; loaCount aggregation and its threading through runAwol/sendAwolFile/the embed builder are removed. Gate green (re-run as the final step pre-commit). Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/awol.go | 32 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/commands/awol.go b/commands/awol.go index 1602789..5d61723 100644 --- a/commands/awol.go +++ b/commands/awol.go @@ -241,13 +241,6 @@ func runAwol(r utils.InteractionResponder, cache loaCacheReader, now time.Time, return } - loaCount := 0 - for _, u := range awolUsers { - if u.OnLOA() { - loaCount++ - } - } - footerText := awolReportFooter if !cacheHealthy { footerText = awolDegradedFooter @@ -257,7 +250,7 @@ func runAwol(r utils.InteractionResponder, cache loaCacheReader, now time.Time, // summaryLine + "\n\n" + chunk, so the chunk budget must reserve room for that // rendered prefix — otherwise a near-4096 chunk overflows once the summary is // prepended (Discord 400). Reserve the longest prefix any embed could carry. - descPrefix := awolSummaryLine(len(awolUsers), loaCount, cacheHealthy) + "\n\n" + descPrefix := awolSummaryLine(len(awolUsers)) + "\n\n" chunkBudget := discordEmbedDescriptionLimit - len(descPrefix) var chunks []string @@ -277,12 +270,12 @@ func runAwol(r utils.InteractionResponder, cache loaCacheReader, now time.Time, utils.Info("Debug chunks info", "chunks_length", len(chunks), "max_embeds", maxEmbedsPerMsg) if len(chunks) > maxEmbedsPerMsg { utils.Info("⚠️ Too many AWOL users for embeds, falling back to file upload", "count", len(awolUsers)) - sendAwolFile(r, i, awolUsers, position, forceFile, cacheHealthy, loaCount, now) + sendAwolFile(r, i, awolUsers, position, forceFile, cacheHealthy, now) utils.Info("✨ Done!", "command", "Awol") return } else if forceFile { utils.Info("⚠️ Force file output enabled, falling back to embeds", "count", len(awolUsers)) - sendAwolFile(r, i, awolUsers, position, forceFile, cacheHealthy, loaCount, now) + sendAwolFile(r, i, awolUsers, position, forceFile, cacheHealthy, now) utils.Info("✨ Done!", "command", "Awol") return } @@ -292,7 +285,7 @@ func runAwol(r utils.InteractionResponder, cache loaCacheReader, now time.Time, title := awolEmbedTitle(position, idx, len(chunks)) embed := &discordgo.MessageEmbed{ Title: title, - Description: awolSummaryLine(len(awolUsers), loaCount, cacheHealthy) + "\n\n" + chunk, + Description: awolSummaryLine(len(awolUsers)) + "\n\n" + chunk, Color: 0xfbcc29, Footer: &discordgo.MessageEmbedFooter{ Text: footerText, @@ -322,13 +315,12 @@ func awolEmbedTitle(position string, idx, total int) string { return fmt.Sprintf("AWOL — %s (Page %d/%d)", position, idx+1, total) } -// awolSummaryLine renders "N total · M LOA". On the degraded path the LOA count -// is meaningless (no adjustment ran), so it is suppressed rather than shown as 0. -func awolSummaryLine(total, loaCount int, cacheHealthy bool) string { - if !cacheHealthy { - return fmt.Sprintf("%d total · LOA unknown", total) - } - return fmt.Sprintf("%d total · %d LOA", total, loaCount) +// awolSummaryLine renders "N flagged" — the count of AWOL-flagged members. The +// LOA dimension is conveyed by the ⚪ rows and the footer, not a separate count: +// a bare "M LOA" misread as "M LOAs subtracted" (it was really the number of +// flagged troopers currently on an active LOA), so it is dropped on both paths. +func awolSummaryLine(flagged int) string { + return fmt.Sprintf("%d flagged", flagged) } // awolUserLine renders one report row: @@ -354,12 +346,12 @@ func awolUserLine(u AwolUser) string { ) } -func sendAwolFile(r utils.InteractionResponder, i *discordgo.InteractionCreate, awolUsers []AwolUser, position string, forceFile, cacheHealthy bool, loaCount int, now time.Time) { +func sendAwolFile(r utils.InteractionResponder, i *discordgo.InteractionCreate, awolUsers []AwolUser, position string, forceFile, cacheHealthy bool, now time.Time) { var content strings.Builder _, _ = fmt.Fprintf(&content, "AWOL Report for %s\nGenerated: %s\n%s\n\n", position, now.Format("2006-01-02 15:04:05"), - awolSummaryLine(len(awolUsers), loaCount, cacheHealthy)) + awolSummaryLine(len(awolUsers))) if !cacheHealthy { // ADR 0008: surface the degraded warning in the file too — same figures // (raw) and the same "adjustment skipped" wording as the embed.