3513e508b7
V-659 measured the destination at 12/33 on the classifier cascade and named the gap: recall 0/15, because nothing anywhere names it. The model could not help, for a structural reason rather than a capability one. Nothing in routeSystem mentioned a Source and routeGrammar could not emit one, so there was no string for it to write. Same shape as the Praxis reach V-517 measured at 0/12. routeGrammar grows a source rule, closed over router.Sources plus the empty floor. A grammar cannot emit a destination that does not exist, which is the guarantee V-546 wants from a softmax and gets here for free. The prompt lists the twelve in Russian, one line each, and says plainly that "" is a normal answer to give often: two sources that can both answer means the chain walks, and guessing is the failure mode this whole field exists to stop. The read-back goes through ValidSource and runs on IntentQuery alone. The grammar already bounds the enum, but it is a request to a server that may be running another build, and only a query reaches queryWalk. Measured against gemma-4-12b on the workstation, same fixture, cascade with a hash fallback: destination 24/33 (72.7%) against the classifier's 12/33, and intent 81/96 (84.4%) which is where it already was. Recall is the whole move, 0/15 to 14/15. The model alone scores 26/33. Four cases the cascade loses and llm-only wins are calendar. The possessive agenda rules claim them at stage 0 and deliberately name nothing, because "что у меня в списке покупок" matches the same rule and naming the calendar would take the list source off the turn. So stage 0's caution now costs four destination points it did not cost before. That is a real trade and it wants its own argument, not a quiet edit here. The resident Qwen3-1.7B is unmeasured: it binds --port 0 inside the container and no host process can reach it. llm/check_prompt_parity.py in the training workspace compares its copy of routeSystem to this one and will fail until that copy gets the same edit. V-362 covers the catch-up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
468 lines
17 KiB
Go
468 lines
17 KiB
Go
package router
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/kami/maven/internal/llm"
|
||
)
|
||
|
||
type mockLLM struct {
|
||
out string
|
||
err error
|
||
got *llm.Req // last request, when the test wants to inspect it
|
||
}
|
||
|
||
func (m mockLLM) Complete(_ context.Context, r llm.Req) (string, error) {
|
||
if m.got != nil {
|
||
*m.got = r
|
||
}
|
||
return m.out, m.err
|
||
}
|
||
|
||
// Without a repeat penalty the model loops inside the text field until MaxTokens
|
||
// and the truncated JSON fails to parse.
|
||
func TestLLMRouterSetsRepeatPenalty(t *testing.T) {
|
||
var got llm.Req
|
||
lr := NewLLMRouter(mockLLM{out: `{"intent":"chat","text":"привет"}`, got: &got})
|
||
if _, _, err := lr.Route(context.Background(), "привет", time.Now()); err != nil {
|
||
t.Fatalf("route: %v", err)
|
||
}
|
||
if got.RepeatPenalty <= 1 {
|
||
t.Fatalf("want repeat penalty above 1, got %v", got.RepeatPenalty)
|
||
}
|
||
}
|
||
|
||
// An unbounded string rule lets one field eat the whole token budget.
|
||
func TestRouteGrammarBoundsStrings(t *testing.T) {
|
||
if !strings.Contains(routeGrammar, `{0,120} "\""`) {
|
||
t.Fatal("grammar string rule lost its length bound")
|
||
}
|
||
// And it must not admit a raw newline or a made-up escape, either of which
|
||
// makes the route unparseable and costs the turn its router (Vikunja #537).
|
||
if !strings.Contains(routeGrammar, `[^"\\\x00-\x1F]`) {
|
||
t.Error("string rule admits raw control characters")
|
||
}
|
||
if strings.Contains(routeGrammar, `"\\" .`) {
|
||
t.Error(`string rule still admits "\\" . — \q satisfies the grammar and fails to parse`)
|
||
}
|
||
}
|
||
|
||
// A question naming a fact key used to be stored as a fact because the fact rule
|
||
// was tested first. Keep the query rule above it.
|
||
func TestRoutePromptTestsQueryBeforeFact(t *testing.T) {
|
||
query := strings.Index(routeSystem, "→ query")
|
||
fact := strings.Index(routeSystem, "состояние/событие → fact")
|
||
if query < 0 || fact < 0 {
|
||
t.Fatalf("prompt lost a rule: query=%d fact=%d", query, fact)
|
||
}
|
||
if query > fact {
|
||
t.Fatal("query rule must come before the fact rule")
|
||
}
|
||
if !strings.Contains(routeSystem, "Задаёт вопрос") {
|
||
t.Fatal("prompt lost the explicit question test")
|
||
}
|
||
}
|
||
|
||
func TestLLMRouterFactMapping(t *testing.T) {
|
||
lr := NewLLMRouter(mockLLM{out: `{"intent":"fact","key":"water","value":"выпил"}`})
|
||
d, ok, err := lr.Route(context.Background(), "я выпил воду", time.Now())
|
||
if err != nil || !ok {
|
||
t.Fatalf("ok=%v err=%v", ok, err)
|
||
}
|
||
if d.Intent != IntentFact || d.Slots.Key != "water" || !d.Slots.HasKey {
|
||
t.Fatalf("bad decision %+v", d)
|
||
}
|
||
}
|
||
|
||
// A note keeps the utterance, whatever the model wrote in its text field
|
||
// (V-576). The note is durable and it is his own words.
|
||
func TestLLMRouterNoteMapping(t *testing.T) {
|
||
lr := NewLLMRouter(mockLLM{out: `{"intent":"note","text":"ты поедешь на дачу"}`})
|
||
d, ok, err := lr.Route(context.Background(), "запомни что кофе закончился", time.Now())
|
||
if err != nil || !ok {
|
||
t.Fatalf("ok=%v err=%v", ok, err)
|
||
}
|
||
if d.Intent != IntentNote || d.Slots.Text != "запомни что кофе закончился" {
|
||
t.Fatalf("bad decision %+v", d)
|
||
}
|
||
}
|
||
|
||
func TestLLMRouterBadJSONFallsBack(t *testing.T) {
|
||
lr := NewLLMRouter(mockLLM{out: `garbage`})
|
||
_, ok, err := lr.Route(context.Background(), "x", time.Now())
|
||
if ok || err == nil {
|
||
t.Fatal("want ok=false, err!=nil on bad json")
|
||
}
|
||
}
|
||
|
||
func TestLLMRouterReminderMapping(t *testing.T) {
|
||
lr := NewLLMRouter(mockLLM{out: `{"intent":"reminder","text":"позвонить маме"}`})
|
||
d, ok, err := lr.Route(context.Background(), "напомни позвонить маме", time.Now())
|
||
if err != nil || !ok {
|
||
t.Fatalf("ok=%v err=%v", ok, err)
|
||
}
|
||
if d.Intent != IntentReminder || d.Slots.Text != "позвонить маме" {
|
||
t.Fatalf("bad decision %+v", d)
|
||
}
|
||
}
|
||
|
||
// An intent name that is not in the contract at all (as opposed to "unknown",
|
||
// which is a real refusal) still defaults to chat.
|
||
func TestLLMRouterChatFallback(t *testing.T) {
|
||
lr := NewLLMRouter(mockLLM{out: `{"intent":"banana"}`})
|
||
d, ok, err := lr.Route(context.Background(), "как дела?", time.Now())
|
||
if err != nil || !ok {
|
||
t.Fatalf("ok=%v err=%v", ok, err)
|
||
}
|
||
if d.Intent != IntentChat {
|
||
t.Fatalf("unknown intent should default to chat, got %s", d.Intent)
|
||
}
|
||
}
|
||
|
||
// The model must be able to say "I could not route this".
|
||
func TestRouteGrammarAllowsUnknown(t *testing.T) {
|
||
if !strings.Contains(routeGrammar, `"\"unknown\""`) {
|
||
t.Fatal("grammar cannot express a refusal")
|
||
}
|
||
}
|
||
|
||
// If the prompt does not tell the model when to refuse, it never will.
|
||
func TestRoutePromptExplainsUnknown(t *testing.T) {
|
||
if !strings.Contains(routeSystem, "unknown") {
|
||
t.Fatal("prompt never mentions the unknown intent")
|
||
}
|
||
if !strings.Contains(routeSystem, `"сделай это" → {"intent":"unknown"}`) {
|
||
t.Fatal("prompt lost its worked refusal example")
|
||
}
|
||
// A refusal-only router is useless, so the prompt must also show cases that
|
||
// look ambiguous but are not.
|
||
if !strings.Contains(routeSystem, "здесь unknown не нужен") {
|
||
t.Fatal("prompt lost its counter-examples")
|
||
}
|
||
}
|
||
|
||
// A refusal is not an error. It reports "no decision" so the cascade moves on.
|
||
func TestLLMRouterUnknownRefuses(t *testing.T) {
|
||
lr := NewLLMRouter(mockLLM{out: `{"intent":"unknown"}`})
|
||
_, ok, err := lr.Route(context.Background(), "сделай это", time.Now())
|
||
if ok {
|
||
t.Fatal("a refusal must not produce a usable decision")
|
||
}
|
||
if err != nil {
|
||
t.Fatalf("a refusal is not an error, got %v", err)
|
||
}
|
||
}
|
||
|
||
// The whole point of the refusal: the turn keeps going on the classifier, the
|
||
// same way it does when the model returns garbage.
|
||
func TestRouterFallsBackWhenLLMRefuses(t *testing.T) {
|
||
c := NewClassifier(NewHashEmbedder(1024))
|
||
seedClassifier(t, c)
|
||
r := New(Config{
|
||
Classifier: c,
|
||
Extractor: Extractor{Time: StubDateTimeParser{}, Facts: DefaultFactParser{}},
|
||
Threshold: 0.4,
|
||
LLM: NewLLMRouter(mockLLM{out: `{"intent":"unknown"}`}),
|
||
})
|
||
d, err := r.Route(context.Background(), "напомни позвонить маме", refNow())
|
||
if err != nil {
|
||
t.Fatalf("route: %v", err)
|
||
}
|
||
// Stage 1 is the LLM's own answer; the classifier lands on stage 2 or 3.
|
||
if d.Stage < 2 {
|
||
t.Fatalf("want the classifier to decide, got stage %d (%+v)", d.Stage, d)
|
||
}
|
||
}
|
||
|
||
func TestLLMRouterLLMError(t *testing.T) {
|
||
lr := NewLLMRouter(mockLLM{out: "", err: fmt.Errorf("llm down")})
|
||
_, ok, err := lr.Route(context.Background(), "x", time.Now())
|
||
if ok || err == nil {
|
||
t.Fatal("want ok=false, err!=nil on llm error")
|
||
}
|
||
}
|
||
|
||
// --- slot extraction on top of an LLM decision --------------------------------
|
||
|
||
// newLLMTestRouter — a router whose route always comes from the mock model.
|
||
func newLLMTestRouter(t *testing.T, out string) *Router {
|
||
t.Helper()
|
||
c := NewClassifier(NewHashEmbedder(1024))
|
||
seedClassifier(t, c)
|
||
acts := DefaultActMatcher{Fns: []string{"restart", "stop", "run", "backup"}}
|
||
return New(Config{
|
||
Classifier: c,
|
||
Extractor: Extractor{Time: StubDateTimeParser{}, Acts: acts, Facts: DefaultFactParser{}},
|
||
Threshold: 0.4,
|
||
LLM: NewLLMRouter(mockLLM{out: out}),
|
||
})
|
||
}
|
||
|
||
// The model cannot produce a fire time, so without extraction every LLM-routed
|
||
// reminder was dropped as "no time".
|
||
func TestLLMDecisionGetsReminderTime(t *testing.T) {
|
||
r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`)
|
||
d, err := r.Route(context.Background(), "напомни позвонить маме через 2 часа", refNow())
|
||
if err != nil {
|
||
t.Fatalf("route: %v", err)
|
||
}
|
||
if d.Intent != IntentReminder {
|
||
t.Fatalf("want reminder, got %v", d.Intent)
|
||
}
|
||
if !d.Slots.HasTime || !d.Slots.Time.Equal(refNow().Add(2*time.Hour)) {
|
||
t.Fatalf("want time now+2h, got %+v", d.Slots)
|
||
}
|
||
if d.Slots.Text != "позвонить маме" {
|
||
t.Fatalf("extraction overwrote the model's text: %q", d.Slots.Text)
|
||
}
|
||
}
|
||
|
||
// No time in the utterance ⇒ no time in the slots. Do not invent one; the
|
||
// daemon says it could not read the time.
|
||
func TestLLMReminderWithoutTimeStaysEmpty(t *testing.T) {
|
||
r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`)
|
||
d, err := r.Route(context.Background(), "напомни позвонить маме", refNow())
|
||
if err != nil {
|
||
t.Fatalf("route: %v", err)
|
||
}
|
||
if d.Slots.HasTime {
|
||
t.Fatalf("invented a time: %v", d.Slots.Time)
|
||
}
|
||
}
|
||
|
||
// An act decision arrived with no Fn, so the tool never ran.
|
||
func TestLLMDecisionGetsActFn(t *testing.T) {
|
||
r := newLLMTestRouter(t, `{"intent":"act","verb":"restart nginx"}`)
|
||
d, err := r.Route(context.Background(), "слушай, restart nginx пожалуйста", refNow())
|
||
if err != nil {
|
||
t.Fatalf("route: %v", err)
|
||
}
|
||
if !d.Slots.HasFn || d.Slots.Fn != "restart" || len(d.Slots.Args) != 1 || d.Slots.Args[0] != "nginx" {
|
||
t.Fatalf("want fn=restart args=[nginx], got %+v", d.Slots)
|
||
}
|
||
}
|
||
|
||
// The model's own slots win; extraction only fills gaps.
|
||
func TestLLMSlotsWinOverExtraction(t *testing.T) {
|
||
r := newLLMTestRouter(t, `{"intent":"fact","key":"hydration","value":"выпил"}`)
|
||
d, err := r.Route(context.Background(), "я выпил воду", refNow())
|
||
if err != nil {
|
||
t.Fatalf("route: %v", err)
|
||
}
|
||
if d.Slots.Key != "hydration" {
|
||
t.Fatalf("extraction overwrote the model's key: %q", d.Slots.Key)
|
||
}
|
||
}
|
||
|
||
// A fact the model left keyless still gets one from the parser.
|
||
func TestLLMFactGetsKeyFromParser(t *testing.T) {
|
||
r := newLLMTestRouter(t, `{"intent":"fact","text":"я выпил воду"}`)
|
||
d, err := r.Route(context.Background(), "я выпил воду", refNow())
|
||
if err != nil {
|
||
t.Fatalf("route: %v", err)
|
||
}
|
||
if !d.Slots.HasKey || d.Slots.Key != "water" {
|
||
t.Fatalf("want key=water, got %+v", d.Slots)
|
||
}
|
||
if d.Clarify {
|
||
t.Fatalf("the parser resolved the key, this must not clarify: %+v", d)
|
||
}
|
||
}
|
||
|
||
// --- confidence / stage-3 gate on the LLM path (Vikunja #359) -----------------
|
||
|
||
// A single-token utterance is thin evidence on its own — "вода" alone is a
|
||
// fact/query coin flip. The gate must ask rather than guess confidently.
|
||
func TestLLMRouterSingleTokenTripsClarify(t *testing.T) {
|
||
r := newLLMTestRouter(t, `{"intent":"query","text":"вода"}`)
|
||
d, err := r.Route(context.Background(), "вода", refNow())
|
||
if err != nil {
|
||
t.Fatalf("route: %v", err)
|
||
}
|
||
if !d.Clarify {
|
||
t.Fatalf("a bare single-token decision must clarify, got %+v", d)
|
||
}
|
||
}
|
||
|
||
// A multi-word utterance with a clean answer must not be punished — the
|
||
// whole point is not trading the confident cases away for clarify coverage.
|
||
func TestLLMRouterMultiWordStaysConfident(t *testing.T) {
|
||
r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`)
|
||
d, err := r.Route(context.Background(), "напомни позвонить маме", refNow())
|
||
if err != nil {
|
||
t.Fatalf("route: %v", err)
|
||
}
|
||
if d.Clarify {
|
||
t.Fatalf("a clean multi-word decision must not clarify: %+v", d)
|
||
}
|
||
if d.Confidence != llmFullConfidence {
|
||
t.Fatalf("want full confidence, got %v", d.Confidence)
|
||
}
|
||
}
|
||
|
||
// "бэкап" alone: the model guesses act, but nothing on the allowlist matches
|
||
// "бэкап" as a verb — that must not fire a tool blind.
|
||
func TestLLMRouterActWithoutFnTripsClarify(t *testing.T) {
|
||
r := newLLMTestRouter(t, `{"intent":"act","verb":"бэкап"}`)
|
||
d, err := r.Route(context.Background(), "бэкап сделай пожалуйста расписание", refNow())
|
||
if err != nil {
|
||
t.Fatalf("route: %v", err)
|
||
}
|
||
if d.Slots.HasFn {
|
||
t.Fatalf("test setup drifted: %q now resolves to an fn", d.Slots.Fn)
|
||
}
|
||
if !d.Clarify {
|
||
t.Fatalf("an unresolved act must clarify rather than guess: %+v", d)
|
||
}
|
||
}
|
||
|
||
// An act that DOES resolve to an allowlisted fn must stay confident even
|
||
// though its own verb is single-word-ish in spirit — guard against the fn
|
||
// check firing on the happy path.
|
||
func TestLLMRouterActWithFnStaysConfident(t *testing.T) {
|
||
r := newLLMTestRouter(t, `{"intent":"act","verb":"restart nginx"}`)
|
||
d, err := r.Route(context.Background(), "слушай, restart nginx пожалуйста", refNow())
|
||
if err != nil {
|
||
t.Fatalf("route: %v", err)
|
||
}
|
||
if !d.Slots.HasFn {
|
||
t.Fatalf("test setup drifted, want fn resolved: %+v", d.Slots)
|
||
}
|
||
if d.Clarify {
|
||
t.Fatalf("a resolved act must not clarify: %+v", d)
|
||
}
|
||
}
|
||
|
||
// A fact where NEITHER the model NOR the deterministic parser can name a key
|
||
// must clarify instead of silently writing under an empty/guessed key.
|
||
func TestLLMRouterFactWithoutKeyTripsClarify(t *testing.T) {
|
||
r := newLLMTestRouter(t, `{"intent":"fact","value":"что-то"}`)
|
||
d, err := r.Route(context.Background(), "у меня какая-то фигня случилась вот прямо только что", refNow())
|
||
if err != nil {
|
||
t.Fatalf("route: %v", err)
|
||
}
|
||
if d.Slots.HasKey {
|
||
t.Fatalf("test setup drifted: parser now resolves a key for this utterance")
|
||
}
|
||
if !d.Clarify {
|
||
t.Fatalf("a keyless fact must clarify rather than guess: %+v", d)
|
||
}
|
||
}
|
||
|
||
// The whole point of #359: the classifier cascade cannot be traded away for
|
||
// clarify coverage. A multi-word fact the parser CAN key must stay confident
|
||
// through the full Router.Route path, not just the raw LLMRouter.
|
||
func TestRouterLLMFactWithResolvedKeyStaysConfident(t *testing.T) {
|
||
r := newLLMTestRouter(t, `{"intent":"fact","text":"я выпил воду"}`)
|
||
d, err := r.Route(context.Background(), "я выпил воду", refNow())
|
||
if err != nil {
|
||
t.Fatalf("route: %v", err)
|
||
}
|
||
if d.Clarify {
|
||
t.Fatalf("a fact the parser could key must not clarify: %+v", d)
|
||
}
|
||
}
|
||
|
||
// A reminder with a time and no subject must come back empty and gated, not
|
||
// backfilled with the raw words. "напомни в 11" carries an hour and nothing to
|
||
// say at that hour; parking the utterance in Text made the request look
|
||
// complete, so the daemon set a reminder that fires saying "напомни в 11"
|
||
// (Vikunja #383).
|
||
func TestLLMReminderWithoutSubjectAsksInsteadOfGuessing(t *testing.T) {
|
||
r := newLLMTestRouter(t, `{"intent":"reminder"}`)
|
||
d, err := r.Route(context.Background(), "напомни в 11", refNow())
|
||
if err != nil {
|
||
t.Fatalf("route: %v", err)
|
||
}
|
||
if d.Slots.Text != "" {
|
||
t.Fatalf("subject backfilled from the utterance: %q", d.Slots.Text)
|
||
}
|
||
if !d.Clarify {
|
||
t.Fatalf("a subjectless reminder was accepted, confidence %v", d.Confidence)
|
||
}
|
||
}
|
||
|
||
// The gate is about the subject, not about reminders in general: one that has
|
||
// both halves still runs without a question.
|
||
func TestLLMReminderWithSubjectIsNotGated(t *testing.T) {
|
||
r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`)
|
||
d, err := r.Route(context.Background(), "напомни в 11 позвонить маме", refNow())
|
||
if err != nil {
|
||
t.Fatalf("route: %v", err)
|
||
}
|
||
if d.Clarify {
|
||
t.Fatalf("a complete reminder was sent back as a question: %+v", d.Slots)
|
||
}
|
||
}
|
||
|
||
// TestRouteGrammarCoversSources — the grammar enum and router.Sources are two
|
||
// hand-written lists of the same twelve destinations, and nothing else notices
|
||
// when one grows. A destination missing from the grammar is a destination the
|
||
// model is structurally unable to name, which is the exact defect V-517
|
||
// measured for Praxis: not a weak model, an absent string.
|
||
func TestRouteGrammarCoversSources(t *testing.T) {
|
||
for _, s := range Sources {
|
||
if !strings.Contains(routeGrammar, `"\"`+string(s)+`\""`) {
|
||
t.Errorf("routeGrammar cannot emit %q — the model can never name it", s)
|
||
}
|
||
}
|
||
// The floor has to be reachable too, or the model is forced to pick one.
|
||
if !strings.Contains(routeGrammar, `"\"\""`) {
|
||
t.Error(`routeGrammar cannot emit "" — the model cannot decline a destination`)
|
||
}
|
||
// Count the alternatives on the source rule: an extra one is a destination
|
||
// the daemon would drop to SourceUnknown after the model spent tokens on it.
|
||
for _, line := range strings.Split(routeGrammar, "\n") {
|
||
if !strings.HasPrefix(line, "source ") {
|
||
continue
|
||
}
|
||
if got, want := strings.Count(line, "|")+1, len(Sources)+1; got != want {
|
||
t.Errorf("source rule has %d alternatives, want %d (Sources plus the floor)", got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// The destination is read back only through ValidSource. A model on an older or
|
||
// newer build can write a string this binary does not know, and trusting it
|
||
// would take real query sources off the turn for a name nothing answers.
|
||
func TestLLMUnknownSourceFallsToTheFloor(t *testing.T) {
|
||
r := newLLMTestRouter(t, `{"intent":"query","text":"что там с бэкапами","source":"praxis"}`)
|
||
d, err := r.Route(context.Background(), "что там с бэкапами", refNow())
|
||
if err != nil {
|
||
t.Fatalf("route: %v", err)
|
||
}
|
||
if d.Source != SourceUnknown {
|
||
t.Fatalf("invented destination %q was trusted, want the floor", d.Source)
|
||
}
|
||
}
|
||
|
||
// And a known one survives, or the read-back is just a filter.
|
||
func TestLLMNamedSourceSurvives(t *testing.T) {
|
||
r := newLLMTestRouter(t, `{"intent":"query","text":"кто такой Линус Торвальдс","source":"world"}`)
|
||
d, err := r.Route(context.Background(), "кто такой Линус Торвальдс?", refNow())
|
||
if err != nil {
|
||
t.Fatalf("route: %v", err)
|
||
}
|
||
if d.Source != SourceWorld {
|
||
t.Fatalf("source %q, want %q", d.Source, SourceWorld)
|
||
}
|
||
}
|
||
|
||
// A destination on anything but a query is dropped. Only IntentQuery reaches
|
||
// queryWalk, so a source elsewhere is a field nobody reads and a claim nobody
|
||
// checks.
|
||
func TestLLMSourceIsQueryOnly(t *testing.T) {
|
||
r := newLLMTestRouter(t, `{"intent":"note","text":"кофе кончился","source":"recall"}`)
|
||
d, err := r.Route(context.Background(), "запиши что кофе кончился", refNow())
|
||
if err != nil {
|
||
t.Fatalf("route: %v", err)
|
||
}
|
||
if d.Source != SourceUnknown {
|
||
t.Fatalf("a note carried destination %q", d.Source)
|
||
}
|
||
}
|