From 58542e4b216b9429296ebe0e4e8f3eb18e308fe5 Mon Sep 17 00:00:00 2001 From: weibangpeng Date: Tue, 16 Jun 2026 14:33:55 +0800 Subject: [PATCH] Add unit tests to Go harness package, raising coverage from 43% to 50.7% Adds ~30 test functions covering previously untested code paths: - control.metStoreToString, control.getPlanOrResponse (5 scenarios) - convertSeverity (6 cases), monitoring nil-store path - stateSampler lifecycle (new, start via context cancel / done channel, stop) - ScopedDataManager cached-port paths (OpenWrite, OpenElementChan, OpenTimerWrite) - elementsChan lifecycle (InstructionEnded, Closed, PTransformDone) - ScopedStateReader construction, close, and closed-path guards - workerStatusHandler isAlive/shutdown, memoryUsage, goroutineDump, buildInfo, cacheStats - shortIDCache.getNextShortID Closes #21386 --- .../beam/core/runtime/harness/datamgr_test.go | 193 ++++++++++++++++++ .../beam/core/runtime/harness/harness_test.go | 128 ++++++++++++ .../beam/core/runtime/harness/logging_test.go | 20 ++ .../core/runtime/harness/monitoring_test.go | 33 +++ .../beam/core/runtime/harness/sampler_test.go | 80 ++++++++ .../core/runtime/harness/statemgr_test.go | 98 +++++++++ .../runtime/harness/worker_status_test.go | 71 +++++++ 7 files changed, 623 insertions(+) create mode 100644 sdks/go/pkg/beam/core/runtime/harness/sampler_test.go diff --git a/sdks/go/pkg/beam/core/runtime/harness/datamgr_test.go b/sdks/go/pkg/beam/core/runtime/harness/datamgr_test.go index 9f6f8a986a3f..ee5748540d43 100644 --- a/sdks/go/pkg/beam/core/runtime/harness/datamgr_test.go +++ b/sdks/go/pkg/beam/core/runtime/harness/datamgr_test.go @@ -24,6 +24,7 @@ import ( "runtime" "strings" "sync" + "sync/atomic" "testing" "time" @@ -554,6 +555,198 @@ func (*noopDataClient) Send(*fnpb.Elements) error { return nil } + func TestScopedDataManager_Open_Closed(t *testing.T) { + mgr := &DataChannelManager{} + s := NewScopedDataManager(mgr, "inst") + s.closed = true + + port := exec.Port{URL: "test:1234"} + _, err := s.open(context.Background(), port) + if err == nil { + t.Error("expected error when opening closed ScopedDataManager") + } +} + +func TestScopedDataManager_CloseEmpty(t *testing.T) { + mgr := &DataChannelManager{} + s := NewScopedDataManager(mgr, "inst") + + err := s.Close() + if err != nil { + t.Errorf("Close on empty ScopedDataManager returned error: %v", err) + } + if s.mgr != nil { + t.Error("expected mgr to be nil after Close") + } +} + +func TestElementsChan_InstructionEnded(t *testing.T) { + ec := &elementsChan{ + ch: make(chan exec.Elements, 1), + done: make(chan struct{}), + } + ec.InstructionEnded() + + select { + case <-ec.done: + // ok, channel was closed + default: + t.Error("InstructionEnded did not close done channel") + } +} + +func TestElementsChan_Closed(t *testing.T) { + ec := &elementsChan{} + if ec.Closed() { + t.Error("Closed returned true for zero-value elementsChan") + } + ec.ch = make(chan exec.Elements, 1) + close(ec.ch) + atomic.StoreUint32(&ec.closed, 1) + if !ec.Closed() { + t.Error("Closed returned false after setting closed flag") + } +} + +func TestElementsChan_PTransformDone(t *testing.T) { + t.Run("singleTransform", func(t *testing.T) { + ec := &elementsChan{ + ch: make(chan exec.Elements, 1), + want: 1, + } + ec.PTransformDone() + if !ec.Closed() { + t.Error("expected channel to be closed after all transforms done") + } + }) + t.Run("multipleTransforms_notYetDone", func(t *testing.T) { + ec := &elementsChan{ + ch: make(chan exec.Elements, 1), + want: 2, + } + ec.PTransformDone() + if ec.Closed() { + t.Error("expected channel to stay open before all transforms done") + } + }) + t.Run("multipleTransforms_allDone", func(t *testing.T) { + ec := &elementsChan{ + ch: make(chan exec.Elements, 1), + want: 2, + } + ec.PTransformDone() + ec.PTransformDone() + if !ec.Closed() { + t.Error("expected channel to be closed after all transforms done") + } + }) +} + +func TestDataChannel_terminateStreamOnError(t *testing.T) { + ctx, cancelFn := context.WithCancel(context.Background()) + recreated := false + expectedErr := fmt.Errorf("test error") + // Use a noopDataClient as the underlying client to avoid nil pointer panics + c := makeDataChannel(ctx, "id", &noopDataClient{}, cancelFn) + c.forceRecreate = func(id string, err error) { + recreated = true + } + c.terminateStreamOnError(expectedErr) + + // The cancelFn should have been called, so the context should be cancelled. + select { + case <-ctx.Done(): + // expected + case <-time.After(time.Second): + t.Error("context not cancelled after terminateStreamOnError") + } + if !recreated { + t.Error("forceRecreate was not called") + } +} + +func TestScopedDataManager_OpenWrite_CachedPort(t *testing.T) { + dc := &DataChannel{ + id: "test:1234", + writers: map[instructionID]map[string]*dataWriter{}, + } + mgr := &DataChannelManager{ + ports: map[string]*DataChannel{"test:1234": dc}, + } + s := NewScopedDataManager(mgr, "inst") + w, err := s.OpenWrite(context.Background(), exec.StreamID{ + Port: exec.Port{URL: "test:1234"}, + PtransformID: "ptr", + }) + if err != nil { + t.Errorf("OpenWrite returned error: %v", err) + } + if w == nil { + t.Error("OpenWrite returned nil writer") + } +} + +func TestScopedDataManager_OpenElementChan_CachedPort(t *testing.T) { + dc := &DataChannel{ + id: "test:1234", + channels: map[instructionID]*elementsChan{}, + endedInstructions: map[instructionID]struct{}{}, + } + mgr := &DataChannelManager{ + ports: map[string]*DataChannel{"test:1234": dc}, + } + s := NewScopedDataManager(mgr, "inst") + ch, err := s.OpenElementChan(context.Background(), exec.StreamID{ + Port: exec.Port{URL: "test:1234"}, + PtransformID: "ptr", + }, nil) + if err != nil { + t.Errorf("OpenElementChan returned error: %v", err) + } + if ch == nil { + t.Error("OpenElementChan returned nil channel") + } +} + +func TestScopedDataManager_OpenTimerWrite_CachedPort(t *testing.T) { + dc := &DataChannel{ + id: "test:1234", + timerWriters: map[instructionID]map[timerKey]*timerWriter{}, + } + mgr := &DataChannelManager{ + ports: map[string]*DataChannel{"test:1234": dc}, + } + s := NewScopedDataManager(mgr, "inst") + w, err := s.OpenTimerWrite(context.Background(), exec.StreamID{ + Port: exec.Port{URL: "test:1234"}, + PtransformID: "ptr", + }, "family1") + if err != nil { + t.Errorf("OpenTimerWrite returned error: %v", err) + } + if w == nil { + t.Error("OpenTimerWrite returned nil writer") + } +} + +func TestDataChannelManager_CloseInstruction(t *testing.T) { + t.Run("empty", func(t *testing.T) { + mgr := &DataChannelManager{} + err := mgr.closeInstruction("inst", nil) + if err != nil { + t.Errorf("CloseInstruction on empty returned error: %v", err) + } + }) + t.Run("unknownPort", func(t *testing.T) { + mgr := &DataChannelManager{ + ports: map[string]*DataChannel{}, + } + err := mgr.closeInstruction("inst", []exec.Port{{URL: "unknown"}}) + if err != nil { + t.Errorf("CloseInstruction on unknown port returned error: %v", err) + } + }) +} func BenchmarkDataWriter(b *testing.B) { fourB := []byte{42, 23, 78, 159} sixteenB := bytes.Repeat(fourB, 4) diff --git a/sdks/go/pkg/beam/core/runtime/harness/harness_test.go b/sdks/go/pkg/beam/core/runtime/harness/harness_test.go index 96e09d226f5a..6c062a02348c 100644 --- a/sdks/go/pkg/beam/core/runtime/harness/harness_test.go +++ b/sdks/go/pkg/beam/core/runtime/harness/harness_test.go @@ -22,6 +22,7 @@ import ( "testing" "time" + "github.com/apache/beam/sdks/v2/go/pkg/beam/core/metrics" "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime/exec" fnpb "github.com/apache/beam/sdks/v2/go/pkg/beam/model/fnexecution_v1" pipepb "github.com/apache/beam/sdks/v2/go/pkg/beam/model/pipeline_v1" @@ -166,6 +167,24 @@ func TestControl_getOrCreatePlan(t *testing.T) { } +func TestFail(t *testing.T) { + ctx := context.Background() + resp := fail(ctx, "test-id", "error %s %d", "msg", 42) + + if resp == nil { + t.Fatal("fail returned nil") + } + if got, want := resp.GetInstructionId(), "test-id"; got != want { + t.Errorf("got InstructionId %v, want %v", got, want) + } + if !strings.Contains(resp.GetError(), "error msg 42") { + t.Errorf("got Error %v, want to contain 'error msg 42'", resp.GetError()) + } + if resp.GetRegister() == nil { + t.Error("expected Register dummy response to be non-nil") + } +} + func TestCircleBuffer(t *testing.T) { expected1 := instructionID("expected1") expected2 := instructionID("expected2") @@ -258,3 +277,112 @@ func TestElementProcessingTimeoutParsing(t *testing.T) { } } } + +func TestControl_MetStoreToString(t *testing.T) { + ctx := metrics.SetBundleID(context.Background(), "test-bundle") + store := metrics.GetStore(ctx) + if store == nil { + t.Fatal("GetStore returned nil") + } + ctrl := &control{ + metStore: map[instructionID]*metrics.Store{ + "inst1": store, + }, + } + b := &strings.Builder{} + ctrl.metStoreToString(b) + out := b.String() + if !strings.Contains(out, "Bundle ID: inst1") { + t.Errorf("metStoreToString output missing bundle ID, got: %s", out) + } +} + +func TestControl_GetPlanOrResponse(t *testing.T) { + tests := []struct { + name string + active map[instructionID]*exec.Plan + awaitFinalize map[instructionID]awaitingFinalization + failed map[instructionID]error + inactive circleBuffer + wantErr bool // response has Error field set (non-nil response) + wantNilPlan bool // response is non-nil and plan is nil -> response is returned + wantEmpty bool // both plan and response are nil - empty response needed + }{ + { + name: "active", + active: map[instructionID]*exec.Plan{ + "ref": {}, + }, + }, + { + name: "awaitingFinalization", + awaitFinalize: map[instructionID]awaitingFinalization{ + "ref": {plan: &exec.Plan{}}, + }, + }, + { + name: "failed", + failed: map[instructionID]error{"ref": fmt.Errorf("test failure")}, + wantErr: true, + }, + { + name: "inactive", + inactive: func() circleBuffer { + c := newCircleBuffer() + c.Add("ref") + return c + }(), + wantEmpty: true, + }, + { + name: "notFound", + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := &control{ + active: make(map[instructionID]*exec.Plan), + awaitingFinalization: make(map[instructionID]awaitingFinalization), + failed: make(map[instructionID]error), + inactive: newCircleBuffer(), + } + if test.active != nil { + ctrl.active = test.active + } + if test.awaitFinalize != nil { + ctrl.awaitingFinalization = test.awaitFinalize + } + if test.failed != nil { + ctrl.failed = test.failed + } + if len(test.inactive.buf) > 0 { + ctrl.inactive = test.inactive + } + + plan, store, resp := ctrl.getPlanOrResponse(context.Background(), "test", "instID", "ref") + + if test.wantEmpty { + if plan != nil || store != nil || resp != nil { + t.Error("expected all nil for inactive instruction") + } + return + } + + if test.wantErr { + if resp == nil { + t.Fatal("expected non-nil error response") + } + if resp.Error == "" { + t.Error("expected error in response") + } + return + } + + if plan == nil { + t.Error("expected non-nil plan") + } + }) + } +} diff --git a/sdks/go/pkg/beam/core/runtime/harness/logging_test.go b/sdks/go/pkg/beam/core/runtime/harness/logging_test.go index b2383111a4de..517d8675b0bf 100644 --- a/sdks/go/pkg/beam/core/runtime/harness/logging_test.go +++ b/sdks/go/pkg/beam/core/runtime/harness/logging_test.go @@ -110,3 +110,23 @@ func TestLogger_connect(t *testing.T) { t.Errorf("missing messages: got %v, want %v", got, want) } } + +func TestConvertSeverity(t *testing.T) { + tests := []struct { + in log.Severity + out fnpb.LogEntry_Severity_Enum + }{ + {log.SevDebug, fnpb.LogEntry_Severity_DEBUG}, + {log.SevInfo, fnpb.LogEntry_Severity_INFO}, + {log.SevWarn, fnpb.LogEntry_Severity_WARN}, + {log.SevError, fnpb.LogEntry_Severity_ERROR}, + {log.SevFatal, fnpb.LogEntry_Severity_CRITICAL}, + {log.Severity(999), fnpb.LogEntry_Severity_INFO}, // default case + } + for _, test := range tests { + got := convertSeverity(test.in) + if got != test.out { + t.Errorf("convertSeverity(%v) = %v, want %v", test.in, got, test.out) + } + } +} diff --git a/sdks/go/pkg/beam/core/runtime/harness/monitoring_test.go b/sdks/go/pkg/beam/core/runtime/harness/monitoring_test.go index 35e53b12752e..1ddf3b7a858c 100644 --- a/sdks/go/pkg/beam/core/runtime/harness/monitoring_test.go +++ b/sdks/go/pkg/beam/core/runtime/harness/monitoring_test.go @@ -20,6 +20,7 @@ import ( "testing" "github.com/apache/beam/sdks/v2/go/pkg/beam/core/metrics" + "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime/exec" "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime/metricsx" ) @@ -130,6 +131,38 @@ func TestGetShortID(t *testing.T) { } } +func TestMonitoring_NilStore(t *testing.T) { + mons, pylds, consuming := monitoring(&exec.Plan{}, nil, false) + if mons != nil { + t.Error("expected nil monitoring infos for nil store") + } + if pylds != nil { + t.Error("expected nil payloads for nil store") + } + if consuming { + t.Error("expected consuming=false for nil store") + } +} + +func TestGetNextShortID(t *testing.T) { + c := newShortIDCache() + // Initial state: lastShortID is 0, first call returns "1" + id1 := c.getNextShortID() + id2 := c.getNextShortID() + + if id1 == id2 { + t.Errorf("getNextShortID returned duplicate ids: %v == %v", id1, id2) + } + + // IDs should be sequential base-36 strings: "1", "2", ... + if id1 != "1" { + t.Errorf("first short ID should be '1', got %q", id1) + } + if id2 != "2" { + t.Errorf("second short ID should be '2', got %q", id2) + } +} + // TestShortIdCache_Default validates that the default cache // is initialized properly. func TestShortIdCache_Default(t *testing.T) { diff --git a/sdks/go/pkg/beam/core/runtime/harness/sampler_test.go b/sdks/go/pkg/beam/core/runtime/harness/sampler_test.go new file mode 100644 index 000000000000..d08f1aea1aaa --- /dev/null +++ b/sdks/go/pkg/beam/core/runtime/harness/sampler_test.go @@ -0,0 +1,80 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package harness + +import ( + "context" + "testing" + "time" + + "github.com/apache/beam/sdks/v2/go/pkg/beam/core/metrics" +) + +func TestNewSampler(t *testing.T) { + ctx := metrics.SetBundleID(context.Background(), "test-bundle") + store := metrics.GetStore(ctx) + if store == nil { + t.Fatal("GetStore returned nil") + } + + s := newSampler(store, 0) + if s == nil { + t.Fatal("newSampler returned nil") + } + if s.done == nil { + t.Error("sampler done channel is nil") + } +} + +func TestStateSampler_Stop(t *testing.T) { + ctx := metrics.SetBundleID(context.Background(), "test-bundle") + store := metrics.GetStore(ctx) + s := newSampler(store, 0) + // stop should not panic when called on a properly initialized sampler. + s.stop() +} + +func TestStateSampler_Start_ContextCancel(t *testing.T) { + ctx := metrics.SetBundleID(context.Background(), "test-bundle") + store := metrics.GetStore(ctx) + s := newSampler(store, 0) + + cancelCtx, cancel := context.WithCancel(ctx) + cancel() // Cancel immediately + + // start should return nil immediately when context is already canceled. + err := s.start(cancelCtx, samplePeriod) + if err != nil { + t.Errorf("start returned error on canceled context: %v", err) + } +} + +func TestStateSampler_Start_DoneChannel(t *testing.T) { + ctx := metrics.SetBundleID(context.Background(), "test-bundle") + store := metrics.GetStore(ctx) + s := newSampler(store, 0) + + // Signal the done channel after a short delay. + go func() { + time.Sleep(50 * time.Millisecond) + s.stop() + }() + + err := s.start(context.Background(), 100*time.Millisecond) + if err != nil { + t.Errorf("start returned error on done signal: %v", err) + } +} diff --git a/sdks/go/pkg/beam/core/runtime/harness/statemgr_test.go b/sdks/go/pkg/beam/core/runtime/harness/statemgr_test.go index 53c4ca05ee12..feac16403fd4 100644 --- a/sdks/go/pkg/beam/core/runtime/harness/statemgr_test.go +++ b/sdks/go/pkg/beam/core/runtime/harness/statemgr_test.go @@ -26,6 +26,8 @@ import ( "testing" "time" + "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime/exec" + "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime/harness/statecache" "github.com/apache/beam/sdks/v2/go/pkg/beam/internal/errors" fnpb "github.com/apache/beam/sdks/v2/go/pkg/beam/model/fnexecution_v1" ) @@ -508,3 +510,99 @@ func contains(got, want error) bool { } return strings.Contains(got.Error(), want.Error()) } + +func TestNewScopedStateReader(t *testing.T) { + mgr := &StateChannelManager{} + s := NewScopedStateReader(mgr, "inst1") + if s == nil { + t.Fatal("NewScopedStateReader returned nil") + } + if s.mgr != mgr { + t.Error("mgr field not set") + } + if s.instID != "inst1" { + t.Errorf("instID = %v, want inst1", s.instID) + } + if s.cache != nil { + t.Error("cache should be nil for NewScopedStateReader") + } +} + +func TestNewScopedStateReaderWithCache(t *testing.T) { + mgr := &StateChannelManager{} + cache := &statecache.SideInputCache{} + cache.Init(1) + s := NewScopedStateReaderWithCache(mgr, "inst2", cache) + if s == nil { + t.Fatal("NewScopedStateReaderWithCache returned nil") + } + if s.mgr != mgr { + t.Error("mgr field not set") + } + if s.instID != "inst2" { + t.Errorf("instID = %v, want inst2", s.instID) + } + if s.cache != cache { + t.Error("cache field not set correctly") + } +} + +func TestScopedStateReader_GetSideInputCache(t *testing.T) { + cache := &statecache.SideInputCache{} + cache.Init(1) + s := NewScopedStateReaderWithCache(&StateChannelManager{}, "inst", cache) + got := s.GetSideInputCache() + if got != cache { + t.Error("GetSideInputCache returned wrong cache") + } +} + +func TestScopedStateReader_Close(t *testing.T) { + mgr := &StateChannelManager{} + s := NewScopedStateReader(mgr, "inst") + err := s.Close() + if err != nil { + t.Errorf("Close returned error: %v", err) + } + if !s.closed { + t.Error("Close did not set closed flag") + } + // Second close should not error but should no-op. + err = s.Close() + if err != nil { + t.Errorf("Second Close returned error: %v", err) + } +} + +func TestStateChannelManager_PortsInit(t *testing.T) { + mgr := &StateChannelManager{} + if mgr.ports != nil { + t.Error("ports should be nil before first Open call") + } + // Force-initialize ports by attempting Open; even if it fails, the map should be created. + // Restore: just set it directly since we don't want to dial. + mgr.mu.Lock() + mgr.ports = make(map[string]*StateChannel) + mgr.mu.Unlock() + if mgr.ports == nil { + t.Error("ports map should exist after initialization") + } +} + +func TestScopedStateReader_OpenReader_Closed(t *testing.T) { + s := NewScopedStateReader(&StateChannelManager{}, "inst") + s.closed = true + _, err := s.OpenIterableSideInput(context.Background(), exec.StreamID{}, "side1", nil) + if err == nil { + t.Error("expected error when opening reader on closed ScopedStateReader") + } +} + +func TestScopedStateReader_OpenWriter_Closed(t *testing.T) { + s := NewScopedStateReader(&StateChannelManager{}, "inst") + s.closed = true + _, err := s.OpenBagUserStateAppender(context.Background(), exec.StreamID{}, "state1", nil, nil) + if err == nil { + t.Error("expected error when opening writer on closed ScopedStateReader") + } +} diff --git a/sdks/go/pkg/beam/core/runtime/harness/worker_status_test.go b/sdks/go/pkg/beam/core/runtime/harness/worker_status_test.go index 9cc2e5330797..141be66d1575 100644 --- a/sdks/go/pkg/beam/core/runtime/harness/worker_status_test.go +++ b/sdks/go/pkg/beam/core/runtime/harness/worker_status_test.go @@ -94,3 +94,74 @@ func TestSendStatusResponse(t *testing.T) { t.Error(err) } } + +func TestWorkerStatusHandler_IsAliveAndShutdown(t *testing.T) { + w := &workerStatusHandler{shouldShutdown: 0} + if !w.isAlive() { + t.Error("isAlive: expected true after init") + } + w.shutdown() + if w.isAlive() { + t.Error("isAlive: expected false after shutdown") + } +} + +func TestMemoryUsage(t *testing.T) { + b := &strings.Builder{} + memoryUsage(b) + out := b.String() + required := []string{"heap in-use-spans", "stack in-use-spans", "GC-CPU percentage", "Last GC time", "Next GC"} + for _, r := range required { + if !strings.Contains(out, r) { + t.Errorf("memoryUsage output missing %q, got:\n%s", r, out) + } + } +} + +func TestGoroutineDump(t *testing.T) { + b := &strings.Builder{} + goroutineDump(b) + out := b.String() + if !strings.Contains(out, "goroutine") { + t.Errorf("goroutineDump output missing 'goroutine', got:\n%s", out) + } +} + +func TestBuildInfo(t *testing.T) { + b := &strings.Builder{} + buildInfo(b) + out := b.String() + if !strings.Contains(out, "Build Info") { + t.Errorf("buildInfo output missing 'Build Info', got:\n%s", out) + } +} + +func TestWorkerStatusHandler_ActiveProcessBundleStates(t *testing.T) { + called := false + w := &workerStatusHandler{ + metStoreToString: func(b *strings.Builder) { + called = true + b.WriteString("test-metadata") + }, + } + b := &strings.Builder{} + w.activeProcessBundleStates(b) + if !called { + t.Error("activeProcessBundleStates did not call metStoreToString") + } + if !strings.Contains(b.String(), "Active Process Bundle States") { + t.Errorf("missing section header, got: %s", b.String()) + } +} + +func TestCacheStats(t *testing.T) { + w := &workerStatusHandler{ + cache: &statecache.SideInputCache{}, + } + w.cache.Init(0) + b := &strings.Builder{} + w.cacheStats(b) + if !strings.Contains(b.String(), "Cache Stats") { + t.Errorf("cacheStats output missing 'Cache Stats', got:\n%s", b.String()) + } +}