Phase 5a slice D. The MCP timeline tool no longer depends on *web.Server — it talks to *aggregate.Aggregator directly. The wrong-way mcp → web layering that necessitated the TimelineBuilder interface is gone. - mcp/tools.go: TimelineBuilder interface deleted. RegisterProjaxTools(s, st, agg *aggregate.Aggregator) now takes the aggregator directly; passing nil keeps the timeline tool unregistered (kill-switch contract unchanged). - mcp/tools.go: TimelineArgs moved from web/ to mcp/ since it is the MCP-facing input shape. The timeline tool runs the full pipeline: store.ListByFilters → in-mem timeline-exclude + has-link narrowing → agg.All(...) → Result.ToTimelineRows() → aggregate.BuildTimelineDays → timelineView. No web/ import in the timeline path. - internal/aggregate/rows.go: new Result.ToTimelineRows() helper that projects the typed rows into the flat TimelineRow sum-type both web/timeline.go and mcp/tools.go consume. Single source of truth for the Date-anchor choice across kinds. - internal/aggregate/timeline_days.go: FormatPERDate lifted from web/ so timeline-row builders outside web/ can render PER strings without re-importing web/. - web/timeline.go: BuildTimelinePayloadFromArgs + TimelineArgs deleted (no remaining callers — slice D inlined the MCP path). - cmd/projax/main.go: pass srv.Aggregator() into RegisterProjaxTools. MCP tree-filter parity note: the move to store.ListByFilters narrows status to a single value (first of args.Status) and AND-matches management (vs the web TreeFilter's OR). m's documented MCP uses (tag + default status) round-trip identically. Logged as a footnote in docs/plans/aggregator-refactor.md. All mcp + web + aggregate tests green. Task: t-projax-5a-aggregator
153 lines
5.1 KiB
Go
153 lines
5.1 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/m/projax/internal/aggregate"
|
|
"github.com/m/projax/store"
|
|
)
|
|
|
|
func mustTime(t *testing.T, s string) time.Time {
|
|
t.Helper()
|
|
tt, err := time.Parse(time.RFC3339, s)
|
|
if err != nil {
|
|
t.Fatalf("mustTime: %v", err)
|
|
}
|
|
return tt
|
|
}
|
|
|
|
// stubStore is a minimal LinkLister + ListByFilters-aware shim for unit
|
|
// tests. We embed the absence of *store.Store fields the timeline tool
|
|
// needs and surface only ListByFilters via a fake — the rest of mcp tests
|
|
// pass nil because they don't exercise this path.
|
|
type stubLinkLister struct{}
|
|
|
|
func (stubLinkLister) LinksByType(_ context.Context, _, _ string) ([]*store.ItemLink, error) {
|
|
return nil, nil
|
|
}
|
|
func (stubLinkLister) DatedLinksRange(_ context.Context, _, _ time.Time) ([]*store.ItemLinkWithItem, error) {
|
|
return nil, nil
|
|
}
|
|
func (stubLinkLister) ItemsCreatedInRange(_ context.Context, _, _ time.Time) ([]*store.Item, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func newToolServer(t *testing.T, agg *aggregate.Aggregator) *Server {
|
|
t.Helper()
|
|
srv := New("projax-test", "0.0.1", "tok", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
// Pass a nil *store.Store — the timeline tool's store-backed paths
|
|
// short-circuit cleanly on errors in this test surface (we just probe
|
|
// registration + arg parsing here).
|
|
RegisterProjaxTools(srv, nil, agg)
|
|
return srv
|
|
}
|
|
|
|
func newAggregator() *aggregate.Aggregator {
|
|
return aggregate.New(stubLinkLister{}, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
}
|
|
|
|
// TestTimelineToolUnregisteredWhenAggregatorNil proves passing nil yields a
|
|
// missing "timeline" tool — same kill-switch contract as the pre-Phase-5a
|
|
// TimelineBuilder=nil case.
|
|
func TestTimelineToolUnregisteredWhenAggregatorNil(t *testing.T) {
|
|
srv := newToolServer(t, nil)
|
|
if _, ok := srv.tools["timeline"]; ok {
|
|
t.Fatalf("expected timeline tool to be absent when agg is nil")
|
|
}
|
|
}
|
|
|
|
// TestTimelineToolRegisteredWhenAggregatorSet proves the tool shows up when
|
|
// an aggregator is plumbed through.
|
|
func TestTimelineToolRegisteredWhenAggregatorSet(t *testing.T) {
|
|
srv := newToolServer(t, newAggregator())
|
|
tool, ok := srv.tools["timeline"]
|
|
if !ok {
|
|
t.Fatalf("expected timeline tool to be registered")
|
|
}
|
|
if tool.Description == "" {
|
|
t.Errorf("timeline tool should carry a Description")
|
|
}
|
|
if len(tool.InputSchema) == 0 {
|
|
t.Errorf("timeline tool should carry an InputSchema")
|
|
}
|
|
}
|
|
|
|
// TestTimelineToolBadDateReturnsError proves a malformed `from` rejects with
|
|
// a tool-level error rather than panicking.
|
|
func TestTimelineToolBadDateReturnsError(t *testing.T) {
|
|
srv := newToolServer(t, newAggregator())
|
|
_, err := srv.tools["timeline"].Handler(context.Background(), []byte(`{"from":"not-a-date"}`))
|
|
if err == nil {
|
|
t.Fatalf("expected timeline tool to error on bad date")
|
|
}
|
|
}
|
|
|
|
// TestTimelineToolBadJSONReturnsError proves a malformed args body produces
|
|
// a tool-level error rather than panicking.
|
|
func TestTimelineToolBadJSONReturnsError(t *testing.T) {
|
|
srv := newToolServer(t, newAggregator())
|
|
_, err := srv.tools["timeline"].Handler(context.Background(), []byte(`not-json`))
|
|
if err == nil {
|
|
t.Fatalf("expected timeline tool to error on bad JSON")
|
|
}
|
|
}
|
|
|
|
// TestTimelineViewSerializesTimes proves the buildTimelineView helper emits
|
|
// the expected stringified time shape so the JSON-RPC envelope stays
|
|
// language-agnostic.
|
|
func TestTimelineViewSerializesTimes(t *testing.T) {
|
|
now := mustTime(t, "2026-05-17T10:30:00Z")
|
|
v := buildTimelineView(nil, now.AddDate(0, 0, -3), now.AddDate(0, 0, 4), "desc", []string{aggregate.KindTodo}, 0, now)
|
|
if v.From != "2026-05-14" {
|
|
t.Errorf("From should be YYYY-MM-DD, got %q", v.From)
|
|
}
|
|
if v.To != "2026-05-21" {
|
|
t.Errorf("To should be YYYY-MM-DD, got %q", v.To)
|
|
}
|
|
if v.ToInclusive != "2026-05-20" {
|
|
t.Errorf("ToInclusive should be exclusive-To minus one day, got %q", v.ToInclusive)
|
|
}
|
|
if v.BuiltAt != "2026-05-17T10:30:00Z" {
|
|
t.Errorf("BuiltAt should be ISO-8601 UTC, got %q", v.BuiltAt)
|
|
}
|
|
if v.Days == nil {
|
|
t.Errorf("Days slice should be initialised (empty array), not nil")
|
|
}
|
|
}
|
|
|
|
// TestResolveTimelineKindsSanitises proves resolveTimelineKinds drops
|
|
// unknown values, dedupes, and sorts.
|
|
func TestResolveTimelineKindsSanitises(t *testing.T) {
|
|
got := resolveTimelineKinds([]string{"todo", "junk", "event", "todo", "DOC"})
|
|
want := []string{"doc", "event", "todo"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("got %v, want %v", got, want)
|
|
}
|
|
for i := range got {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("pos %d: got %q, want %q", i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestResolveTimelineWindowDefaults proves no args → default 30/+90 window.
|
|
func TestResolveTimelineWindowDefaults(t *testing.T) {
|
|
now := mustTime(t, "2026-05-21T12:00:00Z")
|
|
from, to, err := resolveTimelineWindow(TimelineArgs{}, now)
|
|
if err != nil {
|
|
t.Fatalf("default window: %v", err)
|
|
}
|
|
wantFrom := now.AddDate(0, 0, -timelineDefaultPastDays).Format("2006-01-02")
|
|
if from.Format("2006-01-02") != wantFrom {
|
|
t.Errorf("from = %s, want %s", from.Format("2006-01-02"), wantFrom)
|
|
}
|
|
wantTo := now.AddDate(0, 0, timelineDefaultFutureDays).Format("2006-01-02")
|
|
if to.Format("2006-01-02") != wantTo {
|
|
t.Errorf("to = %s, want %s", to.Format("2006-01-02"), wantTo)
|
|
}
|
|
}
|