From 6dbd4d3db76453de1c9e142740e61b87e21af5f6 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:41:41 -0400 Subject: [PATCH 1/6] feat(awol): accountable-day count + days-AWOL severity report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #159 (parent PRD #157, ADR 0008). /awol now flags on accountable days โ€” UTC calendar dates in (lastPost, today] not covered by a valid LOA window โ€” instead of raw time since last forum post, and renders the agreed days-AWOL severity report. - New pure calc utils.AccountableDaysAWOL(lastPost, now, []LOAEntry) int: candidate set minus the union of inclusive [Start,End] LOA ranges, overage past the 7-day requirement. Overlap/adjacent/multiple/future windows fall out of set coverage; backwards ranges are ignored and DEBUG-logged. Exhaustively table-tested. Companion utils.RawDaysAWOL / utils.DaysSinceLastPost and a clock-injected utils.ActiveWindow selector. - loaCacheReader gains GetEntries (drops GetEntry); diverges from /loa's loaCacheView, which stays {GetEntry, IsHealthy} and single-window (#161 #6). - AwolUser models the LOA backing as *utils.LOAEntry (nil = none) instead of the zero-value LOAEntry + HasLOAEntry pair (#161 #4). - Active-window selection and the accountable-day verdict both run against the handler's single injected now off one GetEntries read per user, so selection and verdict can't disagree at a boundary (#161 #2). - Render: worst-first by days AWOL (tie-break username), severity glyphs (๐Ÿ”ด >14 ยท ๐ŸŸ  >7 ยท ๐ŸŸก >0 ยท โšช active LOA), milpac + [LOA] thread links, "N total ยท M LOA" summary, "AWOL days subtracts valid LOA days." footer. - Degraded mode (unhealthy cache): raw inactivity, NO LOA subtraction, loud "accountable-day adjustment SKIPPED; LOA NOT subtracted" warning on both embed and file paths; summary reads "LOA unknown", never a silent non-LOA all-clear. /loa is unchanged. No-LOA troopers render raw == accountable as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/awol.go | 268 +++++++++++------- commands/awol_test.go | 504 ++++++++++++++++++++------------- utils/awol_accountable.go | 133 +++++++++ utils/awol_accountable_test.go | 256 +++++++++++++++++ 4 files changed, 855 insertions(+), 306 deletions(-) create mode 100644 utils/awol_accountable.go create mode 100644 utils/awol_accountable_test.go diff --git a/commands/awol.go b/commands/awol.go index bf07092..5fa3363 100644 --- a/commands/awol.go +++ b/commands/awol.go @@ -16,44 +16,71 @@ func stringPtr(s string) *string { return &s } -const ( - maxEmbedsPerMsg = 10 - awolThresholdDays = 8 -) +const maxEmbedsPerMsg = 10 + +// awolReportFooter explains the displayed figure: days AWOL already has valid +// LOA-covered days subtracted (ADR 0008). Rendered on the healthy path. +const awolReportFooter = "AWOL days subtracts valid LOA days." + +// awolDegradedFooter is the loud degraded-mode warning (#96 machinery, reworded +// for ADR 0008): staff must know the accountable-day adjustment was SKIPPED and +// the figures are raw whole-date inactivity, not merely that a column is stale. +// Never silently treats everyone as non-LOA. +const awolDegradedFooter = "โš ๏ธ LOA cache unavailable โ€” accountable-day adjustment SKIPPED; figures are raw inactivity (LOA NOT subtracted)." -// loaCacheUnavailableFooter is the warning appended to /awol output (#96) when -// the LOA cache is unhealthy. Styled after loaUnavailableMessage; the On LOA -// column is rendered "unknown" rather than a misleading "false" in this state. -const loaCacheUnavailableFooter = "โš ๏ธ LOA cache unavailable; On LOA column may be stale." - -// loaCacheReader is the minimal LOA-cache surface /awol consumes. Production -// wires *utils.LOACache (GlobalLOACache); tests substitute a fake with canned -// entries and a forced health verdict so the handler stays deterministic -// without touching the process-global singleton. /awol reads each member's LOA -// state with a SINGLE GetEntry call and derives both the On LOA verdict -// (entry.IsActive) and the [[LOA]] link from that one snapshot (#158/S4), so the -// two can't disagree across a concurrent refresh. IsHealthy gates whether the -// per-member On LOA column can be trusted (#96): it is an age check on the last -// successful refresh, so an unhealthy cache is stale, not empty โ€” GetEntry may -// still return (now possibly outdated) windows. When unhealthy, the render path -// gates the column to "unknown" rather than trusting those stale reads. +// loaCacheReader is the LOA-cache surface /awol consumes. Production wires +// *utils.LOACache (GlobalLOACache); tests substitute a fake. GetEntries returns +// the full retained window history for a username (clock-free); /awol applies its +// own injected `now` to that history for the accountable-day calc AND for active- +// window selection, so selection and verdict can never disagree at a boundary +// (PR #161 clock-skew item). IsHealthy gates degraded mode (#96): an unhealthy +// cache is stale, so /awol falls back to raw inactivity with no LOA subtraction. +// +// This is intentionally a SUPERSET of /loa's loaCacheView ({GetEntry,IsHealthy}): +// /awol needs the full history to subtract LOA-covered dates, /loa renders a +// single most-relevant window. Keeping them distinct keeps each command's +// dependency scoped to what it reads. type loaCacheReader interface { - GetEntry(username string) (utils.LOAEntry, bool) + GetEntries(username string) []utils.LOAEntry IsHealthy(maxAge time.Duration) (bool, time.Time) } type AwolUser struct { - Username string - MilpacUrl string - TimeSinceLastPost string - LastPostDate time.Time - OnLOA bool - // LOAEntry is the single cache snapshot read for this user (see the GetEntry - // call in runAwol). HasLOAEntry records whether that read found a window, so - // the [[LOA]] link can reuse the same snapshot instead of a second, possibly - // inconsistent, cache lookup. OnLOA is derived from this snapshot's window. - LOAEntry utils.LOAEntry - HasLOAEntry bool + Username string + MilpacUrl string + LastPostDate time.Time + // DaysAWOL is the displayed figure: accountable dates past the 7-day + // requirement (LOA-subtracted on the healthy path, raw inactivity when + // degraded). Always > 0 for a listed user. + DaysAWOL int + // RawDaysSincePost is the raw whole-date inactivity span, shown as the + // "last post Nd" secondary context regardless of LOA subtraction. + RawDaysSincePost int + // loaWindow is the active LOA window backing this row's โšช glyph and thread + // link, or nil when the trooper is not currently on LOA. Modeled as a pointer + // (not a value + bool pair) so a caller can't read a meaningless zero window + // without the nil discriminator (PR #161 optional-field item). + loaWindow *utils.LOAEntry +} + +// OnLOA reports whether the trooper is currently on an active LOA (which forces +// the โšช glyph and a thread link regardless of days AWOL). +func (u AwolUser) OnLOA() bool { return u.loaWindow != nil } + +// severityGlyph picks the row glyph: an active LOA always overrides to โšช +// ("on LOA, still AWOL"); otherwise by days-AWOL tier (ADR 0008). +func (u AwolUser) severityGlyph() string { + if u.OnLOA() { + return "โšช" + } + switch { + case u.DaysAWOL > 14: + return "๐Ÿ”ด" + case u.DaysAWOL > 7: + return "๐ŸŸ " + default: // > 0 by construction (listed users only) + return "๐ŸŸก" + } } func Awol() Command { @@ -106,16 +133,15 @@ func runAwol(r utils.InteractionResponder, cache loaCacheReader, now time.Time, return } - // #96: probe cache health once per invocation. IsHealthy is an age check on - // the last successful refresh, so an unhealthy cache is stale, not empty โ€” - // per-user GetEntry reads may still return (possibly outdated) windows that - // the On LOA column would otherwise present as current truth. We do NOT abort - // โ€” /awol's primary signal (lastForumPostDate) is independent โ€” but the render - // path below gates the column to "unknown" via cacheHealthy and warns. No + // #96 / ADR 0008: probe cache health once per invocation. When unhealthy the + // LOA history can't be trusted, so we degrade LOUDLY โ€” compute raw whole-date + // inactivity with NO LOA subtraction, still flag > 7, and warn that the + // accountable-day adjustment was skipped. We never abort (the last-forum-post + // signal is independent) and never silently treat everyone as non-LOA. No // Sentry capture: operational degradation, not an internal error (ADR 0001). cacheHealthy, lastRefresh := cache.IsHealthy(loaCacheMaxAge) if !cacheHealthy { - utils.Debug("AWOL served with unhealthy LOA cache", + utils.Debug("AWOL served with unhealthy LOA cache (accountable-day adjustment skipped)", "command", "Awol", "username", i.Member.User.Username, "discord_id", i.Member.User.ID, @@ -135,7 +161,6 @@ func runAwol(r utils.InteractionResponder, cache loaCacheReader, now time.Time, return } - awolThreshold := now.AddDate(0, 0, -awolThresholdDays) awolUsers := []AwolUser{} for _, member := range roster.LiteProfiles { if member.User.Username == "Tester.B" || strings.Contains(member.Rank.RankFull, "General") { @@ -146,31 +171,50 @@ func runAwol(r utils.InteractionResponder, cache loaCacheReader, now time.Time, utils.HandleError(r, i, fmt.Sprintf("โŒ Failed to parse last forum post date: %v", err)) return } - if lastPostDate.Before(awolThreshold) { + + rawDays := utils.DaysSinceLastPost(lastPostDate, now) + + var daysAWOL int + var loaWindow *utils.LOAEntry + if cacheHealthy { + // Read the full retained history ONCE and apply our injected `now` to + // both the accountable-day calc and active-window selection, so the + // flag, the figure, and the [[LOA]] link can't disagree (PR #161). + windows := cache.GetEntries(member.User.Username) + daysAWOL = utils.AccountableDaysAWOL(lastPostDate, now, windows) + if active, ok := utils.ActiveWindow(windows, now); ok { + w := active + loaWindow = &w + } + } else { + // Degraded: raw inactivity overage, no LOA subtraction, no LOA decoration. + daysAWOL = utils.RawDaysAWOL(lastPostDate, now) + } + + if daysAWOL > 0 { matches := regexp.MustCompile(`/\d+/(\d+)\.jpg`).FindStringSubmatch(member.UniformUrl) if len(matches) < 2 { utils.HandleError(r, i, "โŒ Failed to parse uniform URL") return } - // #158/S4: read the LOA cache ONCE per user. Deriving OnLOA from this - // same snapshot (rather than a separate active-window lookup) means the - // On LOA verdict and the [[LOA]] link below can never disagree, even if a - // 15-min refresh lands mid-loop now that ended windows are retained. - entry, hasEntry := cache.GetEntry(member.User.Username) awolUsers = append(awolUsers, AwolUser{ - Username: member.User.Username, - MilpacUrl: fmt.Sprintf("https://7cav.us/rosters/profile/%s", matches[1]), - TimeSinceLastPost: utils.FormatTimeSinceDuration(lastPostDate), - LastPostDate: lastPostDate, - OnLOA: hasEntry && entry.IsActive(now), - LOAEntry: entry, - HasLOAEntry: hasEntry, + Username: member.User.Username, + MilpacUrl: fmt.Sprintf("https://7cav.us/rosters/profile/%s", matches[1]), + LastPostDate: lastPostDate, + DaysAWOL: daysAWOL, + RawDaysSincePost: rawDays, + loaWindow: loaWindow, }) } } - sort.Slice(awolUsers, func(i, j int) bool { - return awolUsers[i].LastPostDate.Before(awolUsers[j].LastPostDate) + // Sort worst-first by days AWOL; tie-break username ascending so the order is + // stable and deterministic for tests and staff. + sort.Slice(awolUsers, func(a, b int) bool { + if awolUsers[a].DaysAWOL != awolUsers[b].DaysAWOL { + return awolUsers[a].DaysAWOL > awolUsers[b].DaysAWOL + } + return awolUsers[a].Username < awolUsers[b].Username }) if len(awolUsers) == 0 { @@ -184,39 +228,16 @@ func runAwol(r utils.InteractionResponder, cache loaCacheReader, now time.Time, } loaCount := 0 - if cacheHealthy { - for _, u := range awolUsers { - if u.OnLOA { - loaCount++ - } + for _, u := range awolUsers { + if u.OnLOA() { + loaCount++ } } var chunks []string currentChunk := "" for _, user := range awolUsers { - // #96: only trust the cache lookup when it's healthy. When unhealthy, - // mark every row "On LOA: unknown" and suppress the [LOA] decoration so - // the column never silently reads all-clear. - var loaTag string - switch { - case !cacheHealthy: - loaTag = "On LOA: unknown โ€” " - case user.OnLOA: - // Reuse the single per-user snapshot captured above โ€” no second cache - // read โ€” so the link can't point at a thread the row's OnLOA disagrees with. - if user.HasLOAEntry && user.LOAEntry.ThreadID != 0 { - loaTag = fmt.Sprintf("**[[LOA]](https://7cav.us/threads/%d/)** ", user.LOAEntry.ThreadID) - } else { - loaTag = "**[LOA]** " - } - } - userLine := fmt.Sprintf("%s[%s](%s) (%s)\n", - loaTag, - user.Username, - user.MilpacUrl, - user.TimeSinceLastPost) - + userLine := awolUserLine(user) if len(currentChunk)+len(userLine) > 4096 { chunks = append(chunks, currentChunk) currentChunk = userLine @@ -230,28 +251,27 @@ 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, now) + sendAwolFile(r, i, awolUsers, position, forceFile, cacheHealthy, loaCount, 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, now) + sendAwolFile(r, i, awolUsers, position, forceFile, cacheHealthy, loaCount, now) utils.Info("โœจ Done!", "command", "Awol") return } - // #96: when the cache is unhealthy the loaCount is meaningless, so report - // the warning instead of a concrete "(N on LOA)" tally. - footerText := fmt.Sprintf("Total AWOL: %d (%d on LOA)", len(awolUsers), loaCount) + footerText := awolReportFooter if !cacheHealthy { - footerText = fmt.Sprintf("Total AWOL: %d โ€” %s", len(awolUsers), loaCacheUnavailableFooter) + footerText = awolDegradedFooter } var embeds []*discordgo.MessageEmbed for idx, chunk := range chunks { + title := awolEmbedTitle(position, idx, len(chunks)) embed := &discordgo.MessageEmbed{ - Title: fmt.Sprintf("AWOL Users for %s (Page %d/%d)", position, idx+1, len(chunks)), - Description: chunk, + Title: title, + Description: awolSummaryLine(len(awolUsers), loaCount, cacheHealthy) + "\n\n" + chunk, Color: 0xfbcc29, Footer: &discordgo.MessageEmbedFooter{ Text: footerText, @@ -272,32 +292,74 @@ func runAwol(r utils.InteractionResponder, cache loaCacheReader, now time.Time, utils.Info("โœจ Done!", "command", "Awol") } -func sendAwolFile(r utils.InteractionResponder, i *discordgo.InteractionCreate, awolUsers []AwolUser, position string, forceFile, cacheHealthy bool, now time.Time) { +// awolEmbedTitle renders the "AWOL โ€” " heading, paginated only when the +// list spans multiple embeds. +func awolEmbedTitle(position string, idx, total int) string { + if total <= 1 { + return fmt.Sprintf("AWOL โ€” %s", position) + } + 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) +} + +// awolUserLine renders one report row: +// +// ๐Ÿ”ด Snuffy.B โ€” 16d AWOL ยท last post 23d +// โšช [LOA] Reyes.J โ€” 1d AWOL ยท last post 8d +// +// The name links to the milpac; an active-LOA row prefixes a [LOA] thread link. +func awolUserLine(u AwolUser) string { + loaTag := "" + if u.OnLOA() && u.loaWindow.ThreadID != 0 { + loaTag = fmt.Sprintf("[[LOA]](https://7cav.us/threads/%d/) ", u.loaWindow.ThreadID) + } else if u.OnLOA() { + loaTag = "[LOA] " + } + return fmt.Sprintf("%s %s[%s](%s) โ€” %dd AWOL ยท last post %dd\n", + u.severityGlyph(), + loaTag, + u.Username, + u.MilpacUrl, + u.DaysAWOL, + u.RawDaysSincePost, + ) +} + +func sendAwolFile(r utils.InteractionResponder, i *discordgo.InteractionCreate, awolUsers []AwolUser, position string, forceFile, cacheHealthy bool, loaCount int, now time.Time) { var content strings.Builder - _, _ = fmt.Fprintf(&content, "AWOL Report for %s\nGenerated: %s\n\n", + _, _ = fmt.Fprintf(&content, "AWOL Report for %s\nGenerated: %s\n%s\n\n", position, - now.Format("2006-01-02 15:04:05")) + now.Format("2006-01-02 15:04:05"), + awolSummaryLine(len(awolUsers), loaCount, cacheHealthy)) if !cacheHealthy { - // #96: surface the cache-unavailable warning in the file too. - _, _ = fmt.Fprintf(&content, "%s\n\n", loaCacheUnavailableFooter) + // ADR 0008: surface the degraded warning in the file too โ€” same figures + // (raw) and the same "adjustment skipped" wording as the embed. + _, _ = fmt.Fprintf(&content, "%s\n\n", awolDegradedFooter) } for _, user := range awolUsers { - // #96: only trust the OnLOA snapshot when the cache is healthy; otherwise - // the column is unknown, not all-clear. loaTag := "" - switch { - case !cacheHealthy: - loaTag = " (On LOA: unknown)" - case user.OnLOA: + if user.OnLOA() { loaTag = " [LOA]" } - _, _ = fmt.Fprintf(&content, "%s%s - %s\nMilpac: %s\n\n", + _, _ = fmt.Fprintf(&content, "%s%s โ€” %dd AWOL ยท last post %dd\nMilpac: %s\n\n", user.Username, loaTag, - user.TimeSinceLastPost, + user.DaysAWOL, + user.RawDaysSincePost, user.MilpacUrl) } + if cacheHealthy { + _, _ = fmt.Fprintf(&content, "%s\n", awolReportFooter) + } file := &discordgo.File{ Name: fmt.Sprintf("awol_report_%s.txt", strings.ReplaceAll(position, "/", "-")), diff --git a/commands/awol_test.go b/commands/awol_test.go index 4eea278..8a5cdfc 100644 --- a/commands/awol_test.go +++ b/commands/awol_test.go @@ -14,8 +14,10 @@ import ( "github.com/bwmarrin/discordgo" ) -// awolRefDate pins "now" for /awol tests. With awolThresholdDays=8 the AWOL -// cutoff lands at 2026-05-07; LastForumPostDate values older than that are AWOL. +// awolRefDate pins "now" for /awol tests at 2026-05-15 12:00 UTC. With the +// accountable-day model, a member is flagged when accountable dates exceed 7, +// so LastForumPostDate values more than 7 UTC dates before this (and not covered +// by LOA) are AWOL. var awolRefDate = mustParseAwolDate("2026-05-15 12:00:00") func mustParseAwolDate(s string) time.Time { @@ -53,52 +55,33 @@ func serveAwolRoster(t *testing.T, roster utils.LiteRosterResponse, rosterStatus // fakeLOACache is a deterministic loaCacheReader for /awol integration tests. // entries are keyed by lowercased username (matches the production cache's -// case-folding). The handler derives OnLOA from GetEntry's window via -// IsActive(now), so entries whose dates straddle the injected `now` read active. -// healthy/lastRefresh drive the IsHealthy staleness guard; the zero value is -// unhealthy, so existing tests that want the original behavior should construct -// via healthyCache. +// case-folding) and hold the FULL retained window history per user โ€” /awol +// applies its own injected `now` to that history for the accountable-day calc and +// active-window selection. healthy/lastRefresh drive the IsHealthy staleness +// guard; the zero value is unhealthy, so existing tests that want the original +// behavior should construct via healthyCache. type fakeLOACache struct { - entries map[string]utils.LOAEntry + entries map[string][]utils.LOAEntry healthy bool lastRefresh time.Time } -func (f *fakeLOACache) GetEntry(username string) (utils.LOAEntry, bool) { - e, ok := f.entries[strings.ToLower(username)] - return e, ok +func (f *fakeLOACache) GetEntries(username string) []utils.LOAEntry { + return f.entries[strings.ToLower(username)] } func (f *fakeLOACache) IsHealthy(_ time.Duration) (bool, time.Time) { return f.healthy, f.lastRefresh } -// healthyCache builds a healthy fakeLOACache from the given entries (keyed by -// lowercased username), with lastRefresh pinned at awolRefDate. -func healthyCache(entries map[string]utils.LOAEntry) *fakeLOACache { +// healthyCache builds a healthy fakeLOACache from the given window history (keyed +// by lowercased username), with lastRefresh pinned at awolRefDate. +func healthyCache(entries map[string][]utils.LOAEntry) *fakeLOACache { return &fakeLOACache{entries: entries, healthy: true, lastRefresh: awolRefDate} } -// dateAwareLOACache is a loaCacheReader returning entries whose StartDate/EndDate -// windows are meaningful relative to a FIXED `at` instant. The handler derives -// OnLOA from GetEntry's window via IsActive(now), so this lets an /awol test -// exercise the "entry exists but is not active today" path (upcoming or expired), -// proving such a member is excluded from the (N on LOA) tally and the [LOA] -// decoration on the healthy path. `at` is also returned as the IsHealthy -// timestamp so the handler's injected `now` and the cache clock agree. -type dateAwareLOACache struct { - entries map[string]utils.LOAEntry - at time.Time -} - -func (f *dateAwareLOACache) GetEntry(username string) (utils.LOAEntry, bool) { - e, ok := f.entries[strings.ToLower(username)] - return e, ok -} - -func (f *dateAwareLOACache) IsHealthy(_ time.Duration) (bool, time.Time) { - return true, f.at // always healthy: this fake exercises the date-window path -} +// oneWindow is a convenience for the common single-window-per-user case. +func oneWindow(e utils.LOAEntry) []utils.LOAEntry { return []utils.LOAEntry{e} } func boolOption(name string, value bool) *discordgo.ApplicationCommandInteractionDataOption { return &discordgo.ApplicationCommandInteractionDataOption{ @@ -120,7 +103,7 @@ func awolMember(username, milpacID, lastForumPost string) utils.LiteProfileRespo } func TestRunAwol_SmallResultRendersEmbedChunks(t *testing.T) { - // Two AWOL members (last post >8d before awolRefDate); no LOA; no force_file. + // Two AWOL members (last post well over 7 dates before awolRefDate); no LOA. roster := utils.LiteRosterResponse{ LiteProfiles: map[string]utils.LiteProfileResponse{ "100": awolMember("Trooper.A", "100", "2026-03-01 12:00:00"), @@ -155,12 +138,108 @@ func TestRunAwol_SmallResultRendersEmbedChunks(t *testing.T) { if len(edit.Files) > 0 { t.Fatalf("expected no files for small-result path; got %d files", len(edit.Files)) } - desc := (*edit.Embeds)[0].Description + embed := (*edit.Embeds)[0] + desc := embed.Description for _, want := range []string{"Trooper.A", "Trooper.B"} { if !strings.Contains(desc, want) { t.Fatalf("embed description missing %q.\nGot:\n%s", want, desc) } } + // Title and footer match the agreed layout. + 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") { + t.Fatalf("summary line missing.\nGot:\n%s", desc) + } + if embed.Footer == nil || embed.Footer.Text != awolReportFooter { + got := "" + if embed.Footer != nil { + got = embed.Footer.Text + } + t.Fatalf("footer = %q, want %q", got, awolReportFooter) + } + // Days-AWOL secondary context present. + if !strings.Contains(desc, "d AWOL ยท last post") { + t.Fatalf("rows should show 'Nd AWOL ยท last post Md'.\nGot:\n%s", desc) + } +} + +// TestRunAwol_NoLOARegressionAndSortOrder pins the common-case regression guard: +// with no LOA, days AWOL == raw overage (accountable == raw), and rows sort +// worst-first by days AWOL. +func TestRunAwol_NoLOARegressionAndSortOrder(t *testing.T) { + // awolRefDate = 2026-05-15. UTC dates: last post 2026-05-01 โ†’ (05-01,05-15] = + // 14 dates โ†’ 7 AWOL. last post 2026-03-01 โ†’ many โ†’ larger. + roster := utils.LiteRosterResponse{ + LiteProfiles: map[string]utils.LiteProfileResponse{ + "100": awolMember("Closer.C", "100", "2026-05-01 12:00:00"), // 14 dates โ†’ 7 AWOL + "200": awolMember("Farther.F", "200", "2026-03-01 12:00:00"), // big AWOL + }, + } + serveAwolRoster(t, roster, http.StatusOK) + + f := &fakeResponder{} + i := fakeAppCommandInteraction(stringOption("position", "1-7")) + runAwol(f, healthyCache(nil), awolRefDate, i) + + desc := (*f.Calls()[1].Edit.Embeds)[0].Description + // Farther.F (worse) must appear before Closer.C. + iF := strings.Index(desc, "Farther.F") + iC := strings.Index(desc, "Closer.C") + if iF == -1 || iC == -1 || iF > iC { + t.Fatalf("expected Farther.F before Closer.C (worst-first).\nGot:\n%s", desc) + } + // Closer.C: 14 candidate dates, no LOA โ†’ 7d AWOL. Raw last post also 14d. + if !strings.Contains(desc, "Closer.C](") || !strings.Contains(desc, "โ€” 7d AWOL ยท last post 14d") { + t.Fatalf("Closer.C should read '7d AWOL ยท last post 14d' (raw==accountable, no LOA).\nGot:\n%s", desc) + } +} + +// TestRunAwol_SeverityGlyphs pins the glyph tiers: ๐Ÿ”ด >14, ๐ŸŸ  >7, ๐ŸŸก >0. +func TestRunAwol_SeverityGlyphs(t *testing.T) { + roster := utils.LiteRosterResponse{ + LiteProfiles: map[string]utils.LiteProfileResponse{ + // last post 2026-04-20 โ†’ (04-20,05-15] = 25 dates โ†’ 18 AWOL โ†’ ๐Ÿ”ด (>14) + "100": awolMember("Red.R", "100", "2026-04-20 12:00:00"), + // last post 2026-05-02 โ†’ 13 dates โ†’ 6 AWOL โ†’ ๐ŸŸ  (>7? no, 6) ... pick 05-01 โ†’ 14โ†’7 not >7. + // Use 2026-04-28 โ†’ (04-28,05-15]=17 โ†’ 10 AWOL โ†’ ๐ŸŸ  (>7) + "200": awolMember("Orange.O", "200", "2026-04-28 12:00:00"), + // last post 2026-05-06 โ†’ (05-06,05-15]=9 โ†’ 2 AWOL โ†’ ๐ŸŸก (>0) + "300": awolMember("Yellow.Y", "300", "2026-05-06 12:00:00"), + }, + } + serveAwolRoster(t, roster, http.StatusOK) + + f := &fakeResponder{} + i := fakeAppCommandInteraction(stringOption("position", "1-7")) + runAwol(f, healthyCache(nil), awolRefDate, i) + + desc := (*f.Calls()[1].Edit.Embeds)[0].Description + checks := []struct{ glyph, name string }{ + {"๐Ÿ”ด", "Red.R"}, + {"๐ŸŸ ", "Orange.O"}, + {"๐ŸŸก", "Yellow.Y"}, + } + for _, c := range checks { + line := lineContaining(desc, c.name) + if line == "" { + t.Fatalf("missing row for %s.\nGot:\n%s", c.name, desc) + } + if !strings.HasPrefix(line, c.glyph) { + t.Fatalf("row for %s should start with %s.\nGot line: %q", c.name, c.glyph, line) + } + } +} + +// lineContaining returns the first line of s containing sub, or "". +func lineContaining(s, sub string) string { + for _, ln := range strings.Split(s, "\n") { + if strings.Contains(ln, sub) { + return ln + } + } + return "" } func TestRunAwol_LargeResultFallsBackToFile(t *testing.T) { @@ -246,6 +325,10 @@ func TestRunAwol_ForceFileOutputWithSmallResult(t *testing.T) { } t.Fatalf("expected 'Force File Set True' prefix, got %q", got) } + raw, _ := io.ReadAll(edit.Files[0].Reader) + if !strings.Contains(string(raw), "d AWOL ยท last post") { + t.Fatalf("file should carry days-AWOL rows.\nGot:\n%s", string(raw)) + } } func TestRunAwol_EmptyRosterSurfacesFormatHint(t *testing.T) { @@ -300,206 +383,196 @@ func TestRunAwol_RosterFetch500SurfacesError(t *testing.T) { } } -func TestRunAwol_OnLOAAnnotationRenders(t *testing.T) { - // Two AWOL members; only Trooper.A has a matching LOA cache entry, with a - // non-zero ThreadID โ€” so the row must render with **[[LOA]](...)** linked - // to that thread. Trooper.B has no entry โ†’ no LOA tag. +// TestRunAwol_ActiveLOAStillAWOL pins the "on LOA, still AWOL" case: a trooper +// with a pre-LOA unexcused gap is still flagged, rendered with the โšช glyph and a +// [LOA] thread link (active LOA overrides the severity tier regardless of days). +func TestRunAwol_ActiveLOAStillAWOL(t *testing.T) { + // awolRefDate = 2026-05-15. Last post 2026-04-20. Candidate (04-20,05-15] = + // 04-21..05-15 = 25 dates. Active LOA 2026-05-10..2026-05-31 covers 05-10..05-15 + // (6 candidate dates). Accountable = 19 โ†’ 12 AWOL. Active window โ†’ โšช + link. roster := utils.LiteRosterResponse{ LiteProfiles: map[string]utils.LiteProfileResponse{ - "100": awolMember("Trooper.A", "100", "2026-03-01 12:00:00"), - "200": awolMember("Trooper.B", "200", "2026-04-01 12:00:00"), + "100": awolMember("Reyes.J", "100", "2026-04-20 12:00:00"), }, } serveAwolRoster(t, roster, http.StatusOK) - cache := healthyCache(map[string]utils.LOAEntry{ - "trooper.a": { - Username: "Trooper.A", - StartDate: mustParseAwolDate("2026-04-01 00:00:00"), - EndDate: mustParseAwolDate("2026-06-01 00:00:00"), + cache := healthyCache(map[string][]utils.LOAEntry{ + "reyes.j": oneWindow(utils.LOAEntry{ + Username: "Reyes.J", + StartDate: mustParseAwolDate("2026-05-10 00:00:00"), + EndDate: mustParseAwolDate("2026-05-31 00:00:00"), ThreadID: 4242, - }, + }), }) f := &fakeResponder{} i := fakeAppCommandInteraction(stringOption("position", "1-7")) - runAwol(f, cache, awolRefDate, i) - calls := f.Calls() - if len(calls) != 2 { - t.Fatalf("expected 2 calls, got %d: %+v", len(calls), calls) + embed := (*f.Calls()[1].Edit.Embeds)[0] + desc := embed.Description + line := lineContaining(desc, "Reyes.J") + if !strings.HasPrefix(line, "โšช") { + t.Fatalf("active-LOA row must use โšช glyph regardless of days.\nGot line: %q", line) } - edit := calls[1].Edit - if edit.Embeds == nil || len(*edit.Embeds) == 0 { - t.Fatalf("expected embeds; got Embeds=%v", edit.Embeds) + if !strings.Contains(line, "[[LOA]](https://7cav.us/threads/4242/)") { + t.Fatalf("active-LOA row must link the thread.\nGot line: %q", line) } - desc := (*edit.Embeds)[0].Description - wantTag := "**[[LOA]](https://7cav.us/threads/4242/)**" - if !strings.Contains(desc, wantTag) { - t.Fatalf("expected LOA tag %q in description.\nGot:\n%s", wantTag, desc) + if !strings.Contains(line, "12d AWOL ยท last post 25d") { + t.Fatalf("expected '12d AWOL ยท last post 25d'.\nGot line: %q", line) } - // Footer must reflect 1 of 2 on LOA. - footer := (*edit.Embeds)[0].Footer - if footer == nil || !strings.Contains(footer.Text, "Total AWOL: 2 (1 on LOA)") { - got := "" - if footer != nil { - got = footer.Text - } - t.Fatalf("footer should report 1 on LOA; got %q", got) + if !strings.Contains(desc, "1 total ยท 1 LOA") { + t.Fatalf("summary should report 1 LOA.\nGot:\n%s", desc) } } -func TestRunAwol_InactiveLOAEntryNotCountedOrTagged(t *testing.T) { - // Three AWOL members on the healthy path, each with a cache entry, but only - // Trooper.B's window covers awolRefDate (2026-05-15): - // - Trooper.A: UPCOMING (starts after now) โ†’ not active โ†’ no tag, not counted. - // - Trooper.B: ACTIVE (window straddles now) โ†’ tagged + counted. - // - Trooper.C: EXPIRED (ended before now) โ†’ not active โ†’ no tag, not counted. - // Asserts the (N on LOA) tally is exactly 1 and only the active member is tagged. +// TestRunAwol_ExpiredLOASubtractedNotTagged pins that an expired LOA still +// subtracts its covered dates (lowering days AWOL) but does NOT mark the trooper +// on LOA (no โšช, no thread link) since the window isn't active now. +func TestRunAwol_ExpiredLOASubtractedNotTagged(t *testing.T) { + // Last post 2026-01-01. awolRefDate 2026-05-15. Candidate huge. Expired LOA + // 2026-01-03..2026-04-14 subtracts a big chunk but isn't active at now. roster := utils.LiteRosterResponse{ LiteProfiles: map[string]utils.LiteProfileResponse{ - "100": awolMember("Trooper.A", "100", "2026-03-01 12:00:00"), - "200": awolMember("Trooper.B", "200", "2026-03-15 12:00:00"), - "300": awolMember("Trooper.C", "300", "2026-04-01 12:00:00"), + "100": awolMember("Tanner.K", "100", "2026-01-01 12:00:00"), + "200": awolMember("Vasquez.A", "200", "2026-01-01 12:00:00"), // no LOA โ†’ much worse }, } serveAwolRoster(t, roster, http.StatusOK) - cache := &dateAwareLOACache{ - at: awolRefDate, - entries: map[string]utils.LOAEntry{ - "trooper.a": { // upcoming: starts AFTER awolRefDate - Username: "Trooper.A", - StartDate: mustParseAwolDate("2026-06-01 00:00:00"), - EndDate: mustParseAwolDate("2026-06-30 00:00:00"), - ThreadID: 1111, - }, - "trooper.b": { // active: window straddles awolRefDate - Username: "Trooper.B", - StartDate: mustParseAwolDate("2026-05-01 00:00:00"), - EndDate: mustParseAwolDate("2026-05-31 00:00:00"), - ThreadID: 2222, - }, - "trooper.c": { // expired: ended BEFORE awolRefDate - Username: "Trooper.C", - StartDate: mustParseAwolDate("2026-04-01 00:00:00"), - EndDate: mustParseAwolDate("2026-04-30 00:00:00"), - ThreadID: 3333, - }, - }, - } + cache := healthyCache(map[string][]utils.LOAEntry{ + "tanner.k": oneWindow(utils.LOAEntry{ + Username: "Tanner.K", + StartDate: mustParseAwolDate("2026-01-03 00:00:00"), + EndDate: mustParseAwolDate("2026-05-08 00:00:00"), + ThreadID: 9001, + }), + }) f := &fakeResponder{} i := fakeAppCommandInteraction(stringOption("position", "1-7")) - runAwol(f, cache, awolRefDate, i) - calls := f.Calls() - if len(calls) != 2 { - t.Fatalf("expected 2 calls, got %d: %+v", len(calls), calls) + desc := (*f.Calls()[1].Edit.Embeds)[0].Description + tanner := lineContaining(desc, "Tanner.K") + if strings.Contains(tanner, "โšช") || strings.Contains(tanner, "LOA]") { + t.Fatalf("expired LOA must not tag the row on-LOA.\nGot line: %q", tanner) } - edit := calls[1].Edit - if edit.Embeds == nil || len(*edit.Embeds) == 0 { - t.Fatalf("expected embeds; got Embeds=%v", edit.Embeds) + // Expired LOA subtracted โ†’ Tanner.K has fewer days AWOL than the no-LOA Vasquez.A, + // so Vasquez.A sorts first. + 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) } - desc := (*edit.Embeds)[0].Description - // All three members still listed (AWOL is independent of LOA state). - for _, want := range []string{"Trooper.A", "Trooper.B", "Trooper.C"} { - if !strings.Contains(desc, want) { - t.Fatalf("embed description missing %q.\nGot:\n%s", want, desc) - } + if !strings.Contains(desc, "1 LOA") && !strings.Contains(desc, "0 LOA") { + t.Fatalf("summary line missing.\nGot:\n%s", desc) } - // Only the ACTIVE member is decorated; the upcoming/expired threads must not appear. - if !strings.Contains(desc, "https://7cav.us/threads/2222/") { - t.Fatalf("active member Trooper.B should be LOA-tagged to thread 2222.\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) } - for _, badThread := range []string{"threads/1111", "threads/3333"} { - if strings.Contains(desc, badThread) { - t.Fatalf("inactive (upcoming/expired) entry must NOT be LOA-tagged (%s leaked).\nGot:\n%s", badThread, desc) - } +} + +// TestRunAwol_FullyCoveredMemberNotListed pins that a trooper whose entire gap is +// covered by an active LOA (0 days AWOL) is not listed at all. +func TestRunAwol_FullyCoveredMemberNotListed(t *testing.T) { + roster := utils.LiteRosterResponse{ + LiteProfiles: map[string]utils.LiteProfileResponse{ + "100": awolMember("Covered.C", "100", "2026-05-01 12:00:00"), + "200": awolMember("Bare.B", "200", "2026-03-01 12:00:00"), + }, } - // Footer tally counts ONLY the currently-active LOA: exactly 1 of 3. - footer := (*edit.Embeds)[0].Footer - if footer == nil || !strings.Contains(footer.Text, "Total AWOL: 3 (1 on LOA)") { - got := "" - if footer != nil { - got = footer.Text - } - t.Fatalf("footer should report exactly 1 on LOA (active only); got %q", got) + serveAwolRoster(t, roster, http.StatusOK) + + cache := healthyCache(map[string][]utils.LOAEntry{ + // Covers (05-01, 05-15] entirely โ†’ 0 accountable โ†’ not listed. + "covered.c": oneWindow(utils.LOAEntry{ + Username: "Covered.C", + StartDate: mustParseAwolDate("2026-05-02 00:00:00"), + EndDate: mustParseAwolDate("2026-05-31 00:00:00"), + ThreadID: 555, + }), + }) + f := &fakeResponder{} + i := fakeAppCommandInteraction(stringOption("position", "1-7")) + runAwol(f, cache, awolRefDate, i) + + desc := (*f.Calls()[1].Edit.Embeds)[0].Description + if strings.Contains(desc, "Covered.C") { + t.Fatalf("fully-covered member must not be listed.\nGot:\n%s", desc) + } + if !strings.Contains(desc, "Bare.B") { + t.Fatalf("uncovered member must be listed.\nGot:\n%s", desc) + } + if !strings.Contains(desc, "1 total") { + t.Fatalf("summary should count only listed members.\nGot:\n%s", desc) } } -// unhealthyCache builds an unhealthy fakeLOACache: entries may exist (and the -// roster member may even have a "real" LOA) but the health probe reports stale, -// so the handler must NOT trust GetEntry's window and must render "unknown". -func unhealthyCache(entries map[string]utils.LOAEntry, lastRefresh time.Time) *fakeLOACache { +// unhealthyCache builds an unhealthy fakeLOACache: windows may exist but the +// health probe reports stale, so the handler must NOT subtract LOA and must warn +// loudly that the accountable-day adjustment was skipped. +func unhealthyCache(entries map[string][]utils.LOAEntry, lastRefresh time.Time) *fakeLOACache { return &fakeLOACache{entries: entries, healthy: false, lastRefresh: lastRefresh} } -func TestRunAwol_UnhealthyCacheRendersUnknownColumn(t *testing.T) { - // Two AWOL members. Trooper.A even has a (stale) cache entry with a thread, - // but because the cache is unhealthy the handler must treat On LOA as unknown - // for EVERY row: no [LOA]/[[LOA]] decoration, no loaCount, plus a footer - // warning line. +func TestRunAwol_UnhealthyCacheRendersRawFallback(t *testing.T) { + // Two members. Trooper.A even has a (stale) window that would fully cover its + // gap โ€” but because the cache is unhealthy the handler computes RAW inactivity + // (no subtraction), still flags, and warns that adjustment was skipped. roster := utils.LiteRosterResponse{ LiteProfiles: map[string]utils.LiteProfileResponse{ - "100": awolMember("Trooper.A", "100", "2026-03-01 12:00:00"), + "100": awolMember("Trooper.A", "100", "2026-05-01 12:00:00"), "200": awolMember("Trooper.B", "200", "2026-04-01 12:00:00"), }, } serveAwolRoster(t, roster, http.StatusOK) - cache := unhealthyCache(map[string]utils.LOAEntry{ - "trooper.a": { + cache := unhealthyCache(map[string][]utils.LOAEntry{ + "trooper.a": oneWindow(utils.LOAEntry{ Username: "Trooper.A", - StartDate: mustParseAwolDate("2026-04-01 00:00:00"), + StartDate: mustParseAwolDate("2026-05-02 00:00:00"), EndDate: mustParseAwolDate("2026-06-01 00:00:00"), ThreadID: 4242, - }, + }), }, awolRefDate.Add(-45*time.Minute)) f := &fakeResponder{} i := fakeAppCommandInteraction(stringOption("position", "1-7")) runAwol(f, cache, awolRefDate, i) - calls := f.Calls() - if len(calls) != 2 { - t.Fatalf("expected 2 calls (placeholder + Edit), got %d: %+v", len(calls), calls) - } - edit := calls[1].Edit - if edit.Embeds == nil || len(*edit.Embeds) == 0 { - t.Fatalf("expected embeds even on unhealthy path; got Embeds=%v", edit.Embeds) - } - desc := (*edit.Embeds)[0].Description - // Members still listed. - for _, want := range []string{"Trooper.A", "Trooper.B"} { - if !strings.Contains(desc, want) { - t.Fatalf("embed description missing %q.\nGot:\n%s", want, desc) - } + embed := (*f.Calls()[1].Edit.Embeds)[0] + desc := embed.Description + // Trooper.A would be fully covered if LOA were applied; raw fallback still + // lists it with raw days (14 dates โ†’ 7 AWOL). + if !strings.Contains(desc, "Trooper.A") { + t.Fatalf("raw fallback must still flag Trooper.A.\nGot:\n%s", desc) } - // No LOA decoration leaks through on the unhealthy path. - if strings.Contains(desc, "[LOA]") || strings.Contains(desc, "threads/4242") { - t.Fatalf("expected no LOA decoration on unhealthy path.\nGot:\n%s", desc) + // No LOA decoration leaks through. + 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) } - // An "unknown" marker is present. - if !strings.Contains(desc, "On LOA: unknown") { - t.Fatalf("expected per-row 'On LOA: unknown' marker.\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) } - footer := (*edit.Embeds)[0].Footer - if footer == nil { - t.Fatalf("expected footer on unhealthy path") + if !strings.Contains(desc, "LOA unknown") { + t.Fatalf("degraded summary should mark LOA unknown.\nGot:\n%s", desc) } - // loaCount aggregate must NOT report a concrete number. - if strings.Contains(footer.Text, "on LOA)") { - t.Fatalf("footer must not report a concrete loaCount on unhealthy path; got %q", footer.Text) + // Footer must say the adjustment was SKIPPED, not merely "stale column". + if embed.Footer == nil || !strings.Contains(embed.Footer.Text, "SKIPPED") { + got := "" + if embed.Footer != nil { + got = embed.Footer.Text + } + t.Fatalf("degraded footer must say adjustment SKIPPED.\nGot: %q", got) } - if !strings.Contains(footer.Text, "LOA cache unavailable") { - t.Fatalf("footer should carry cache-unavailable warning; got %q", footer.Text) + if embed.Footer == nil || !strings.Contains(embed.Footer.Text, "LOA NOT subtracted") { + t.Fatalf("degraded footer must say LOA NOT subtracted.\nGot: %q", embed.Footer.Text) } } func TestRunAwol_UnhealthyCacheFileOutputCarriesWarning(t *testing.T) { - // Force the file path; the unhealthy cache must surface the warning and an - // unknown marker in the generated report rather than silent [LOA]/all-clear. + // Force the file path; the unhealthy cache must surface the skipped-adjustment + // warning and raw figures in the generated report rather than silent all-clear. roster := utils.LiteRosterResponse{ LiteProfiles: map[string]utils.LiteProfileResponse{ "100": awolMember("Trooper.A", "100", "2026-03-01 12:00:00"), @@ -507,8 +580,8 @@ func TestRunAwol_UnhealthyCacheFileOutputCarriesWarning(t *testing.T) { } serveAwolRoster(t, roster, http.StatusOK) - cache := unhealthyCache(map[string]utils.LOAEntry{ - "trooper.a": {Username: "Trooper.A", ThreadID: 4242}, + cache := unhealthyCache(map[string][]utils.LOAEntry{ + "trooper.a": oneWindow(utils.LOAEntry{Username: "Trooper.A", ThreadID: 4242}), }, time.Time{}) f := &fakeResponder{} i := fakeAppCommandInteraction( @@ -528,53 +601,49 @@ func TestRunAwol_UnhealthyCacheFileOutputCarriesWarning(t *testing.T) { } body := string(raw) if strings.Contains(body, "[LOA]") { - t.Fatalf("file must not carry [LOA] tag on unhealthy path.\nGot:\n%s", body) + t.Fatalf("file must not carry [LOA] tag on degraded path.\nGot:\n%s", body) } - if !strings.Contains(body, "LOA cache unavailable") { - t.Fatalf("file should carry cache-unavailable warning.\nGot:\n%s", body) + if !strings.Contains(body, "SKIPPED") { + t.Fatalf("file should carry skipped-adjustment warning.\nGot:\n%s", body) } - if !strings.Contains(body, "unknown") { - t.Fatalf("file should mark On LOA unknown.\nGot:\n%s", body) + if !strings.Contains(body, "d AWOL ยท last post") { + t.Fatalf("file should carry raw days-AWOL rows.\nGot:\n%s", body) } } -// countingLOACache wraps fakeLOACache to count GetEntry calls per username, -// proving the S4 single-snapshot invariant: /awol reads each member's LOA state -// exactly ONCE (deriving both OnLOA and the [[LOA]] link from that one snapshot), -// not once for the verdict and again for the link. +// countingLOACache wraps fakeLOACache to count GetEntries calls per username, +// proving /awol reads each member's LOA history exactly ONCE โ€” deriving the +// accountable-day figure AND the active-window link from that single read against +// one injected `now` (PR #161 clock-skew item). type countingLOACache struct { *fakeLOACache - getEntryCalls map[string]int + getEntriesCalls map[string]int } -func (c *countingLOACache) GetEntry(username string) (utils.LOAEntry, bool) { - if c.getEntryCalls == nil { - c.getEntryCalls = map[string]int{} +func (c *countingLOACache) GetEntries(username string) []utils.LOAEntry { + if c.getEntriesCalls == nil { + c.getEntriesCalls = map[string]int{} } - c.getEntryCalls[strings.ToLower(username)]++ - return c.fakeLOACache.GetEntry(username) + c.getEntriesCalls[strings.ToLower(username)]++ + return c.fakeLOACache.GetEntries(username) } -// TestRunAwol_S4_SingleSnapshotPerUser pins the #158/S4 fix: an active LOA member -// with a thread renders the [[LOA]] link, and the handler reads the cache for that -// member exactly once โ€” so a concurrent refresh can't make the link and the OnLOA -// verdict disagree. -func TestRunAwol_S4_SingleSnapshotPerUser(t *testing.T) { +func TestRunAwol_SingleHistoryReadPerUser(t *testing.T) { roster := utils.LiteRosterResponse{ LiteProfiles: map[string]utils.LiteProfileResponse{ - "100": awolMember("Trooper.A", "100", "2026-03-01 12:00:00"), + "100": awolMember("Trooper.A", "100", "2026-04-20 12:00:00"), }, } serveAwolRoster(t, roster, http.StatusOK) cache := &countingLOACache{ - fakeLOACache: healthyCache(map[string]utils.LOAEntry{ - "trooper.a": { + fakeLOACache: healthyCache(map[string][]utils.LOAEntry{ + "trooper.a": oneWindow(utils.LOAEntry{ Username: "Trooper.A", StartDate: mustParseAwolDate("2026-05-01 00:00:00"), EndDate: mustParseAwolDate("2026-06-01 00:00:00"), // active at awolRefDate ThreadID: 4242, - }, + }), }), } f := &fakeResponder{} @@ -582,11 +651,40 @@ func TestRunAwol_S4_SingleSnapshotPerUser(t *testing.T) { runAwol(f, cache, awolRefDate, i) - if n := cache.getEntryCalls["trooper.a"]; n != 1 { - t.Fatalf("S4: GetEntry must be called exactly once per member, got %d", n) + if n := cache.getEntriesCalls["trooper.a"]; n != 1 { + t.Fatalf("GetEntries must be called exactly once per member, got %d", n) } - desc := (*f.Calls()[1].Edit.Embeds)[0].Description - if !strings.Contains(desc, "**[[LOA]](https://7cav.us/threads/4242/)**") { - t.Fatalf("active member must render the [[LOA]] link from the single snapshot.\nGot:\n%s", desc) + line := lineContaining((*f.Calls()[1].Edit.Embeds)[0].Description, "Trooper.A") + if !strings.Contains(line, "[[LOA]](https://7cav.us/threads/4242/)") { + t.Fatalf("active member must render the [[LOA]] link from the single history read.\nGot:\n%s", line) + } +} + +// TestRunAwol_MultipleWindowsMergedNoDoubleCount pins that overlapping windows in +// a user's history don't double-subtract. +func TestRunAwol_MultipleWindowsMergedNoDoubleCount(t *testing.T) { + // Last post 2026-04-15. awolRefDate 2026-05-15. Candidate (04-15,05-15] = 30. + // Two overlapping windows 04-20..05-01 and 04-28..05-05 โ†’ union 04-20..05-05 + // (16 dates). Accountable = 14 โ†’ 7 AWOL. Neither active now โ†’ ๐ŸŸ  not โšช. + roster := utils.LiteRosterResponse{ + LiteProfiles: map[string]utils.LiteProfileResponse{ + "100": awolMember("Multi.M", "100", "2026-04-15 12:00:00"), + }, + } + serveAwolRoster(t, roster, http.StatusOK) + + cache := healthyCache(map[string][]utils.LOAEntry{ + "multi.m": { + {Username: "Multi.M", StartDate: mustParseAwolDate("2026-04-20 00:00:00"), EndDate: mustParseAwolDate("2026-05-01 00:00:00"), ThreadID: 1}, + {Username: "Multi.M", StartDate: mustParseAwolDate("2026-04-28 00:00:00"), EndDate: mustParseAwolDate("2026-05-05 00:00:00"), ThreadID: 2}, + }, + }) + f := &fakeResponder{} + i := fakeAppCommandInteraction(stringOption("position", "1-7")) + runAwol(f, cache, awolRefDate, i) + + line := lineContaining((*f.Calls()[1].Edit.Embeds)[0].Description, "Multi.M") + if !strings.Contains(line, "7d AWOL ยท last post 30d") { + t.Fatalf("overlapping windows must merge (7d AWOL, raw 30d).\nGot line: %q", line) } } diff --git a/utils/awol_accountable.go b/utils/awol_accountable.go new file mode 100644 index 0000000..58bd2c8 --- /dev/null +++ b/utils/awol_accountable.go @@ -0,0 +1,133 @@ +package utils + +import "time" + +// awolRequirementDays is the SOP forum-activity requirement: a trooper must post +// at least once every 7 days. Accountable dates beyond this are AWOL overage. +const awolRequirementDays = 7 + +// utcDate truncates an instant to its UTC calendar date (midnight UTC). UTC is +// 7Cav standard time, so "a day" is a UTC calendar date throughout the AWOL calc. +func utcDate(t time.Time) time.Time { + u := t.UTC() + return time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC) +} + +// rawAccountableDates is the count of candidate UTC dates in (lastPost, now] with +// no LOA subtraction: the whole-date inactivity span. This is the figure the +// degraded path flags on when the LOA store can't be trusted (ADR 0008). +func rawAccountableDates(lastPost, now time.Time) int { + start := utcDate(lastPost) + today := utcDate(now) + if !today.After(start) { + return 0 + } + // Days strictly after the post date through today inclusive: (today - start). + return int(today.Sub(start).Hours() / 24) +} + +// coveredByLOA reports whether a UTC date is covered by any LOA window. An LOA +// covers the inclusive range [StartDate, EndDate]. A backwards/zero-after-trunc +// range (end before start) covers nothing โ€” those are filtered upstream by +// validLOAWindows so they're also DEBUG-logged once, not silently per-date. +func coveredByLOA(date time.Time, windows []LOAEntry) bool { + for _, w := range windows { + s := utcDate(w.StartDate) + e := utcDate(w.EndDate) + if !date.Before(s) && !date.After(e) { + return true + } + } + return false +} + +// validLOAWindows returns the windows whose UTC date range is well-formed +// (EndDate not before StartDate). Backwards ranges are dropped and DEBUG-logged +// so one malformed forum post can't crash or skew the count (ADR 0008). +func validLOAWindows(windows []LOAEntry) []LOAEntry { + out := make([]LOAEntry, 0, len(windows)) + for _, w := range windows { + if utcDate(w.EndDate).Before(utcDate(w.StartDate)) { + Debug("LOA window ignored (end before start)", + "username", w.Username, + "thread_id", w.ThreadID, + "start", w.StartDate, + "end", w.EndDate, + ) + continue + } + out = append(out, w) + } + return out +} + +// accountableDates counts candidate UTC dates in (lastPost, now] that are NOT +// covered by any (valid) LOA window. The candidate set excludes the post day and +// includes today; coverage is the union of inclusive LOA date ranges, so +// overlapping / adjacent / multiple / future LOAs need no special handling. +func accountableDates(lastPost, now time.Time, windows []LOAEntry) int { + start := utcDate(lastPost) + today := utcDate(now) + valid := validLOAWindows(windows) + count := 0 + for date := start.AddDate(0, 0, 1); !date.After(today); date = date.AddDate(0, 0, 1) { + if !coveredByLOA(date, valid) { + count++ + } + } + return count +} + +// AccountableDaysAWOL is the pure accountable-day calc (ADR 0008): given a +// trooper's last forum post, the current instant, and their LOA windows, it +// returns days AWOL = accountable dates โˆ’ 7 (the overage past the 7-day forum +// requirement), clamped at zero. Clock-injected and DB-free so every edge case is +// table-testable. Backwards LOA ranges are ignored and DEBUG-logged. +func AccountableDaysAWOL(lastPost, now time.Time, windows []LOAEntry) int { + return overage(accountableDates(lastPost, now, windows)) +} + +// RawDaysAWOL is the degraded-mode figure: whole-date inactivity with NO LOA +// subtraction, overage past the 7-day requirement, clamped at zero. Used when the +// LOA store is unhealthy so the report degrades loudly rather than silently +// treating everyone as non-LOA (ADR 0008). +func RawDaysAWOL(lastPost, now time.Time) int { + return overage(rawAccountableDates(lastPost, now)) +} + +// DaysSinceLastPost is the raw whole-date inactivity span (candidate UTC dates in +// (lastPost, now], no LOA subtraction, no 7-day clamp). It is the "last post Nd" +// secondary context shown on every row โ€” the unadjusted figure, so staff still +// know when the trooper actually last posted regardless of LOA coverage. +func DaysSinceLastPost(lastPost, now time.Time) int { + return rawAccountableDates(lastPost, now) +} + +func overage(accountable int) int { + if accountable <= awolRequirementDays { + return 0 + } + return accountable - awolRequirementDays +} + +// ActiveWindow returns the LOA window active at `now` (clock-injected by the +// caller, NOT an internal time.Now()), preferring the latest-ending if several +// overlap. The bool is false when no window is active. /awol uses this so the +// On-LOA verdict, the days-AWOL calc, and the [[LOA]] thread link are all decided +// against the SAME `now` โ€” no boundary disagreement between selection and verdict +// (PR #161 clock-skew item). Clock-free GetEntries supplies the windows. +func ActiveWindow(windows []LOAEntry, now time.Time) (LOAEntry, bool) { + var ( + best LOAEntry + found bool + ) + for _, w := range windows { + if !w.isActiveAt(now) { + continue + } + if !found || w.EndDate.After(best.EndDate) { + best, found = w, true + } + } + return best, found +} diff --git a/utils/awol_accountable_test.go b/utils/awol_accountable_test.go new file mode 100644 index 0000000..192565d --- /dev/null +++ b/utils/awol_accountable_test.go @@ -0,0 +1,256 @@ +package utils + +import ( + "testing" + "time" +) + +// d builds a UTC midnight date for accountable-day table tests. +func d(year int, month time.Month, day int) time.Time { + return time.Date(year, month, day, 0, 0, 0, 0, time.UTC) +} + +// dt builds an arbitrary-time UTC instant โ€” used to prove the calc truncates to +// the calendar date regardless of the time-of-day component. +func dt(year int, month time.Month, day, hour, min int) time.Time { + return time.Date(year, month, day, hour, min, 0, 0, time.UTC) +} + +func loa(start, end time.Time) LOAEntry { + return LOAEntry{StartDate: start, EndDate: end} +} + +func TestAccountableDaysAWOL(t *testing.T) { + tests := []struct { + name string + lastPost time.Time + now time.Time + loas []LOAEntry + want int + }{ + { + // (Jan 1, Jan 6] = Jan 2..6 = 5 accountable; 5-7 clamped to 0. + name: "no LOA, under threshold", + lastPost: d(2026, time.January, 1), + now: d(2026, time.January, 6), + want: 0, + }, + { + // (Jan 1, Jan 8] = Jan 2..8 = 7 accountable; exactly 7 is NOT over. + name: "no LOA, exactly 7 accountable (boundary, not flagged)", + lastPost: d(2026, time.January, 1), + now: d(2026, time.January, 8), + want: 0, + }, + { + // (Jan 1, Jan 9] = Jan 2..9 = 8 accountable; 8-7 = 1 day AWOL. + name: "no LOA, 8 accountable (boundary, 1 day AWOL)", + lastPost: d(2026, time.January, 1), + now: d(2026, time.January, 9), + want: 1, + }, + { + // 30 accountable dates, no LOA โ†’ 30-7 = 23. + name: "no LOA long gap, raw equals accountable", + lastPost: d(2026, time.January, 1), + now: d(2026, time.January, 31), + want: 23, + }, + { + // Candidate (Jan 1, Jan 31] = Jan 2..31 (30 dates). LOA Jan 3..14 + // inclusive covers 12 dates. 30-12 = 18 accountable; 18-7 = 11. + name: "expired LOA subtracts covered dates", + lastPost: d(2026, time.January, 1), + now: d(2026, time.January, 31), + loas: []LOAEntry{loa(d(2026, time.January, 3), d(2026, time.January, 14))}, + want: 11, + }, + { + // PRD example: last post Jan 1, LOA Jan 3..14, today Jan 15. + // Candidate Jan 2..15 (14). Covered Jan 3..14 (12). 14-12 = 2 + // accountable. 2-7 clamps to 0 โ†’ not flagged. + name: "PRD example: only Jan 2 + Jan 15 accountable", + lastPost: d(2026, time.January, 1), + now: d(2026, time.January, 15), + loas: []LOAEntry{loa(d(2026, time.January, 3), d(2026, time.January, 14))}, + want: 0, + }, + { + // Active LOA covering through today: last post Jan 1, LOA Jan 2..today(Jan 20). + // Candidate Jan 2..20 (19) all covered โ†’ 0 accountable โ†’ 0. + name: "active LOA covers whole gap", + lastPost: d(2026, time.January, 1), + now: d(2026, time.January, 20), + loas: []LOAEntry{loa(d(2026, time.January, 2), d(2026, time.January, 20))}, + want: 0, + }, + { + // On LOA, still AWOL: 10 unexcused dates before the LOA started. + // Last post Jan 1. Candidate Jan 2..Jan 31 (30). LOA Jan 12..31 (20) + // covers the tail. Accountable = Jan 2..11 = 10. 10-7 = 3. + name: "on LOA still AWOL (pre-LOA gap counts)", + lastPost: d(2026, time.January, 1), + now: d(2026, time.January, 31), + loas: []LOAEntry{loa(d(2026, time.January, 12), d(2026, time.January, 31))}, + want: 3, + }, + { + // Post AFTER an LOA ended: LOA entirely before lastPost โ†’ no candidate + // date is covered. Candidate (Feb 1, Feb 20] = 19. 19-7 = 12. + name: "post after LOA, LOA before window contributes nothing", + lastPost: d(2026, time.February, 1), + now: d(2026, time.February, 20), + loas: []LOAEntry{loa(d(2026, time.January, 1), d(2026, time.January, 20))}, + want: 12, + }, + { + // Partial overlap: LOA starts before lastPost, ends mid-window. + // Candidate Jan 11..31 (21). LOA Jan 1..20 โ†’ covers Jan 11..20 (10). + // Accountable = Jan 21..31 = 11. 11-7 = 4. + name: "partial overlap clamps to candidate window", + lastPost: d(2026, time.January, 10), + now: d(2026, time.January, 31), + loas: []LOAEntry{loa(d(2026, time.January, 1), d(2026, time.January, 20))}, + want: 4, + }, + { + // Multiple disjoint LOAs both subtracted. + // Candidate Jan 2..31 (30). LOA1 Jan 3..7 (5), LOA2 Jan 20..25 (6). + // Covered 11. Accountable 19. 19-7 = 12. + name: "multiple disjoint LOAs", + lastPost: d(2026, time.January, 1), + now: d(2026, time.January, 31), + loas: []LOAEntry{ + loa(d(2026, time.January, 3), d(2026, time.January, 7)), + loa(d(2026, time.January, 20), d(2026, time.January, 25)), + }, + want: 12, + }, + { + // Overlapping LOAs merge โ€” no double count. + // Candidate Jan 2..31 (30). LOA1 Jan 3..10, LOA2 Jan 8..15 โ†’ union + // Jan 3..15 (13). Accountable 17. 17-7 = 10. + name: "overlapping LOAs merged (no double-count)", + lastPost: d(2026, time.January, 1), + now: d(2026, time.January, 31), + loas: []LOAEntry{ + loa(d(2026, time.January, 3), d(2026, time.January, 10)), + loa(d(2026, time.January, 8), d(2026, time.January, 15)), + }, + want: 10, + }, + { + // Adjacent windows treated as continuous (no phantom day between). + // Candidate Jan 2..31 (30). LOA1 Jan 3..10, LOA2 Jan 11..20 โ†’ Jan 3..20 + // (18). Accountable 12. 12-7 = 5. + name: "adjacent LOAs continuous", + lastPost: d(2026, time.January, 1), + now: d(2026, time.January, 31), + loas: []LOAEntry{ + loa(d(2026, time.January, 3), d(2026, time.January, 10)), + loa(d(2026, time.January, 11), d(2026, time.January, 20)), + }, + want: 5, + }, + { + // Future LOA does not reduce the current count (no candidate date in it). + // Candidate Jan 2..15 (14). LOA Feb 1..10 is entirely future. 14-7 = 7. + name: "future LOA does not reduce count", + lastPost: d(2026, time.January, 1), + now: d(2026, time.January, 15), + loas: []LOAEntry{loa(d(2026, time.February, 1), d(2026, time.February, 10))}, + want: 7, + }, + { + // Backwards range (end before start) covers nothing. + // Candidate Jan 2..20 (19). Bad LOA ignored. 19-7 = 12. + name: "backwards range ignored", + lastPost: d(2026, time.January, 1), + now: d(2026, time.January, 20), + loas: []LOAEntry{loa(d(2026, time.January, 15), d(2026, time.January, 5))}, + want: 12, + }, + { + // Time-of-day components must not matter: truncates to UTC date. + // lastPost late Jan 1, now early Jan 9 โ†’ still (Jan 1, Jan 9] = 8 โ†’ 1. + name: "truncates time-of-day to UTC date", + lastPost: dt(2026, time.January, 1, 23, 59), + now: dt(2026, time.January, 9, 0, 1), + want: 1, + }, + { + // lastPost == now (posted today): zero candidate dates โ†’ 0. + name: "posted today, zero candidates", + lastPost: d(2026, time.January, 9), + now: d(2026, time.January, 9), + want: 0, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := AccountableDaysAWOL(tc.lastPost, tc.now, tc.loas) + if got != tc.want { + t.Fatalf("AccountableDaysAWOL = %d, want %d", got, tc.want) + } + }) + } +} + +// TestActiveWindow_UsesInjectedNow pins the PR #161 clock-skew fix: window +// selection is decided against the caller's `now`, not an internal clock, and at +// an exact boundary (now == EndDate is still active; now one tick past is not). +func TestActiveWindow_UsesInjectedNow(t *testing.T) { + w := loa(d(2026, time.January, 1), d(2026, time.January, 10)) + w.ThreadID = 7 + windows := []LOAEntry{w} + + // On the EndDate (inclusive) โ†’ active. + if got, ok := ActiveWindow(windows, d(2026, time.January, 10)); !ok || got.ThreadID != 7 { + t.Fatalf("expected active window on EndDate; got ok=%v id=%d", ok, got.ThreadID) + } + // One day past EndDate โ†’ not active. + if _, ok := ActiveWindow(windows, d(2026, time.January, 11)); ok { + t.Fatalf("expected no active window past EndDate") + } + // Before StartDate โ†’ not active. + if _, ok := ActiveWindow(windows, d(2025, time.December, 31)); ok { + t.Fatalf("expected no active window before StartDate") + } +} + +func TestActiveWindow_LatestEndingWinsAmongOverlapping(t *testing.T) { + a := loa(d(2026, time.January, 1), d(2026, time.January, 10)) + a.ThreadID = 1 + b := loa(d(2026, time.January, 5), d(2026, time.January, 20)) + b.ThreadID = 2 + got, ok := ActiveWindow([]LOAEntry{a, b}, d(2026, time.January, 8)) + if !ok || got.ThreadID != 2 { + t.Fatalf("expected latest-ending active window (id 2); got ok=%v id=%d", ok, got.ThreadID) + } +} + +func TestRawDaysAWOL_NoSubtractionBoundary(t *testing.T) { + // 7 whole dates โ†’ not flagged; 8 โ†’ 1. LOA windows are irrelevant here (raw). + if got := RawDaysAWOL(d(2026, time.January, 1), d(2026, time.January, 8)); got != 0 { + t.Fatalf("RawDaysAWOL 7 dates = %d, want 0", got) + } + if got := RawDaysAWOL(d(2026, time.January, 1), d(2026, time.January, 9)); got != 1 { + t.Fatalf("RawDaysAWOL 8 dates = %d, want 1", got) + } +} + +// TestAccountableDaysAWOL_ZeroRangeCovered pins that a single-date LOA +// (StartDate == EndDate) covers exactly that one inclusive date โ€” it is valid, +// not a malformed zero range. Candidate Jan 2..10 (9). LOA Jan 5..5 covers 1. +// Accountable 8. 8-7 = 1. +func TestAccountableDaysAWOL_SingleDateLOACoversOneDay(t *testing.T) { + got := AccountableDaysAWOL( + d(2026, time.January, 1), + d(2026, time.January, 10), + []LOAEntry{loa(d(2026, time.January, 5), d(2026, time.January, 5))}, + ) + if got != 1 { + t.Fatalf("AccountableDaysAWOL = %d, want 1", got) + } +} From f76cbe935a84d62e25520d78448122fe8a4fa540 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:59:31 -0400 Subject: [PATCH 2/6] fix(awol): keep embed description within Discord 4096 limit + warn on degraded empty result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reserve the summary-line prefix + separator length when sizing the per-chunk budget so the final embed description (summary + "\n\n" + chunk) can never exceed Discord's 4096-char cap (was: raw chunk capped at 4096, then ~18 bytes of prefix prepended โ†’ API 400). Also surface the degraded/SKIPPED warning on the empty-result reply when the LOA cache is unhealthy, so an unhealthy cache is never reported as a silent all-clear (every other terminal degraded path already warns). Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/awol.go | 41 +++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/commands/awol.go b/commands/awol.go index 5fa3363..1602789 100644 --- a/commands/awol.go +++ b/commands/awol.go @@ -18,6 +18,11 @@ func stringPtr(s string) *string { const maxEmbedsPerMsg = 10 +// discordEmbedDescriptionLimit is Discord's hard cap on an embed description. +// The rendered description is summaryLine + separator + chunk, so the per-chunk +// budget is this minus the prefix length (see runAwol) to keep the total โ‰ค limit. +const discordEmbedDescriptionLimit = 4096 + // awolReportFooter explains the displayed figure: days AWOL already has valid // LOA-covered days subtracted (ADR 0008). Rendered on the healthy path. const awolReportFooter = "AWOL days subtracts valid LOA days." @@ -36,10 +41,12 @@ const awolDegradedFooter = "โš ๏ธ LOA cache unavailable โ€” accountable-day adj // (PR #161 clock-skew item). IsHealthy gates degraded mode (#96): an unhealthy // cache is stale, so /awol falls back to raw inactivity with no LOA subtraction. // -// This is intentionally a SUPERSET of /loa's loaCacheView ({GetEntry,IsHealthy}): -// /awol needs the full history to subtract LOA-covered dates, /loa renders a -// single most-relevant window. Keeping them distinct keeps each command's -// dependency scoped to what it reads. +// This is intentionally a DIFFERENT surface from /loa's loaCacheView +// ({GetEntry,IsHealthy}): both share IsHealthy, but /awol reads the full window +// history via GetEntries (to subtract every LOA-covered date), whereas /loa reads +// a single most-relevant window via GetEntry. It is not a superset of /loa's set โ€” +// the read methods differ (GetEntries vs GetEntry). Keeping the two interfaces +// distinct scopes each command's dependency to exactly what it reads. type loaCacheReader interface { GetEntries(username string) []utils.LOAEntry IsHealthy(maxAge time.Duration) (bool, time.Time) @@ -219,6 +226,13 @@ func runAwol(r utils.InteractionResponder, cache loaCacheReader, now time.Time, if len(awolUsers) == 0 { response := fmt.Sprintf("Search completed successfully: no users matching \"%s\" are AWOL.", position) + if !cacheHealthy { + // Never a silent all-clear while degraded: raw figures are >= the + // LOA-adjusted figure so no AWOL member is hidden, but staff must still + // know the cache was down and the accountable-day adjustment was skipped + // (ADR 0008) โ€” same wording as every other terminal degraded path. + response += "\n" + awolDegradedFooter + } if err := r.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ Content: &response, }); err != nil { @@ -234,11 +248,23 @@ func runAwol(r utils.InteractionResponder, cache loaCacheReader, now time.Time, } } + footerText := awolReportFooter + if !cacheHealthy { + footerText = awolDegradedFooter + } + + // Discord caps an embed description at 4096 chars. The final description is + // 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" + chunkBudget := discordEmbedDescriptionLimit - len(descPrefix) + var chunks []string currentChunk := "" for _, user := range awolUsers { userLine := awolUserLine(user) - if len(currentChunk)+len(userLine) > 4096 { + if currentChunk != "" && len(currentChunk)+len(userLine) > chunkBudget { chunks = append(chunks, currentChunk) currentChunk = userLine } else { @@ -261,11 +287,6 @@ func runAwol(r utils.InteractionResponder, cache loaCacheReader, now time.Time, return } - footerText := awolReportFooter - if !cacheHealthy { - footerText = awolDegradedFooter - } - var embeds []*discordgo.MessageEmbed for idx, chunk := range chunks { title := awolEmbedTitle(position, idx, len(chunks)) From 00520a8e1267886d10030fe39e30201b194d553c Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:59:36 -0400 Subject: [PATCH 3/6] fix(awol): drop zero-value LOA windows from the accountable-day calc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validLOAWindows previously only dropped end-before-start ranges; a zero-value LOAEntry (both dates zero) slipped through and would "cover" the epoch-zero UTC date. Unreachable from production today (parseLOAPost only emits when both dates parse) but cheap to harden. Also reword the coveredByLOA/validLOAWindows comments: a zero-width range (StartDate == EndDate) is VALID and covers exactly one day โ€” only end-before-start covers nothing. Co-Authored-By: Claude Opus 4.8 (1M context) --- utils/awol_accountable.go | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/utils/awol_accountable.go b/utils/awol_accountable.go index 58bd2c8..643b99d 100644 --- a/utils/awol_accountable.go +++ b/utils/awol_accountable.go @@ -27,9 +27,10 @@ func rawAccountableDates(lastPost, now time.Time) int { } // coveredByLOA reports whether a UTC date is covered by any LOA window. An LOA -// covers the inclusive range [StartDate, EndDate]. A backwards/zero-after-trunc -// range (end before start) covers nothing โ€” those are filtered upstream by -// validLOAWindows so they're also DEBUG-logged once, not silently per-date. +// covers the inclusive range [StartDate, EndDate]; a zero-width range +// (StartDate == EndDate) is valid and covers exactly that one day. Only an +// end-before-start range covers nothing โ€” those are filtered upstream by +// validLOAWindows so they're DEBUG-logged once, not silently per-date. func coveredByLOA(date time.Time, windows []LOAEntry) bool { for _, w := range windows { s := utcDate(w.StartDate) @@ -41,12 +42,24 @@ func coveredByLOA(date time.Time, windows []LOAEntry) bool { return false } -// validLOAWindows returns the windows whose UTC date range is well-formed -// (EndDate not before StartDate). Backwards ranges are dropped and DEBUG-logged -// so one malformed forum post can't crash or skew the count (ADR 0008). +// validLOAWindows returns the windows whose UTC date range is well-formed: both +// bounds set and EndDate not before StartDate. Backwards ranges and zero-date +// entries are dropped and DEBUG-logged so one malformed forum post can't crash or +// skew the count (ADR 0008). A zero-value entry is unreachable from production +// (parseLOAPost only emits when both dates parse) but filtering it keeps the calc +// from ever "covering" the epoch-zero UTC date. func validLOAWindows(windows []LOAEntry) []LOAEntry { out := make([]LOAEntry, 0, len(windows)) for _, w := range windows { + if w.StartDate.IsZero() || w.EndDate.IsZero() { + Debug("LOA window ignored (zero-value date)", + "username", w.Username, + "thread_id", w.ThreadID, + "start", w.StartDate, + "end", w.EndDate, + ) + continue + } if utcDate(w.EndDate).Before(utcDate(w.StartDate)) { Debug("LOA window ignored (end before start)", "username", w.Username, From b5b52439d8882774b02f1630584f8c6cd05b7d6d Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:59:41 -0400 Subject: [PATCH 4/6] docs(awol): drop orphaned LOAEntry.IsActive + correct stale interface comments IsActive had zero callers after this PR switched /awol to ActiveWindow/isActiveAt (`rg IsActive` shows only its own def). Delete it and scrub its stale doc comment. Correct loaCacheReader's doc (it is {GetEntries,IsHealthy}, a different surface from /loa's {GetEntry,IsHealthy}, not a superset) and /loa's loaCacheView comment that still claimed /awol's reader had the same {GetEntry,IsHealthy} shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/loa.go | 8 ++++---- utils/loa.go | 11 ++--------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/commands/loa.go b/commands/loa.go index d96bc32..3f76c56 100644 --- a/commands/loa.go +++ b/commands/loa.go @@ -17,10 +17,10 @@ import ( // *utils.LOACache (GlobalLOACache); tests substitute a fake with canned // entries and a forced health verdict so the handler stays deterministic // without touching the process-global singleton. /awol's loaCacheReader is a -// separate, per-command minimal surface over the same cache with the same -// {GetEntry, IsHealthy} shape today; the two are kept distinct so each command's -// dependency stays scoped to what it actually reads (and so they can diverge โ€” -// see #159, which adds GetEntries to loaCacheReader). /awol additionally gates on +// separate, per-command minimal surface over the same cache; the two share +// IsHealthy but read differently โ€” /loa uses GetEntry (one most-relevant window) +// while /awol uses GetEntries (the full history, #159). They are kept distinct so +// each command's dependency stays scoped to what it actually reads. /awol gates on // IsHealthy to render its On-LOA column (#96); /loa uses it for the staleness // guard above. type loaCacheView interface { diff --git a/utils/loa.go b/utils/loa.go index b6002e3..d36ad69 100644 --- a/utils/loa.go +++ b/utils/loa.go @@ -149,19 +149,12 @@ func (e LOAEntry) hasEnded(now time.Time) bool { // StartDate through its EndDate (so Start==now and End==now both count as active). // The upper bound is hasEnded. Clock-injected so the boundary semantics are // unit-testable at the exact edge (cf. PR #135); the production callers pass -// time.Now(). IsActive is the exported wrapper used by /awol's single-snapshot read. +// time.Now(). /awol selects the active window via utils.ActiveWindow, which +// delegates here against its single injected `now`. func (e LOAEntry) isActiveAt(now time.Time) bool { return !now.Before(e.StartDate) && !e.hasEnded(now) } -// IsActive reports whether the entry's LOA window is active at `now`. Exported so -// /awol can derive its On LOA verdict from the same single GetEntry snapshot it -// uses for the [[LOA]] link, instead of a second cache lock acquisition that a -// concurrent refresh could make inconsistent (now that ended windows are retained). -func (e LOAEntry) IsActive(now time.Time) bool { - return e.isActiveAt(now) -} - // isRetired reports whether the window's EndDate is older than the retention // horizon (loaRetentionYears before now) and so should be dropped from the // history store. This is the retention cutoff that replaces prune-on-expiry: a From 577edad1960285cd3a647af2f959b2e71c6ac081 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:59:49 -0400 Subject: [PATCH 5/6] test(awol): pin embed-limit + degraded-empty paths and boundary coverage gaps New behavioral coverage: - embed description stays <= 4096 with a near-limit accumulated chunk - unhealthy cache + empty list surfaces the SKIPPED warning; healthy stays all-clear New boundary/contract coverage: - DaysSinceLastPost no-clamp contract vs RawDaysAWOL; rawAccountableDates early-return guard - severityGlyph EXACT tier edges (14->orange, 7->yellow) + active-LOA override - ActiveWindow now==StartDate inclusive edge and the no-active (_, false) return - validLOAWindows drops a zero-value entry; mixed slice still subtracts the valid window Also fix test fixture comment drift (expired-LOA EndDate, scratch glyph notes) and the IsActive reference in the isActiveAt doc. Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/awol_test.go | 117 ++++++++++++++++++++++++++++++++- utils/awol_accountable_test.go | 85 ++++++++++++++++++++++++ utils/loa_test.go | 2 +- 3 files changed, 200 insertions(+), 4 deletions(-) diff --git a/commands/awol_test.go b/commands/awol_test.go index 8a5cdfc..5fe97a4 100644 --- a/commands/awol_test.go +++ b/commands/awol_test.go @@ -202,8 +202,7 @@ func TestRunAwol_SeverityGlyphs(t *testing.T) { LiteProfiles: map[string]utils.LiteProfileResponse{ // last post 2026-04-20 โ†’ (04-20,05-15] = 25 dates โ†’ 18 AWOL โ†’ ๐Ÿ”ด (>14) "100": awolMember("Red.R", "100", "2026-04-20 12:00:00"), - // last post 2026-05-02 โ†’ 13 dates โ†’ 6 AWOL โ†’ ๐ŸŸ  (>7? no, 6) ... pick 05-01 โ†’ 14โ†’7 not >7. - // Use 2026-04-28 โ†’ (04-28,05-15]=17 โ†’ 10 AWOL โ†’ ๐ŸŸ  (>7) + // last post 2026-04-28 โ†’ (04-28,05-15] = 17 dates โ†’ 10 AWOL โ†’ ๐ŸŸ  (>7) "200": awolMember("Orange.O", "200", "2026-04-28 12:00:00"), // last post 2026-05-06 โ†’ (05-06,05-15]=9 โ†’ 2 AWOL โ†’ ๐ŸŸก (>0) "300": awolMember("Yellow.Y", "300", "2026-05-06 12:00:00"), @@ -232,6 +231,32 @@ func TestRunAwol_SeverityGlyphs(t *testing.T) { } } +// TestSeverityGlyph_TierBoundaries pins the EXACT strict-> tier edges that the +// inside-tier handler test (18/10/2) can't catch: 15โ†’๐Ÿ”ด, 14โ†’๐ŸŸ  (not ๐Ÿ”ด), 8โ†’๐ŸŸ , +// 7โ†’๐ŸŸก (not ๐ŸŸ ), 1โ†’๐ŸŸก. An active LOA always overrides to โšช regardless of days. +func TestSeverityGlyph_TierBoundaries(t *testing.T) { + cases := []struct { + days int + want string + }{ + {15, "๐Ÿ”ด"}, + {14, "๐ŸŸ "}, // exactly 14 is NOT >14 + {8, "๐ŸŸ "}, + {7, "๐ŸŸก"}, // exactly 7 is NOT >7 + {1, "๐ŸŸก"}, + } + for _, c := range cases { + if got := (AwolUser{DaysAWOL: c.days}).severityGlyph(); got != c.want { + t.Fatalf("severityGlyph(%d) = %q, want %q", c.days, got, c.want) + } + } + // Active LOA overrides the tier. + w := utils.LOAEntry{} + if got := (AwolUser{DaysAWOL: 99, loaWindow: &w}).severityGlyph(); got != "โšช" { + t.Fatalf("active-LOA severityGlyph = %q, want โšช", got) + } +} + // lineContaining returns the first line of s containing sub, or "". func lineContaining(s, sub string) string { for _, ln := range strings.Split(s, "\n") { @@ -242,6 +267,40 @@ func lineContaining(s, sub string) string { return "" } +// TestRunAwol_EmbedDescriptionWithinDiscordLimit pins item 1: the per-chunk +// budget must leave room for the summary-line prefix + separator (and any footer +// that rides along), so the FINAL embed description never exceeds Discord's 4096 +// limit. A single near-limit user line previously produced a chunk at ~4096 that, +// once the summary + "\n\n" was prepended, overflowed โ†’ Discord 400. +func TestRunAwol_EmbedDescriptionWithinDiscordLimit(t *testing.T) { + // Many moderately-padded users whose rendered lines accumulate into a chunk + // sitting just under the raw 4096 cap. Without the prefix-aware budget the + // summary line + "\n\n" prepended on top pushes the description past 4096. + // ~60-char usernames โ†’ ~110-byte lines; 36 of them โ‰ˆ 3960 bytes, the next + // fills toward the cap. Each line individually stays well under the budget. + pad := strings.Repeat("x", 60) + roster := utils.LiteRosterResponse{LiteProfiles: map[string]utils.LiteProfileResponse{}} + for n := range 40 { + id := fmt.Sprintf("%d", 100+n) + roster.LiteProfiles[id] = awolMember(fmt.Sprintf("U%02d_%s", n, pad), id, "2026-03-01 12:00:00") + } + serveAwolRoster(t, roster, http.StatusOK) + + f := &fakeResponder{} + i := fakeAppCommandInteraction(stringOption("position", "1-7")) + runAwol(f, healthyCache(nil), awolRefDate, i) + + edit := f.Calls()[1].Edit + if edit.Embeds == nil { + t.Fatalf("expected embeds on Edit; got %+v", edit) + } + for idx, e := range *edit.Embeds { + if n := len([]rune(e.Description)); n > 4096 { + t.Fatalf("embed[%d] description = %d runes, exceeds Discord 4096 limit", idx, n) + } + } +} + func TestRunAwol_LargeResultFallsBackToFile(t *testing.T) { // Inflate per-user line length with long padded usernames so a single user // line saturates a chunk (~2.5KB each, threshold 4096). 11 such users yield @@ -431,7 +490,7 @@ func TestRunAwol_ActiveLOAStillAWOL(t *testing.T) { // on LOA (no โšช, no thread link) since the window isn't active now. func TestRunAwol_ExpiredLOASubtractedNotTagged(t *testing.T) { // Last post 2026-01-01. awolRefDate 2026-05-15. Candidate huge. Expired LOA - // 2026-01-03..2026-04-14 subtracts a big chunk but isn't active at now. + // 2026-01-03..2026-05-08 subtracts a big chunk but isn't active at now. roster := utils.LiteRosterResponse{ LiteProfiles: map[string]utils.LiteProfileResponse{ "100": awolMember("Tanner.K", "100", "2026-01-01 12:00:00"), @@ -570,6 +629,58 @@ func TestRunAwol_UnhealthyCacheRendersRawFallback(t *testing.T) { } } +// TestRunAwol_UnhealthyCacheEmptyListWarns pins item 2: when the LOA cache is +// unhealthy AND no member is raw-AWOL, the empty-result reply must still surface +// the degraded/SKIPPED warning rather than a silent all-clear, so staff know the +// cache was down (every other terminal degraded path warns; this one must too). +func TestRunAwol_UnhealthyCacheEmptyListWarns(t *testing.T) { + // All members posted recently โ†’ no raw-AWOL, empty result. Cache unhealthy. + roster := utils.LiteRosterResponse{ + LiteProfiles: map[string]utils.LiteProfileResponse{ + "100": awolMember("Fresh.A", "100", "2026-05-14 12:00:00"), + "200": awolMember("Fresh.B", "200", "2026-05-13 12:00:00"), + }, + } + serveAwolRoster(t, roster, http.StatusOK) + + cache := unhealthyCache(nil, awolRefDate.Add(-30*time.Minute)) + f := &fakeResponder{} + i := fakeAppCommandInteraction(stringOption("position", "1-7")) + + runAwol(f, cache, awolRefDate, i) + + edit := f.Calls()[1].Edit + got := "" + if edit.Content != nil { + got = *edit.Content + } + if !strings.Contains(got, "SKIPPED") { + t.Fatalf("unhealthy-cache empty result must warn the adjustment was SKIPPED, not a silent all-clear.\nGot: %q", got) + } +} + +func TestRunAwol_HealthyCacheEmptyListAllClear(t *testing.T) { + // Healthy cache + empty result โ†’ the plain all-clear (no degraded warning). + roster := utils.LiteRosterResponse{ + LiteProfiles: map[string]utils.LiteProfileResponse{ + "100": awolMember("Fresh.A", "100", "2026-05-14 12:00:00"), + }, + } + serveAwolRoster(t, roster, http.StatusOK) + + f := &fakeResponder{} + i := fakeAppCommandInteraction(stringOption("position", "1-7")) + runAwol(f, healthyCache(nil), awolRefDate, i) + + got := "" + if edit := f.Calls()[1].Edit; edit.Content != nil { + got = *edit.Content + } + if !strings.Contains(got, "no users matching") || strings.Contains(got, "SKIPPED") { + t.Fatalf("healthy empty result should be the plain all-clear without a degraded warning.\nGot: %q", got) + } +} + func TestRunAwol_UnhealthyCacheFileOutputCarriesWarning(t *testing.T) { // Force the file path; the unhealthy cache must surface the skipped-adjustment // warning and raw figures in the generated report rather than silent all-clear. diff --git a/utils/awol_accountable_test.go b/utils/awol_accountable_test.go index 192565d..e3c07c4 100644 --- a/utils/awol_accountable_test.go +++ b/utils/awol_accountable_test.go @@ -197,6 +197,48 @@ func TestAccountableDaysAWOL(t *testing.T) { } } +// TestValidLOAWindows_DropsZeroValueEntry pins item 9: a zero-value LOAEntry +// (both dates zero) is malformed and must be filtered, never treated as covering +// the epoch-zero UTC date. Unreachable from production (parseLOAPost only emits +// when both dates parse) but cheap to harden. +func TestValidLOAWindows_DropsZeroValueEntry(t *testing.T) { + got := validLOAWindows([]LOAEntry{{}}) + if len(got) != 0 { + t.Fatalf("validLOAWindows should drop a zero-value entry; got %d kept", len(got)) + } +} + +// TestValidLOAWindows_MixedSliceKeepsValid pins that a mixed slice (one valid +// window + one end-before-start window for the same user) keeps the valid one so +// it still subtracts. +func TestValidLOAWindows_MixedSliceKeepsValid(t *testing.T) { + valid := loa(d(2026, time.January, 3), d(2026, time.January, 10)) + backwards := loa(d(2026, time.January, 20), d(2026, time.January, 5)) + got := validLOAWindows([]LOAEntry{valid, backwards}) + if len(got) != 1 || !got[0].StartDate.Equal(valid.StartDate) { + t.Fatalf("validLOAWindows should keep only the well-formed window; got %+v", got) + } +} + +// TestAccountableDaysAWOL_MixedSliceValidStillSubtracts pins the same via the +// public calc: one valid + one backwards window for the user โ†’ the valid one +// still subtracts its covered dates. +func TestAccountableDaysAWOL_MixedSliceValidStillSubtracts(t *testing.T) { + // Candidate (Jan 1, Jan 20] = Jan 2..20 (19). Valid LOA Jan 3..10 covers 8. + // Backwards LOA ignored. Accountable 11 โ†’ 11-7 = 4. + got := AccountableDaysAWOL( + d(2026, time.January, 1), + d(2026, time.January, 20), + []LOAEntry{ + loa(d(2026, time.January, 3), d(2026, time.January, 10)), + loa(d(2026, time.January, 20), d(2026, time.January, 5)), + }, + ) + if got != 4 { + t.Fatalf("AccountableDaysAWOL = %d, want 4", got) + } +} + // TestActiveWindow_UsesInjectedNow pins the PR #161 clock-skew fix: window // selection is decided against the caller's `now`, not an internal clock, and at // an exact boundary (now == EndDate is still active; now one tick past is not). @@ -205,6 +247,10 @@ func TestActiveWindow_UsesInjectedNow(t *testing.T) { w.ThreadID = 7 windows := []LOAEntry{w} + // On the StartDate (inclusive lower bound) โ†’ active. + if got, ok := ActiveWindow(windows, d(2026, time.January, 1)); !ok || got.ThreadID != 7 { + t.Fatalf("expected active window on StartDate (inclusive); got ok=%v id=%d", ok, got.ThreadID) + } // On the EndDate (inclusive) โ†’ active. if got, ok := ActiveWindow(windows, d(2026, time.January, 10)); !ok || got.ThreadID != 7 { t.Fatalf("expected active window on EndDate; got ok=%v id=%d", ok, got.ThreadID) @@ -219,6 +265,18 @@ func TestActiveWindow_UsesInjectedNow(t *testing.T) { } } +// TestActiveWindow_NoActiveReturnsFalse pins the empty/no-active return: an empty +// history and a history with no window covering `now` both yield (_, false). +func TestActiveWindow_NoActiveReturnsFalse(t *testing.T) { + if _, ok := ActiveWindow(nil, d(2026, time.January, 5)); ok { + t.Fatalf("empty history should yield no active window") + } + windows := []LOAEntry{loa(d(2026, time.January, 1), d(2026, time.January, 10))} + if _, ok := ActiveWindow(windows, d(2026, time.February, 1)); ok { + t.Fatalf("no window covers now โ†’ expected (_, false)") + } +} + func TestActiveWindow_LatestEndingWinsAmongOverlapping(t *testing.T) { a := loa(d(2026, time.January, 1), d(2026, time.January, 10)) a.ThreadID = 1 @@ -230,6 +288,33 @@ func TestActiveWindow_LatestEndingWinsAmongOverlapping(t *testing.T) { } } +// TestDaysSinceLastPost_NoClampContract pins that DaysSinceLastPost is the raw +// whole-date span with NO 7-day clamp, unlike RawDaysAWOL which subtracts the +// requirement. 8 candidate dates โ†’ DaysSinceLastPost 8, RawDaysAWOL 1; 7 dates โ†’ +// DaysSinceLastPost 7, RawDaysAWOL 0. +func TestDaysSinceLastPost_NoClampContract(t *testing.T) { + // (Jan 1, Jan 8] = 7 candidate dates. + if got := DaysSinceLastPost(d(2026, time.January, 1), d(2026, time.January, 8)); got != 7 { + t.Fatalf("DaysSinceLastPost(Jan1,Jan8) = %d, want 7 (no clamp)", got) + } + if got := RawDaysAWOL(d(2026, time.January, 1), d(2026, time.January, 8)); got != 0 { + t.Fatalf("RawDaysAWOL(Jan1,Jan8) = %d, want 0 (clamped at requirement)", got) + } +} + +// TestDaysSinceLastPost_EarlyReturnGuard exercises the rawAccountableDates +// now<=lastPost guard via the raw path: posted today and a future-clock skew both +// yield zero candidate dates. +func TestDaysSinceLastPost_EarlyReturnGuard(t *testing.T) { + if got := DaysSinceLastPost(d(2026, time.January, 9), d(2026, time.January, 9)); got != 0 { + t.Fatalf("DaysSinceLastPost posted-today = %d, want 0", got) + } + // now before lastPost (clock skew) โ†’ still 0, not negative. + if got := DaysSinceLastPost(d(2026, time.January, 9), d(2026, time.January, 1)); got != 0 { + t.Fatalf("DaysSinceLastPost now Date: Thu, 4 Jun 2026 16:11:10 -0400 Subject: [PATCH 6/6] test(awol): make embed-limit guard real + relabel calc-level smoke Rework TestRunAwol_EmbedDescriptionWithinDiscordLimit so a chunk provably lands in the danger band (4077, 4096]: 51 no-LOA rows render at exactly 80 bytes each, so the pre-fix budget (4096) packs all 51 into one chunk (19-byte summary prefix + 4080 = 4099 > 4096) while the prefix-aware budget (4096 - 19 = 4077) flushes after 50 rows (4019 <= 4096). The old ~139-byte lines stepped over the band, so the test passed even against the un-fixed budget. Assert on bytes (the conservative production measure) across every emitted embed, and reference discordEmbedDescriptionLimit. Verified: PASS on current code, FAIL (4099 bytes) against a reverted budget, PASS after restore. Relabel TestAccountableDaysAWOL_MixedSliceValidStillSubtracts as a calc-level smoke test: it does not pin the validLOAWindows end-before-start filter (that branch is guarded by TestValidLOAWindows_MixedSliceKeepsValid); coveredByLOA's range check already covers nothing for a backwards window. No production logic changed. Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/awol_test.go | 50 ++++++++++++++++++++++++---------- utils/awol_accountable_test.go | 14 +++++++--- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/commands/awol_test.go b/commands/awol_test.go index 5fe97a4..d2bf4d4 100644 --- a/commands/awol_test.go +++ b/commands/awol_test.go @@ -268,19 +268,38 @@ func lineContaining(s, sub string) string { } // TestRunAwol_EmbedDescriptionWithinDiscordLimit pins item 1: the per-chunk -// budget must leave room for the summary-line prefix + separator (and any footer -// that rides along), so the FINAL embed description never exceeds Discord's 4096 -// limit. A single near-limit user line previously produced a chunk at ~4096 that, -// once the summary + "\n\n" was prepended, overflowed โ†’ Discord 400. +// budget must leave room for the summary-line prefix + separator, so the FINAL +// rendered description (summaryLine + "\n\n" + chunk) never exceeds Discord's +// 4096-byte cap. Production budgets on len(...) (bytes), so this test asserts on +// bytes โ€” the conservative measure โ€” not runes. +// +// 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. +// +// 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 +// 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 +// so we stay on the embed path, not the file fallback.) +const ( + embedLimitTestUsers = 51 + embedLimitTestPad = 1 // โ†’ 80-byte rows; see sizing math above +) + func TestRunAwol_EmbedDescriptionWithinDiscordLimit(t *testing.T) { - // Many moderately-padded users whose rendered lines accumulate into a chunk - // sitting just under the raw 4096 cap. Without the prefix-aware budget the - // summary line + "\n\n" prepended on top pushes the description past 4096. - // ~60-char usernames โ†’ ~110-byte lines; 36 of them โ‰ˆ 3960 bytes, the next - // fills toward the cap. Each line individually stays well under the budget. - pad := strings.Repeat("x", 60) + pad := strings.Repeat("x", embedLimitTestPad) roster := utils.LiteRosterResponse{LiteProfiles: map[string]utils.LiteProfileResponse{}} - for n := range 40 { + for n := range embedLimitTestUsers { id := fmt.Sprintf("%d", 100+n) roster.LiteProfiles[id] = awolMember(fmt.Sprintf("U%02d_%s", n, pad), id, "2026-03-01 12:00:00") } @@ -292,11 +311,14 @@ func TestRunAwol_EmbedDescriptionWithinDiscordLimit(t *testing.T) { edit := f.Calls()[1].Edit if edit.Embeds == nil { - t.Fatalf("expected embeds on Edit; got %+v", edit) + t.Fatalf("expected embeds on Edit (stay on embed path); got %+v", edit) } + // Assert on bytes across EVERY emitted embed โ€” the overflow can land in any + // chunk, and production budgets on bytes (len), not runes. for idx, e := range *edit.Embeds { - if n := len([]rune(e.Description)); n > 4096 { - t.Fatalf("embed[%d] description = %d runes, exceeds Discord 4096 limit", idx, n) + if n := len(e.Description); n > discordEmbedDescriptionLimit { + t.Fatalf("embed[%d] description = %d bytes, exceeds Discord %d-byte limit", + idx, n, discordEmbedDescriptionLimit) } } } diff --git a/utils/awol_accountable_test.go b/utils/awol_accountable_test.go index e3c07c4..7170f27 100644 --- a/utils/awol_accountable_test.go +++ b/utils/awol_accountable_test.go @@ -220,12 +220,18 @@ func TestValidLOAWindows_MixedSliceKeepsValid(t *testing.T) { } } -// TestAccountableDaysAWOL_MixedSliceValidStillSubtracts pins the same via the -// public calc: one valid + one backwards window for the user โ†’ the valid one -// still subtracts its covered dates. +// TestAccountableDaysAWOL_MixedSliceValidStillSubtracts is a calc-level smoke +// test of the public AccountableDaysAWOL path on a mixed slice (one valid + one +// end-before-start window): the valid window still subtracts its covered dates +// end-to-end. +// +// It does NOT pin the validLOAWindows end-before-start filter โ€” that branch is +// guarded directly by TestValidLOAWindows_MixedSliceKeepsValid (len==1). Removing +// the filter here would still yield 4, because coveredByLOA's own range check +// (!Before(start) && !After(end)) already covers nothing for a backwards window. func TestAccountableDaysAWOL_MixedSliceValidStillSubtracts(t *testing.T) { // Candidate (Jan 1, Jan 20] = Jan 2..20 (19). Valid LOA Jan 3..10 covers 8. - // Backwards LOA ignored. Accountable 11 โ†’ 11-7 = 4. + // Backwards LOA covers nothing either way. Accountable 11 โ†’ 11-7 = 4. got := AccountableDaysAWOL( d(2026, time.January, 1), d(2026, time.January, 20),