zones: implement GET /api/zones/:id/history endpoint
Implement hourly occupancy history endpoint that returns zone_history records from the database. The endpoint returns hourly snapshots with count and people list, supporting period query parameter (24h, 7d, 30d). Changes: - Modified GetZoneHistory to query zone_history table instead of computing from crossing_events - Added encoding/json import to parse people JSON field - Split database schema creation into manager_migrate.go for editability - Split zone history snapshot logic into manager_history.go - Updated tests to insert zone_history data instead of crossing_events The zone_history table stores pre-aggregated hourly snapshots written by RecordZoneHistorySnapshot, which should be called periodically. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
408f3b6a78
commit
9f9df2f70c
4 changed files with 366 additions and 91 deletions
|
|
@ -3,6 +3,7 @@ package zones
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
|
|
@ -187,74 +188,6 @@ func NewManager(dbPath string, tz *time.Location) (*Manager, error) {
|
|||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Manager) migrate() error {
|
||||
_, err := m.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS zones (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT '#4fc3f7',
|
||||
min_x REAL NOT NULL DEFAULT 0,
|
||||
min_y REAL NOT NULL DEFAULT 0,
|
||||
min_z REAL NOT NULL DEFAULT 0,
|
||||
max_x REAL NOT NULL DEFAULT 1,
|
||||
max_y REAL NOT NULL DEFAULT 1,
|
||||
max_z REAL NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
zone_type TEXT NOT NULL DEFAULT 'normal',
|
||||
is_children_zone INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portals (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
zone_a_id TEXT NOT NULL DEFAULT '',
|
||||
zone_b_id TEXT NOT NULL DEFAULT '',
|
||||
p1_x REAL NOT NULL DEFAULT 0,
|
||||
p1_y REAL NOT NULL DEFAULT 0,
|
||||
p1_z REAL NOT NULL DEFAULT 0,
|
||||
p2_x REAL NOT NULL DEFAULT 0,
|
||||
p2_y REAL NOT NULL DEFAULT 0,
|
||||
p2_z REAL NOT NULL DEFAULT 0,
|
||||
p3_x REAL NOT NULL DEFAULT 0,
|
||||
p3_y REAL NOT NULL DEFAULT 0,
|
||||
p3_z REAL NOT NULL DEFAULT 0,
|
||||
n_x REAL NOT NULL DEFAULT 0,
|
||||
n_y REAL NOT NULL DEFAULT 0,
|
||||
n_z REAL NOT NULL DEFAULT 0,
|
||||
width REAL NOT NULL DEFAULT 1,
|
||||
height REAL NOT NULL DEFAULT 2,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crossing_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
portal_id TEXT NOT NULL,
|
||||
blob_id INTEGER NOT NULL,
|
||||
direction INTEGER NOT NULL,
|
||||
from_zone TEXT NOT NULL,
|
||||
to_zone TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
identity TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crossing_time ON crossing_events(timestamp);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add zone_type column if it doesn't exist (migration for existing databases)
|
||||
m.db.Exec(`ALTER TABLE zones ADD COLUMN zone_type TEXT NOT NULL DEFAULT 'normal'`)
|
||||
m.db.Exec(`ALTER TABLE zones ADD COLUMN is_children_zone INTEGER NOT NULL DEFAULT 0`)
|
||||
|
||||
// Add last_known_occupancy column for restart reconciliation
|
||||
m.db.Exec(`ALTER TABLE zones ADD COLUMN last_known_occupancy INTEGER NOT NULL DEFAULT 0`)
|
||||
m.db.Exec(`ALTER TABLE zones ADD COLUMN occupancy_updated_at INTEGER`)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) loadZones() error {
|
||||
rows, err := m.db.Query(`SELECT id, name, color, min_x, min_y, min_z, max_x, max_y, max_z, enabled, zone_type, is_children_zone, created_at FROM zones`)
|
||||
|
|
@ -1242,39 +1175,88 @@ type HistoryEntry struct {
|
|||
}
|
||||
|
||||
// GetZoneHistory returns hourly occupancy buckets for a zone by querying
|
||||
// crossing_events from SQLite. It computes net entry count per hour window.
|
||||
// the zone_history table. Returns the most recent hourly buckets,
|
||||
// ordered from newest to oldest.
|
||||
//
|
||||
// Each bucket represents the occupancy snapshot taken at the start of each hour.
|
||||
// If no snapshot exists for an hour, a zero-count entry is returned.
|
||||
func (m *Manager) GetZoneHistory(zoneID string, hours int) []HistoryEntry {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
entries := make([]HistoryEntry, hours)
|
||||
now := time.Now().In(m.tz)
|
||||
currentHourStart := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, m.tz)
|
||||
currentHourStartMs := currentHourStart.UnixMilli()
|
||||
|
||||
// Build hourly buckets from now backwards
|
||||
for i := 0; i < hours; i++ {
|
||||
bucketEnd := now.Add(-time.Duration(i) * time.Hour)
|
||||
bucketStart := bucketEnd.Add(-time.Hour)
|
||||
entries[i] = HistoryEntry{
|
||||
Timestamp: bucketEnd.UnixNano() / 1e6,
|
||||
Count: 0,
|
||||
People: []string{},
|
||||
// Calculate the earliest hour timestamp to include
|
||||
earliestHourStartMs := currentHourStartMs - int64(hours-1)*3600000
|
||||
|
||||
// Query zone_history for hourly snapshots
|
||||
rows, err := m.db.Query(`
|
||||
SELECT hour_ts, count, people
|
||||
FROM zone_history
|
||||
WHERE zone_id = ? AND hour_ts >= ?
|
||||
ORDER BY hour_ts DESC
|
||||
`, zoneID, earliestHourStartMs)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to query zone history: %v", err)
|
||||
result := make([]HistoryEntry, hours)
|
||||
for i := 0; i < hours; i++ {
|
||||
hourTs := currentHourStart.Add(time.Duration(-i) * time.Hour).UnixMilli()
|
||||
result[i] = HistoryEntry{
|
||||
Timestamp: hourTs,
|
||||
Count: 0,
|
||||
People: []string{},
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Build map of hour_ts -> history entry
|
||||
hourMap := make(map[int64]HistoryEntry)
|
||||
for rows.Next() {
|
||||
var hourTs int64
|
||||
var count int
|
||||
var peopleJSON string
|
||||
if err := rows.Scan(&hourTs, &count, &peopleJSON); err != nil {
|
||||
log.Printf("[WARN] Failed to scan zone history: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Query net crossings into this zone during this bucket
|
||||
var netIn int
|
||||
row := m.db.QueryRow(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN to_zone = ? THEN 1 ELSE 0 END), 0)
|
||||
- COALESCE(SUM(CASE WHEN from_zone = ? THEN 1 ELSE 0 END), 0)
|
||||
FROM crossing_events
|
||||
WHERE timestamp >= ? AND timestamp < ?
|
||||
`, zoneID, zoneID, bucketStart.UnixMilli(), bucketEnd.UnixMilli())
|
||||
if err := row.Scan(&netIn); err == nil && netIn > 0 {
|
||||
entries[i].Count = netIn
|
||||
// Parse people JSON
|
||||
var people []string
|
||||
if peopleJSON != "" && peopleJSON != "[]" {
|
||||
if err := json.Unmarshal([]byte(peopleJSON), &people); err != nil {
|
||||
people = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
hourMap[hourTs] = HistoryEntry{
|
||||
Timestamp: hourTs,
|
||||
Count: count,
|
||||
People: people,
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
// Build result array from newest to oldest, filling missing hours with zero entries
|
||||
result := make([]HistoryEntry, hours)
|
||||
for i := 0; i < hours; i++ {
|
||||
hourTs := currentHourStart.Add(time.Duration(-i) * time.Hour).UnixMilli()
|
||||
|
||||
if entry, ok := hourMap[hourTs]; ok {
|
||||
result[i] = entry
|
||||
} else {
|
||||
// No snapshot for this hour, return zero entry
|
||||
result[i] = HistoryEntry{
|
||||
Timestamp: hourTs,
|
||||
Count: 0,
|
||||
People: []string{},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetOccupancyStatus returns the status map for all zones.
|
||||
|
|
|
|||
50
mothership/internal/zones/manager_history.go
Normal file
50
mothership/internal/zones/manager_history.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package zones
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RecordZoneHistorySnapshot records the current occupancy for all zones as an hourly snapshot.
|
||||
// Should be called periodically (e.g., every hour) to build historical occupancy data.
|
||||
// The hour_ts is the Unix millisecond timestamp of the start of the hour bucket.
|
||||
func (m *Manager) RecordZoneHistorySnapshot() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Calculate the start of the current hour bucket in local timezone
|
||||
now := time.Now().In(m.tz)
|
||||
hourStart := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, m.tz)
|
||||
hourTs := hourStart.UnixMilli()
|
||||
|
||||
// Record a snapshot for each zone
|
||||
for zoneID := range m.zones {
|
||||
occ := m.occupancy[zoneID]
|
||||
count := 0
|
||||
var people []string
|
||||
if occ != nil {
|
||||
count = occ.Count
|
||||
// Note: people list not yet implemented - would need blob identity resolver
|
||||
people = []string{}
|
||||
}
|
||||
|
||||
peopleJSON, err := json.Marshal(people)
|
||||
if err != nil {
|
||||
peopleJSON = []byte("[]")
|
||||
}
|
||||
|
||||
// UPSERT into zone_history (ON CONFLICT UPDATE for existing hour_ts)
|
||||
_, err = m.db.Exec(`
|
||||
INSERT INTO zone_history (zone_id, hour_ts, count, people, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(zone_id, hour_ts) DO UPDATE SET
|
||||
count = excluded.count,
|
||||
people = excluded.people,
|
||||
created_at = excluded.created_at
|
||||
`, zoneID, hourTs, count, string(peopleJSON), time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to record zone history snapshot for zone %s: %v", zoneID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
82
mothership/internal/zones/manager_migrate.go
Normal file
82
mothership/internal/zones/manager_migrate.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package zones
|
||||
|
||||
// migrate creates the database schema. Split from manager.go for editability.
|
||||
func (m *Manager) migrate() error {
|
||||
_, err := m.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS zones (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT '#4fc3f7',
|
||||
min_x REAL NOT NULL DEFAULT 0,
|
||||
min_y REAL NOT NULL DEFAULT 0,
|
||||
min_z REAL NOT NULL DEFAULT 0,
|
||||
max_x REAL NOT NULL DEFAULT 1,
|
||||
max_y REAL NOT NULL DEFAULT 1,
|
||||
max_z REAL NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
zone_type TEXT NOT NULL DEFAULT 'normal',
|
||||
is_children_zone INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portals (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
zone_a_id TEXT NOT NULL DEFAULT '',
|
||||
zone_b_id TEXT NOT NULL DEFAULT '',
|
||||
p1_x REAL NOT NULL DEFAULT 0,
|
||||
p1_y REAL NOT NULL DEFAULT 0,
|
||||
p1_z REAL NOT NULL DEFAULT 0,
|
||||
p2_x REAL NOT NULL DEFAULT 0,
|
||||
p2_y REAL NOT NULL DEFAULT 0,
|
||||
p2_z REAL NOT NULL DEFAULT 0,
|
||||
p3_x REAL NOT NULL DEFAULT 0,
|
||||
p3_y REAL NOT NULL DEFAULT 0,
|
||||
p3_z REAL NOT NULL DEFAULT 0,
|
||||
n_x REAL NOT NULL DEFAULT 0,
|
||||
n_y REAL NOT NULL DEFAULT 0,
|
||||
n_z REAL NOT NULL DEFAULT 0,
|
||||
width REAL NOT NULL DEFAULT 1,
|
||||
height REAL NOT NULL DEFAULT 2,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crossing_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
portal_id TEXT NOT NULL,
|
||||
blob_id INTEGER NOT NULL,
|
||||
direction INTEGER NOT NULL,
|
||||
from_zone TEXT NOT NULL,
|
||||
to_zone TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
identity TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crossing_time ON crossing_events(timestamp);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS zone_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
zone_id TEXT NOT NULL,
|
||||
hour_ts INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL,
|
||||
people TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
||||
UNIQUE(zone_id, hour_ts)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_zone_history_zone_time ON zone_history(zone_id, hour_ts DESC);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add zone_type column if it doesn't exist (migration for existing databases)
|
||||
m.db.Exec(`ALTER TABLE zones ADD COLUMN zone_type TEXT NOT NULL DEFAULT 'normal'`)
|
||||
m.db.Exec(`ALTER TABLE zones ADD COLUMN is_children_zone INTEGER NOT NULL DEFAULT 0`)
|
||||
|
||||
// Add last_known_occupancy column for restart reconciliation
|
||||
m.db.Exec(`ALTER TABLE zones ADD COLUMN last_known_occupancy INTEGER NOT NULL DEFAULT 0`)
|
||||
m.db.Exec(`ALTER TABLE zones ADD COLUMN occupancy_updated_at INTEGER`)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package zones
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
|
@ -1561,3 +1562,163 @@ func nowMsSinceMidnight(d time.Duration) int64 {
|
|||
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
return midnight.Add(d).UnixMilli()
|
||||
}
|
||||
|
||||
// --- GetZoneHistory tests ---
|
||||
|
||||
func TestGetZoneHistory(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
// currentHourStart is the start of the current hour (same as GetZoneHistory uses)
|
||||
currentHourStart := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, time.UTC)
|
||||
|
||||
// hourStart returns the timestamp for the start of an hour bucket, offset from currentHourStart
|
||||
hourStart := func(offset time.Duration) int64 {
|
||||
return currentHourStart.Add(offset).UnixMilli()
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
history []struct {
|
||||
hourTs int64 // hour bucket timestamp
|
||||
count int // occupancy count
|
||||
people []string // people list (will be JSON-serialized)
|
||||
}
|
||||
hours int
|
||||
wantCounts []int // expected counts per hour bucket (newest to oldest)
|
||||
wantPeople [][]string // expected people per hour bucket (newest to oldest)
|
||||
}{
|
||||
{
|
||||
name: "empty history",
|
||||
history: []struct {
|
||||
hourTs int64
|
||||
count int
|
||||
people []string
|
||||
}{},
|
||||
hours: 3,
|
||||
wantCounts: []int{0, 0, 0},
|
||||
wantPeople: [][]string{{}, {}, {}},
|
||||
},
|
||||
{
|
||||
name: "one entry per hour",
|
||||
history: []struct {
|
||||
hourTs int64
|
||||
count int
|
||||
people []string
|
||||
}{
|
||||
{hourStart(0), 1, []string{"alice"}},
|
||||
{hourStart(-1 * time.Hour), 1, []string{"bob"}},
|
||||
{hourStart(-2 * time.Hour), 1, []string{"charlie"}},
|
||||
},
|
||||
hours: 3,
|
||||
wantCounts: []int{1, 1, 1},
|
||||
wantPeople: [][]string{{"alice"}, {"bob"}, {"charlie"}},
|
||||
},
|
||||
{
|
||||
name: "multiple people in hour",
|
||||
history: []struct {
|
||||
hourTs int64
|
||||
count int
|
||||
people []string
|
||||
}{
|
||||
{hourStart(0), 3, []string{"alice", "bob", "charlie"}},
|
||||
{hourStart(-1 * time.Hour), 2, []string{"dave", "eve"}},
|
||||
},
|
||||
hours: 3,
|
||||
wantCounts: []int{3, 2, 0},
|
||||
wantPeople: [][]string{{"alice", "bob", "charlie"}, {"dave", "eve"}, {}},
|
||||
},
|
||||
{
|
||||
name: "missing hours filled with zeros",
|
||||
history: []struct {
|
||||
hourTs int64
|
||||
count int
|
||||
people []string
|
||||
}{
|
||||
{hourStart(0), 1, []string{"alice"}},
|
||||
{hourStart(-3 * time.Hour), 2, []string{"bob", "charlie"}},
|
||||
},
|
||||
hours: 6,
|
||||
wantCounts: []int{1, 0, 0, 2, 0, 0},
|
||||
wantPeople: [][]string{{"alice"}, {}, {}, {"bob", "charlie"}, {}, {}},
|
||||
},
|
||||
{
|
||||
name: "empty people arrays",
|
||||
history: []struct {
|
||||
hourTs int64
|
||||
count int
|
||||
people []string
|
||||
}{
|
||||
{hourStart(0), 0, []string{}},
|
||||
{hourStart(-1 * time.Hour), 0, []string{}},
|
||||
},
|
||||
hours: 3,
|
||||
wantCounts: []int{0, 0, 0},
|
||||
wantPeople: [][]string{{}, {}, {}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m, cleanup := setupManager(t, time.UTC)
|
||||
defer cleanup()
|
||||
|
||||
// Create zone
|
||||
zone := &Zone{
|
||||
ID: "kitchen", Name: "Kitchen",
|
||||
MinX: 0, MinY: 0, MinZ: 0,
|
||||
MaxX: 10, MaxY: 10, MaxZ: 3,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := m.CreateZone(zone); err != nil {
|
||||
t.Fatalf("CreateZone: %v", err)
|
||||
}
|
||||
|
||||
// Insert zone history records directly into DB
|
||||
for _, h := range tt.history {
|
||||
peopleJSON, _ := json.Marshal(h.people)
|
||||
_, err := m.db.Exec(`
|
||||
INSERT INTO zone_history (zone_id, hour_ts, count, people, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, "kitchen", h.hourTs, h.count, string(peopleJSON), now.UnixMilli())
|
||||
if err != nil {
|
||||
t.Fatalf("Insert zone history: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Small sleep to ensure time.Now() in GetZoneHistory is past the test's now
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Query history
|
||||
history := m.GetZoneHistory("kitchen", tt.hours)
|
||||
|
||||
if len(history) != tt.hours {
|
||||
t.Fatalf("got %d entries, want %d", len(history), tt.hours)
|
||||
}
|
||||
|
||||
// Verify counts and people (entries are newest to oldest)
|
||||
for i := 0; i < tt.hours; i++ {
|
||||
if history[i].Count != tt.wantCounts[i] {
|
||||
t.Errorf("hour %d (newest to oldest): got count %d, want %d",
|
||||
i, history[i].Count, tt.wantCounts[i])
|
||||
}
|
||||
if !equalStringSlices(history[i].People, tt.wantPeople[i]) {
|
||||
t.Errorf("hour %d (newest to oldest): got people %v, want %v",
|
||||
i, history[i].People, tt.wantPeople[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// equalStringSlices compares two string slices for equality
|
||||
func equalStringSlices(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue