96b474223d
Netscan and the crawler both declined their own turn when the wiring was nil, and the question fell through to the search leg. "какие устройства в сети?" came back as a paragraph about routers in general, and a question about his own LAN went to an upstream engine — the personal boundary exists to stop exactly that. A URL he named came back answered as though he had not named it. Both now claim the turn once their own recogniser has matched, and say which capability is missing: net_off and page_off in the query family. TestQueryWebPassesWhenNotConfigured encoded the old decision, that announcing a configuration status is only for a capability that exists and failed. It is rewritten, not deleted: the gap is the answer now.
193 lines
6.2 KiB
Go
193 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/config"
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/phraser"
|
|
"github.com/kami/maven/internal/router"
|
|
)
|
|
|
|
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
|
for name, cfg := range map[string]*config.Config{
|
|
"no block": {},
|
|
"written but dark": {NetScan: &config.NetScanConfig{
|
|
Subnets: []string{"192.168.1.0/24"},
|
|
}},
|
|
"enabled but nothing to scan": {NetScan: &config.NetScanConfig{Enabled: true}},
|
|
"enabled but public": {NetScan: &config.NetScanConfig{
|
|
Subnets: []string{"8.8.8.0/24"}, Enabled: true,
|
|
}},
|
|
"enabled but far too wide": {NetScan: &config.NetScanConfig{
|
|
Subnets: []string{"10.0.0.0/8"}, Enabled: true,
|
|
}},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if w := wireNetScan(cfg, nil); w != nil {
|
|
t.Fatal("the scanner must not wire for this config")
|
|
}
|
|
})
|
|
}
|
|
|
|
var w *netWiring
|
|
if _, ok := w.scanSummary(context.Background()); ok {
|
|
t.Fatal("a nil wiring must not claim a query")
|
|
}
|
|
|
|
ok := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
|
Subnets: []string{"192.168.1.0/24"}, Enabled: true,
|
|
}}, nil)
|
|
if ok == nil {
|
|
t.Fatal("a valid enabled block should wire")
|
|
}
|
|
}
|
|
|
|
// A loopback /32 with nothing listening on the scanned port: the summary must
|
|
// come back honest rather than inventing a host. This also exercises the real
|
|
// dialer end to end without touching anything outside this box.
|
|
func TestScanSummaryOnAnEmptyRange(t *testing.T) {
|
|
w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
|
// Port 1 on loopback: nothing listens and the connection is refused
|
|
// immediately, so the scan is fast and touches only this machine.
|
|
Subnets: []string{"127.0.0.1/32"}, Ports: []int{1}, Rate: 1000, Enabled: true,
|
|
}}, nil)
|
|
if w == nil {
|
|
t.Fatal("wireNetScan returned nil")
|
|
}
|
|
out, claimed := w.scanSummary(context.Background())
|
|
if !claimed {
|
|
t.Fatal("the summary did not claim the turn")
|
|
}
|
|
if out == "" {
|
|
t.Fatal("empty summary")
|
|
}
|
|
// Persona: feminine self-reference, informal address, no pet names.
|
|
low := strings.ToLower(out)
|
|
for _, bad := range []string{"нашёл", "не смог ", "вы ", "ваш", "милый", "дорогой"} {
|
|
if strings.Contains(low, bad) {
|
|
t.Errorf("persona violation %q in %q", bad, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHostWordAgreesWithTheCount(t *testing.T) {
|
|
for n, want := range map[int]string{
|
|
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
|
|
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
|
|
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
|
|
} {
|
|
if got := hostWord(n); got != want {
|
|
t.Errorf("hostWord(%d) = %q, want %q", n, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsNetworkQuery(t *testing.T) {
|
|
yes := []string{
|
|
"какие устройства в сети?",
|
|
"кто в сети?",
|
|
"просканируй сеть",
|
|
"покажи устройства в локальной сети",
|
|
"сколько машин в сети",
|
|
}
|
|
no := []string{
|
|
"",
|
|
"интернет не работает",
|
|
"сеть какая-то медленная",
|
|
"я в сети инстаграма",
|
|
"что включено дома?",
|
|
"напомни оплатить интернет",
|
|
}
|
|
for _, u := range yes {
|
|
if !isNetworkQuery(u) {
|
|
t.Errorf("isNetworkQuery(%q) = false, want true", u)
|
|
}
|
|
}
|
|
for _, u := range no {
|
|
if isNetworkQuery(u) {
|
|
t.Errorf("isNetworkQuery(%q) = true, want false", u)
|
|
}
|
|
}
|
|
}
|
|
|
|
// notingAPI counts the notes a scan writes, and remembers the last one.
|
|
type notingAPI struct {
|
|
ipc.CoreAPI
|
|
n int
|
|
last string
|
|
}
|
|
|
|
func (a *notingAPI) WriteNote(_ context.Context, _ time.Time, text string, _ []float32, _ string) (int64, error) {
|
|
a.n++
|
|
a.last = text
|
|
return int64(a.n), nil
|
|
}
|
|
|
|
// The spoken answer must not be a list of IP addresses. It goes to piper as
|
|
// well as to /chat, and six dotted quads read out as a digit stream is not an
|
|
// answer anybody can use. The addresses belong in the written record.
|
|
func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer ln.Close()
|
|
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
|
port, _ := strconv.Atoi(portStr)
|
|
|
|
api := ¬ingAPI{}
|
|
w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
|
Subnets: []string{"127.0.0.1/32"}, Ports: []int{port}, Rate: 1000, Enabled: true,
|
|
}}, api)
|
|
if w == nil {
|
|
t.Fatal("wireNetScan returned nil")
|
|
}
|
|
out, claimed := w.scanSummary(context.Background())
|
|
if !claimed {
|
|
t.Fatal("the summary did not claim the turn")
|
|
}
|
|
if strings.Contains(out, "127.0.0.1") || strings.Contains(out, portStr) {
|
|
t.Errorf("the spoken reply reads addresses out loud: %q", out)
|
|
}
|
|
if !strings.Contains(out, "нашла 1 устройство") {
|
|
t.Errorf("reply = %q, want a count", out)
|
|
}
|
|
if api.n != 1 {
|
|
t.Fatalf("wrote %d notes, want 1", api.n)
|
|
}
|
|
if !strings.Contains(api.last, "127.0.0.1") {
|
|
t.Errorf("the written record has no addresses: %q", api.last)
|
|
}
|
|
|
|
// A follow-up question inside the TTL reuses the answer: two questions in
|
|
// a row must not be two sweeps of the LAN.
|
|
if _, _ = w.scanSummary(context.Background()); api.n != 1 {
|
|
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
|
|
}
|
|
}
|
|
|
|
// An unconfigured scanner names the gap instead of declining the turn.
|
|
//
|
|
// Falling through sent "какие устройства в сети?" to the search leg, which
|
|
// answered with a paragraph about routers in general — and put a question about
|
|
// his own LAN on an upstream engine, which the personal boundary exists to
|
|
// prevent (Vikunja #479).
|
|
func TestQueryNetworkNamesTheGapWhenNotConfigured(t *testing.T) {
|
|
h := &reactiveHandler{}
|
|
reply, ok := h.queryNetwork(context.Background(), &queryTurn{
|
|
dec: router.Decision{Utterance: "какие устройства в сети?"},
|
|
})
|
|
if !ok {
|
|
t.Fatal("an unconfigured scanner let the question fall through to search")
|
|
}
|
|
if !phraser.IsQ(phraser.QueryNetOff, nil, reply) {
|
|
t.Errorf("got %q, want the gap named", reply)
|
|
}
|
|
}
|