Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 12 additions & 20 deletions commands/awol.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand Down
53 changes: 25 additions & 28 deletions commands/awol_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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/<id>) — 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/<id>) — 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) {
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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") {
Expand Down