新增 POST /api/devices/:id/inference/options,讓已燒錄的模型不重燒
即可切換 detection/classification 與更換 label。
KL520 一次只能載一個模型(換模型必須重燒),但解析方式與 label 都
只是後處理與顯示層,不碰 NPU,因此可即時生效。
- JSON: {taskType?, labels?};multipart: taskType + labelFile
- 欄位用指標型別以區分「省略 = 不動」與「labels:[] = 清空」
- label 解析器(internal/labelfile):格式 <index> <名稱>
略過空行與 # 註解、trim CRLF、名稱含空白只 split 第一個空白、
index 非整數/負數/重複則整檔拒絕並回報行號、稀疏 index 補空字串
- index 上限 4095、檔案上限 256KB,防記憶體耗盡
- 非法 taskType 一律拒絕(含舊別名 detection),因為 bridge 對無法
辨識的值是靜默 fallback,忽略會回 200 但用錯解析方式
- 走窄能力介面 InferenceOptionsDriver,不動 DeviceDriver 簽章
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
501 lines
17 KiB
Go
501 lines
17 KiB
Go
package handlers
|
||
|
||
// device_inference_options_test.go — M4:POST /api/devices/:id/inference/options
|
||
// 的 HTTP 契約測試(推論期切換解析方式 / label 表)。
|
||
//
|
||
// 測試分兩層:
|
||
//
|
||
// 1. parseInferenceOptionsRequest — 這裡是本 endpoint 幾乎全部的邏輯
|
||
// (JSON / multipart 解析、值域驗證、大小上限、nil vs 空陣列的語意)。
|
||
// 它只依賴 *gin.Context,可以完整單元測試。
|
||
// 2. SetInferenceOptions handler — 只驗它自己負責的分支:device 不存在、
|
||
// driver 不支援、driver 回錯。
|
||
//
|
||
// ⚠️ 測試接縫限制(與 device_flash_tasktype_test.go 同一個既有問題):
|
||
// DeviceHandler.deviceMgr 是具體的 *device.Manager,其 sessions map 未匯出、
|
||
// 只能由真實硬體填入,跨 package 無法注入 fake session。因此「成功路徑打到
|
||
// driver」這段沒有 handler 級測試 —— 由 buildSetInferenceOptionsCommand 的
|
||
// 單元測試(driver/kneron)與實機驗收覆蓋。這是既有架構限制,不是本次引入。
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"visiona-local/server/internal/device"
|
||
"visiona-local/server/internal/driver"
|
||
"visiona-local/server/internal/driver/kneron"
|
||
"visiona-local/server/internal/labelfile"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// ── helpers ──────────────────────────────────────────────────────────
|
||
|
||
// errorCode 取出統一錯誤格式裡的 error.code。
|
||
//
|
||
// 原先住在 device_flash_tasktype_test.go;該檔隨「燒錄時選推論種類」功能一起
|
||
// 移除後搬來這裡(本檔是目前唯一的使用者)。
|
||
func errorCode(parsed map[string]interface{}) string {
|
||
errObj, ok := parsed["error"].(map[string]interface{})
|
||
if !ok {
|
||
return ""
|
||
}
|
||
code, _ := errObj["code"].(string)
|
||
return code
|
||
}
|
||
|
||
// runParse 以指定的 body / content-type 呼叫 parseInferenceOptionsRequest。
|
||
func runParse(t *testing.T, contentType string, body []byte) (driver.InferenceOptions, labelSummary, *apiError) {
|
||
t.Helper()
|
||
|
||
var (
|
||
opts driver.InferenceOptions
|
||
summary labelSummary
|
||
apiErr *apiError
|
||
)
|
||
|
||
router := gin.New()
|
||
router.POST("/x", func(c *gin.Context) {
|
||
opts, summary, apiErr = parseInferenceOptionsRequest(c)
|
||
c.Status(http.StatusOK)
|
||
})
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/x", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", contentType)
|
||
router.ServeHTTP(httptest.NewRecorder(), req)
|
||
|
||
return opts, summary, apiErr
|
||
}
|
||
|
||
func parseJSON(t *testing.T, body string) (driver.InferenceOptions, labelSummary, *apiError) {
|
||
t.Helper()
|
||
return runParse(t, "application/json", []byte(body))
|
||
}
|
||
|
||
// buildMultipart 組出 multipart body(labelFileName 為空表示不帶檔案)。
|
||
func buildMultipart(t *testing.T, taskType, labelFileName, labelContent string) (string, []byte) {
|
||
t.Helper()
|
||
|
||
var buf bytes.Buffer
|
||
w := multipart.NewWriter(&buf)
|
||
if taskType != "" {
|
||
if err := w.WriteField("taskType", taskType); err != nil {
|
||
t.Fatalf("WriteField: %v", err)
|
||
}
|
||
}
|
||
if labelFileName != "" {
|
||
fw, err := w.CreateFormFile("labelFile", labelFileName)
|
||
if err != nil {
|
||
t.Fatalf("CreateFormFile: %v", err)
|
||
}
|
||
if _, err := fw.Write([]byte(labelContent)); err != nil {
|
||
t.Fatalf("write file part: %v", err)
|
||
}
|
||
}
|
||
if err := w.Close(); err != nil {
|
||
t.Fatalf("close writer: %v", err)
|
||
}
|
||
return w.FormDataContentType(), buf.Bytes()
|
||
}
|
||
|
||
func requireNoAPIError(t *testing.T, err *apiError) {
|
||
t.Helper()
|
||
if err != nil {
|
||
t.Fatalf("unexpected apiError: code=%s message=%s", err.code, err.message)
|
||
}
|
||
}
|
||
|
||
func requireAPIError(t *testing.T, err *apiError, wantCode string) *apiError {
|
||
t.Helper()
|
||
if err == nil {
|
||
t.Fatalf("expected apiError with code %s, got nil", wantCode)
|
||
}
|
||
if err.code != wantCode {
|
||
t.Fatalf("error code = %q, want %q (message=%q)", err.code, wantCode, err.message)
|
||
}
|
||
return err
|
||
}
|
||
|
||
// ── JSON body ────────────────────────────────────────────────────────
|
||
|
||
func TestParseInferenceOptions_JSON_TaskTypeOnly(t *testing.T) {
|
||
opts, _, err := parseJSON(t, `{"taskType":"classification"}`)
|
||
requireNoAPIError(t, err)
|
||
|
||
if opts.TaskType != "classification" {
|
||
t.Errorf("TaskType = %q, want classification", opts.TaskType)
|
||
}
|
||
if opts.Labels != nil {
|
||
t.Errorf("Labels = %v, want nil(沒帶 labels 就不該動 label 表)", opts.Labels)
|
||
}
|
||
}
|
||
|
||
func TestParseInferenceOptions_JSON_LabelsOnly(t *testing.T) {
|
||
opts, summary, err := parseJSON(t, `{"labels":["剪刀","石頭","布"]}`)
|
||
requireNoAPIError(t, err)
|
||
|
||
if opts.TaskType != "" {
|
||
t.Errorf("TaskType = %q, want 空(沒帶就不該動解析方式)", opts.TaskType)
|
||
}
|
||
want := []string{"剪刀", "石頭", "布"}
|
||
if !equalStringSlice(opts.Labels, want) {
|
||
t.Errorf("Labels = %v, want %v", opts.Labels, want)
|
||
}
|
||
if summary.namedCount != 3 {
|
||
t.Errorf("namedCount = %d, want 3", summary.namedCount)
|
||
}
|
||
}
|
||
|
||
// 關鍵語意:空陣列 = 清空 label 表,必須與「沒帶欄位」區分。
|
||
func TestParseInferenceOptions_JSON_EmptyLabelsMeansClear(t *testing.T) {
|
||
opts, summary, err := parseJSON(t, `{"labels":[]}`)
|
||
requireNoAPIError(t, err)
|
||
|
||
if opts.Labels == nil {
|
||
t.Fatal("Labels = nil —— 空陣列被塌成 nil,「清空 label 表」的意圖丟失了")
|
||
}
|
||
if len(opts.Labels) != 0 {
|
||
t.Errorf("len(Labels) = %d, want 0", len(opts.Labels))
|
||
}
|
||
if summary.namedCount != 0 {
|
||
t.Errorf("namedCount = %d, want 0", summary.namedCount)
|
||
}
|
||
}
|
||
|
||
// 對照組:沒帶 labels 欄位時 Labels 必須是 nil(= 不動)。
|
||
func TestParseInferenceOptions_JSON_AbsentLabelsMeansUnchanged(t *testing.T) {
|
||
opts, _, err := parseJSON(t, `{"taskType":"classification"}`)
|
||
requireNoAPIError(t, err)
|
||
|
||
if opts.Labels != nil {
|
||
t.Errorf("Labels = %v, want nil", opts.Labels)
|
||
}
|
||
}
|
||
|
||
// JSON null 與「沒帶」同義 —— 都是不動。
|
||
func TestParseInferenceOptions_JSON_NullLabelsMeansUnchanged(t *testing.T) {
|
||
opts, _, err := parseJSON(t, `{"taskType":"classification","labels":null}`)
|
||
requireNoAPIError(t, err)
|
||
|
||
if opts.Labels != nil {
|
||
t.Errorf("Labels = %v, want nil", opts.Labels)
|
||
}
|
||
}
|
||
|
||
func TestParseInferenceOptions_JSON_Both(t *testing.T) {
|
||
opts, summary, err := parseJSON(t, `{"taskType":"classification","labels":["a","b"]}`)
|
||
requireNoAPIError(t, err)
|
||
|
||
if opts.TaskType != "classification" {
|
||
t.Errorf("TaskType = %q", opts.TaskType)
|
||
}
|
||
if summary.namedCount != 2 {
|
||
t.Errorf("namedCount = %d, want 2", summary.namedCount)
|
||
}
|
||
}
|
||
|
||
func TestParseInferenceOptions_JSON_SparseLabelsCountedCorrectly(t *testing.T) {
|
||
opts, summary, err := parseJSON(t, `{"labels":["a","","c"]}`)
|
||
requireNoAPIError(t, err)
|
||
|
||
if len(opts.Labels) != 3 {
|
||
t.Errorf("len(Labels) = %d, want 3(稀疏佔位要保留,位置就是 class index)", len(opts.Labels))
|
||
}
|
||
if summary.namedCount != 2 {
|
||
t.Errorf("namedCount = %d, want 2(空字串不算一筆)", summary.namedCount)
|
||
}
|
||
}
|
||
|
||
func TestParseInferenceOptions_JSON_MalformedBody(t *testing.T) {
|
||
_, _, err := parseJSON(t, `{not json`)
|
||
requireAPIError(t, err, "BAD_REQUEST")
|
||
}
|
||
|
||
// ── 值域驗證(沿用燒錄時同一套)──────────────────────────────────────
|
||
|
||
func TestParseInferenceOptions_RejectsInvalidTaskType(t *testing.T) {
|
||
// R-4:舊別名 detection 也要擋 —— bridge 收得下,但不讓兩套命名同時
|
||
// 出現在 wire 上。與 POST /flash 的規則保持完全一致。
|
||
bad := []string{
|
||
"detection",
|
||
"segmentation",
|
||
"pose_estimation",
|
||
"Classification",
|
||
"classifcation",
|
||
"garbage",
|
||
}
|
||
for _, tt := range bad {
|
||
t.Run(tt, func(t *testing.T) {
|
||
body := fmt.Sprintf(`{"taskType":%q}`, tt)
|
||
_, _, err := parseJSON(t, body)
|
||
e := requireAPIError(t, err, "VALIDATION_ERROR")
|
||
if e.status != http.StatusBadRequest {
|
||
t.Errorf("status = %d, want 400", e.status)
|
||
}
|
||
if !strings.Contains(e.message, "classification") ||
|
||
!strings.Contains(e.message, "object_detection") {
|
||
t.Errorf("message = %q, 應列出合法值", e.message)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestParseInferenceOptions_AcceptsValidTaskType(t *testing.T) {
|
||
for _, tt := range []string{"classification", "object_detection"} {
|
||
t.Run(tt, func(t *testing.T) {
|
||
opts, _, err := parseJSON(t, fmt.Sprintf(`{"taskType":%q}`, tt))
|
||
requireNoAPIError(t, err)
|
||
if opts.TaskType != tt {
|
||
t.Errorf("TaskType = %q, want %q", opts.TaskType, tt)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 兩者都沒帶 = 呼叫端沒表達任何意圖。回 200 等於假裝做了事。
|
||
func TestParseInferenceOptions_RejectsEmptyRequest(t *testing.T) {
|
||
cases := map[string]string{
|
||
"空物件": `{}`,
|
||
"taskType 空字串": `{"taskType":""}`,
|
||
"兩者皆 null": `{"taskType":null,"labels":null}`,
|
||
}
|
||
for name, body := range cases {
|
||
t.Run(name, func(t *testing.T) {
|
||
_, _, err := parseJSON(t, body)
|
||
requireAPIError(t, err, "BAD_REQUEST")
|
||
})
|
||
}
|
||
}
|
||
|
||
// 只帶空 labels 陣列是有意義的(清空),不該被「空請求」規則誤擋。
|
||
func TestParseInferenceOptions_EmptyLabelsAloneIsNotEmptyRequest(t *testing.T) {
|
||
_, _, err := parseJSON(t, `{"labels":[]}`)
|
||
requireNoAPIError(t, err)
|
||
}
|
||
|
||
// ── JSON labels 數量上限(S-2 / R-7)─────────────────────────────────
|
||
|
||
func TestParseInferenceOptions_JSON_RejectsTooManyLabels(t *testing.T) {
|
||
labels := make([]string, labelfile.MaxIndex+2)
|
||
for i := range labels {
|
||
labels[i] = "x"
|
||
}
|
||
payload, err := json.Marshal(map[string]interface{}{"labels": labels})
|
||
if err != nil {
|
||
t.Fatalf("marshal: %v", err)
|
||
}
|
||
|
||
_, _, apiErr := runParse(t, "application/json", payload)
|
||
requireAPIError(t, apiErr, "LABEL_TOO_LARGE")
|
||
}
|
||
|
||
func TestParseInferenceOptions_JSON_AcceptsExactlyMaxLabels(t *testing.T) {
|
||
labels := make([]string, labelfile.MaxIndex+1)
|
||
for i := range labels {
|
||
labels[i] = "x"
|
||
}
|
||
payload, err := json.Marshal(map[string]interface{}{"labels": labels})
|
||
if err != nil {
|
||
t.Fatalf("marshal: %v", err)
|
||
}
|
||
|
||
_, _, apiErr := runParse(t, "application/json", payload)
|
||
requireNoAPIError(t, apiErr)
|
||
}
|
||
|
||
// ── multipart 上傳 ───────────────────────────────────────────────────
|
||
|
||
func TestParseInferenceOptions_Multipart_LabelFile(t *testing.T) {
|
||
ct, body := buildMultipart(t, "classification", "labels.txt", "0 剪刀\n1 石頭\n2 布\n")
|
||
opts, summary, err := runParse(t, ct, body)
|
||
requireNoAPIError(t, err)
|
||
|
||
if opts.TaskType != "classification" {
|
||
t.Errorf("TaskType = %q", opts.TaskType)
|
||
}
|
||
want := []string{"剪刀", "石頭", "布"}
|
||
if !equalStringSlice(opts.Labels, want) {
|
||
t.Errorf("Labels = %v, want %v", opts.Labels, want)
|
||
}
|
||
if summary.namedCount != 3 {
|
||
t.Errorf("namedCount = %d, want 3", summary.namedCount)
|
||
}
|
||
}
|
||
|
||
func TestParseInferenceOptions_Multipart_TaskTypeOnlyNoFile(t *testing.T) {
|
||
ct, body := buildMultipart(t, "object_detection", "", "")
|
||
opts, _, err := runParse(t, ct, body)
|
||
requireNoAPIError(t, err)
|
||
|
||
if opts.TaskType != "object_detection" {
|
||
t.Errorf("TaskType = %q", opts.TaskType)
|
||
}
|
||
if opts.Labels != nil {
|
||
t.Errorf("Labels = %v, want nil(沒上傳檔案就不該動 label 表)", opts.Labels)
|
||
}
|
||
}
|
||
|
||
func TestParseInferenceOptions_Multipart_SparseLabelFile(t *testing.T) {
|
||
ct, body := buildMultipart(t, "classification", "labels.txt", "0 a\n3 d\n")
|
||
opts, summary, err := runParse(t, ct, body)
|
||
requireNoAPIError(t, err)
|
||
|
||
want := []string{"a", "", "", "d"}
|
||
if !equalStringSlice(opts.Labels, want) {
|
||
t.Errorf("Labels = %v, want %v", opts.Labels, want)
|
||
}
|
||
if summary.namedCount != 2 {
|
||
t.Errorf("namedCount = %d, want 2", summary.namedCount)
|
||
}
|
||
}
|
||
|
||
// 解析失敗要帶行號回去,前端才能指出是哪一行。
|
||
func TestParseInferenceOptions_Multipart_ParseErrorCarriesLine(t *testing.T) {
|
||
ct, body := buildMultipart(t, "", "labels.txt", "0 a\n1 b\nabc c\n")
|
||
_, _, err := runParse(t, ct, body)
|
||
e := requireAPIError(t, err, "LABEL_PARSE_ERROR")
|
||
|
||
if e.line != 3 {
|
||
t.Errorf("line = %d, want 3", e.line)
|
||
}
|
||
if _, ok := e.body()["line"].(int); !ok {
|
||
t.Error("回應 body 應包含 line 欄位")
|
||
}
|
||
}
|
||
|
||
func TestParseInferenceOptions_Multipart_ParseErrorBodyOmitsLineWhenZero(t *testing.T) {
|
||
ct, body := buildMultipart(t, "", "labels.txt", "\n\n\n")
|
||
_, _, err := runParse(t, ct, body)
|
||
e := requireAPIError(t, err, "LABEL_PARSE_ERROR")
|
||
|
||
if _, present := e.body()["line"]; present {
|
||
t.Error("與行號無關的錯誤不應帶 line 欄位(前端會亂標第 0 行)")
|
||
}
|
||
}
|
||
|
||
func TestParseInferenceOptions_Multipart_RejectsOversizedFile(t *testing.T) {
|
||
big := strings.Repeat("0 aaaaaaaa\n", labelfile.MaxFileSize/10+100)
|
||
ct, body := buildMultipart(t, "", "labels.txt", big)
|
||
_, _, err := runParse(t, ct, body)
|
||
requireAPIError(t, err, "LABEL_TOO_LARGE")
|
||
}
|
||
|
||
func TestParseInferenceOptions_Multipart_RejectsBadExtension(t *testing.T) {
|
||
ct, body := buildMultipart(t, "", "labels.exe", "0 a\n")
|
||
_, _, err := runParse(t, ct, body)
|
||
requireAPIError(t, err, "BAD_REQUEST")
|
||
}
|
||
|
||
func TestParseInferenceOptions_Multipart_AcceptsNamesExtension(t *testing.T) {
|
||
ct, body := buildMultipart(t, "", "coco.names", "0 person\n")
|
||
opts, _, err := runParse(t, ct, body)
|
||
requireNoAPIError(t, err)
|
||
|
||
if !equalStringSlice(opts.Labels, []string{"person"}) {
|
||
t.Errorf("Labels = %v", opts.Labels)
|
||
}
|
||
}
|
||
|
||
func TestParseInferenceOptions_Multipart_RejectsInvalidTaskType(t *testing.T) {
|
||
ct, body := buildMultipart(t, "detection", "labels.txt", "0 a\n")
|
||
_, _, err := runParse(t, ct, body)
|
||
requireAPIError(t, err, "VALIDATION_ERROR")
|
||
}
|
||
|
||
func TestParseInferenceOptions_Multipart_RejectsEmptyRequest(t *testing.T) {
|
||
ct, body := buildMultipart(t, "", "", "")
|
||
_, _, err := runParse(t, ct, body)
|
||
requireAPIError(t, err, "BAD_REQUEST")
|
||
}
|
||
|
||
// 上傳的 index 超過上限 → 由 labelfile 擋下並回 LABEL_PARSE_ERROR。
|
||
// 若這道防線失效,handler 會嘗試配置巨大 slice。
|
||
func TestParseInferenceOptions_Multipart_RejectsHugeIndex(t *testing.T) {
|
||
ct, body := buildMultipart(t, "", "labels.txt", "999999999 boom\n")
|
||
_, _, err := runParse(t, ct, body)
|
||
requireAPIError(t, err, "LABEL_PARSE_ERROR")
|
||
}
|
||
|
||
// ── handler 分支(不需要 device session 的部分)──────────────────────
|
||
|
||
// postOptions 呼叫 SetInferenceOptions 並回傳 status / 解析後 body。
|
||
func postOptions(t *testing.T, h *DeviceHandler, contentType string, body []byte) (int, map[string]interface{}) {
|
||
t.Helper()
|
||
|
||
router := gin.New()
|
||
router.POST("/devices/:id/inference/options", h.SetInferenceOptions)
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/devices/dev-1/inference/options", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", contentType)
|
||
w := httptest.NewRecorder()
|
||
router.ServeHTTP(w, req)
|
||
|
||
parsed := map[string]interface{}{}
|
||
_ = json.Unmarshal(w.Body.Bytes(), &parsed)
|
||
return w.Code, parsed
|
||
}
|
||
|
||
func TestSetInferenceOptions_DeviceNotFound(t *testing.T) {
|
||
h := &DeviceHandler{deviceMgr: device.NewManager(device.NewRegistry(), "")}
|
||
|
||
status, parsed := postOptions(t, h, "application/json", []byte(`{"taskType":"classification"}`))
|
||
|
||
if status != http.StatusNotFound {
|
||
t.Errorf("status = %d, want 404", status)
|
||
}
|
||
if got := errorCode(parsed); got != "DEVICE_NOT_FOUND" {
|
||
t.Errorf("error.code = %q, want DEVICE_NOT_FOUND", got)
|
||
}
|
||
}
|
||
|
||
// device 查找必須排在 body 解析之前 —— 對不存在的裝置回「body 有問題」
|
||
// 會把使用者引去改 payload,而真正的問題是裝置不在。
|
||
func TestSetInferenceOptions_DeviceLookupPrecedesBodyValidation(t *testing.T) {
|
||
h := &DeviceHandler{deviceMgr: device.NewManager(device.NewRegistry(), "")}
|
||
|
||
status, parsed := postOptions(t, h, "application/json", []byte(`{not json`))
|
||
|
||
if status != http.StatusNotFound {
|
||
t.Errorf("status = %d, want 404(裝置查找應先於 body 解析)", status)
|
||
}
|
||
if got := errorCode(parsed); got != "DEVICE_NOT_FOUND" {
|
||
t.Errorf("error.code = %q, want DEVICE_NOT_FOUND", got)
|
||
}
|
||
}
|
||
|
||
// ── 介面契約 ─────────────────────────────────────────────────────────
|
||
|
||
// *kneron.KneronDriver 必須真的滿足 InferenceOptionsDriver。
|
||
//
|
||
// 這是整條鏈路唯一會「靜默壞掉」的接點:handler 用 type assert 取得能力,
|
||
// 若 driver 的 method 簽章改了(或被誤刪),編譯完全不會報錯 —— endpoint
|
||
// 會對所有裝置回 UNSUPPORTED_DEVICE,而且只有實機才看得出來。
|
||
//
|
||
// 刻意 assert 具體型別而非自己寫一個滿足介面的 stub:stub 只證明「我寫的
|
||
// stub 符合我寫的介面」,對真正的實作零保障。
|
||
var _ InferenceOptionsDriver = (*kneron.KneronDriver)(nil)
|
||
|
||
// KneronDriver 同時必須仍是合法的 driver.DeviceDriver —— 加新能力不能
|
||
// 破壞既有契約。
|
||
var _ driver.DeviceDriver = (*kneron.KneronDriver)(nil)
|
||
|
||
// ── helper ───────────────────────────────────────────────────────────
|
||
|
||
func equalStringSlice(a, b []string) bool {
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
for i := range a {
|
||
if a[i] != b[i] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|