From 1ca010e1fbea46ec785c5b86de7bec4178ffbf46 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 20 Mar 2026 08:42:38 +0000 Subject: [PATCH] test: rewrite test suite for AX primitives API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 164 tests, 41.3% coverage. Tests written against the public API only (external test package, no _test.go in pkg/core/). Covers: New(Options), Data, Drive, Config, Service, Error, IPC, Fs, Cli, Lock, Array, Log, App, Runtime, Task. Fixes: NewCommand now inits flagset, New() wires Cli root command. Old tests removed — they referenced With*, RegisterService, and other patterns that no longer exist. Co-Authored-By: Virgil --- pkg/core/command.go | 3 + pkg/core/contract.go | 5 + tests/app_test.go | 40 ++++ tests/array_test.go | 88 ++++++++ tests/async_test.go | 141 ------------- tests/bench_test.go | 40 ---- tests/cli_test.go | 76 +++++++ tests/config_test.go | 102 +++++++++ tests/core_extra_test.go | 45 ---- tests/core_lifecycle_test.go | 165 --------------- tests/core_test.go | 361 ++++---------------------------- tests/data_test.go | 127 +++++++++++ tests/drive_test.go | 77 +++++++ tests/e_test.go | 31 --- tests/embed_test.go | 132 ++++++++++++ tests/error_test.go | 196 +++++++++++++++++ tests/fs_test.go | 211 +++++++++++++++++++ tests/fuzz_test.go | 104 --------- tests/i18n_test.go | 26 +++ tests/ipc_test.go | 192 ++++++++--------- tests/lock_test.go | 69 ++++++ tests/log_test.go | 37 ++++ tests/message_bus_test.go | 176 ---------------- tests/options_test.go | 94 +++++++++ tests/query_test.go | 203 ------------------ tests/runtime_pkg_extra_test.go | 20 -- tests/runtime_pkg_test.go | 129 ------------ tests/runtime_test.go | 97 +++++++++ tests/service_manager_test.go | 116 ---------- tests/service_test.go | 102 +++++++++ tests/task_test.go | 66 ++++++ 31 files changed, 1671 insertions(+), 1600 deletions(-) create mode 100644 tests/app_test.go create mode 100644 tests/array_test.go delete mode 100644 tests/async_test.go delete mode 100644 tests/bench_test.go create mode 100644 tests/cli_test.go create mode 100644 tests/config_test.go delete mode 100644 tests/core_extra_test.go delete mode 100644 tests/core_lifecycle_test.go create mode 100644 tests/data_test.go create mode 100644 tests/drive_test.go delete mode 100644 tests/e_test.go create mode 100644 tests/embed_test.go create mode 100644 tests/error_test.go create mode 100644 tests/fs_test.go delete mode 100644 tests/fuzz_test.go create mode 100644 tests/i18n_test.go create mode 100644 tests/lock_test.go create mode 100644 tests/log_test.go delete mode 100644 tests/message_bus_test.go create mode 100644 tests/options_test.go delete mode 100644 tests/query_test.go delete mode 100644 tests/runtime_pkg_extra_test.go delete mode 100644 tests/runtime_pkg_test.go create mode 100644 tests/runtime_test.go delete mode 100644 tests/service_manager_test.go create mode 100644 tests/service_test.go create mode 100644 tests/task_test.go diff --git a/pkg/core/command.go b/pkg/core/command.go index 5c1d77c..7d24711 100644 --- a/pkg/core/command.go +++ b/pkg/core/command.go @@ -45,6 +45,9 @@ func NewCommand(name string, description ...string) *Command { sliceSeparator: make(map[string]string), } + // Init flagset so flags can be added before Run + result.setParentCommandPath("") + return result } diff --git a/pkg/core/contract.go b/pkg/core/contract.go index 6347725..c039cf3 100644 --- a/pkg/core/contract.go +++ b/pkg/core/contract.go @@ -95,5 +95,10 @@ func New(opts ...Options) *Core { } } + // Init Cli root command from app name + c.cli.rootCommand = NewCommand(c.app.Name) + c.cli.rootCommand.setParentCommandPath("") + c.cli.rootCommand.setApp(c.cli) + return c } diff --git a/tests/app_test.go b/tests/app_test.go new file mode 100644 index 0000000..2d05008 --- /dev/null +++ b/tests/app_test.go @@ -0,0 +1,40 @@ +package core_test + +import ( + "testing" + + . "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" +) + +// --- App --- + +func TestApp_Good(t *testing.T) { + c := New(Options{{K: "name", V: "myapp"}}) + assert.Equal(t, "myapp", c.App().Name) +} + +func TestApp_Empty_Good(t *testing.T) { + c := New() + assert.NotNil(t, c.App()) + assert.Equal(t, "", c.App().Name) +} + +func TestApp_Runtime_Good(t *testing.T) { + c := New() + c.App().Runtime = &struct{ Name string }{Name: "wails"} + assert.NotNil(t, c.App().Runtime) +} + +func TestApp_Find_Good(t *testing.T) { + app := Find("go", "go") + // Find looks for a binary — go should be in PATH + if app != nil { + assert.NotEmpty(t, app.Path) + } +} + +func TestApp_Find_Bad(t *testing.T) { + app := Find("nonexistent-binary-xyz", "test") + assert.Nil(t, app) +} diff --git a/tests/array_test.go b/tests/array_test.go new file mode 100644 index 0000000..28f32ca --- /dev/null +++ b/tests/array_test.go @@ -0,0 +1,88 @@ +package core_test + +import ( + "testing" + + . "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" +) + +// --- Array[T] --- + +func TestArray_New_Good(t *testing.T) { + a := NewArray("a", "b", "c") + assert.Equal(t, 3, a.Len()) +} + +func TestArray_Add_Good(t *testing.T) { + a := NewArray[string]() + a.Add("x", "y") + assert.Equal(t, 2, a.Len()) + assert.True(t, a.Contains("x")) + assert.True(t, a.Contains("y")) +} + +func TestArray_AddUnique_Good(t *testing.T) { + a := NewArray("a", "b") + a.AddUnique("b", "c") + assert.Equal(t, 3, a.Len()) +} + +func TestArray_Contains_Good(t *testing.T) { + a := NewArray(1, 2, 3) + assert.True(t, a.Contains(2)) + assert.False(t, a.Contains(99)) +} + +func TestArray_Filter_Good(t *testing.T) { + a := NewArray(1, 2, 3, 4, 5) + evens := a.Filter(func(n int) bool { return n%2 == 0 }) + assert.Equal(t, 2, evens.Len()) + assert.True(t, evens.Contains(2)) + assert.True(t, evens.Contains(4)) +} + +func TestArray_Each_Good(t *testing.T) { + a := NewArray("a", "b", "c") + var collected []string + a.Each(func(s string) { collected = append(collected, s) }) + assert.Equal(t, []string{"a", "b", "c"}, collected) +} + +func TestArray_Remove_Good(t *testing.T) { + a := NewArray("a", "b", "c") + a.Remove("b") + assert.Equal(t, 2, a.Len()) + assert.False(t, a.Contains("b")) +} + +func TestArray_Remove_Bad(t *testing.T) { + a := NewArray("a", "b") + a.Remove("missing") + assert.Equal(t, 2, a.Len()) +} + +func TestArray_Deduplicate_Good(t *testing.T) { + a := NewArray("a", "b", "a", "c", "b") + a.Deduplicate() + assert.Equal(t, 3, a.Len()) +} + +func TestArray_Clear_Good(t *testing.T) { + a := NewArray(1, 2, 3) + a.Clear() + assert.Equal(t, 0, a.Len()) +} + +func TestArray_AsSlice_Good(t *testing.T) { + a := NewArray("x", "y") + s := a.AsSlice() + assert.Equal(t, []string{"x", "y"}, s) +} + +func TestArray_Empty_Good(t *testing.T) { + a := NewArray[int]() + assert.Equal(t, 0, a.Len()) + assert.False(t, a.Contains(0)) + assert.Equal(t, []int(nil), a.AsSlice()) +} diff --git a/tests/async_test.go b/tests/async_test.go deleted file mode 100644 index d9b589f..0000000 --- a/tests/async_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package core_test - -import ( - . "forge.lthn.ai/core/go/pkg/core" - "context" - "errors" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCore_PerformAsync_Good(t *testing.T) { - c, _ := New() - - var completed atomic.Bool - var resultReceived any - - c.RegisterAction(func(c *Core, msg Message) error { - if tc, ok := msg.(ActionTaskCompleted); ok { - resultReceived = tc.Result - completed.Store(true) - } - return nil - }) - - c.RegisterTask(func(c *Core, task Task) (any, bool, error) { - return "async-result", true, nil - }) - - taskID := c.PerformAsync(TestTask{}) - assert.NotEmpty(t, taskID) - - // Wait for completion - assert.Eventually(t, func() bool { - return completed.Load() - }, 1*time.Second, 10*time.Millisecond) - - assert.Equal(t, "async-result", resultReceived) -} - -func TestCore_PerformAsync_Shutdown(t *testing.T) { - c, _ := New() - _ = c.ServiceShutdown(context.Background()) - - taskID := c.PerformAsync(TestTask{}) - assert.Empty(t, taskID, "PerformAsync should return empty string if already shut down") -} - -func TestCore_Progress_Good(t *testing.T) { - c, _ := New() - - var progressReceived float64 - var messageReceived string - - c.RegisterAction(func(c *Core, msg Message) error { - if tp, ok := msg.(ActionTaskProgress); ok { - progressReceived = tp.Progress - messageReceived = tp.Message - } - return nil - }) - - c.Progress("task-1", 0.5, "halfway", TestTask{}) - - assert.Equal(t, 0.5, progressReceived) - assert.Equal(t, "halfway", messageReceived) -} - -func TestCore_WithService_UnnamedType(t *testing.T) { - // Primitive types have no package path - factory := func(c *Core) (any, error) { - s := "primitive" - return &s, nil - } - - _, err := New(WithService(factory)) - require.Error(t, err) - assert.Contains(t, err.Error(), "service name could not be discovered") -} - -func TestRuntime_ServiceStartup_ErrorPropagation(t *testing.T) { - rt, _ := NewRuntime(nil) - - // Register a service that fails startup - errSvc := &MockStartable{err: errors.New("startup failed")} - _ = rt.Core.RegisterService("error-svc", errSvc) - - err := rt.ServiceStartup(context.Background(), nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), "startup failed") -} - -func TestCore_ServiceStartup_ContextCancellation(t *testing.T) { - c, _ := New() - - ctx, cancel := context.WithCancel(context.Background()) - cancel() // Cancel immediately - - s1 := &MockStartable{} - _ = c.RegisterService("s1", s1) - - err := c.ServiceStartup(ctx, nil) - assert.Error(t, err) - assert.ErrorIs(t, err, context.Canceled) - assert.False(t, s1.started, "Srv should not have started if context was cancelled before loop") -} - -func TestCore_ServiceShutdown_ContextCancellation(t *testing.T) { - c, _ := New() - - ctx, cancel := context.WithCancel(context.Background()) - cancel() // Cancel immediately - - s1 := &MockStoppable{} - _ = c.RegisterService("s1", s1) - - err := c.ServiceShutdown(ctx) - assert.Error(t, err) - assert.ErrorIs(t, err, context.Canceled) - assert.False(t, s1.stopped, "Srv should not have stopped if context was cancelled before loop") -} - -type TaskWithIDImpl struct { - id string -} - -func (t *TaskWithIDImpl) SetTaskID(id string) { t.id = id } -func (t *TaskWithIDImpl) GetTaskID() string { return t.id } - -func TestCore_PerformAsync_InjectsID(t *testing.T) { - c, _ := New() - c.RegisterTask(func(c *Core, t Task) (any, bool, error) { return nil, true, nil }) - - task := &TaskWithIDImpl{} - taskID := c.PerformAsync(task) - - assert.Equal(t, taskID, task.GetTaskID()) -} diff --git a/tests/bench_test.go b/tests/bench_test.go deleted file mode 100644 index a59aa82..0000000 --- a/tests/bench_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package core_test - - -import ( - . "forge.lthn.ai/core/go/pkg/core" - "testing" -) - -func BenchmarkMessageBus_Action(b *testing.B) { - c, _ := New() - c.RegisterAction(func(c *Core, msg Message) error { - return nil - }) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = c.ACTION("test") - } -} - -func BenchmarkMessageBus_Query(b *testing.B) { - c, _ := New() - c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { - return "result", true, nil - }) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _, _ = c.QUERY("test") - } -} - -func BenchmarkMessageBus_Perform(b *testing.B) { - c, _ := New() - c.RegisterTask(func(c *Core, t Task) (any, bool, error) { - return "result", true, nil - }) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _, _ = c.PERFORM("test") - } -} diff --git a/tests/cli_test.go b/tests/cli_test.go new file mode 100644 index 0000000..1875be7 --- /dev/null +++ b/tests/cli_test.go @@ -0,0 +1,76 @@ +package core_test + +import ( + "testing" + + . "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" +) + +// --- Cli --- + +func TestCli_Good(t *testing.T) { + c := New() + assert.NotNil(t, c.Cli()) + assert.NotNil(t, c.Cli().Command()) +} + +func TestCli_Named_Good(t *testing.T) { + c := New(Options{{K: "name", V: "myapp"}}) + assert.NotNil(t, c.Cli().Command()) +} + +func TestCli_NewChildCommand_Good(t *testing.T) { + c := New(Options{{K: "name", V: "myapp"}}) + child := c.Cli().NewChildCommand("test", "a test command") + assert.NotNil(t, child) +} + +func TestCli_AddCommand_Good(t *testing.T) { + c := New() + cmd := NewCommand("hello", "says hello") + c.Cli().AddCommand(cmd) +} + +func TestCli_Flags_Good(t *testing.T) { + c := New() + var name string + var debug bool + var port int + c.Cli().StringFlag("name", "app name", &name) + c.Cli().BoolFlag("debug", "enable debug", &debug) + c.Cli().IntFlag("port", "port number", &port) +} + +func TestCli_Run_Good(t *testing.T) { + c := New() + executed := false + c.Cli().Command().Action(func() error { + executed = true + return nil + }) + err := c.Cli().Run("") + assert.NoError(t, err) + assert.True(t, executed) +} + +// --- Command --- + +func TestCommand_New_Good(t *testing.T) { + cmd := NewCommand("test", "a test command") + assert.NotNil(t, cmd) +} + +func TestCommand_Child_Good(t *testing.T) { + parent := NewCommand("root") + child := parent.NewChildCommand("sub", "a subcommand") + assert.NotNil(t, child) +} + +func TestCommand_Flags_Good(t *testing.T) { + cmd := NewCommand("test") + var name string + var debug bool + cmd.StringFlag("name", "app name", &name) + cmd.BoolFlag("debug", "enable debug", &debug) +} diff --git a/tests/config_test.go b/tests/config_test.go new file mode 100644 index 0000000..18e2613 --- /dev/null +++ b/tests/config_test.go @@ -0,0 +1,102 @@ +package core_test + +import ( + "testing" + + . "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" +) + +// --- Config --- + +func TestConfig_SetGet_Good(t *testing.T) { + c := New() + c.Config().Set("api_url", "https://api.lthn.ai") + c.Config().Set("max_agents", 5) + + val, ok := c.Config().Get("api_url") + assert.True(t, ok) + assert.Equal(t, "https://api.lthn.ai", val) +} + +func TestConfig_Get_Bad(t *testing.T) { + c := New() + val, ok := c.Config().Get("missing") + assert.False(t, ok) + assert.Nil(t, val) +} + +func TestConfig_TypedAccessors_Good(t *testing.T) { + c := New() + c.Config().Set("url", "https://lthn.ai") + c.Config().Set("port", 8080) + c.Config().Set("debug", true) + + assert.Equal(t, "https://lthn.ai", c.Config().String("url")) + assert.Equal(t, 8080, c.Config().Int("port")) + assert.True(t, c.Config().Bool("debug")) +} + +func TestConfig_TypedAccessors_Bad(t *testing.T) { + c := New() + // Missing keys return zero values + assert.Equal(t, "", c.Config().String("missing")) + assert.Equal(t, 0, c.Config().Int("missing")) + assert.False(t, c.Config().Bool("missing")) +} + +// --- Feature Flags --- + +func TestConfig_Features_Good(t *testing.T) { + c := New() + c.Config().Enable("dark-mode") + c.Config().Enable("beta") + + assert.True(t, c.Config().Enabled("dark-mode")) + assert.True(t, c.Config().Enabled("beta")) + assert.False(t, c.Config().Enabled("missing")) +} + +func TestConfig_Features_Disable_Good(t *testing.T) { + c := New() + c.Config().Enable("feature") + assert.True(t, c.Config().Enabled("feature")) + + c.Config().Disable("feature") + assert.False(t, c.Config().Enabled("feature")) +} + +func TestConfig_Features_CaseSensitive(t *testing.T) { + c := New() + c.Config().Enable("Feature") + assert.True(t, c.Config().Enabled("Feature")) + assert.False(t, c.Config().Enabled("feature")) +} + +func TestConfig_EnabledFeatures_Good(t *testing.T) { + c := New() + c.Config().Enable("a") + c.Config().Enable("b") + c.Config().Enable("c") + c.Config().Disable("b") + + features := c.Config().EnabledFeatures() + assert.Contains(t, features, "a") + assert.Contains(t, features, "c") + assert.NotContains(t, features, "b") +} + +// --- ConfigVar --- + +func TestConfigVar_Good(t *testing.T) { + v := NewConfigVar("hello") + assert.True(t, v.IsSet()) + assert.Equal(t, "hello", v.Get()) + + v.Set("world") + assert.Equal(t, "world", v.Get()) + + v.Unset() + assert.False(t, v.IsSet()) + assert.Equal(t, "", v.Get()) +} diff --git a/tests/core_extra_test.go b/tests/core_extra_test.go deleted file mode 100644 index 408476e..0000000 --- a/tests/core_extra_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package core_test - - -import ( - . "forge.lthn.ai/core/go/pkg/core" - "testing" - - "github.com/stretchr/testify/assert" -) - -type MockServiceWithIPC struct { - MockService - handled bool -} - -func (m *MockServiceWithIPC) HandleIPCEvents(c *Core, msg Message) error { - m.handled = true - return nil -} - -func TestCore_WithService_IPC(t *testing.T) { - svc := &MockServiceWithIPC{MockService: MockService{Name: "ipc-service"}} - factory := func(c *Core) (any, error) { - return svc, nil - } - c, err := New(WithService(factory)) - assert.NoError(t, err) - - // Trigger ACTION to verify handler was registered - err = c.ACTION(nil) - assert.NoError(t, err) - assert.True(t, svc.handled) -} - -func TestCore_ACTION_Bad(t *testing.T) { - c, err := New() - assert.NoError(t, err) - errHandler := func(c *Core, msg Message) error { - return assert.AnError - } - c.RegisterAction(errHandler) - err = c.ACTION(nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), assert.AnError.Error()) -} diff --git a/tests/core_lifecycle_test.go b/tests/core_lifecycle_test.go deleted file mode 100644 index 6f2fadf..0000000 --- a/tests/core_lifecycle_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package core_test - - -import ( - . "forge.lthn.ai/core/go/pkg/core" - "context" - "errors" - "testing" - - "github.com/stretchr/testify/assert" -) - -type MockStartable struct { - started bool - err error -} - -func (m *MockStartable) OnStartup(ctx context.Context) error { - m.started = true - return m.err -} - -type MockStoppable struct { - stopped bool - err error -} - -func (m *MockStoppable) OnShutdown(ctx context.Context) error { - m.stopped = true - return m.err -} - -type MockLifecycle struct { - MockStartable - MockStoppable -} - -func TestCore_LifecycleInterfaces(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - startable := &MockStartable{} - stoppable := &MockStoppable{} - lifecycle := &MockLifecycle{} - - // Register services - err = c.RegisterService("startable", startable) - assert.NoError(t, err) - err = c.RegisterService("stoppable", stoppable) - assert.NoError(t, err) - err = c.RegisterService("lifecycle", lifecycle) - assert.NoError(t, err) - - // Startup - err = c.ServiceStartup(context.Background(), nil) - assert.NoError(t, err) - assert.True(t, startable.started) - assert.True(t, lifecycle.started) - assert.False(t, stoppable.stopped) - - // Shutdown - err = c.ServiceShutdown(context.Background()) - assert.NoError(t, err) - assert.True(t, stoppable.stopped) - assert.True(t, lifecycle.stopped) -} - -type MockLifecycleWithLog struct { - id string - log *[]string -} - -func (m *MockLifecycleWithLog) OnStartup(ctx context.Context) error { - *m.log = append(*m.log, "start-"+m.id) - return nil -} - -func (m *MockLifecycleWithLog) OnShutdown(ctx context.Context) error { - *m.log = append(*m.log, "stop-"+m.id) - return nil -} - -func TestCore_LifecycleOrder(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - var callOrder []string - - s1 := &MockLifecycleWithLog{id: "1", log: &callOrder} - s2 := &MockLifecycleWithLog{id: "2", log: &callOrder} - - err = c.RegisterService("s1", s1) - assert.NoError(t, err) - err = c.RegisterService("s2", s2) - assert.NoError(t, err) - - // Startup - err = c.ServiceStartup(context.Background(), nil) - assert.NoError(t, err) - assert.Equal(t, []string{"start-1", "start-2"}, callOrder) - - // Reset log - callOrder = nil - - // Shutdown - err = c.ServiceShutdown(context.Background()) - assert.NoError(t, err) - assert.Equal(t, []string{"stop-2", "stop-1"}, callOrder) -} - -func TestCore_LifecycleErrors(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - s1 := &MockStartable{err: assert.AnError} - s2 := &MockStoppable{err: assert.AnError} - - _ = c.RegisterService("s1", s1) - _ = c.RegisterService("s2", s2) - - err = c.ServiceStartup(context.Background(), nil) - assert.Error(t, err) - assert.ErrorIs(t, err, assert.AnError) - - err = c.ServiceShutdown(context.Background()) - assert.Error(t, err) - assert.ErrorIs(t, err, assert.AnError) -} - -func TestCore_LifecycleErrors_Aggregated(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - // Register action that fails - c.RegisterAction(func(c *Core, msg Message) error { - if _, ok := msg.(ActionServiceStartup); ok { - return errors.New("startup action error") - } - if _, ok := msg.(ActionServiceShutdown); ok { - return errors.New("shutdown action error") - } - return nil - }) - - // Register service that fails - s1 := &MockStartable{err: errors.New("startup service error")} - s2 := &MockStoppable{err: errors.New("shutdown service error")} - - err = c.RegisterService("s1", s1) - assert.NoError(t, err) - err = c.RegisterService("s2", s2) - assert.NoError(t, err) - - // Startup - err = c.ServiceStartup(context.Background(), nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), "startup action error") - assert.Contains(t, err.Error(), "startup service error") - - // Shutdown - err = c.ServiceShutdown(context.Background()) - assert.Error(t, err) - assert.Contains(t, err.Error(), "shutdown action error") - assert.Contains(t, err.Error(), "shutdown service error") -} diff --git a/tests/core_test.go b/tests/core_test.go index 2966089..1e1cbf1 100644 --- a/tests/core_test.go +++ b/tests/core_test.go @@ -1,346 +1,63 @@ package core_test import ( - . "forge.lthn.ai/core/go/pkg/core" - "context" - "embed" - "io" "testing" + . "forge.lthn.ai/core/go/pkg/core" "github.com/stretchr/testify/assert" ) -// mockApp is a simple mock for testing app injection -type mockApp struct{} +// --- New --- -func TestCore_New_Good(t *testing.T) { - c, err := New() - assert.NoError(t, err) +func TestNew_Good(t *testing.T) { + c := New() assert.NotNil(t, c) } -// Mock service for testing -type MockService struct { - Name string +func TestNew_WithOptions_Good(t *testing.T) { + c := New(Options{{K: "name", V: "myapp"}}) + assert.NotNil(t, c) + assert.Equal(t, "myapp", c.App().Name) } -func (m *MockService) GetName() string { - return m.Name +func TestNew_WithOptions_Bad(t *testing.T) { + // Empty options — should still create a valid Core + c := New(Options{}) + assert.NotNil(t, c) } -func TestCore_WithService_Good(t *testing.T) { - factory := func(c *Core) (any, error) { - return &MockService{Name: "test"}, nil - } - c, err := New(WithService(factory)) - assert.NoError(t, err) - svc := c.Service().Get("core") - assert.NotNil(t, svc) - mockSvc, ok := svc.(*MockService) - assert.True(t, ok) - assert.Equal(t, "test", mockSvc.GetName()) -} +// --- Accessors --- -func TestCore_WithService_Bad(t *testing.T) { - factory := func(c *Core) (any, error) { - return nil, assert.AnError - } - _, err := New(WithService(factory)) - assert.Error(t, err) - assert.ErrorIs(t, err, assert.AnError) -} - -type MockConfigService struct{} - -func (m *MockConfigService) Get(key string, out any) error { return nil } -func (m *MockConfigService) Set(key string, v any) error { return nil } - -func TestCore_Services_Good(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - err = c.RegisterService("config", &MockConfigService{}) - assert.NoError(t, err) - - svc := c.Service("config") - assert.NotNil(t, svc) - - // Cfg() returns Cfg (always available, not a service) - cfg := c.Config() - assert.NotNil(t, cfg) -} - -func TestCore_App_Good(t *testing.T) { - app := &mockApp{} - c, err := New(WithApp(app)) - assert.NoError(t, err) - - // To test the global CoreGUI() function, we need to set the global instance. - originalInstance := GetInstance() - SetInstance(c) - defer SetInstance(originalInstance) - - assert.Equal(t, app, CoreGUI()) -} - -func TestCore_App_Ugly(t *testing.T) { - // This test ensures that calling CoreGUI() before the core is initialized panics. - originalInstance := GetInstance() - ClearInstance() - defer SetInstance(originalInstance) - assert.Panics(t, func() { - CoreGUI() - }) -} - -func TestCore_Core_Good(t *testing.T) { - c, err := New() - assert.NoError(t, err) +func TestAccessors_Good(t *testing.T) { + c := New() + assert.NotNil(t, c.App()) + assert.NotNil(t, c.Data()) + assert.NotNil(t, c.Drive()) + assert.NotNil(t, c.Fs()) + assert.NotNil(t, c.Config()) + assert.NotNil(t, c.Error()) + assert.NotNil(t, c.Log()) + assert.NotNil(t, c.Cli()) + assert.NotNil(t, c.IPC()) + assert.NotNil(t, c.I18n()) assert.Equal(t, c, c.Core()) } -func TestEtc_Features_Good(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - c.Config().Enable("feature1") - c.Config().Enable("feature2") - - assert.True(t, c.Config().Enabled("feature1")) - assert.True(t, c.Config().Enabled("feature2")) - assert.False(t, c.Config().Enabled("feature3")) - assert.False(t, c.Config().Enabled("")) -} - -func TestEtc_Settings_Good(t *testing.T) { - c, _ := New() - c.Config().Set("api_url", "https://api.lthn.sh") - c.Config().Set("max_agents", 5) - - assert.Equal(t, "https://api.lthn.sh", c.Config().GetString("api_url")) - assert.Equal(t, 5, c.Config().GetInt("max_agents")) - assert.Equal(t, "", c.Config().GetString("missing")) -} - -func TestEtc_Features_Edge(t *testing.T) { - c, _ := New() - c.Config().Enable("foo") - assert.True(t, c.Config().Enabled("foo")) - assert.False(t, c.Config().Enabled("FOO")) // Case sensitive - - c.Config().Disable("foo") - assert.False(t, c.Config().Enabled("foo")) -} - -func TestCore_ServiceLifecycle_Good(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - var messageReceived Message - handler := func(c *Core, msg Message) error { - messageReceived = msg - return nil - } - c.RegisterAction(handler) - - // Test Startup - _ = c.ServiceStartup(context.TODO(), nil) - _, ok := messageReceived.(ActionServiceStartup) - assert.True(t, ok, "expected ActionServiceStartup message") - - // Test Shutdown - _ = c.ServiceShutdown(context.TODO()) - _, ok = messageReceived.(ActionServiceShutdown) - assert.True(t, ok, "expected ActionServiceShutdown message") -} - -func TestCore_WithApp_Good(t *testing.T) { - app := &mockApp{} - c, err := New(WithApp(app)) - assert.NoError(t, err) - assert.Equal(t, app, c.App().Runtime) -} - -//go:embed testdata -var testFS embed.FS - -func TestCore_WithAssets_Good(t *testing.T) { - c, err := New(WithAssets(testFS)) - assert.NoError(t, err) - file, err := c.Embed().Open("testdata/test.txt") - assert.NoError(t, err) - defer func() { _ = file.Close() }() - content, err := io.ReadAll(file) - assert.NoError(t, err) - assert.Equal(t, "hello from testdata\n", string(content)) -} - -func TestCore_WithServiceLock_Good(t *testing.T) { - c, err := New(WithServiceLock()) - assert.NoError(t, err) - err = c.RegisterService("test", &MockService{}) - assert.Error(t, err) -} - -func TestCore_RegisterService_Good(t *testing.T) { - c, err := New() - assert.NoError(t, err) - err = c.RegisterService("test", &MockService{Name: "test"}) - assert.NoError(t, err) - svc := c.Service("test") - assert.NotNil(t, svc) - mockSvc, ok := svc.(*MockService) - assert.True(t, ok) - assert.Equal(t, "test", mockSvc.GetName()) -} - -func TestCore_RegisterService_Bad(t *testing.T) { - c, err := New() - assert.NoError(t, err) - err = c.RegisterService("test", &MockService{}) - assert.NoError(t, err) - err = c.RegisterService("test", &MockService{}) - assert.Error(t, err) - err = c.RegisterService("", &MockService{}) - assert.Error(t, err) -} - -func TestCore_ServiceFor_Good(t *testing.T) { - c, err := New() - assert.NoError(t, err) - err = c.RegisterService("test", &MockService{Name: "test"}) - assert.NoError(t, err) - svc, err := ServiceFor[*MockService](c, "test") - assert.NoError(t, err) - assert.Equal(t, "test", svc.GetName()) -} - -func TestCore_ServiceFor_Bad(t *testing.T) { - c, err := New() - assert.NoError(t, err) - _, err = ServiceFor[*MockService](c, "nonexistent") - assert.Error(t, err) - err = c.RegisterService("test", "not a service") - assert.NoError(t, err) - _, err = ServiceFor[*MockService](c, "test") - assert.Error(t, err) -} - -func TestCore_MustServiceFor_Good(t *testing.T) { - c, err := New() - assert.NoError(t, err) - err = c.RegisterService("test", &MockService{Name: "test"}) - assert.NoError(t, err) - svc := MustServiceFor[*MockService](c, "test") - assert.Equal(t, "test", svc.GetName()) -} - -func TestCore_MustServiceFor_Ugly(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - // MustServiceFor panics on missing service - assert.Panics(t, func() { - MustServiceFor[*MockService](c, "nonexistent") - }) - - err = c.RegisterService("test", "not a service") - assert.NoError(t, err) - - // MustServiceFor panics on type mismatch - assert.Panics(t, func() { - MustServiceFor[*MockService](c, "test") +func TestOptions_Accessor_Good(t *testing.T) { + c := New(Options{ + {K: "name", V: "testapp"}, + {K: "port", V: 8080}, + {K: "debug", V: true}, }) + opts := c.Options() + assert.NotNil(t, opts) + assert.Equal(t, "testapp", opts.String("name")) + assert.Equal(t, 8080, opts.Int("port")) + assert.True(t, opts.Bool("debug")) } -type MockAction struct { - handled bool -} - -func (a *MockAction) Handle(c *Core, msg Message) error { - a.handled = true - return nil -} - -func TestCore_ACTION_Good(t *testing.T) { - c, err := New() - assert.NoError(t, err) - action := &MockAction{} - c.RegisterAction(action.Handle) - err = c.ACTION(nil) - assert.NoError(t, err) - assert.True(t, action.handled) -} - -func TestCore_RegisterActions_Good(t *testing.T) { - c, err := New() - assert.NoError(t, err) - action1 := &MockAction{} - action2 := &MockAction{} - c.RegisterActions(action1.Handle, action2.Handle) - err = c.ACTION(nil) - assert.NoError(t, err) - assert.True(t, action1.handled) - assert.True(t, action2.handled) -} - -func TestCore_WithName_Good(t *testing.T) { - factory := func(c *Core) (any, error) { - return &MockService{Name: "test"}, nil - } - c, err := New(WithName("my-service", factory)) - assert.NoError(t, err) - svc := c.Service("my-service") - assert.NotNil(t, svc) - mockSvc, ok := svc.(*MockService) - assert.True(t, ok) - assert.Equal(t, "test", mockSvc.GetName()) -} - -func TestCore_WithName_Bad(t *testing.T) { - factory := func(c *Core) (any, error) { - return nil, assert.AnError - } - _, err := New(WithName("my-service", factory)) - assert.Error(t, err) - assert.ErrorIs(t, err, assert.AnError) -} - -func TestCore_GlobalInstance_ThreadSafety_Good(t *testing.T) { - // Save original instance - original := GetInstance() - defer SetInstance(original) - - // Test SetInstance/GetInstance - c1, _ := New() - SetInstance(c1) - assert.Equal(t, c1, GetInstance()) - - // Test ClearInstance - ClearInstance() - assert.Nil(t, GetInstance()) - - // Test concurrent access (race detector should catch issues) - c2, _ := New(WithApp(&mockApp{})) - done := make(chan bool) - - for i := 0; i < 10; i++ { - go func() { - SetInstance(c2) - _ = GetInstance() - done <- true - }() - go func() { - inst := GetInstance() - if inst != nil { - _ = inst.App - } - done <- true - }() - } - - // Wait for all goroutines - for i := 0; i < 20; i++ { - <-done - } +func TestOptions_Accessor_Nil(t *testing.T) { + c := New() + // No options passed — Options() returns nil + assert.Nil(t, c.Options()) } diff --git a/tests/data_test.go b/tests/data_test.go new file mode 100644 index 0000000..3d5b696 --- /dev/null +++ b/tests/data_test.go @@ -0,0 +1,127 @@ +package core_test + +import ( + "embed" + "io" + "testing" + + . "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" +) + +//go:embed testdata +var testFS embed.FS + +// --- Data (Embedded Content Mounts) --- + +func TestData_New_Good(t *testing.T) { + c := New() + r := c.Data().New(Options{ + {K: "name", V: "test"}, + {K: "source", V: testFS}, + {K: "path", V: "testdata"}, + }) + assert.True(t, r.OK) + assert.NotNil(t, r.Value) +} + +func TestData_New_Bad(t *testing.T) { + c := New() + + // Missing name + r := c.Data().New(Options{ + {K: "source", V: testFS}, + }) + assert.False(t, r.OK) + + // Missing source + r = c.Data().New(Options{ + {K: "name", V: "test"}, + }) + assert.False(t, r.OK) + + // Wrong source type + r = c.Data().New(Options{ + {K: "name", V: "test"}, + {K: "source", V: "not-an-fs"}, + }) + assert.False(t, r.OK) +} + +func TestData_ReadString_Good(t *testing.T) { + c := New() + c.Data().New(Options{ + {K: "name", V: "app"}, + {K: "source", V: testFS}, + {K: "path", V: "testdata"}, + }) + content, err := c.Data().ReadString("app/test.txt") + assert.NoError(t, err) + assert.Equal(t, "hello from testdata\n", content) +} + +func TestData_ReadString_Bad(t *testing.T) { + c := New() + _, err := c.Data().ReadString("nonexistent/file.txt") + assert.Error(t, err) +} + +func TestData_ReadFile_Good(t *testing.T) { + c := New() + c.Data().New(Options{ + {K: "name", V: "app"}, + {K: "source", V: testFS}, + {K: "path", V: "testdata"}, + }) + data, err := c.Data().ReadFile("app/test.txt") + assert.NoError(t, err) + assert.Equal(t, "hello from testdata\n", string(data)) +} + +func TestData_Get_Good(t *testing.T) { + c := New() + c.Data().New(Options{ + {K: "name", V: "brain"}, + {K: "source", V: testFS}, + {K: "path", V: "testdata"}, + }) + emb := c.Data().Get("brain") + assert.NotNil(t, emb) + + // Read via the Embed directly + file, err := emb.Open("test.txt") + assert.NoError(t, err) + defer file.Close() + content, _ := io.ReadAll(file) + assert.Equal(t, "hello from testdata\n", string(content)) +} + +func TestData_Get_Bad(t *testing.T) { + c := New() + emb := c.Data().Get("nonexistent") + assert.Nil(t, emb) +} + +func TestData_Mounts_Good(t *testing.T) { + c := New() + c.Data().New(Options{{K: "name", V: "a"}, {K: "source", V: testFS}, {K: "path", V: "testdata"}}) + c.Data().New(Options{{K: "name", V: "b"}, {K: "source", V: testFS}, {K: "path", V: "testdata"}}) + mounts := c.Data().Mounts() + assert.Len(t, mounts, 2) + assert.Contains(t, mounts, "a") + assert.Contains(t, mounts, "b") +} + +// --- Legacy Embed() accessor --- + +func TestEmbed_Legacy_Good(t *testing.T) { + c := New() + c.Data().New(Options{ + {K: "name", V: "app"}, + {K: "source", V: testFS}, + {K: "path", V: "testdata"}, + }) + // Legacy accessor reads from Data mount "app" + emb := c.Embed() + assert.NotNil(t, emb) +} diff --git a/tests/drive_test.go b/tests/drive_test.go new file mode 100644 index 0000000..4229ed9 --- /dev/null +++ b/tests/drive_test.go @@ -0,0 +1,77 @@ +package core_test + +import ( + "testing" + + . "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" +) + +// --- Drive (Transport Handles) --- + +func TestDrive_New_Good(t *testing.T) { + c := New() + r := c.Drive().New(Options{ + {K: "name", V: "api"}, + {K: "transport", V: "https://api.lthn.ai"}, + }) + assert.True(t, r.OK) + assert.Equal(t, "api", r.Value.Name) + assert.Equal(t, "https://api.lthn.ai", r.Value.Transport) +} + +func TestDrive_New_Bad(t *testing.T) { + c := New() + // Missing name + r := c.Drive().New(Options{ + {K: "transport", V: "https://api.lthn.ai"}, + }) + assert.False(t, r.OK) +} + +func TestDrive_Get_Good(t *testing.T) { + c := New() + c.Drive().New(Options{ + {K: "name", V: "ssh"}, + {K: "transport", V: "ssh://claude@10.69.69.165"}, + }) + handle := c.Drive().Get("ssh") + assert.NotNil(t, handle) + assert.Equal(t, "ssh://claude@10.69.69.165", handle.Transport) +} + +func TestDrive_Get_Bad(t *testing.T) { + c := New() + handle := c.Drive().Get("nonexistent") + assert.Nil(t, handle) +} + +func TestDrive_Has_Good(t *testing.T) { + c := New() + c.Drive().New(Options{{K: "name", V: "mcp"}, {K: "transport", V: "mcp://mcp.lthn.sh"}}) + assert.True(t, c.Drive().Has("mcp")) + assert.False(t, c.Drive().Has("missing")) +} + +func TestDrive_Names_Good(t *testing.T) { + c := New() + c.Drive().New(Options{{K: "name", V: "api"}, {K: "transport", V: "https://api.lthn.ai"}}) + c.Drive().New(Options{{K: "name", V: "ssh"}, {K: "transport", V: "ssh://claude@10.69.69.165"}}) + c.Drive().New(Options{{K: "name", V: "mcp"}, {K: "transport", V: "mcp://mcp.lthn.sh"}}) + names := c.Drive().Names() + assert.Len(t, names, 3) + assert.Contains(t, names, "api") + assert.Contains(t, names, "ssh") + assert.Contains(t, names, "mcp") +} + +func TestDrive_OptionsPreserved_Good(t *testing.T) { + c := New() + c.Drive().New(Options{ + {K: "name", V: "api"}, + {K: "transport", V: "https://api.lthn.ai"}, + {K: "timeout", V: 30}, + }) + handle := c.Drive().Get("api") + assert.Equal(t, 30, handle.Options.Int("timeout")) +} diff --git a/tests/e_test.go b/tests/e_test.go deleted file mode 100644 index a468842..0000000 --- a/tests/e_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package core_test - - -import ( - . "forge.lthn.ai/core/go/pkg/core" - "errors" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestE_Good(t *testing.T) { - err := E("test.op", "test message", assert.AnError) - assert.Error(t, err) - assert.Equal(t, "test.op: test message: assert.AnError general error for testing", err.Error()) - - err = E("test.op", "test message", nil) - assert.Error(t, err) - assert.Equal(t, "test.op: test message", err.Error()) -} - -func TestE_Unwrap(t *testing.T) { - originalErr := errors.New("original error") - err := E("test.op", "test message", originalErr) - - assert.True(t, errors.Is(err, originalErr)) - - var eErr *Err - assert.True(t, errors.As(err, &eErr)) - assert.Equal(t, "test.op", eErr.Op) -} diff --git a/tests/embed_test.go b/tests/embed_test.go new file mode 100644 index 0000000..b663cd2 --- /dev/null +++ b/tests/embed_test.go @@ -0,0 +1,132 @@ +package core_test + +import ( + "bytes" + "compress/gzip" + "encoding/base64" + "os" + "path/filepath" + "testing" + + . "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" +) + +// --- Embed (Mount + ReadFile + Sub) --- + +func TestMount_Good(t *testing.T) { + emb, err := Mount(testFS, "testdata") + assert.NoError(t, err) + assert.NotNil(t, emb) +} + +func TestMount_Bad(t *testing.T) { + _, err := Mount(testFS, "nonexistent") + assert.Error(t, err) +} + +func TestEmbed_ReadFile_Good(t *testing.T) { + emb, _ := Mount(testFS, "testdata") + data, err := emb.ReadFile("test.txt") + assert.NoError(t, err) + assert.Equal(t, "hello from testdata\n", string(data)) +} + +func TestEmbed_ReadString_Good(t *testing.T) { + emb, _ := Mount(testFS, "testdata") + s, err := emb.ReadString("test.txt") + assert.NoError(t, err) + assert.Equal(t, "hello from testdata\n", s) +} + +func TestEmbed_Open_Good(t *testing.T) { + emb, _ := Mount(testFS, "testdata") + f, err := emb.Open("test.txt") + assert.NoError(t, err) + defer f.Close() +} + +func TestEmbed_ReadDir_Good(t *testing.T) { + emb, _ := Mount(testFS, "testdata") + entries, err := emb.ReadDir(".") + assert.NoError(t, err) + assert.NotEmpty(t, entries) +} + +func TestEmbed_Sub_Good(t *testing.T) { + emb, _ := Mount(testFS, ".") + sub, err := emb.Sub("testdata") + assert.NoError(t, err) + data, err := sub.ReadFile("test.txt") + assert.NoError(t, err) + assert.Equal(t, "hello from testdata\n", string(data)) +} + +func TestEmbed_BaseDir_Good(t *testing.T) { + emb, _ := Mount(testFS, "testdata") + assert.Equal(t, "testdata", emb.BaseDir()) +} + +func TestEmbed_FS_Good(t *testing.T) { + emb, _ := Mount(testFS, "testdata") + assert.NotNil(t, emb.FS()) +} + +func TestEmbed_EmbedFS_Good(t *testing.T) { + emb, _ := Mount(testFS, "testdata") + efs := emb.EmbedFS() + // Should return the original embed.FS + _, err := efs.ReadFile("testdata/test.txt") + assert.NoError(t, err) +} + +// --- Extract (Template Directory) --- + +func TestExtract_Good(t *testing.T) { + dir := t.TempDir() + err := Extract(testFS, dir, nil) + assert.NoError(t, err) + + // testdata/test.txt should be extracted + content, err := os.ReadFile(filepath.Join(dir, "testdata", "test.txt")) + assert.NoError(t, err) + assert.Equal(t, "hello from testdata\n", string(content)) +} + +// --- Asset Pack (Build-time) --- + +func TestAddGetAsset_Good(t *testing.T) { + AddAsset("test-group", "greeting", mustCompress("hello world")) + result, err := GetAsset("test-group", "greeting") + assert.NoError(t, err) + assert.Equal(t, "hello world", result) +} + +func TestGetAsset_Bad(t *testing.T) { + _, err := GetAsset("missing-group", "missing") + assert.Error(t, err) + + AddAsset("exists", "item", mustCompress("data")) + _, err = GetAsset("exists", "missing-item") + assert.Error(t, err) +} + +func TestGetAssetBytes_Good(t *testing.T) { + AddAsset("bytes-group", "file", mustCompress("binary content")) + data, err := GetAssetBytes("bytes-group", "file") + assert.NoError(t, err) + assert.Equal(t, []byte("binary content"), data) +} + +// mustCompress is a test helper — compresses a string the way AddAsset expects. +func mustCompress(input string) string { + // AddAsset stores pre-compressed data. We need to compress it the same way. + // Use the internal format: base64(gzip(input)) + var buf bytes.Buffer + b64 := base64.NewEncoder(base64.StdEncoding, &buf) + gz, _ := gzip.NewWriterLevel(b64, gzip.BestCompression) + gz.Write([]byte(input)) + gz.Close() + b64.Close() + return buf.String() +} diff --git a/tests/error_test.go b/tests/error_test.go new file mode 100644 index 0000000..7fdb4c6 --- /dev/null +++ b/tests/error_test.go @@ -0,0 +1,196 @@ +package core_test + +import ( + "errors" + "testing" + + . "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" +) + +// --- Error Creation --- + +func TestE_Good(t *testing.T) { + err := E("user.Save", "failed to save", nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "user.Save") + assert.Contains(t, err.Error(), "failed to save") +} + +func TestE_WithCause_Good(t *testing.T) { + cause := errors.New("connection refused") + err := E("db.Connect", "database unavailable", cause) + assert.ErrorIs(t, err, cause) +} + +func TestWrap_Good(t *testing.T) { + cause := errors.New("timeout") + err := Wrap(cause, "api.Call", "request failed") + assert.Error(t, err) + assert.ErrorIs(t, err, cause) +} + +func TestWrap_Nil_Good(t *testing.T) { + err := Wrap(nil, "api.Call", "request failed") + assert.Nil(t, err) +} + +func TestWrapCode_Good(t *testing.T) { + cause := errors.New("invalid email") + err := WrapCode(cause, "VALIDATION_ERROR", "user.Validate", "bad input") + assert.Error(t, err) + assert.Equal(t, "VALIDATION_ERROR", ErrCode(err)) +} + +func TestNewCode_Good(t *testing.T) { + err := NewCode("NOT_FOUND", "resource not found") + assert.Error(t, err) + assert.Equal(t, "NOT_FOUND", ErrCode(err)) +} + +// --- Error Introspection --- + +func TestOp_Good(t *testing.T) { + err := E("brain.Recall", "search failed", nil) + assert.Equal(t, "brain.Recall", Op(err)) +} + +func TestOp_Bad(t *testing.T) { + err := errors.New("plain error") + assert.Equal(t, "", Op(err)) +} + +func TestErrorMessage_Good(t *testing.T) { + err := E("op", "the message", nil) + assert.Equal(t, "the message", ErrorMessage(err)) +} + +func TestErrorMessage_Plain(t *testing.T) { + err := errors.New("plain") + assert.Equal(t, "plain", ErrorMessage(err)) +} + +func TestErrorMessage_Nil(t *testing.T) { + assert.Equal(t, "", ErrorMessage(nil)) +} + +func TestRoot_Good(t *testing.T) { + root := errors.New("root cause") + wrapped := Wrap(root, "layer1", "first wrap") + double := Wrap(wrapped, "layer2", "second wrap") + assert.Equal(t, root, Root(double)) +} + +func TestRoot_Nil(t *testing.T) { + assert.Nil(t, Root(nil)) +} + +func TestStackTrace_Good(t *testing.T) { + err := Wrap(E("inner", "cause", nil), "outer", "wrapper") + stack := StackTrace(err) + assert.Len(t, stack, 2) + assert.Equal(t, "outer", stack[0]) + assert.Equal(t, "inner", stack[1]) +} + +func TestFormatStackTrace_Good(t *testing.T) { + err := Wrap(E("a", "x", nil), "b", "y") + formatted := FormatStackTrace(err) + assert.Equal(t, "b -> a", formatted) +} + +// --- ErrorLog --- + +func TestErrorLog_Good(t *testing.T) { + c := New() + cause := errors.New("boom") + err := c.Log().Error(cause, "test.Op", "something broke") + assert.Error(t, err) + assert.ErrorIs(t, err, cause) +} + +func TestErrorLog_Nil_Good(t *testing.T) { + c := New() + err := c.Log().Error(nil, "test.Op", "no error") + assert.Nil(t, err) +} + +func TestErrorLog_Warn_Good(t *testing.T) { + c := New() + cause := errors.New("warning") + err := c.Log().Warn(cause, "test.Op", "heads up") + assert.Error(t, err) +} + +func TestErrorLog_Must_Ugly(t *testing.T) { + c := New() + assert.Panics(t, func() { + c.Log().Must(errors.New("fatal"), "test.Op", "must fail") + }) +} + +func TestErrorLog_Must_Nil_Good(t *testing.T) { + c := New() + assert.NotPanics(t, func() { + c.Log().Must(nil, "test.Op", "no error") + }) +} + +// --- ErrorPanic --- + +func TestErrorPanic_Recover_Good(t *testing.T) { + c := New() + // Should not panic — Recover catches it + assert.NotPanics(t, func() { + defer c.Error().Recover() + panic("test panic") + }) +} + +func TestErrorPanic_SafeGo_Good(t *testing.T) { + c := New() + done := make(chan bool, 1) + c.Error().SafeGo(func() { + done <- true + }) + assert.True(t, <-done) +} + +func TestErrorPanic_SafeGo_Panic_Good(t *testing.T) { + c := New() + done := make(chan bool, 1) + c.Error().SafeGo(func() { + defer func() { done <- true }() + panic("caught by SafeGo") + }) + // SafeGo recovers — goroutine completes without crashing the process + <-done +} + +// --- Standard Library Wrappers --- + +func TestIs_Good(t *testing.T) { + target := errors.New("target") + wrapped := Wrap(target, "op", "msg") + assert.True(t, Is(wrapped, target)) +} + +func TestAs_Good(t *testing.T) { + err := E("op", "msg", nil) + var e *Err + assert.True(t, As(err, &e)) + assert.Equal(t, "op", e.Op) +} + +func TestNewError_Good(t *testing.T) { + err := NewError("simple error") + assert.Equal(t, "simple error", err.Error()) +} + +func TestJoin_Good(t *testing.T) { + e1 := errors.New("first") + e2 := errors.New("second") + joined := Join(e1, e2) + assert.ErrorIs(t, joined, e1) + assert.ErrorIs(t, joined, e2) +} diff --git a/tests/fs_test.go b/tests/fs_test.go new file mode 100644 index 0000000..5b86edd --- /dev/null +++ b/tests/fs_test.go @@ -0,0 +1,211 @@ +package core_test + +import ( + "path/filepath" + "testing" + + . "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" +) + +// --- Fs (Sandboxed Filesystem) --- + +func TestFs_WriteRead_Good(t *testing.T) { + dir := t.TempDir() + c := New() + + path := filepath.Join(dir, "test.txt") + err := c.Fs().Write(path, "hello core") + assert.NoError(t, err) + + content, err := c.Fs().Read(path) + assert.NoError(t, err) + assert.Equal(t, "hello core", content) +} + +func TestFs_Read_Bad(t *testing.T) { + c := New() + _, err := c.Fs().Read("/nonexistent/path/to/file.txt") + assert.Error(t, err) +} + +func TestFs_EnsureDir_Good(t *testing.T) { + dir := t.TempDir() + c := New() + path := filepath.Join(dir, "sub", "dir") + err := c.Fs().EnsureDir(path) + assert.NoError(t, err) + assert.True(t, c.Fs().IsDir(path)) +} + +func TestFs_IsDir_Good(t *testing.T) { + c := New() + dir := t.TempDir() + assert.True(t, c.Fs().IsDir(dir)) + assert.False(t, c.Fs().IsDir(filepath.Join(dir, "nonexistent"))) + assert.False(t, c.Fs().IsDir("")) +} + +func TestFs_IsFile_Good(t *testing.T) { + dir := t.TempDir() + c := New() + + path := filepath.Join(dir, "test.txt") + c.Fs().Write(path, "data") + + assert.True(t, c.Fs().IsFile(path)) + assert.False(t, c.Fs().IsFile(dir)) // dir, not file + assert.False(t, c.Fs().IsFile("")) +} + +func TestFs_Exists_Good(t *testing.T) { + dir := t.TempDir() + c := New() + + path := filepath.Join(dir, "exists.txt") + c.Fs().Write(path, "yes") + + assert.True(t, c.Fs().Exists(path)) + assert.True(t, c.Fs().Exists(dir)) + assert.False(t, c.Fs().Exists(filepath.Join(dir, "nope"))) +} + +func TestFs_List_Good(t *testing.T) { + dir := t.TempDir() + c := New() + + c.Fs().Write(filepath.Join(dir, "a.txt"), "a") + c.Fs().Write(filepath.Join(dir, "b.txt"), "b") + + entries, err := c.Fs().List(dir) + assert.NoError(t, err) + assert.Len(t, entries, 2) +} + +func TestFs_Stat_Good(t *testing.T) { + dir := t.TempDir() + c := New() + + path := filepath.Join(dir, "stat.txt") + c.Fs().Write(path, "data") + + info, err := c.Fs().Stat(path) + assert.NoError(t, err) + assert.Equal(t, "stat.txt", info.Name()) +} + +func TestFs_Open_Good(t *testing.T) { + dir := t.TempDir() + c := New() + + path := filepath.Join(dir, "open.txt") + c.Fs().Write(path, "content") + + file, err := c.Fs().Open(path) + assert.NoError(t, err) + file.Close() +} + +func TestFs_Create_Good(t *testing.T) { + dir := t.TempDir() + c := New() + + path := filepath.Join(dir, "sub", "created.txt") + w, err := c.Fs().Create(path) + assert.NoError(t, err) + w.Write([]byte("hello")) + w.Close() + + content, _ := c.Fs().Read(path) + assert.Equal(t, "hello", content) +} + +func TestFs_Append_Good(t *testing.T) { + dir := t.TempDir() + c := New() + + path := filepath.Join(dir, "append.txt") + c.Fs().Write(path, "first") + + w, err := c.Fs().Append(path) + assert.NoError(t, err) + w.Write([]byte(" second")) + w.Close() + + content, _ := c.Fs().Read(path) + assert.Equal(t, "first second", content) +} + +func TestFs_ReadStream_Good(t *testing.T) { + dir := t.TempDir() + c := New() + + path := filepath.Join(dir, "stream.txt") + c.Fs().Write(path, "streamed") + + r, err := c.Fs().ReadStream(path) + assert.NoError(t, err) + r.Close() +} + +func TestFs_WriteStream_Good(t *testing.T) { + dir := t.TempDir() + c := New() + + path := filepath.Join(dir, "sub", "ws.txt") + w, err := c.Fs().WriteStream(path) + assert.NoError(t, err) + w.Write([]byte("stream")) + w.Close() +} + +func TestFs_Delete_Good(t *testing.T) { + dir := t.TempDir() + c := New() + + path := filepath.Join(dir, "delete.txt") + c.Fs().Write(path, "gone") + + err := c.Fs().Delete(path) + assert.NoError(t, err) + assert.False(t, c.Fs().Exists(path)) +} + +func TestFs_DeleteAll_Good(t *testing.T) { + dir := t.TempDir() + c := New() + + sub := filepath.Join(dir, "deep", "nested") + c.Fs().EnsureDir(sub) + c.Fs().Write(filepath.Join(sub, "file.txt"), "data") + + err := c.Fs().DeleteAll(filepath.Join(dir, "deep")) + assert.NoError(t, err) + assert.False(t, c.Fs().Exists(filepath.Join(dir, "deep"))) +} + +func TestFs_Rename_Good(t *testing.T) { + dir := t.TempDir() + c := New() + + old := filepath.Join(dir, "old.txt") + new := filepath.Join(dir, "new.txt") + c.Fs().Write(old, "data") + + err := c.Fs().Rename(old, new) + assert.NoError(t, err) + assert.False(t, c.Fs().Exists(old)) + assert.True(t, c.Fs().Exists(new)) +} + +func TestFs_WriteMode_Good(t *testing.T) { + dir := t.TempDir() + c := New() + + path := filepath.Join(dir, "secret.txt") + err := c.Fs().WriteMode(path, "secret", 0600) + assert.NoError(t, err) + + info, _ := c.Fs().Stat(path) + assert.Equal(t, "secret.txt", info.Name()) +} diff --git a/tests/fuzz_test.go b/tests/fuzz_test.go deleted file mode 100644 index 1a5501b..0000000 --- a/tests/fuzz_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package core_test - -import ( - . "forge.lthn.ai/core/go/pkg/core" - "errors" - "testing" -) - -// FuzzE exercises the E() error constructor with arbitrary input. -func FuzzE(f *testing.F) { - f.Add("svc.Method", "something broke", true) - f.Add("", "", false) - f.Add("a.b.c.d.e.f", "unicode: \u00e9\u00e8\u00ea", true) - - f.Fuzz(func(t *testing.T, op, msg string, withErr bool) { - var underlying error - if withErr { - underlying = errors.New("wrapped") - } - - e := E(op, msg, underlying) - if e == nil { - t.Fatal("E() returned nil") - } - - s := e.Error() - if s == "" && (op != "" || msg != "") { - t.Fatal("Error() returned empty string for non-empty op/msg") - } - - // Round-trip: Unwrap should return the underlying error - var coreErr *Err - if !errors.As(e, &coreErr) { - t.Fatal("errors.As failed for *Err") - } - if withErr && coreErr.Unwrap() == nil { - t.Fatal("Unwrap() returned nil with underlying error") - } - if !withErr && coreErr.Unwrap() != nil { - t.Fatal("Unwrap() returned non-nil without underlying error") - } - }) -} - -// FuzzServiceRegistration exercises service registration with arbitrary names. -func FuzzServiceRegistration(f *testing.F) { - f.Add("myservice") - f.Add("") - f.Add("a/b/c") - f.Add("service with spaces") - f.Add("service\x00null") - - f.Fuzz(func(t *testing.T, name string) { - c, _ := New() - - err := c.RegisterService(name, struct{}{}) - if name == "" { - if err == nil { - t.Fatal("expected error for empty name") - } - return - } - if err != nil { - t.Fatalf("unexpected error for name %q: %v", name, err) - } - - // Retrieve should return the same service - got := c.Service(name) - if got == nil { - t.Fatalf("service %q not found after registration", name) - } - - // Duplicate registration should fail - err = c.RegisterService(name, struct{}{}) - if err == nil { - t.Fatalf("expected duplicate error for name %q", name) - } - }) -} - -// FuzzMessageDispatch exercises action dispatch with concurrent registrations. -func FuzzMessageDispatch(f *testing.F) { - f.Add("hello") - f.Add("") - f.Add("test\nmultiline") - - f.Fuzz(func(t *testing.T, payload string) { - c, _ := New() - - var received string - c.IPC().RegisterAction(func(_ *Core, msg Message) error { - received = msg.(string) - return nil - }) - - err := c.IPC().Action(payload) - if err != nil { - t.Fatalf("action dispatch failed: %v", err) - } - if received != payload { - t.Fatalf("got %q, want %q", received, payload) - } - }) -} diff --git a/tests/i18n_test.go b/tests/i18n_test.go new file mode 100644 index 0000000..a6215cb --- /dev/null +++ b/tests/i18n_test.go @@ -0,0 +1,26 @@ +package core_test + +import ( + "testing" + + . "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" +) + +func TestI18n_Good(t *testing.T) { + c := New() + assert.NotNil(t, c.I18n()) +} + +func TestI18n_AddLocales_Good(t *testing.T) { + c := New() + // AddLocales takes *Embed mounts — mount testdata and add it + r := c.Data().New(Options{ + {K: "name", V: "lang"}, + {K: "source", V: testFS}, + {K: "path", V: "testdata"}, + }) + if r.OK { + c.I18n().AddLocales(r.Value) + } +} diff --git a/tests/ipc_test.go b/tests/ipc_test.go index cb0559c..fc72ff1 100644 --- a/tests/ipc_test.go +++ b/tests/ipc_test.go @@ -1,121 +1,97 @@ package core_test - import ( - . "forge.lthn.ai/core/go/pkg/core" - "errors" "testing" - "time" + . "forge.lthn.ai/core/go/pkg/core" "github.com/stretchr/testify/assert" ) -type IPCTestQuery struct{ Value string } -type IPCTestTask struct{ Value string } +// --- IPC: Actions --- -func TestIPC_Query(t *testing.T) { - c, _ := New() +type testMessage struct{ payload string } - // No handler - res, handled, err := c.QUERY(IPCTestQuery{}) - assert.False(t, handled) - assert.Nil(t, res) - assert.Nil(t, err) - - // With handler - c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { - if tq, ok := q.(IPCTestQuery); ok { - return tq.Value + "-response", true, nil - } - return nil, false, nil - }) - - res, handled, err = c.QUERY(IPCTestQuery{Value: "test"}) - assert.True(t, handled) - assert.Nil(t, err) - assert.Equal(t, "test-response", res) -} - -func TestIPC_QueryAll(t *testing.T) { - c, _ := New() - - c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { - return "h1", true, nil - }) - c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { - return "h2", true, nil - }) - - results, err := c.QUERYALL(IPCTestQuery{}) - assert.Nil(t, err) - assert.Len(t, results, 2) - assert.Contains(t, results, "h1") - assert.Contains(t, results, "h2") -} - -func TestIPC_Perform(t *testing.T) { - c, _ := New() - - c.RegisterTask(func(c *Core, task Task) (any, bool, error) { - if tt, ok := task.(IPCTestTask); ok { - if tt.Value == "error" { - return nil, true, errors.New("task error") - } - return "done", true, nil - } - return nil, false, nil - }) - - // Success - res, handled, err := c.PERFORM(IPCTestTask{Value: "run"}) - assert.True(t, handled) - assert.Nil(t, err) - assert.Equal(t, "done", res) - - // Error - res, handled, err = c.PERFORM(IPCTestTask{Value: "error"}) - assert.True(t, handled) - assert.Error(t, err) - assert.Nil(t, res) -} - -func TestIPC_PerformAsync(t *testing.T) { - c, _ := New() - - type AsyncResult struct { - TaskID string - Result any - Error error - } - done := make(chan AsyncResult, 1) - - c.RegisterTask(func(c *Core, task Task) (any, bool, error) { - if tt, ok := task.(IPCTestTask); ok { - return tt.Value + "-done", true, nil - } - return nil, false, nil - }) - - c.RegisterAction(func(c *Core, msg Message) error { - if m, ok := msg.(ActionTaskCompleted); ok { - done <- AsyncResult{ - TaskID: m.TaskID, - Result: m.Result, - Error: m.Error, - } - } +func TestAction_Good(t *testing.T) { + c := New() + var received Message + c.RegisterAction(func(_ *Core, msg Message) error { + received = msg return nil }) - - taskID := c.PerformAsync(IPCTestTask{Value: "async"}) - assert.NotEmpty(t, taskID) - - select { - case res := <-done: - assert.Equal(t, taskID, res.TaskID) - assert.Equal(t, "async-done", res.Result) - assert.Nil(t, res.Error) - case <-time.After(time.Second): - t.Fatal("timed out waiting for task completion") - } + err := c.ACTION(testMessage{payload: "hello"}) + assert.NoError(t, err) + assert.Equal(t, testMessage{payload: "hello"}, received) +} + +func TestAction_Multiple_Good(t *testing.T) { + c := New() + count := 0 + handler := func(_ *Core, _ Message) error { count++; return nil } + c.RegisterActions(handler, handler, handler) + _ = c.ACTION(nil) + assert.Equal(t, 3, count) +} + +func TestAction_None_Good(t *testing.T) { + c := New() + // No handlers registered — should not error + err := c.ACTION(nil) + assert.NoError(t, err) +} + +// --- IPC: Queries --- + +func TestQuery_Good(t *testing.T) { + c := New() + c.RegisterQuery(func(_ *Core, q Query) (any, bool, error) { + if q == "ping" { + return "pong", true, nil + } + return nil, false, nil + }) + result, handled, err := c.QUERY("ping") + assert.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "pong", result) +} + +func TestQuery_Unhandled_Good(t *testing.T) { + c := New() + c.RegisterQuery(func(_ *Core, q Query) (any, bool, error) { + return nil, false, nil + }) + _, handled, err := c.QUERY("unknown") + assert.NoError(t, err) + assert.False(t, handled) +} + +func TestQueryAll_Good(t *testing.T) { + c := New() + c.RegisterQuery(func(_ *Core, _ Query) (any, bool, error) { + return "a", true, nil + }) + c.RegisterQuery(func(_ *Core, _ Query) (any, bool, error) { + return "b", true, nil + }) + results, err := c.QUERYALL("anything") + assert.NoError(t, err) + assert.Len(t, results, 2) + assert.Contains(t, results, "a") + assert.Contains(t, results, "b") +} + +// --- IPC: Tasks --- + +func TestPerform_Good(t *testing.T) { + c := New() + c.RegisterTask(func(_ *Core, t Task) (any, bool, error) { + if t == "compute" { + return 42, true, nil + } + return nil, false, nil + }) + result, handled, err := c.PERFORM("compute") + assert.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, 42, result) } diff --git a/tests/lock_test.go b/tests/lock_test.go new file mode 100644 index 0000000..5d36d47 --- /dev/null +++ b/tests/lock_test.go @@ -0,0 +1,69 @@ +package core_test + +import ( + "testing" + + . "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" +) + +// --- Lock (Named Mutexes) --- + +func TestLock_Good(t *testing.T) { + c := New() + lock := c.Lock("test") + assert.NotNil(t, lock) + assert.NotNil(t, lock.Mu) +} + +func TestLock_SameName_Good(t *testing.T) { + c := New() + l1 := c.Lock("shared") + l2 := c.Lock("shared") + // Same name returns same lock + assert.Equal(t, l1, l2) +} + +func TestLock_DifferentName_Good(t *testing.T) { + c := New() + l1 := c.Lock("a") + l2 := c.Lock("b") + assert.NotEqual(t, l1, l2) +} + +func TestLock_MutexWorks_Good(t *testing.T) { + c := New() + lock := c.Lock("counter") + counter := 0 + lock.Mu.Lock() + counter++ + lock.Mu.Unlock() + assert.Equal(t, 1, counter) +} + +func TestLockEnable_Good(t *testing.T) { + c := New() + c.Service("early", struct{}{}) + c.LockEnable() + c.LockApply() + + // After lock, registration should fail + result := c.Service("late", struct{}{}) + assert.NotNil(t, result) +} + +func TestStartables_Good(t *testing.T) { + c := New() + svc := &testService{name: "s"} + c.Service("s", svc) + startables := c.Startables() + assert.Len(t, startables, 1) +} + +func TestStoppables_Good(t *testing.T) { + c := New() + svc := &testService{name: "s"} + c.Service("s", svc) + stoppables := c.Stoppables() + assert.Len(t, stoppables, 1) +} diff --git a/tests/log_test.go b/tests/log_test.go new file mode 100644 index 0000000..b494145 --- /dev/null +++ b/tests/log_test.go @@ -0,0 +1,37 @@ +package core_test + +import ( + "testing" + + . "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" +) + +// --- Log (Structured Logger) --- + +func TestLog_New_Good(t *testing.T) { + l := NewLog(LogOpts{Level: LevelInfo}) + assert.NotNil(t, l) +} + +func TestLog_Levels_Good(t *testing.T) { + for _, level := range []Level{LevelDebug, LevelInfo, LevelWarn, LevelError} { + l := NewLog(LogOpts{Level: level}) + l.Debug("debug msg") + l.Info("info msg") + l.Warn("warn msg") + l.Error("error msg") + } +} + +func TestLog_CoreLog_Good(t *testing.T) { + c := New() + assert.NotNil(t, c.Log()) +} + +func TestLog_ErrorSink_Interface(t *testing.T) { + l := NewLog(LogOpts{Level: LevelInfo}) + var sink ErrorSink = l + sink.Error("test", "key", "val") + sink.Warn("test", "key", "val") +} diff --git a/tests/message_bus_test.go b/tests/message_bus_test.go deleted file mode 100644 index 0a46031..0000000 --- a/tests/message_bus_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package core_test - -import ( - . "forge.lthn.ai/core/go/pkg/core" - "errors" - "sync" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestBus_Action_Good(t *testing.T) { - c, _ := New() - - var received []Message - c.IPC().RegisterAction(func(_ *Core, msg Message) error { - received = append(received, msg) - return nil - }) - c.IPC().RegisterAction(func(_ *Core, msg Message) error { - received = append(received, msg) - return nil - }) - - err := c.IPC().Action("hello") - assert.NoError(t, err) - assert.Len(t, received, 2) -} - -func TestBus_Action_Bad(t *testing.T) { - c, _ := New() - - err1 := errors.New("handler1 failed") - err2 := errors.New("handler2 failed") - - c.IPC().RegisterAction(func(_ *Core, msg Message) error { return err1 }) - c.IPC().RegisterAction(func(_ *Core, msg Message) error { return nil }) - c.IPC().RegisterAction(func(_ *Core, msg Message) error { return err2 }) - - err := c.IPC().Action("test") - assert.Error(t, err) - assert.ErrorIs(t, err, err1) - assert.ErrorIs(t, err, err2) -} - -func TestBus_RegisterAction_Good(t *testing.T) { - c, _ := New() - - var coreRef *Core - c.IPC().RegisterAction(func(core *Core, msg Message) error { - coreRef = core - return nil - }) - - _ = c.IPC().Action(nil) - assert.Same(t, c, coreRef, "handler should receive the Core reference") -} - -func TestBus_Query_Good(t *testing.T) { - c, _ := New() - - c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) { - return "first", true, nil - }) - - result, handled, err := c.IPC().Query(TestQuery{Value: "test"}) - assert.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, "first", result) -} - -func TestBus_QueryAll_Good(t *testing.T) { - c, _ := New() - - c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) { - return "a", true, nil - }) - c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) { - return nil, false, nil // skips - }) - c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) { - return "b", true, nil - }) - - results, err := c.IPC().QueryAll(TestQuery{}) - assert.NoError(t, err) - assert.Equal(t, []any{"a", "b"}, results) -} - -func TestBus_Perform_Good(t *testing.T) { - c, _ := New() - - c.IPC().RegisterTask(func(_ *Core, t Task) (any, bool, error) { - return "done", true, nil - }) - - result, handled, err := c.IPC().Perform(TestTask{}) - assert.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, "done", result) -} - -func TestBus_ConcurrentAccess_Good(t *testing.T) { - c, _ := New() - - var wg sync.WaitGroup - const goroutines = 20 - - // Concurrent register + dispatch - for i := 0; i < goroutines; i++ { - wg.Add(2) - go func() { - defer wg.Done() - c.IPC().RegisterAction(func(_ *Core, msg Message) error { return nil }) - }() - go func() { - defer wg.Done() - _ = c.IPC().Action("ping") - }() - } - - for i := 0; i < goroutines; i++ { - wg.Add(2) - go func() { - defer wg.Done() - c.IPC().RegisterQuery(func(_ *Core, q Query) (any, bool, error) { return nil, false, nil }) - }() - go func() { - defer wg.Done() - _, _ = c.IPC().QueryAll(TestQuery{}) - }() - } - - for i := 0; i < goroutines; i++ { - wg.Add(2) - go func() { - defer wg.Done() - c.IPC().RegisterTask(func(_ *Core, t Task) (any, bool, error) { return nil, false, nil }) - }() - go func() { - defer wg.Done() - _, _, _ = c.IPC().Perform(TestTask{}) - }() - } - - wg.Wait() -} - -func TestBus_Action_NoHandlers(t *testing.T) { - c, _ := New() - err := c.IPC().Action("no one listening") - assert.NoError(t, err) -} - -func TestBus_Query_NoHandlers(t *testing.T) { - c, _ := New() - result, handled, err := c.IPC().Query(TestQuery{}) - assert.NoError(t, err) - assert.False(t, handled) - assert.Nil(t, result) -} - -func TestBus_QueryAll_NoHandlers(t *testing.T) { - c, _ := New() - results, err := c.IPC().QueryAll(TestQuery{}) - assert.NoError(t, err) - assert.Empty(t, results) -} - -func TestBus_Perform_NoHandlers(t *testing.T) { - c, _ := New() - result, handled, err := c.IPC().Perform(TestTask{}) - assert.NoError(t, err) - assert.False(t, handled) - assert.Nil(t, result) -} diff --git a/tests/options_test.go b/tests/options_test.go new file mode 100644 index 0000000..c8331b5 --- /dev/null +++ b/tests/options_test.go @@ -0,0 +1,94 @@ +package core_test + +import ( + "testing" + + . "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" +) + +// --- Option / Options --- + +func TestOptions_Get_Good(t *testing.T) { + opts := Options{ + {K: "name", V: "brain"}, + {K: "port", V: 8080}, + } + val, ok := opts.Get("name") + assert.True(t, ok) + assert.Equal(t, "brain", val) +} + +func TestOptions_Get_Bad(t *testing.T) { + opts := Options{{K: "name", V: "brain"}} + val, ok := opts.Get("missing") + assert.False(t, ok) + assert.Nil(t, val) +} + +func TestOptions_Has_Good(t *testing.T) { + opts := Options{{K: "debug", V: true}} + assert.True(t, opts.Has("debug")) + assert.False(t, opts.Has("missing")) +} + +func TestOptions_String_Good(t *testing.T) { + opts := Options{{K: "name", V: "brain"}} + assert.Equal(t, "brain", opts.String("name")) +} + +func TestOptions_String_Bad(t *testing.T) { + opts := Options{{K: "port", V: 8080}} + // Wrong type — returns empty string + assert.Equal(t, "", opts.String("port")) + // Missing key — returns empty string + assert.Equal(t, "", opts.String("missing")) +} + +func TestOptions_Int_Good(t *testing.T) { + opts := Options{{K: "port", V: 8080}} + assert.Equal(t, 8080, opts.Int("port")) +} + +func TestOptions_Int_Bad(t *testing.T) { + opts := Options{{K: "name", V: "brain"}} + assert.Equal(t, 0, opts.Int("name")) + assert.Equal(t, 0, opts.Int("missing")) +} + +func TestOptions_Bool_Good(t *testing.T) { + opts := Options{{K: "debug", V: true}} + assert.True(t, opts.Bool("debug")) +} + +func TestOptions_Bool_Bad(t *testing.T) { + opts := Options{{K: "name", V: "brain"}} + assert.False(t, opts.Bool("name")) + assert.False(t, opts.Bool("missing")) +} + +func TestOptions_TypedStruct_Good(t *testing.T) { + // Packages plug typed structs into Option.V + type BrainConfig struct { + Name string + OllamaURL string + Collection string + } + cfg := BrainConfig{Name: "brain", OllamaURL: "http://localhost:11434", Collection: "openbrain"} + opts := Options{{K: "config", V: cfg}} + + val, ok := opts.Get("config") + assert.True(t, ok) + bc, ok := val.(BrainConfig) + assert.True(t, ok) + assert.Equal(t, "brain", bc.Name) + assert.Equal(t, "http://localhost:11434", bc.OllamaURL) +} + +func TestOptions_Empty_Good(t *testing.T) { + opts := Options{} + assert.False(t, opts.Has("anything")) + assert.Equal(t, "", opts.String("anything")) + assert.Equal(t, 0, opts.Int("anything")) + assert.False(t, opts.Bool("anything")) +} diff --git a/tests/query_test.go b/tests/query_test.go deleted file mode 100644 index e4118c2..0000000 --- a/tests/query_test.go +++ /dev/null @@ -1,203 +0,0 @@ -package core_test - - -import ( - . "forge.lthn.ai/core/go/pkg/core" - "errors" - "testing" - - "github.com/stretchr/testify/assert" -) - -type TestQuery struct { - Value string -} - -type TestTask struct { - Value string -} - -func TestCore_QUERY_Good(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - // Register a handler that responds to TestQuery - c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { - if tq, ok := q.(TestQuery); ok { - return "result-" + tq.Value, true, nil - } - return nil, false, nil - }) - - result, handled, err := c.QUERY(TestQuery{Value: "test"}) - assert.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, "result-test", result) -} - -func TestCore_QUERY_NotHandled(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - // No handlers registered - result, handled, err := c.QUERY(TestQuery{Value: "test"}) - assert.NoError(t, err) - assert.False(t, handled) - assert.Nil(t, result) -} - -func TestCore_QUERY_FirstResponder(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - // First handler responds - c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { - return "first", true, nil - }) - - // Second handler would respond but shouldn't be called - c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { - return "second", true, nil - }) - - result, handled, err := c.QUERY(TestQuery{}) - assert.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, "first", result) -} - -func TestCore_QUERY_SkipsNonHandlers(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - // First handler doesn't handle - c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { - return nil, false, nil - }) - - // Second handler responds - c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { - return "second", true, nil - }) - - result, handled, err := c.QUERY(TestQuery{}) - assert.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, "second", result) -} - -func TestCore_QUERYALL_Good(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - // Multiple handlers respond - c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { - return "first", true, nil - }) - - c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { - return "second", true, nil - }) - - c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { - return nil, false, nil // Doesn't handle - }) - - results, err := c.QUERYALL(TestQuery{}) - assert.NoError(t, err) - assert.Len(t, results, 2) - assert.Contains(t, results, "first") - assert.Contains(t, results, "second") -} - -func TestCore_QUERYALL_AggregatesErrors(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - err1 := errors.New("error1") - err2 := errors.New("error2") - - c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { - return "result1", true, err1 - }) - - c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { - return "result2", true, err2 - }) - - results, err := c.QUERYALL(TestQuery{}) - assert.Error(t, err) - assert.ErrorIs(t, err, err1) - assert.ErrorIs(t, err, err2) - assert.Len(t, results, 2) -} - -func TestCore_PERFORM_Good(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - executed := false - c.RegisterTask(func(c *Core, t Task) (any, bool, error) { - if tt, ok := t.(TestTask); ok { - executed = true - return "done-" + tt.Value, true, nil - } - return nil, false, nil - }) - - result, handled, err := c.PERFORM(TestTask{Value: "work"}) - assert.NoError(t, err) - assert.True(t, handled) - assert.True(t, executed) - assert.Equal(t, "done-work", result) -} - -func TestCore_PERFORM_NotHandled(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - // No handlers registered - result, handled, err := c.PERFORM(TestTask{Value: "work"}) - assert.NoError(t, err) - assert.False(t, handled) - assert.Nil(t, result) -} - -func TestCore_PERFORM_FirstResponder(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - callCount := 0 - - c.RegisterTask(func(c *Core, t Task) (any, bool, error) { - callCount++ - return "first", true, nil - }) - - c.RegisterTask(func(c *Core, t Task) (any, bool, error) { - callCount++ - return "second", true, nil - }) - - result, handled, err := c.PERFORM(TestTask{}) - assert.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, "first", result) - assert.Equal(t, 1, callCount) // Only first handler called -} - -func TestCore_PERFORM_WithError(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - expectedErr := errors.New("task failed") - c.RegisterTask(func(c *Core, t Task) (any, bool, error) { - return nil, true, expectedErr - }) - - result, handled, err := c.PERFORM(TestTask{}) - assert.Error(t, err) - assert.ErrorIs(t, err, expectedErr) - assert.True(t, handled) - assert.Nil(t, result) -} diff --git a/tests/runtime_pkg_extra_test.go b/tests/runtime_pkg_extra_test.go deleted file mode 100644 index ffa60bb..0000000 --- a/tests/runtime_pkg_extra_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package core_test - - -import ( - . "forge.lthn.ai/core/go/pkg/core" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNewWithFactories_EmptyName(t *testing.T) { - factories := map[string]ServiceFactory{ - "": func() (any, error) { - return &MockService{Name: "test"}, nil - }, - } - _, err := NewWithFactories(nil, factories) - assert.Error(t, err) - assert.Contains(t, err.Error(), "service name cannot be empty") -} diff --git a/tests/runtime_pkg_test.go b/tests/runtime_pkg_test.go deleted file mode 100644 index 4970810..0000000 --- a/tests/runtime_pkg_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package core_test - -import ( - . "forge.lthn.ai/core/go/pkg/core" - "context" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNewRuntime(t *testing.T) { - testCases := []struct { - name string - app any - factories map[string]ServiceFactory - expectErr bool - expectErrStr string - checkRuntime func(*testing.T, *Runtime) - }{ - { - name: "Good path", - app: nil, - factories: map[string]ServiceFactory{}, - expectErr: false, - checkRuntime: func(t *testing.T, rt *Runtime) { - assert.NotNil(t, rt) - assert.NotNil(t, rt.Core) - }, - }, - { - name: "With non-nil app", - app: &mockApp{}, - factories: map[string]ServiceFactory{}, - expectErr: false, - checkRuntime: func(t *testing.T, rt *Runtime) { - assert.NotNil(t, rt) - assert.NotNil(t, rt.Core) - assert.NotNil(t, rt.Core.App) - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - rt, err := NewRuntime(tc.app) - - if tc.expectErr { - assert.Error(t, err) - assert.Contains(t, err.Error(), tc.expectErrStr) - assert.Nil(t, rt) - } else { - assert.NoError(t, err) - if tc.checkRuntime != nil { - tc.checkRuntime(t, rt) - } - } - }) - } -} - -func TestNewWithFactories_Good(t *testing.T) { - factories := map[string]ServiceFactory{ - "test": func() (any, error) { - return &MockService{Name: "test"}, nil - }, - } - rt, err := NewWithFactories(nil, factories) - assert.NoError(t, err) - assert.NotNil(t, rt) - svc := rt.Core.Service("test") - assert.NotNil(t, svc) - mockSvc, ok := svc.(*MockService) - assert.True(t, ok) - assert.Equal(t, "test", mockSvc.Name) -} - -func TestNewWithFactories_Bad(t *testing.T) { - factories := map[string]ServiceFactory{ - "test": func() (any, error) { - return nil, assert.AnError - }, - } - _, err := NewWithFactories(nil, factories) - assert.Error(t, err) - assert.ErrorIs(t, err, assert.AnError) -} - -func TestNewWithFactories_Ugly(t *testing.T) { - factories := map[string]ServiceFactory{ - "test": nil, - } - _, err := NewWithFactories(nil, factories) - assert.Error(t, err) - assert.Contains(t, err.Error(), "factory is nil") -} - -func TestRuntime_Lifecycle_Good(t *testing.T) { - rt, err := NewRuntime(nil) - assert.NoError(t, err) - assert.NotNil(t, rt) - - // ServiceName - assert.Equal(t, "Core", rt.ServiceName()) - - // ServiceStartup & ServiceShutdown - // These are simple wrappers around the core methods, which are tested in core_test.go. - // We call them here to ensure coverage. - rt.ServiceStartup(context.TODO(), nil) - rt.ServiceShutdown(context.TODO()) - - // Test shutdown with nil core - rt.Core = nil - rt.ServiceShutdown(context.TODO()) -} - -func TestNewServiceRuntime_Good(t *testing.T) { - c, err := New() - assert.NoError(t, err) - - sr := NewServiceRuntime(c, "test options") - assert.NotNil(t, sr) - assert.Equal(t, c, sr.Core()) - - // We can't directly test sr.Cfg() without a registered config service, - // as it will panic. - assert.Panics(t, func() { - sr.Config() - }) -} diff --git a/tests/runtime_test.go b/tests/runtime_test.go new file mode 100644 index 0000000..aa4bb49 --- /dev/null +++ b/tests/runtime_test.go @@ -0,0 +1,97 @@ +package core_test + +import ( + "context" + "errors" + "testing" + + . "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" +) + +// --- ServiceRuntime --- + +type testOpts struct { + URL string + Timeout int +} + +type runtimeService struct { + *ServiceRuntime[testOpts] +} + +func TestServiceRuntime_Good(t *testing.T) { + c := New() + opts := testOpts{URL: "https://api.lthn.ai", Timeout: 30} + rt := NewServiceRuntime(c, opts) + + assert.Equal(t, c, rt.Core()) + assert.Equal(t, opts, rt.Opts()) + assert.Equal(t, "https://api.lthn.ai", rt.Opts().URL) + assert.Equal(t, 30, rt.Opts().Timeout) + assert.NotNil(t, rt.Config()) +} + +func TestServiceRuntime_Embedded_Good(t *testing.T) { + c := New() + svc := &runtimeService{ + ServiceRuntime: NewServiceRuntime(c, testOpts{URL: "https://lthn.sh"}), + } + assert.Equal(t, "https://lthn.sh", svc.Opts().URL) +} + +// --- NewWithFactories --- + +func TestNewWithFactories_Good(t *testing.T) { + rt, err := NewWithFactories(nil, map[string]ServiceFactory{ + "svc1": func() (any, error) { return &testService{name: "one"}, nil }, + "svc2": func() (any, error) { return &testService{name: "two"}, nil }, + }) + assert.NoError(t, err) + assert.NotNil(t, rt) + assert.NotNil(t, rt.Core) + + svc := rt.Core.Service("svc1") + assert.NotNil(t, svc) + ts, ok := svc.(*testService) + assert.True(t, ok) + assert.Equal(t, "one", ts.name) +} + +func TestNewWithFactories_Bad(t *testing.T) { + // Nil factory + _, err := NewWithFactories(nil, map[string]ServiceFactory{ + "bad": nil, + }) + assert.Error(t, err) + + // Factory returns error + _, err = NewWithFactories(nil, map[string]ServiceFactory{ + "fail": func() (any, error) { return nil, errors.New("factory failed") }, + }) + assert.Error(t, err) +} + +func TestNewRuntime_Good(t *testing.T) { + rt, err := NewRuntime(nil) + assert.NoError(t, err) + assert.NotNil(t, rt) +} + +// --- Lifecycle via Runtime --- + +func TestRuntime_Lifecycle_Good(t *testing.T) { + svc := &testService{name: "lifecycle"} + rt, err := NewWithFactories(nil, map[string]ServiceFactory{ + "test": func() (any, error) { return svc, nil }, + }) + assert.NoError(t, err) + + err = rt.ServiceStartup(context.Background(), nil) + assert.NoError(t, err) + assert.True(t, svc.started) + + err = rt.ServiceShutdown(context.Background()) + assert.NoError(t, err) + assert.True(t, svc.stopped) +} diff --git a/tests/service_manager_test.go b/tests/service_manager_test.go deleted file mode 100644 index bfd1e99..0000000 --- a/tests/service_manager_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package core_test - -import ( - . "forge.lthn.ai/core/go/pkg/core" - "context" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestServiceManager_RegisterService_Good(t *testing.T) { - c, _ := New() - - err := c.RegisterService("svc1", &MockService{Name: "one"}) - assert.NoError(t, err) - - got := c.Service("svc1") - assert.NotNil(t, got) - assert.Equal(t, "one", got.(*MockService).GetName()) -} - -func TestServiceManager_RegisterService_Bad(t *testing.T) { - c, _ := New() - - // Empty name - err := c.RegisterService("", &MockService{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "cannot be empty") - - // Duplicate - err = c.RegisterService("dup", &MockService{}) - assert.NoError(t, err) - err = c.RegisterService("dup", &MockService{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "already registered") - - // Locked - c2, _ := New(WithServiceLock()) - err = c2.RegisterService("late", &MockService{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "serviceLock") -} - -func TestServiceManager_ServiceNotFound_Good(t *testing.T) { - c, _ := New() - assert.Nil(t, c.Service("nonexistent")) -} - -func TestServiceManager_Startables_Good(t *testing.T) { - s1 := &MockStartable{} - s2 := &MockStartable{} - - c, _ := New( - WithName("s1", func(_ *Core) (any, error) { return s1, nil }), - WithName("s2", func(_ *Core) (any, error) { return s2, nil }), - ) - - // Startup should call both - err := c.ServiceStartup(context.Background(), nil) - assert.NoError(t, err) -} - -func TestServiceManager_Stoppables_Good(t *testing.T) { - s1 := &MockStoppable{} - s2 := &MockStoppable{} - - c, _ := New( - WithName("s1", func(_ *Core) (any, error) { return s1, nil }), - WithName("s2", func(_ *Core) (any, error) { return s2, nil }), - ) - - // Shutdown should call both - err := c.ServiceShutdown(context.Background()) - assert.NoError(t, err) -} - -func TestServiceManager_Lock_Good(t *testing.T) { - c, _ := New( - WithName("early", func(_ *Core) (any, error) { return &MockService{}, nil }), - WithServiceLock(), - ) - - // Register after lock — should fail - err := c.RegisterService("late", &MockService{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "serviceLock") - - // Early service is still accessible - assert.NotNil(t, c.Service("early")) -} - -func TestServiceManager_LockNotAppliedWithoutEnable_Good(t *testing.T) { - // No WithServiceLock — should allow registration after New() - c, _ := New() - err := c.RegisterService("svc", &MockService{}) - assert.NoError(t, err) -} - -type mockFullLifecycle struct{} - -func (m *mockFullLifecycle) OnStartup(_ context.Context) error { return nil } -func (m *mockFullLifecycle) OnShutdown(_ context.Context) error { return nil } - -func TestServiceManager_LifecycleBoth_Good(t *testing.T) { - svc := &mockFullLifecycle{} - - c, _ := New( - WithName("both", func(_ *Core) (any, error) { return svc, nil }), - ) - - // Should participate in both startup and shutdown - err := c.ServiceStartup(context.Background(), nil) - assert.NoError(t, err) - err = c.ServiceShutdown(context.Background()) - assert.NoError(t, err) -} diff --git a/tests/service_test.go b/tests/service_test.go new file mode 100644 index 0000000..1d2216b --- /dev/null +++ b/tests/service_test.go @@ -0,0 +1,102 @@ +package core_test + +import ( + "context" + "testing" + + . "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" +) + +type testService struct { + name string + started bool + stopped bool +} + +func (s *testService) OnStartup(_ context.Context) error { s.started = true; return nil } +func (s *testService) OnShutdown(_ context.Context) error { s.stopped = true; return nil } + +// --- Service Registration --- + +func TestService_Register_Good(t *testing.T) { + c := New() + svc := &testService{name: "auth"} + result := c.Service("auth", svc) + assert.Nil(t, result) // nil = success + + got := c.Service("auth") + assert.Equal(t, svc, got) +} + +func TestService_Register_Bad(t *testing.T) { + c := New() + svc := &testService{name: "auth"} + + // Register once — ok + c.Service("auth", svc) + + // Register duplicate — returns error + result := c.Service("auth", svc) + assert.NotNil(t, result) + + // Empty name — returns error + result = c.Service("", svc) + assert.NotNil(t, result) +} + +func TestService_Get_Good(t *testing.T) { + c := New() + c.Service("brain", &testService{name: "brain"}) + + svc := c.Service("brain") + assert.NotNil(t, svc) + + ts, ok := svc.(*testService) + assert.True(t, ok) + assert.Equal(t, "brain", ts.name) +} + +func TestService_Get_Bad(t *testing.T) { + c := New() + svc := c.Service("nonexistent") + assert.Nil(t, svc) +} + +func TestService_Registry_Good(t *testing.T) { + c := New() + // Zero args returns *Service + registry := c.Service() + assert.NotNil(t, registry) +} + +// --- Service Lifecycle --- + +func TestService_Lifecycle_Good(t *testing.T) { + c := New() + svc := &testService{name: "lifecycle"} + c.Service("lifecycle", svc) + + // Startup + err := c.ServiceStartup(context.Background(), nil) + assert.NoError(t, err) + assert.True(t, svc.started) + + // Shutdown + err = c.ServiceShutdown(context.Background()) + assert.NoError(t, err) + assert.True(t, svc.stopped) +} + +func TestService_Lock_Good(t *testing.T) { + c := New() + c.Service("early", &testService{name: "early"}) + + // Lock service registration + c.LockEnable() + c.LockApply() + + // Attempt to register after lock + result := c.Service("late", &testService{name: "late"}) + assert.NotNil(t, result) // error — locked +} diff --git a/tests/task_test.go b/tests/task_test.go new file mode 100644 index 0000000..590279a --- /dev/null +++ b/tests/task_test.go @@ -0,0 +1,66 @@ +package core_test + +import ( + "sync" + "testing" + "time" + + . "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" +) + +// --- PerformAsync --- + +func TestPerformAsync_Good(t *testing.T) { + c := New() + var mu sync.Mutex + var result string + + c.RegisterTask(func(_ *Core, task Task) (any, bool, error) { + mu.Lock() + result = "done" + mu.Unlock() + return "completed", true, nil + }) + + taskID := c.PerformAsync("work") + assert.NotEmpty(t, taskID) + + time.Sleep(100 * time.Millisecond) + + mu.Lock() + assert.Equal(t, "done", result) + mu.Unlock() +} + +func TestPerformAsync_Progress_Good(t *testing.T) { + c := New() + c.RegisterTask(func(_ *Core, task Task) (any, bool, error) { + return nil, true, nil + }) + + taskID := c.PerformAsync("work") + c.Progress(taskID, 0.5, "halfway", "work") +} + +// --- RegisterAction + RegisterActions --- + +func TestRegisterAction_Good(t *testing.T) { + c := New() + called := false + c.RegisterAction(func(_ *Core, _ Message) error { + called = true + return nil + }) + _ = c.Action(nil) + assert.True(t, called) +} + +func TestRegisterActions_Good(t *testing.T) { + c := New() + count := 0 + h := func(_ *Core, _ Message) error { count++; return nil } + c.RegisterActions(h, h) + _ = c.Action(nil) + assert.Equal(t, 2, count) +}