Serve entries, groups and status: filtered, sorted, with the weekly reading-lessons measure (issue 1521)
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
// Ordering, filtering and the weekly measure the verbs entries, groups and status serve (novox/hq ADR 0459
|
||||
// §2, §10; issue 1521): pure functions, no store — tools.go's entriesTool, groupsTool and statusTool call
|
||||
// them over what Store.Entries, Store.Groups and Store.Lessons already answered.
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SortEntries orders mail entries as entries answers them (1521/AC1): those marked matters first, nearest
|
||||
// due date first among them (none last, within that group); then the rest, newest first.
|
||||
func SortEntries(entries []Entry) []Entry {
|
||||
sorted := append([]Entry(nil), entries...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
a, b := sorted[i], sorted[j]
|
||||
aMatters, bMatters := a.Mark == MarkMatters, b.Mark == MarkMatters
|
||||
if aMatters != bMatters {
|
||||
return aMatters
|
||||
}
|
||||
if aMatters {
|
||||
aNone, bNone := a.DueDate.IsZero(), b.DueDate.IsZero()
|
||||
if aNone != bNone {
|
||||
return bNone // a has a due date, b does not: a first (none last)
|
||||
}
|
||||
if !aNone {
|
||||
return a.DueDate.Before(b.DueDate) // nearest due date first
|
||||
}
|
||||
}
|
||||
return a.Date.After(b.Date) // newest first, otherwise
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
// FilterEntries keeps only entries of the given mail group (when group is not 0) and the given mark (when
|
||||
// mark is not "") (1521/AC1, AC4: "filtered by mail group or by mark when asked").
|
||||
func FilterEntries(entries []Entry, group int64, mark Mark) []Entry {
|
||||
var out []Entry
|
||||
for _, e := range entries {
|
||||
if group != 0 && e.Group != group {
|
||||
continue
|
||||
}
|
||||
if mark != "" && e.Mark != mark {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SortGroups orders mail groups largest first (1521/AC2), by label ascending to break a tie.
|
||||
func SortGroups(groups []Group) []Group {
|
||||
sorted := append([]Group(nil), groups...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
if sorted[i].Count != sorted[j].Count {
|
||||
return sorted[i].Count > sorted[j].Count
|
||||
}
|
||||
return sorted[i].Label < sorted[j].Label
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
// WeeksCovered is how many trailing weeks the status verb's own measure covers (ADR 0459 §10: "for each of
|
||||
// the last 8 weeks").
|
||||
const WeeksCovered = 8
|
||||
|
||||
// WeekMeasure is one week's reading-lessons-per-100-read-mails measure (ADR 0459 §10, 1521/AC3).
|
||||
type WeekMeasure struct {
|
||||
WeekStart time.Time
|
||||
MailsRead int
|
||||
Lessons int
|
||||
// Rate is lessons given that week, per 100 mails read that week; nil when MailsRead is 0 ("a week with no
|
||||
// mail read says so" — this nil is that saying).
|
||||
Rate *float64
|
||||
}
|
||||
|
||||
// weekStart is the Monday, UTC midnight, of the week t falls in.
|
||||
func weekStart(t time.Time) time.Time {
|
||||
t = t.UTC()
|
||||
offset := (int(t.Weekday()) + 6) % 7 // Monday=0 ... Sunday=6
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, -offset)
|
||||
}
|
||||
|
||||
// WeeklyMeasure computes the reading-lessons-per-100-read-mails measure for each of the last WeeksCovered
|
||||
// weeks, the oldest first, ending with the week now falls in (1521/AC3): lessons given that week, divided by
|
||||
// mails read (judged) that week, times 100; a week with no mail read carries no rate.
|
||||
func WeeklyMeasure(entries []Entry, lessons []Lesson, now time.Time) []WeekMeasure {
|
||||
start := weekStart(now).AddDate(0, 0, -7*(WeeksCovered-1))
|
||||
out := make([]WeekMeasure, WeeksCovered)
|
||||
for i := range out {
|
||||
out[i].WeekStart = start.AddDate(0, 0, 7*i)
|
||||
}
|
||||
indexOf := func(t time.Time) int {
|
||||
if t.IsZero() || t.Before(start) {
|
||||
return -1
|
||||
}
|
||||
idx := int(t.UTC().Sub(start).Hours() / (24 * 7))
|
||||
if idx < 0 || idx >= WeeksCovered {
|
||||
return -1
|
||||
}
|
||||
return idx
|
||||
}
|
||||
for _, e := range entries {
|
||||
if i := indexOf(e.Judged); i >= 0 {
|
||||
out[i].MailsRead++
|
||||
}
|
||||
}
|
||||
for _, l := range lessons {
|
||||
if i := indexOf(l.Made); i >= 0 {
|
||||
out[i].Lessons++
|
||||
}
|
||||
}
|
||||
for i := range out {
|
||||
if out[i].MailsRead > 0 {
|
||||
rate := float64(out[i].Lessons) / float64(out[i].MailsRead) * 100
|
||||
out[i].Rate = &rate
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func entryAt(id int64, mark Mark, date time.Time, due time.Time, group int64) Entry {
|
||||
return Entry{ID: id, Mark: mark, Date: date, DueDate: due, Group: group, Judged: date}
|
||||
}
|
||||
|
||||
// 1521/AC1: entries newest first, except a mail that matters comes first, nearest due date first among
|
||||
// those (none last).
|
||||
func TestSortEntries(t *testing.T) {
|
||||
d := func(day int) time.Time { return time.Date(2026, 10, day, 0, 0, 0, 0, time.UTC) }
|
||||
entries := []Entry{
|
||||
entryAt(1, MarkNeither, d(10), time.Time{}, 0),
|
||||
entryAt(2, MarkMatters, d(5), d(20), 0), // matters, due the 20th
|
||||
entryAt(3, MarkMatters, d(1), time.Time{}, 0), // matters, no due date
|
||||
entryAt(4, MarkNeither, d(15), time.Time{}, 0),
|
||||
entryAt(5, MarkMatters, d(8), d(12), 0), // matters, due the 12th (nearest)
|
||||
}
|
||||
sorted := SortEntries(entries)
|
||||
var order []int64
|
||||
for _, e := range sorted {
|
||||
order = append(order, e.ID)
|
||||
}
|
||||
want := []int64{5, 2, 3, 4, 1} // matters by nearest due date (5 then 2), matters-no-due-date last among matters (3), then non-matters newest first (4, 1)
|
||||
if len(order) != len(want) {
|
||||
t.Fatalf("order = %v, want %v", order, want)
|
||||
}
|
||||
for i := range want {
|
||||
if order[i] != want[i] {
|
||||
t.Fatalf("order = %v, want %v", order, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1521/AC1, AC4: filtered by mail group or by mark when asked.
|
||||
func TestFilterEntries(t *testing.T) {
|
||||
entries := []Entry{
|
||||
{ID: 1, Group: 10, Mark: MarkMatters},
|
||||
{ID: 2, Group: 10, Mark: MarkNeither},
|
||||
{ID: 3, Group: 20, Mark: MarkMatters},
|
||||
}
|
||||
if got := FilterEntries(entries, 10, ""); len(got) != 2 {
|
||||
t.Fatalf("filter by group 10: %v, want 2", got)
|
||||
}
|
||||
if got := FilterEntries(entries, 0, MarkMatters); len(got) != 2 {
|
||||
t.Fatalf("filter by mark matters: %v, want 2", got)
|
||||
}
|
||||
if got := FilterEntries(entries, 10, MarkMatters); len(got) != 1 || got[0].ID != 1 {
|
||||
t.Fatalf("filter by group 10 and mark matters: %v, want just entry 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1521/AC2: groups largest first.
|
||||
func TestSortGroups(t *testing.T) {
|
||||
groups := []Group{{ID: 1, Label: "small", Count: 1}, {ID: 2, Label: "big", Count: 100}, {ID: 3, Label: "medium", Count: 10}}
|
||||
sorted := SortGroups(groups)
|
||||
if sorted[0].Label != "big" || sorted[1].Label != "medium" || sorted[2].Label != "small" {
|
||||
t.Fatalf("sorted = %+v, want big, medium, small", sorted)
|
||||
}
|
||||
}
|
||||
|
||||
// 1521/AC3: reading lessons per 100 read mails for each of the last 8 weeks; a week with no mail read has
|
||||
// no rate.
|
||||
func TestWeeklyMeasure(t *testing.T) {
|
||||
now := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC) // a Wednesday
|
||||
thisWeek := weekStart(now)
|
||||
lastWeek := thisWeek.AddDate(0, 0, -7)
|
||||
|
||||
entries := []Entry{
|
||||
{ID: 1, Judged: thisWeek.Add(time.Hour)},
|
||||
{ID: 2, Judged: thisWeek.Add(2 * time.Hour)},
|
||||
{ID: 3, Judged: lastWeek.Add(time.Hour)},
|
||||
}
|
||||
lessons := []Lesson{
|
||||
{ID: 1, Made: thisWeek.Add(3 * time.Hour)},
|
||||
}
|
||||
weeks := WeeklyMeasure(entries, lessons, now)
|
||||
if len(weeks) != WeeksCovered {
|
||||
t.Fatalf("len(weeks) = %d, want %d", len(weeks), WeeksCovered)
|
||||
}
|
||||
last := weeks[len(weeks)-1]
|
||||
if !last.WeekStart.Equal(thisWeek) {
|
||||
t.Fatalf("last week's start = %v, want %v", last.WeekStart, thisWeek)
|
||||
}
|
||||
if last.MailsRead != 2 || last.Lessons != 1 || last.Rate == nil || *last.Rate != 50 {
|
||||
t.Fatalf("this week = %+v, want 2 read, 1 lesson, rate 50", last)
|
||||
}
|
||||
secondLast := weeks[len(weeks)-2]
|
||||
if secondLast.MailsRead != 1 || secondLast.Lessons != 0 || secondLast.Rate == nil || *secondLast.Rate != 0 {
|
||||
t.Fatalf("last week = %+v, want 1 read, 0 lessons, rate 0", secondLast)
|
||||
}
|
||||
// Every other week (further back, with no mail read) carries no rate.
|
||||
for i := 0; i < len(weeks)-2; i++ {
|
||||
if weeks[i].Rate != nil {
|
||||
t.Fatalf("week %d = %+v, want no rate (no mail read)", i, weeks[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1521/AC1: the entries verb answers the mail entries newest first, filtered by group or mark, with the
|
||||
// required fields.
|
||||
func TestEntriesToolAnswersFilteredAndSorted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
g, err := store.FindOrCreateGroup(ctx, "newsletters")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.AddEntry(ctx, Entry{Account: "bob", Subject: "a", Mark: MarkNeither, Group: g.ID, Date: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)})
|
||||
store.AddEntry(ctx, Entry{Account: "bob", Subject: "b", Mark: MarkMatters, DueDate: time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC), Date: time.Date(2026, 10, 2, 0, 0, 0, 0, time.UTC)})
|
||||
|
||||
b := &Bundle{
|
||||
Caller: func(map[string]any) string { return "mesh-mail-web" },
|
||||
callersOverride: func() (Callers, error) { return Callers{"mesh-mail-web"}, nil },
|
||||
Store: func(context.Context) (Store, error) { return store, nil },
|
||||
}
|
||||
out, err := b.entriesTool(map[string]any{"group": "newsletters"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
entries := m["entries"].([]map[string]any)
|
||||
if len(entries) != 1 || entries[0]["subject"] != "a" || entries[0]["group"] != "newsletters" {
|
||||
t.Fatalf("filtered by group: %v", entries)
|
||||
}
|
||||
|
||||
out, err = b.entriesTool(map[string]any{"mark": "matters"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries = out.(map[string]any)["entries"].([]map[string]any)
|
||||
if len(entries) != 1 || entries[0]["subject"] != "b" || entries[0]["due-date"] != "2026-11-01" {
|
||||
t.Fatalf("filtered by mark: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// 1521/AC2: the groups verb answers each mail group with how many mails it holds, largest first.
|
||||
func TestGroupsToolAnswersLargestFirst(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
small, _ := store.FindOrCreateGroup(ctx, "small")
|
||||
big, _ := store.FindOrCreateGroup(ctx, "big")
|
||||
store.AddEntry(ctx, Entry{Group: small.ID})
|
||||
store.AddEntry(ctx, Entry{Group: big.ID})
|
||||
store.AddEntry(ctx, Entry{Group: big.ID})
|
||||
|
||||
b := &Bundle{
|
||||
Caller: func(map[string]any) string { return "mesh-mail-web" },
|
||||
callersOverride: func() (Callers, error) { return Callers{"mesh-mail-web"}, nil },
|
||||
Store: func(context.Context) (Store, error) { return store, nil },
|
||||
}
|
||||
out, err := b.groupsTool(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
groups := out.(map[string]any)["groups"].([]map[string]any)
|
||||
if len(groups) != 2 || groups[0]["label"] != "big" || groups[0]["count"] != 2 || groups[1]["label"] != "small" || groups[1]["count"] != 1 {
|
||||
t.Fatalf("groups = %v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
// 1521/AC3: status answers healthy or not, the cause and since when, and the weekly measure.
|
||||
func TestStatusToolAnswersHealthAndWeeklyMeasure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
since := time.Date(2026, 10, 20, 9, 30, 0, 0, time.UTC)
|
||||
store.SetReaderHealth(ctx, ReaderHealth{Healthy: false, Cause: OutcomeRefused, Why: "holds no licence", Since: since})
|
||||
|
||||
b := &Bundle{
|
||||
Caller: func(map[string]any) string { return "mesh-mail-web" },
|
||||
callersOverride: func() (Callers, error) { return Callers{"mesh-mail-web"}, nil },
|
||||
Store: func(context.Context) (Store, error) { return store, nil },
|
||||
}
|
||||
out, err := b.statusTool(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
if m["healthy"] != false || m["cause"] != "refused" || m["why"] != "holds no licence" {
|
||||
t.Fatalf("status = %v", m)
|
||||
}
|
||||
weeks := m["weeks"].([]map[string]any)
|
||||
if len(weeks) != WeeksCovered {
|
||||
t.Fatalf("len(weeks) = %d, want %d", len(weeks), WeeksCovered)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
@@ -93,6 +94,19 @@ func schema(required []string, props map[string]any) map[string]any {
|
||||
func text(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
||||
func integer(d string) map[string]any { return map[string]any{"type": "integer", "description": d} }
|
||||
|
||||
// number reads an optional whole-number argument, otherwise when left out.
|
||||
func number(args map[string]any, key string, otherwise int) (int, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return otherwise, nil
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
if !ok || f != float64(int(f)) {
|
||||
return 0, errors.New(key + " is a whole number")
|
||||
}
|
||||
return int(f), nil
|
||||
}
|
||||
|
||||
// only refuses an argument a tool does not take.
|
||||
func only(args map[string]any, takes ...string) error {
|
||||
for k := range args {
|
||||
@@ -116,15 +130,19 @@ func (b *Bundle) Tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{Name: EntriesTool,
|
||||
Description: "Every mail entry (novox/hq ADR 0459 §4): the mail's own headers, its mail group, a " +
|
||||
"one-line summary, its mark and why, its due date — never a mail body. Answered only to a caller " +
|
||||
"the setting callers names. Read-only.",
|
||||
"one-line summary, its mark and why, its due date — never a mail body. Newest first, except a " +
|
||||
"mail that matters comes first, nearest due date first among those (none last). Filtered by mail " +
|
||||
"group (its label) or by mark when asked. Answered only to a caller the setting callers names. " +
|
||||
"Read-only.",
|
||||
Input: schema(nil, map[string]any{
|
||||
"limit": integer("how many to answer, newest first; a default bounds it when left out"),
|
||||
"limit": integer("how many to answer; a default bounds it when left out"),
|
||||
"group": text("only mails of this mail group, by its label"),
|
||||
"mark": text("only mails of this mark: matters, scam warning or neither"),
|
||||
}),
|
||||
Run: b.entriesTool},
|
||||
{Name: GroupsTool,
|
||||
Description: "Every mail group a batch has made (novox/hq ADR 0459 §4, §5), largest first. " +
|
||||
"Answered only to a caller the setting callers names. Read-only.",
|
||||
Description: "Every mail group a batch has made (novox/hq ADR 0459 §4, §5), with how many mails it " +
|
||||
"holds, largest first. Answered only to a caller the setting callers names. Read-only.",
|
||||
Input: schema(nil, map[string]any{
|
||||
"limit": integer("how many to answer; a default bounds it when left out"),
|
||||
}),
|
||||
@@ -156,9 +174,9 @@ func (b *Bundle) Tools() []stdio.Tool {
|
||||
}),
|
||||
Run: b.undoTool},
|
||||
{Name: StatusTool,
|
||||
Description: "How mail reading stands for the page (novox/hq ADR 0459 §10): lessons kept, lessons " +
|
||||
"undone, and lessons per 100 mails read this week. Answered only to a caller the setting callers " +
|
||||
"names. Read-only.",
|
||||
Description: "How mail reading stands for the page (novox/hq ADR 0459 §10): healthy or not, the cause " +
|
||||
"and since when, and reading lessons per 100 read mails for each of the last 8 weeks (a week with " +
|
||||
"no mail read says so). Answered only to a caller the setting callers names. Read-only.",
|
||||
Input: schema(nil, map[string]any{}),
|
||||
Run: b.statusTool},
|
||||
{Name: HealthTool,
|
||||
@@ -170,14 +188,72 @@ func (b *Bundle) Tools() []stdio.Tool {
|
||||
}
|
||||
}
|
||||
|
||||
// entryOut is one mail entry as entriesTool answers it (1521/AC1): account, message id, date, sender name,
|
||||
// subject, group (by its label), summary, mark, why and due date — never a mail body.
|
||||
func entryOut(e Entry, labels map[int64]string) map[string]any {
|
||||
due := ""
|
||||
if !e.DueDate.IsZero() {
|
||||
due = e.DueDate.Format("2006-01-02")
|
||||
}
|
||||
return map[string]any{
|
||||
"id": e.ID, "account": e.Account, "message-id": e.MessageID, "date": e.Date.UTC().Format(time.RFC3339),
|
||||
"sender-name": e.SenderName, "subject": e.Subject, "group": labels[e.Group], "summary": e.Summary,
|
||||
"mark": string(e.Mark), "why": e.Why, "due-date": due,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bundle) entriesTool(args map[string]any) (any, error) {
|
||||
if _, err := b.checkCaller(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := only(args, "limit"); err != nil {
|
||||
if err := only(args, "limit", "group", "mark"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, notBuiltYet(EntriesTool, "1521")
|
||||
limit, err := number(args, "limit", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupLabel, _ := args["group"].(string)
|
||||
markStr, _ := args["mark"].(string)
|
||||
mark := Mark(markStr)
|
||||
if mark != "" && mark != MarkMatters && mark != MarkScamWarning && mark != MarkNeither {
|
||||
return nil, fmt.Errorf("%s needs mark to be matters, scam warning or neither", EntriesTool)
|
||||
}
|
||||
ctx := context.Background()
|
||||
store, err := b.Store(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups, err := store.Groups(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
labels := map[int64]string{}
|
||||
var groupID int64
|
||||
for _, g := range groups {
|
||||
labels[g.ID] = g.Label
|
||||
if groupLabel != "" && g.Label == groupLabel {
|
||||
groupID = g.ID
|
||||
}
|
||||
}
|
||||
entries, err := store.Entries(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if groupLabel != "" {
|
||||
entries = FilterEntries(entries, groupID, mark)
|
||||
} else {
|
||||
entries = FilterEntries(entries, 0, mark)
|
||||
}
|
||||
entries = SortEntries(entries)
|
||||
if limit > 0 && len(entries) > limit {
|
||||
entries = entries[:limit]
|
||||
}
|
||||
out := make([]map[string]any, len(entries))
|
||||
for i, e := range entries {
|
||||
out[i] = entryOut(e, labels)
|
||||
}
|
||||
return map[string]any{"entries": out}, nil
|
||||
}
|
||||
|
||||
func (b *Bundle) groupsTool(args map[string]any) (any, error) {
|
||||
@@ -187,7 +263,28 @@ func (b *Bundle) groupsTool(args map[string]any) (any, error) {
|
||||
if err := only(args, "limit"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, notBuiltYet(GroupsTool, "1521")
|
||||
limit, err := number(args, "limit", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx := context.Background()
|
||||
store, err := b.Store(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups, err := store.Groups(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups = SortGroups(groups)
|
||||
if limit > 0 && len(groups) > limit {
|
||||
groups = groups[:limit]
|
||||
}
|
||||
out := make([]map[string]any, len(groups))
|
||||
for i, g := range groups {
|
||||
out[i] = map[string]any{"label": g.Label, "count": g.Count}
|
||||
}
|
||||
return map[string]any{"groups": out}, nil
|
||||
}
|
||||
|
||||
func (b *Bundle) correctTool(args map[string]any) (any, error) {
|
||||
@@ -227,7 +324,42 @@ func (b *Bundle) statusTool(args map[string]any) (any, error) {
|
||||
if err := only(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, notBuiltYet(StatusTool, "1521")
|
||||
ctx := context.Background()
|
||||
store, err := b.Store(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h, err := store.ReaderHealth(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := store.Entries(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lessons, err := store.Lessons(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
weeks := WeeklyMeasure(entries, lessons, time.Now())
|
||||
weeksOut := make([]map[string]any, len(weeks))
|
||||
for i, w := range weeks {
|
||||
week := map[string]any{"week-start": w.WeekStart.Format("2006-01-02"), "mails-read": w.MailsRead, "lessons": w.Lessons}
|
||||
if w.Rate != nil {
|
||||
week["lessons-per-100"] = *w.Rate
|
||||
} else {
|
||||
week["lessons-per-100"] = nil
|
||||
week["why"] = "no mail was read this week"
|
||||
}
|
||||
weeksOut[i] = week
|
||||
}
|
||||
out := map[string]any{"healthy": h.Healthy, "weeks": weeksOut}
|
||||
if !h.Healthy {
|
||||
out["cause"] = string(h.Cause)
|
||||
out["why"] = h.Why
|
||||
out["since"] = h.Since.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *Bundle) healthTool(args map[string]any) (any, error) {
|
||||
|
||||
@@ -34,38 +34,46 @@ func TestToolsListsTheSixVerbsAndHealth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1426/AC4: a mail-reading verb answers a caller the setting callers names, and refuses any other, naming why.
|
||||
// 1426/AC4, 1521/AC4: a mail-reading verb answers a caller the setting callers names, and refuses any other,
|
||||
// naming why. entries, groups and status now answer for real (1521); correct, lessons and undo still answer
|
||||
// not built yet (1428).
|
||||
func TestAMailReadingVerbChecksItsCaller(t *testing.T) {
|
||||
granted := func(map[string]any) string { return "mesh-mail-web" }
|
||||
refused := func(map[string]any) string { return "person.someoneelse" }
|
||||
unproven := func(map[string]any) string { return Unproven }
|
||||
storeOf := func(context.Context) (Store, error) { return newMemStore(), nil }
|
||||
|
||||
for _, tool := range []struct {
|
||||
name string
|
||||
run func(*Bundle) (any, error)
|
||||
name string
|
||||
run func(*Bundle) (any, error)
|
||||
built bool // whether its issue has landed: true answers for real, false answers "not built yet"
|
||||
}{
|
||||
{EntriesTool, func(b *Bundle) (any, error) { return b.entriesTool(map[string]any{}) }},
|
||||
{GroupsTool, func(b *Bundle) (any, error) { return b.groupsTool(map[string]any{}) }},
|
||||
{CorrectTool, func(b *Bundle) (any, error) { return b.correctTool(map[string]any{"id": float64(1)}) }},
|
||||
{LessonsTool, func(b *Bundle) (any, error) { return b.lessonsTool(map[string]any{}) }},
|
||||
{UndoTool, func(b *Bundle) (any, error) { return b.undoTool(map[string]any{"id": float64(1)}) }},
|
||||
{StatusTool, func(b *Bundle) (any, error) { return b.statusTool(map[string]any{}) }},
|
||||
{EntriesTool, func(b *Bundle) (any, error) { return b.entriesTool(map[string]any{}) }, true},
|
||||
{GroupsTool, func(b *Bundle) (any, error) { return b.groupsTool(map[string]any{}) }, true},
|
||||
{CorrectTool, func(b *Bundle) (any, error) { return b.correctTool(map[string]any{"id": float64(1)}) }, false},
|
||||
{LessonsTool, func(b *Bundle) (any, error) { return b.lessonsTool(map[string]any{}) }, false},
|
||||
{UndoTool, func(b *Bundle) (any, error) { return b.undoTool(map[string]any{"id": float64(1)}) }, false},
|
||||
{StatusTool, func(b *Bundle) (any, error) { return b.statusTool(map[string]any{}) }, true},
|
||||
} {
|
||||
callers := func() (Callers, error) { return Callers{"mesh-mail-web"}, nil }
|
||||
|
||||
b := &Bundle{Caller: granted, callersOverride: callers}
|
||||
b := &Bundle{Caller: granted, callersOverride: callers, Store: storeOf}
|
||||
_, err := tool.run(b)
|
||||
if err == nil || !strings.Contains(err.Error(), "not built yet") {
|
||||
if tool.built {
|
||||
if err != nil {
|
||||
t.Errorf("%s granted: %v, want it to pass the caller check and answer", tool.name, err)
|
||||
}
|
||||
} else if err == nil || !strings.Contains(err.Error(), "not built yet") {
|
||||
t.Errorf("%s granted: %v, want it to pass the caller check and answer not built yet", tool.name, err)
|
||||
}
|
||||
|
||||
b = &Bundle{Caller: refused, callersOverride: callers}
|
||||
b = &Bundle{Caller: refused, callersOverride: callers, Store: storeOf}
|
||||
_, err = tool.run(b)
|
||||
if err == nil || !strings.Contains(err.Error(), "refused") || !strings.Contains(err.Error(), "person.someoneelse") {
|
||||
t.Errorf("%s refused caller: %v, want a refusal naming person.someoneelse", tool.name, err)
|
||||
}
|
||||
|
||||
b = &Bundle{Caller: unproven, callersOverride: callers}
|
||||
b = &Bundle{Caller: unproven, callersOverride: callers, Store: storeOf}
|
||||
_, err = tool.run(b)
|
||||
if err == nil || !strings.Contains(err.Error(), "refused") {
|
||||
t.Errorf("%s unproven caller: %v, want a refusal", tool.name, err)
|
||||
@@ -73,7 +81,7 @@ func TestAMailReadingVerbChecksItsCaller(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1426/AC4: a setting that cannot be read allows nobody, and says why.
|
||||
// 1426/AC4: a setting that cannot be read allows nobody, and says why — before the store is ever reached.
|
||||
func TestAMailReadingVerbRefusesWhenCallersCannotBeRead(t *testing.T) {
|
||||
b := &Bundle{
|
||||
Caller: func(map[string]any) string { return "mesh-mail-web" },
|
||||
|
||||
Reference in New Issue
Block a user