Files
BlueArchiveToolkit/internal/backendrpc/client_test.go
T
nyaKazuha c17904ee1c
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
fix(repo):统一发布健康与只读质量门禁
2026-09-13 22:09:44 +08:00

1284 lines
40 KiB
Go

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 func() { _ = 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 TestReleaseDistributionUsesTypedRPCContract(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "release.distribution" {
t.Fatalf("method = %s", req.Method)
}
var params ReleaseDistributionParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params.Channel != "localized" || params.ReleaseID != "localized-1" || params.Offset != 2 || params.Limit != 10 {
t.Fatalf("params = %#v", params)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
Data: map[string]any{
"available": true,
"channel": "localized",
"release_id": "localized-1",
"resource_root": "/tmp/localized",
"total": 3,
"offset": 2,
"limit": 10,
"entries": []any{map[string]any{
"url": "https://example.invalid/data.bin",
"destination": "host/data.bin",
"bytes": 4,
"blake3": "abcd",
}},
},
},
}
})
page, err := client.ReleaseDistribution(context.Background(), ReleaseDistributionParams{
Channel: "localized",
ReleaseID: "localized-1",
Offset: 2,
Limit: 10,
})
if err != nil {
t.Fatalf("ReleaseDistribution error: %v", err)
}
if !page.Available || page.ResourceRoot != "/tmp/localized" || len(page.Entries) != 1 {
t.Fatalf("page = %#v", page)
}
}
func TestReleaseStatusUsesWholeReleaseHealthFact(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "release.status" {
t.Fatalf("method = %s", req.Method)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
Data: map[string]any{
"status": "blocked",
"status_code": "distribution.blocked",
"default_distribution_channel": "official",
"official_current_release_id": "official-1",
"official_distribution_ready": false,
"releases": []any{map[string]any{
"channel": "official",
"id": "official-1",
"current": true,
"distribution_integrity_status": "invalid",
}},
},
},
}
})
report, err := client.ReleaseStatus(context.Background())
if err != nil {
t.Fatalf("ReleaseStatus error: %v", err)
}
if report.StatusCode != "distribution.blocked" ||
report.OfficialDistributionReady ||
report.OfficialCurrentReleaseID != "official-1" ||
len(report.Releases) != 1 ||
report.Releases[0].DistributionIntegrityStatus != "invalid" {
t.Fatalf("report = %#v", report)
}
}
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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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: "译文",
GlossaryOverride: &GlossaryOverride{
QAIdentity: "gqa-v1-test",
Reviewer: "reviewer",
Reason: "approved deviation",
Provenance: "manual-review",
ConfirmedUnixSeconds: 100,
},
}},
})
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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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 TestLocalizedPublishSendsPatchManifestSource(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
var params LocalizedPublishParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params.PatchManifest != "/tmp/patch-manifest.json" ||
params.TranslationFile != "" || params.FromWorker {
t.Fatalf("params = %#v", params)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
RequestID: "req-test-localized-patch-manifest",
Data: map[string]any{"status": "published"},
},
}
})
raw, err := client.LocalizedPublish(context.Background(), LocalizedPublishParams{
PatchManifest: "/tmp/patch-manifest.json",
LocalizedReleaseID: "localized-v1",
})
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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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.update":
var params GlossaryTermMutationParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode update params: %v", err)
}
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" || params.ReviewStatus != "draft" {
t.Fatalf("update params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-update",
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": 101,
},
},
},
}}
case "translation.glossary.approve":
var params GlossaryReviewParams
if err := json.Unmarshal(req.Params, &params); 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"},
},
}}
case "translation.glossary.deprecate":
var params GlossaryReviewParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode deprecate params: %v", err)
}
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" || params.Reason != "retired" {
t.Fatalf("deprecate params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-deprecate",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"term": map[string]any{"term_id": "term-sensei", "review_status": "deprecated"},
},
}}
case "translation.glossary.delete":
var params GlossaryDeleteParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode delete params: %v", err)
}
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" || params.Reason != "duplicate" {
t.Fatalf("delete params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-delete",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"deleted": true,
"term": map[string]any{"term_id": "term-sensei"},
},
}}
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)
}
updated, err := client.GlossaryUpdate(context.Background(), GlossaryTermMutationParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
TermID: "term-sensei",
SourceTerm: "Sensei",
RecommendedTranslation: "老师",
ReviewStatus: "draft",
Reviewer: "reviewer",
Source: GlossarySourceRecord{
SourceKind: "manual",
ObservedUnixSeconds: 101,
},
})
if err != nil || updated.Term.ReviewStatus != GlossaryStatusDraft {
t.Fatalf("update=%#v err=%v", updated, 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)
}
deprecated, err := client.GlossaryDeprecate(context.Background(), GlossaryReviewParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
TermID: "term-sensei",
Reviewer: "reviewer",
Reason: "retired",
})
if err != nil || deprecated.Term.ReviewStatus != GlossaryStatusDeprecated {
t.Fatalf("deprecate=%#v err=%v", deprecated, err)
}
deleted, err := client.GlossaryDelete(context.Background(), GlossaryDeleteParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
TermID: "term-sensei",
Reviewer: "reviewer",
Reason: "duplicate",
})
if err != nil || !deleted.Available || !deleted.Deleted {
t.Fatalf("delete=%#v err=%v", deleted, 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")
}
}