package backendrpc import ( "bufio" "context" "encoding/json" "errors" "net" "testing" "time" ) type testRequest struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` Method string `json:"method"` Params json.RawMessage `json:"params,omitempty"` } type testEnvelope struct { OK bool `json:"ok"` Status string `json:"status"` Data any `json:"data,omitempty"` Error any `json:"error,omitempty"` RequestID string `json:"request_id"` } type testResponse struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` Result any `json:"result,omitempty"` Error *JSONRPCError `json:"error,omitempty"` } func newTestClient(t *testing.T, handler func(t *testing.T, req testRequest) testResponse) *Client { t.Helper() client := New("bat.sock") client.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) { clientConn, serverConn := net.Pipe() go func(conn net.Conn) { defer conn.Close() line, err := bufio.NewReader(conn).ReadBytes('\n') if err != nil { return } var req testRequest if err := json.Unmarshal(line, &req); err != nil { return } resp := handler(t, req) if len(resp.ID) == 0 { resp.ID = req.ID } if resp.JSONRPC == "" { resp.JSONRPC = jsonRPCVersion } _ = json.NewEncoder(conn).Encode(resp) }(serverConn) return clientConn, nil } return client } func TestResourceRepairQueuesTask(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "resource.repair" { t.Fatalf("method = %s", req.Method) } return testResponse{ Result: testEnvelope{ OK: true, Status: "accepted", RequestID: "req-test-1", Data: map[string]any{ "task_id": "task-1", "kind": "resource.repair", }, }, } }) task, err := client.ResourceRepair(context.Background()) if err != nil { t.Fatalf("ResourceRepair error: %v", err) } if task.TaskID != "task-1" || task.Kind != "resource.repair" { t.Fatalf("unexpected task: %#v", task) } } func TestDaemonRestartSendsControlMethod(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "daemon.restart" { t.Fatalf("method = %s", req.Method) } return testResponse{ Result: testEnvelope{ OK: true, Status: "accepted", RequestID: "req-test-restart", Data: map[string]any{ "command": "restart", "status": "accepted", "message": "restart accepted", "state_dir": "/tmp/bat-pid", "socket_path": "/tmp/bat-pid/bat.sock", "controller_pid": 4242, }, }, } }) ack, err := client.DaemonRestart(context.Background()) if err != nil { t.Fatalf("DaemonRestart error: %v", err) } if ack.Command != "restart" || ack.ControllerPID == nil || *ack.ControllerPID != 4242 { t.Fatalf("unexpected ack: %#v", ack) } } func TestParseTextUnitsSendsQuery(t *testing.T) { pathID := int64(7) classID := 114 client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "parse.text_units" { t.Fatalf("method = %s", req.Method) } var params TextUnitQueryParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode params: %v", err) } if params.Offset != 3 || params.Limit != 5 || params.Destination != "*Table*" || params.PathID == nil || *params.PathID != pathID || params.ClassID == nil || *params.ClassID != classID { t.Fatalf("params = %#v", params) } return testResponse{ Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-test-parse", Data: map[string]any{ "available": true, "entries": []any{}, }, }, } }) raw, err := client.ParseTextUnits(context.Background(), TextUnitQueryParams{ Offset: 3, Limit: 5, Destination: "*Table*", PathID: &pathID, ClassID: &classID, }) if err != nil { t.Fatalf("ParseTextUnits error: %v", err) } if !json.Valid(raw) { t.Fatalf("invalid raw JSON: %s", string(raw)) } } func TestUnityFSPatchFieldSendsTaggedReplacement(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "unityfs.patch_field" { t.Fatalf("method = %s", req.Method) } var params UnityFSFieldPatchParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode params: %v", err) } if params.BundlePath != "/tmp/source.bundle" || params.FieldPath != "m_Name" || string(params.Replacement) != `{"kind":"string","value":"new text"}` { t.Fatalf("params = %#v replacement=%s", params, string(params.Replacement)) } return testResponse{ Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-test-unityfs", Data: map[string]any{ "command": "unityfs.patch_field", "status": "completed", }, }, } }) raw, err := client.UnityFSPatchField(context.Background(), UnityFSFieldPatchParams{ BundlePath: "/tmp/source.bundle", SerializedFilePath: "CAB-test", PathID: 1, FieldPath: "m_Name", Replacement: json.RawMessage(`{"kind":"string","value":"new text"}`), TargetPath: "/tmp/target.bundle", }) if err != nil { t.Fatalf("UnityFSPatchField error: %v", err) } if !json.Valid(raw) { t.Fatalf("invalid raw JSON: %s", string(raw)) } } func TestResourceListSendsPagination(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "resource.list" { t.Fatalf("method = %s", req.Method) } var params pageParam if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode params: %v", err) } if params.Offset != 2 || params.Limit != 1 { t.Fatalf("params = %#v", params) } bytes := uint64(42) return testResponse{ Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-test-2", Data: map[string]any{ "available": true, "resource_root": "/tmp/resources/current", "manifest_version": 1, "total_entries": 3, "offset": 2, "limit": 1, "entries": []map[string]any{ { "url": "https://example.invalid/a.zip", "destination": "a.zip", "bytes": bytes, "blake3": "abc", }, }, }, }, } }) page, err := client.ResourceList(context.Background(), 2, 1) if err != nil { t.Fatalf("ResourceList error: %v", err) } if !page.Available || page.TotalEntries != 3 || len(page.Entries) != 1 { t.Fatalf("unexpected page: %#v", page) } if page.Entries[0].Bytes == nil || *page.Entries[0].Bytes != 42 { t.Fatalf("unexpected entry bytes: %#v", page.Entries[0].Bytes) } } func TestScheduleUpdateSendsMutationParams(t *testing.T) { every := uint64(3600) count := uint64(4) enabled := true client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "schedule.update" { t.Fatalf("method = %s", req.Method) } var params ScheduleMutationParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode params: %v", err) } if params.ID != "nightly-pull" || params.Group != "res" || params.Action != "pull" || params.EverySeconds == nil || *params.EverySeconds != every || params.Count == nil || *params.Count != count || params.Enabled == nil || !*params.Enabled { t.Fatalf("params = %#v", params) } return testResponse{ Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-test-schedule", Data: map[string]any{ "command": "schedule-update", "status": "updated", }, }, } }) raw, err := client.ScheduleUpdate(context.Background(), ScheduleMutationParams{ ID: "nightly-pull", Group: "res", Action: "pull", EverySeconds: &every, Count: &count, Enabled: &enabled, }) if err != nil { t.Fatalf("ScheduleUpdate error: %v", err) } if !json.Valid(raw) { t.Fatalf("invalid raw JSON: %s", string(raw)) } } func TestScheduleRunSendsScopeAndMaxRuns(t *testing.T) { maxRuns := uint64(2) client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "schedule.run" { t.Fatalf("method = %s", req.Method) } var params ScheduleRunParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode params: %v", err) } if params.ID != "nightly-pull" || params.Group != "res" || !params.Force || params.MaxRuns == nil || *params.MaxRuns != maxRuns { t.Fatalf("params = %#v", params) } return testResponse{ Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-test-schedule-run", Data: map[string]any{"command": "schedule-run", "executed": []any{}}, }, } }) raw, err := client.ScheduleRun(context.Background(), ScheduleRunParams{ ID: "nightly-pull", Group: "res", Force: true, MaxRuns: &maxRuns, }) if err != nil { t.Fatalf("ScheduleRun error: %v", err) } if !json.Valid(raw) { t.Fatalf("invalid raw JSON: %s", string(raw)) } } func TestTranslationTasksSendsQueryParams(t *testing.T) { offset := uint64(10) limit := uint64(25) hasReason := true hasFailureReason := false client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "translation.tasks" { t.Fatalf("method = %s", req.Method) } var params TranslationTaskListParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode params: %v", err) } if params.Offset == nil || *params.Offset != offset || params.Limit == nil || *params.Limit != limit || params.TaskID != "textunit/v-current/Scenario" || params.ReleaseID != "v-current" || params.Destination != "MediaResources/GameData/Scenario.zip" || params.PathPattern != "Scenario" || params.ArchiveEntry != "ScenarioExcelTable.json" || params.Status != "queued_offline" || params.WorkerStatus != "failed" || params.ParseStatus != "ok" || params.Format != "json" || params.HasReason == nil || *params.HasReason != hasReason || params.HasFailureReason == nil || *params.HasFailureReason != hasFailureReason { t.Fatalf("params = %#v", params) } return testResponse{ Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-test-translation-tasks", Data: map[string]any{"tasks": []any{}}, }, } }) raw, err := client.TranslationTasks(context.Background(), TranslationTaskListParams{ Offset: &offset, Limit: &limit, TaskID: "textunit/v-current/Scenario", ReleaseID: "v-current", Destination: "MediaResources/GameData/Scenario.zip", PathPattern: "Scenario", ArchiveEntry: "ScenarioExcelTable.json", Status: "queued_offline", WorkerStatus: "failed", ParseStatus: "ok", Format: "json", HasReason: &hasReason, HasFailureReason: &hasFailureReason, }) if err != nil { t.Fatalf("TranslationTasks error: %v", err) } if !json.Valid(raw) { t.Fatalf("invalid raw JSON: %s", string(raw)) } } func TestTranslationHandoffUsesRustMethod(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "translation.handoff" { t.Fatalf("method = %s", req.Method) } if string(req.Params) != "null" { t.Fatalf("params = %s, want null", req.Params) } return testResponse{ Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-test-translation-handoff", Data: map[string]any{"jobs": []any{}}, }, } }) raw, err := client.TranslationHandoff(context.Background()) if err != nil { t.Fatalf("TranslationHandoff error: %v", err) } if !json.Valid(raw) { t.Fatalf("invalid raw JSON: %s", string(raw)) } } func TestTranslationTaskUpdateSendsWorkerParams(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "translation.task.update" { t.Fatalf("method = %s", req.Method) } var params TranslationTaskUpdateParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode params: %v", err) } if params.TaskID != "textunit/v-current/Scenario" || params.Status != "completed" || params.FailureReason != "" || params.ProviderRunID != "provider-run-1" || params.Provider != "manual" || len(params.TranslationResults) != 1 || params.TranslationResults[0].UnitID != "direct:a#unit:0" || params.TranslationResults[0].SourceText != "source" || params.TranslationResults[0].TranslatedText != "译文" { t.Fatalf("params = %#v", params) } return testResponse{ Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-test-translation-update", Data: map[string]any{"task_status": "completed"}, }, } }) raw, err := client.TranslationTaskUpdate(context.Background(), TranslationTaskUpdateParams{ TaskID: "textunit/v-current/Scenario", Status: "completed", ProviderRunID: "provider-run-1", Provider: "manual", TranslationResults: []TranslationTaskUnitResultParam{{ UnitID: "direct:a#unit:0", SourceText: "source", TranslatedText: "译文", }}, }) if err != nil { t.Fatalf("TranslationTaskUpdate error: %v", err) } if !json.Valid(raw) { t.Fatalf("invalid raw JSON: %s", string(raw)) } } func TestTranslationWorkerRunSendsProviderConfig(t *testing.T) { var concurrency uint64 = 8 var maxAttempts uint64 = 4 var leaseSeconds uint64 = 60 var retryBackoff uint64 = 0 var maxTasks uint64 = 2 client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "translation.worker.run" { t.Fatalf("method = %s", req.Method) } var params TranslationWorkerRunParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode params: %v", err) } if params.Provider != "mock" || params.FixturePath != "/tmp/mock-provider.json" || params.TranslationMemoryPath != "/tmp/translation-memory.sqlite" || params.Concurrency == nil || *params.Concurrency != concurrency || params.MaxAttempts == nil || *params.MaxAttempts != maxAttempts || params.LeaseSeconds == nil || *params.LeaseSeconds != leaseSeconds || params.RetryBackoffSeconds == nil || *params.RetryBackoffSeconds != retryBackoff || params.MaxTasks == nil || *params.MaxTasks != maxTasks || params.WorkerID != "dashboard-worker" { t.Fatalf("params = %#v", params) } return testResponse{ Result: testEnvelope{ OK: true, Status: "accepted", RequestID: "req-test-translation-worker", Data: map[string]any{ "task_id": "task-worker-1", "kind": "translation.worker.run", "worker": map[string]any{ "provider": "mock", "fixture_path": "/tmp/mock-provider.json", "translation_memory_path": "/tmp/translation-memory.sqlite", "concurrency": concurrency, "max_attempts": maxAttempts, "lease_seconds": leaseSeconds, "retry_backoff_seconds": retryBackoff, "max_tasks": maxTasks, "worker_id": "dashboard-worker", }, }, }, } }) result, err := client.TranslationWorkerRun(context.Background(), TranslationWorkerRunParams{ Provider: "mock", FixturePath: "/tmp/mock-provider.json", TranslationMemoryPath: "/tmp/translation-memory.sqlite", Concurrency: &concurrency, MaxAttempts: &maxAttempts, LeaseSeconds: &leaseSeconds, RetryBackoffSeconds: &retryBackoff, MaxTasks: &maxTasks, WorkerID: "dashboard-worker", }) if err != nil { t.Fatalf("TranslationWorkerRun error: %v", err) } if result.TaskID != "task-worker-1" || result.Kind != "translation.worker.run" || result.Worker.Concurrency != concurrency || result.Worker.TranslationMemoryPath != "/tmp/translation-memory.sqlite" || result.Worker.RetryBackoffSeconds != retryBackoff || result.Worker.MaxTasks == nil || *result.Worker.MaxTasks != maxTasks { t.Fatalf("unexpected result: %#v", result) } } func TestTranslationMemoryTypedContract(t *testing.T) { limit := uint64(25) client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { switch req.Method { case "translation.memory.summary": var params TranslationMemorySummaryParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode summary params: %v", err) } if params.TranslationMemoryPath != "/var/lib/bat/translation-memory.sqlite" { t.Fatalf("summary params=%#v", params) } return testResponse{ Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-tm-summary", Data: map[string]any{ "available": true, "path": "/var/lib/bat/translation-memory.sqlite", "schema_version": 1, "summary": map[string]any{ "schema_version": 1, "record_count": 3, "trusted_count": 1, "candidate_count": 1, "superseded_count": 1, "rejected_count": 0, }, }, }, } case "translation.memory.query": var params TranslationMemoryQueryParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode query params: %v", err) } if params.SourceText != "Hello" || params.SourceContext["destination"] != "Bundle/dialogue.bundle" || params.Limit == nil || *params.Limit != limit { t.Fatalf("query params=%#v", params) } return testResponse{ Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-tm-query", Data: map[string]any{ "available": true, "path": "/var/lib/bat/translation-memory.sqlite", "source_text": "Hello", "source_context": map[string]any{ "destination": "Bundle/dialogue.bundle", "field_path": "Dialog.Message", }, "matches": []any{ map[string]any{ "entry": map[string]any{ "record_id": "tm-record-1", "source_text": "Hello", "source_hash": "hash-source", "normalized_source_text": "hello", "source_context": map[string]any{ "destination": "Bundle/dialogue.bundle", "field_path": "Dialog.Message", }, "source_context_hash": "hash-context", "translated_text": "你好", "translation_source_kind": "provider", "trust_status": "trusted", "official_release_id": "release-1", "source_trace": map[string]any{ "official_release_id": "release-1", "unit_id": "textunit-1", "task_id": "task-1", "destination": "Bundle/dialogue.bundle", "archive_entry": "dialogue.json", "serialized_file": "globalgamemanagers", "path_id": 42, "class_id": 114, "field_path": "Dialog.Message", "format": "json", "asset_name": "Dialogue", "text_source_kind": "text_asset", }, "provider": "mock", "provider_run_id": "provider-run-1", "created_unix_seconds": 100, "updated_unix_seconds": 200, "trusted_unix_seconds": 200, "trusted_by": "reviewer", "trusted_reason": "reviewed", }, "match_kind": "strong_exact", "can_auto_reuse": true, }, }, }, }, } case "translation.memory.confirm": var params TranslationMemoryConfirmParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode confirm params: %v", err) } if params.RecordID != "tm-record-1" || params.Reviewer != "reviewer" || params.Reason != "reviewed" || params.TranslationMemoryPath != "/var/lib/bat/translation-memory.sqlite" { t.Fatalf("confirm params=%#v", params) } return testResponse{ Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-tm-confirm", Data: map[string]any{ "available": true, "path": "/var/lib/bat/translation-memory.sqlite", "entry": map[string]any{ "record_id": "tm-record-1", "translation_source_kind": "provider", "trust_status": "trusted", }, }, }, } default: t.Fatalf("unexpected method %q", req.Method) return testResponse{} } }) summary, err := client.TranslationMemorySummary(context.Background(), TranslationMemorySummaryParams{ TranslationMemoryPath: "/var/lib/bat/translation-memory.sqlite", }) if err != nil { t.Fatalf("TranslationMemorySummary error: %v", err) } if !summary.Available || summary.Summary == nil || summary.Summary.TrustedCount != 1 || summary.Summary.CandidateCount != 1 { t.Fatalf("summary=%#v", summary) } query, err := client.TranslationMemoryQuery(context.Background(), TranslationMemoryQueryParams{ TranslationMemoryPath: "/var/lib/bat/translation-memory.sqlite", SourceText: "Hello", SourceContext: TranslationMemoryContext{ "destination": "Bundle/dialogue.bundle", "field_path": "Dialog.Message", }, Limit: &limit, }) if err != nil { t.Fatalf("TranslationMemoryQuery error: %v", err) } if len(query.Matches) != 1 || query.Matches[0].MatchKind != "strong_exact" || !query.Matches[0].CanAutoReuse || query.Matches[0].Entry.TrustStatus != "trusted" || query.Matches[0].Entry.TranslationSourceKind != "provider" || query.Matches[0].Entry.SourceTrace.UnitID == nil || *query.Matches[0].Entry.SourceTrace.UnitID != "textunit-1" || query.Matches[0].Entry.SourceTrace.PathID == nil || *query.Matches[0].Entry.SourceTrace.PathID != 42 || query.Matches[0].Entry.TrustedBy == nil || *query.Matches[0].Entry.TrustedBy != "reviewer" { t.Fatalf("query=%#v", query) } confirmed, err := client.TranslationMemoryConfirm(context.Background(), TranslationMemoryConfirmParams{ TranslationMemoryPath: "/var/lib/bat/translation-memory.sqlite", RecordID: "tm-record-1", Reviewer: "reviewer", Reason: "reviewed", }) if err != nil { t.Fatalf("TranslationMemoryConfirm error: %v", err) } if !confirmed.Available || confirmed.Entry.TrustStatus != "trusted" || confirmed.Entry.RecordID != "tm-record-1" { t.Fatalf("confirmed=%#v", confirmed) } } func TestTranslationProofreadUsesRustMethod(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "translation.proofread" { t.Fatalf("method = %s", req.Method) } if string(req.Params) != "null" { t.Fatalf("params = %s, want null", req.Params) } return testResponse{ Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-test-translation-proofread", Data: map[string]any{ "translation_workflow_status": "manual_proofreading", }, }, } }) raw, err := client.TranslationProofread(context.Background()) if err != nil { t.Fatalf("TranslationProofread error: %v", err) } if !json.Valid(raw) { t.Fatalf("invalid raw JSON: %s", string(raw)) } } func TestLocalizedPublishSendsWorkerSourceAndReleaseOptions(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "localized.publish" { t.Fatalf("method = %s", req.Method) } var params LocalizedPublishParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode params: %v", err) } if !params.FromWorker || params.TranslationFile != "" || params.LocalizedReleaseID != "localized-1" || !params.Force { t.Fatalf("params = %#v", params) } return testResponse{ Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-test-localized-publish", Data: map[string]any{"status": "published"}, }, } }) raw, err := client.LocalizedPublish(context.Background(), LocalizedPublishParams{ FromWorker: true, LocalizedReleaseID: "localized-1", Force: true, }) if err != nil { t.Fatalf("LocalizedPublish error: %v", err) } if !json.Valid(raw) { t.Fatalf("invalid raw JSON: %s", string(raw)) } } func TestLocalizedRollbackSendsExpectedRelease(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "localized.rollback" { t.Fatalf("method = %s", req.Method) } var params LocalizedRollbackParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode params: %v", err) } if params.LocalizedReleaseID != "localized-1" { t.Fatalf("params = %#v", params) } return testResponse{ Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-test-localized-rollback", Data: map[string]any{"status": "rolled_back"}, }, } }) raw, err := client.LocalizedRollback(context.Background(), LocalizedRollbackParams{ LocalizedReleaseID: "localized-1", }) if err != nil { t.Fatalf("LocalizedRollback error: %v", err) } if !json.Valid(raw) { t.Fatalf("invalid raw JSON: %s", string(raw)) } } func TestGlossaryTypedContract(t *testing.T) { limit := uint64(20) client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { switch req.Method { case "translation.glossary.summary": var params GlossarySummaryParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode summary params: %v", err) } if params.GlossaryPath != "/var/lib/bat/glossary.sqlite" { t.Fatalf("summary params=%#v", params) } return testResponse{Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-glossary-summary", Data: map[string]any{ "available": true, "path": "/var/lib/bat/glossary.sqlite", "schema_version": 1, "summary": map[string]any{ "schema_version": 1, "term_count": 2, "approved_count": 1, "draft_count": 1, }, }, }} case "translation.glossary.query": var params GlossaryQueryParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode query params: %v", err) } if params.SourceText != "Sensei" || params.GlossaryPath != "/var/lib/bat/glossary.sqlite" || params.Limit == nil || *params.Limit != limit { t.Fatalf("query params=%#v", params) } return testResponse{Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-glossary-query", Data: map[string]any{ "available": true, "path": "/var/lib/bat/glossary.sqlite", "terms": []any{map[string]any{ "term_id": "term-sensei", "source_term": "Sensei", "recommended_translation": "老师", "priority": 10, "review_status": "approved", "source": map[string]any{ "source_kind": "manual", "observed_unix_seconds": 100, }, }}, }, }} case "translation.glossary.diagnose": var params GlossaryDiagnoseParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode diagnose params: %v", err) } if params.SourceText != "Sensei" || params.Context["destination"] != "Bundle/dialogue.bundle" { t.Fatalf("diagnose params=%#v", params) } return testResponse{Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-glossary-diagnose", Data: map[string]any{ "available": true, "path": "/var/lib/bat/glossary.sqlite", "source_text": "Sensei", "evaluation": map[string]any{ "constraints": []any{map[string]any{ "term_id": "term-sensei", "matched_source": "Sensei", "recommended_translation": "老师", }}, "diagnostics": []any{}, "blocked": false, }, }, }} case "translation.glossary.add": var params GlossaryTermMutationParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode add params: %v", err) } if params.TermID != "term-sensei" || params.Source.SourceKind != "manual" || params.Source.ObservedUnixSeconds != 100 { t.Fatalf("add params=%#v", params) } return testResponse{Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-glossary-add", Data: map[string]any{ "available": true, "path": "/var/lib/bat/glossary.sqlite", "term": map[string]any{ "term_id": "term-sensei", "source_term": "Sensei", "recommended_translation": "老师", "review_status": "draft", "source": map[string]any{ "source_kind": "manual", "observed_unix_seconds": 100, }, }, }, }} case "translation.glossary.approve": var params GlossaryReviewParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode approve params: %v", err) } if params.TermID != "term-sensei" || params.Reviewer != "reviewer" { t.Fatalf("approve params=%#v", params) } return testResponse{Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-glossary-approve", Data: map[string]any{ "available": true, "path": "/var/lib/bat/glossary.sqlite", "term": map[string]any{"term_id": "term-sensei", "review_status": "approved"}, }, }} default: t.Fatalf("unexpected method %q", req.Method) return testResponse{} } }) summary, err := client.GlossarySummary(context.Background(), GlossarySummaryParams{ GlossaryPath: "/var/lib/bat/glossary.sqlite", }) if err != nil || summary.Summary == nil || summary.Summary.ApprovedCount != 1 { t.Fatalf("summary=%#v err=%v", summary, err) } query, err := client.GlossaryQuery(context.Background(), GlossaryQueryParams{ GlossaryPath: "/var/lib/bat/glossary.sqlite", SourceText: "Sensei", Limit: &limit, }) if err != nil || len(query.Terms) != 1 || query.Terms[0].ReviewStatus != GlossaryStatusApproved { t.Fatalf("query=%#v err=%v", query, err) } diagnose, err := client.GlossaryDiagnose(context.Background(), GlossaryDiagnoseParams{ GlossaryPath: "/var/lib/bat/glossary.sqlite", SourceText: "Sensei", Context: TranslationMemoryContext{"destination": "Bundle/dialogue.bundle"}, }) if err != nil || !json.Valid(diagnose.Evaluation) { t.Fatalf("diagnose=%#v err=%v", diagnose, err) } add, err := client.GlossaryAdd(context.Background(), GlossaryTermMutationParams{ GlossaryPath: "/var/lib/bat/glossary.sqlite", TermID: "term-sensei", SourceTerm: "Sensei", RecommendedTranslation: "老师", ReviewStatus: "draft", Source: GlossarySourceRecord{ SourceKind: "manual", ObservedUnixSeconds: 100, }, }) if err != nil || add.Term.TermID != "term-sensei" { t.Fatalf("add=%#v err=%v", add, err) } approved, err := client.GlossaryApprove(context.Background(), GlossaryReviewParams{ GlossaryPath: "/var/lib/bat/glossary.sqlite", TermID: "term-sensei", Reviewer: "reviewer", }) if err != nil || approved.Term.ReviewStatus != GlossaryStatusApproved { t.Fatalf("approve=%#v err=%v", approved, err) } } func TestApplicationErrorReturnsAPIError(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "task.status" { t.Fatalf("method = %s", req.Method) } return testResponse{ Result: testEnvelope{ OK: false, Status: "error", Error: APIError{ Code: "BAT-ERR-700004", Kind: "task_not_found", Domain: "rpc", Location: "task.status", Message: "任务不存在:missing", Retryable: false, }, RequestID: "req-test-3", }, } }) _, err := client.TaskStatus(context.Background(), "missing") var apiErr *APIError if !errors.As(err, &apiErr) { t.Fatalf("expected APIError, got %T %v", err, err) } if apiErr.Code != "BAT-ERR-700004" { t.Fatalf("code = %s", apiErr.Code) } } func TestTransportErrorReturnsJSONRPCError(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { return testResponse{ Error: &JSONRPCError{ Code: -32700, Message: "parse error", }, } }) _, err := client.DaemonStatus(context.Background()) var rpcErr *JSONRPCError if !errors.As(err, &rpcErr) { t.Fatalf("expected JSONRPCError, got %T %v", err, err) } if rpcErr.Code != -32700 { t.Fatalf("code = %d", rpcErr.Code) } } func TestContextDeadlineIsApplied(t *testing.T) { client := New("bat.sock") client.Timeout = time.Millisecond client.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) { <-ctx.Done() return nil, ctx.Err() } ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond) defer cancel() _, err := client.DaemonStatus(ctx) if err == nil { t.Fatal("expected dial error") } }