Files
Maven/cmd/mavend/netscan_test.go
T
claude d79b30a1a6 phraser: one count helper, so the weather says "1 градус" (V-521)
The weather line spelled "градусов" out in the template, which is the wrong
form for 1-4 and for every number ending in 1-4. Russian inflects the noun
after a numeral, so the count splits into the number and {word}.

hostWord in cmd/mavend/netscan.go already knew the rule for устройство and was
the only place that did. It moves to internal/phraser as CountWord, with
Degrees and Devices over it, and the three call sites that counted devices now
read the same helper the weather line does. Degrees rounds before it counts, so
the noun agrees with the number she is about to say rather than the reading
behind it, and a negative reading counts by its magnitude.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 15:37:54 +04:00

160 lines
4.8 KiB
Go

package main
import (
"context"
"net"
"strconv"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
)
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 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 := &notingAPI{}
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)
}
}