feat: WithService with v0.3.3 name discovery + IPC handler auto-registration
- WithService now calls factory, discovers service name from package path via
reflect/runtime (last path segment, _test suffix stripped, lowercased), and
calls RegisterService — which handles Startable/Stoppable/HandleIPCEvents
- If factory returns nil Value (self-registered), WithService returns OK without
a second registration
- Add contract_test.go with _Good/_Bad tests covering all three code paths
- Fix core.go Cli() accessor: use ServiceFor[*Cli](c, "cli") (was cli.New())
- Fix pre-existing })) → }}) syntax errors in command_test, service_test, lock_test
- Fix pre-existing Options{...} → NewOptions(...) in core_test, data_test,
drive_test, i18n_test (Options is a struct, not a slice)
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
198ab839a8
commit
177f73cc99
10 changed files with 167 additions and 48 deletions
|
|
@ -13,13 +13,13 @@ func TestCommand_Register_Good(t *testing.T) {
|
|||
c := New().Value.(*Core)
|
||||
r := c.Command("deploy", Command{Action: func(_ Options) Result {
|
||||
return Result{Value: "deployed", OK: true}
|
||||
}))
|
||||
}})
|
||||
assert.True(t, r.OK)
|
||||
}
|
||||
|
||||
func TestCommand_Get_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }))
|
||||
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
r := c.Command("deploy")
|
||||
assert.True(t, r.OK)
|
||||
assert.NotNil(t, r.Value)
|
||||
|
|
@ -35,7 +35,7 @@ func TestCommand_Run_Good(t *testing.T) {
|
|||
c := New().Value.(*Core)
|
||||
c.Command("greet", Command{Action: func(opts Options) Result {
|
||||
return Result{Value: Concat("hello ", opts.String("name")), OK: true}
|
||||
}))
|
||||
}})
|
||||
cmd := c.Command("greet").Value.(*Command)
|
||||
r := cmd.Run(NewOptions(Option{Key: "name", Value: "world"}))
|
||||
assert.True(t, r.OK)
|
||||
|
|
@ -56,7 +56,7 @@ func TestCommand_Nested_Good(t *testing.T) {
|
|||
c := New().Value.(*Core)
|
||||
c.Command("deploy/to/homelab", Command{Action: func(_ Options) Result {
|
||||
return Result{Value: "deployed to homelab", OK: true}
|
||||
}))
|
||||
}})
|
||||
|
||||
r := c.Command("deploy/to/homelab")
|
||||
assert.True(t, r.OK)
|
||||
|
|
@ -68,9 +68,9 @@ func TestCommand_Nested_Good(t *testing.T) {
|
|||
|
||||
func TestCommand_Paths_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }))
|
||||
c.Command("serve", Command{Action: func(_ Options) Result { return Result{OK: true} }))
|
||||
c.Command("deploy/to/homelab", Command{Action: func(_ Options) Result { return Result{OK: true} }))
|
||||
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
c.Command("serve", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
c.Command("deploy/to/homelab", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
|
||||
paths := c.Commands()
|
||||
assert.Contains(t, paths, "deploy")
|
||||
|
|
@ -108,7 +108,7 @@ func TestCommand_Lifecycle_NoImpl_Good(t *testing.T) {
|
|||
c := New().Value.(*Core)
|
||||
c.Command("serve", Command{Action: func(_ Options) Result {
|
||||
return Result{Value: "running", OK: true}
|
||||
}))
|
||||
}})
|
||||
cmd := c.Command("serve").Value.(*Command)
|
||||
|
||||
r := cmd.Start(NewOptions())
|
||||
|
|
@ -178,8 +178,8 @@ func TestCommand_Lifecycle_WithImpl_Good(t *testing.T) {
|
|||
|
||||
func TestCommand_Duplicate_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }))
|
||||
r := c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }))
|
||||
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
r := c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
|
|
|
|||
53
contract.go
53
contract.go
|
|
@ -6,6 +6,9 @@ package core
|
|||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Message is the type for IPC broadcasts (fire-and-forget).
|
||||
|
|
@ -131,17 +134,61 @@ func WithOptions(opts Options) CoreOption {
|
|||
}
|
||||
|
||||
// WithService registers a service via its factory function.
|
||||
// The factory receives *Core and is responsible for calling c.Service()
|
||||
// to register itself, and c.RegisterAction() for IPC handlers.
|
||||
// If the factory returns a non-nil Value, WithService auto-discovers the
|
||||
// service name from the factory's package path (last path segment, lowercase,
|
||||
// with any "_test" suffix stripped) and calls RegisterService on the instance.
|
||||
// IPC handler auto-registration is handled by RegisterService.
|
||||
//
|
||||
// If the factory returns nil Value (it registered itself), WithService
|
||||
// returns success without a second registration.
|
||||
//
|
||||
// core.WithService(agentic.Register)
|
||||
// core.WithService(display.Register(nil))
|
||||
func WithService(factory func(*Core) Result) CoreOption {
|
||||
return func(c *Core) Result {
|
||||
return factory(c)
|
||||
r := factory(c)
|
||||
if !r.OK {
|
||||
return r
|
||||
}
|
||||
if r.Value == nil {
|
||||
// Factory self-registered — nothing more to do.
|
||||
return Result{OK: true}
|
||||
}
|
||||
// Auto-discover the service name from the factory's package path.
|
||||
name := serviceNameFromFactory(factory)
|
||||
return c.RegisterService(name, r.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// serviceNameFromFactory derives a canonical service name from a factory
|
||||
// function's fully-qualified package path.
|
||||
//
|
||||
// "dappco.re/go/agentic.Register" → "agentic"
|
||||
// "dappco.re/go/core_test.stubFactory" → "core"
|
||||
func serviceNameFromFactory(factory any) string {
|
||||
ptr := reflect.ValueOf(factory).Pointer()
|
||||
fn := runtime.FuncForPC(ptr)
|
||||
if fn == nil {
|
||||
return "unknown"
|
||||
}
|
||||
full := fn.Name() // e.g. "dappco.re/go/agentic.Register"
|
||||
|
||||
// Take the last path segment ("agentic.Register" or "core_test.stubFactory").
|
||||
if idx := strings.LastIndex(full, "/"); idx >= 0 {
|
||||
full = full[idx+1:]
|
||||
}
|
||||
|
||||
// The package name is the part before the first dot.
|
||||
if idx := strings.Index(full, "."); idx >= 0 {
|
||||
full = full[:idx]
|
||||
}
|
||||
|
||||
// Strip the Go test package suffix so "core_test" → "core".
|
||||
full = strings.TrimSuffix(full, "_test")
|
||||
|
||||
return strings.ToLower(full)
|
||||
}
|
||||
|
||||
// WithOption is a convenience for setting a single key-value option.
|
||||
//
|
||||
// core.New(
|
||||
|
|
|
|||
69
contract_test.go
Normal file
69
contract_test.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- WithService ---
|
||||
|
||||
// stub service used only for name-discovery tests.
|
||||
type stubNamedService struct{}
|
||||
|
||||
// stubFactory is a package-level factory so the runtime function name carries
|
||||
// the package path "core_test.stubFactory" — last segment after '/' is
|
||||
// "core_test", and after stripping a "_test" suffix we get "core".
|
||||
// For a real service package such as "dappco.re/go/agentic" the discovered
|
||||
// name would be "agentic".
|
||||
func stubFactory(c *Core) Result {
|
||||
return Result{Value: &stubNamedService{}, OK: true}
|
||||
}
|
||||
|
||||
// TestWithService_NameDiscovery_Good verifies that WithService discovers the
|
||||
// service name from the factory's package path and registers the instance via
|
||||
// RegisterService, making it retrievable through c.Services().
|
||||
//
|
||||
// stubFactory lives in package "dappco.re/go/core_test", so the last path
|
||||
// segment is "core_test" — WithService strips the "_test" suffix and registers
|
||||
// the service under the name "core".
|
||||
func TestWithService_NameDiscovery_Good(t *testing.T) {
|
||||
r := New(WithService(stubFactory))
|
||||
assert.True(t, r.OK)
|
||||
c := r.Value.(*Core)
|
||||
|
||||
names := c.Services()
|
||||
// "core" is the name derived from package "core_test" (test suffix stripped).
|
||||
assert.Contains(t, names, "core", "expected service registered under discovered package name 'core'")
|
||||
}
|
||||
|
||||
// TestWithService_FactorySelfRegisters_Good verifies that when a factory
|
||||
// returns Result{OK:true} with no Value (it registered itself), WithService
|
||||
// does not attempt a second registration and returns success.
|
||||
func TestWithService_FactorySelfRegisters_Good(t *testing.T) {
|
||||
selfReg := func(c *Core) Result {
|
||||
// Factory registers directly, returns no instance.
|
||||
c.Service("self", Service{})
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
r := New(WithService(selfReg))
|
||||
assert.True(t, r.OK)
|
||||
c := r.Value.(*Core)
|
||||
|
||||
// "self" must be present and registered exactly once.
|
||||
svc := c.Service("self")
|
||||
assert.True(t, svc.OK, "expected self-registered service to be present")
|
||||
}
|
||||
|
||||
// TestWithService_FactoryError_Bad verifies that a factory returning an error
|
||||
// causes New() to stop and propagate the failure.
|
||||
func TestWithService_FactoryError_Bad(t *testing.T) {
|
||||
r := New(WithService(func(c *Core) Result {
|
||||
return Result{Value: E("test", "factory failed", nil), OK: false}
|
||||
}))
|
||||
assert.False(t, r.OK, "expected New() to fail when factory returns error")
|
||||
}
|
||||
5
core.go
5
core.go
|
|
@ -49,7 +49,10 @@ func (c *Core) Fs() *Fs { return c.fs }
|
|||
func (c *Core) Config() *Config { return c.config }
|
||||
func (c *Core) Error() *ErrorPanic { return c.error }
|
||||
func (c *Core) Log() *ErrorLog { return c.log }
|
||||
func (c *Core) Cli() *Cli { return cli.New() }
|
||||
func (c *Core) Cli() *Cli {
|
||||
cl, _ := ServiceFor[*Cli](c, "cli")
|
||||
return cl
|
||||
}
|
||||
func (c *Core) IPC() *Ipc { return c.ipc }
|
||||
func (c *Core) I18n() *I18n { return c.i18n }
|
||||
func (c *Core) Env(key string) string { return Env(key) }
|
||||
|
|
|
|||
10
core_test.go
10
core_test.go
|
|
@ -97,11 +97,11 @@ func TestAccessors_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestOptions_Accessor_Good(t *testing.T) {
|
||||
c := New(WithOptions(Options{
|
||||
{Key: "name", Value: "testapp"},
|
||||
{Key: "port", Value: 8080},
|
||||
{Key: "debug", Value: true},
|
||||
})).Value.(*Core)
|
||||
c := New(WithOptions(NewOptions(
|
||||
Option{Key: "name", Value: "testapp"},
|
||||
Option{Key: "port", Value: 8080},
|
||||
Option{Key: "debug", Value: true},
|
||||
))).Value.(*Core)
|
||||
opts := c.Options()
|
||||
assert.NotNil(t, opts)
|
||||
assert.Equal(t, "testapp", opts.String("name"))
|
||||
|
|
|
|||
10
data_test.go
10
data_test.go
|
|
@ -16,11 +16,11 @@ var testFS embed.FS
|
|||
|
||||
func TestData_New_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
r := c.Data().New(Options{
|
||||
{Key: "name", Value: "test"},
|
||||
{Key: "source", Value: testFS},
|
||||
{Key: "path", Value: "testdata"},
|
||||
})
|
||||
r := c.Data().New(NewOptions(
|
||||
Option{Key: "name", Value: "test"},
|
||||
Option{Key: "source", Value: testFS},
|
||||
Option{Key: "path", Value: "testdata"},
|
||||
))
|
||||
assert.True(t, r.OK)
|
||||
assert.NotNil(t, r.Value)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ import (
|
|||
|
||||
func TestDrive_New_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
r := c.Drive().New(Options{
|
||||
{Key: "name", Value: "api"},
|
||||
{Key: "transport", Value: "https://api.lthn.ai"},
|
||||
})
|
||||
r := c.Drive().New(NewOptions(
|
||||
Option{Key: "name", Value: "api"},
|
||||
Option{Key: "transport", Value: "https://api.lthn.ai"},
|
||||
))
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "api", r.Value.(*DriveHandle).Name)
|
||||
assert.Equal(t, "https://api.lthn.ai", r.Value.(*DriveHandle).Transport)
|
||||
|
|
@ -23,18 +23,18 @@ func TestDrive_New_Good(t *testing.T) {
|
|||
func TestDrive_New_Bad(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
// Missing name
|
||||
r := c.Drive().New(Options{
|
||||
{Key: "transport", Value: "https://api.lthn.ai"},
|
||||
})
|
||||
r := c.Drive().New(NewOptions(
|
||||
Option{Key: "transport", Value: "https://api.lthn.ai"},
|
||||
))
|
||||
assert.False(t, r.OK)
|
||||
}
|
||||
|
||||
func TestDrive_Get_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Drive().New(Options{
|
||||
{Key: "name", Value: "ssh"},
|
||||
{Key: "transport", Value: "ssh://claude@10.69.69.165"},
|
||||
})
|
||||
c.Drive().New(NewOptions(
|
||||
Option{Key: "name", Value: "ssh"},
|
||||
Option{Key: "transport", Value: "ssh://claude@10.69.69.165"},
|
||||
))
|
||||
r := c.Drive().Get("ssh")
|
||||
assert.True(t, r.OK)
|
||||
handle := r.Value.(*DriveHandle)
|
||||
|
|
@ -68,11 +68,11 @@ func TestDrive_Names_Good(t *testing.T) {
|
|||
|
||||
func TestDrive_OptionsPreserved_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Drive().New(Options{
|
||||
{Key: "name", Value: "api"},
|
||||
{Key: "transport", Value: "https://api.lthn.ai"},
|
||||
{Key: "timeout", Value: 30},
|
||||
})
|
||||
c.Drive().New(NewOptions(
|
||||
Option{Key: "name", Value: "api"},
|
||||
Option{Key: "transport", Value: "https://api.lthn.ai"},
|
||||
Option{Key: "timeout", Value: 30},
|
||||
))
|
||||
r := c.Drive().Get("api")
|
||||
assert.True(t, r.OK)
|
||||
handle := r.Value.(*DriveHandle)
|
||||
|
|
|
|||
10
i18n_test.go
10
i18n_test.go
|
|
@ -16,11 +16,11 @@ func TestI18n_Good(t *testing.T) {
|
|||
|
||||
func TestI18n_AddLocales_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
r := c.Data().New(Options{
|
||||
{Key: "name", Value: "lang"},
|
||||
{Key: "source", Value: testFS},
|
||||
{Key: "path", Value: "testdata"},
|
||||
})
|
||||
r := c.Data().New(NewOptions(
|
||||
Option{Key: "name", Value: "lang"},
|
||||
Option{Key: "source", Value: testFS},
|
||||
Option{Key: "path", Value: "testdata"},
|
||||
))
|
||||
if r.OK {
|
||||
c.I18n().AddLocales(r.Value.(*Embed))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ func TestLockEnable_Good(t *testing.T) {
|
|||
|
||||
func TestStartables_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Service("s", Service{OnStart: func() Result { return Result{OK: true} }))
|
||||
c.Service("s", Service{OnStart: func() Result { return Result{OK: true} }})
|
||||
r := c.Startables()
|
||||
assert.True(t, r.OK)
|
||||
assert.Len(t, r.Value.([]*Service), 1)
|
||||
|
|
@ -48,7 +48,7 @@ func TestStartables_Good(t *testing.T) {
|
|||
|
||||
func TestStoppables_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Service("s", Service{OnStop: func() Result { return Result{OK: true} }))
|
||||
c.Service("s", Service{OnStop: func() Result { return Result{OK: true} }})
|
||||
r := c.Stoppables()
|
||||
assert.True(t, r.OK)
|
||||
assert.Len(t, r.Value.([]*Service), 1)
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ func TestService_Register_Empty_Bad(t *testing.T) {
|
|||
|
||||
func TestService_Get_Good(t *testing.T) {
|
||||
c := New().Value.(*Core)
|
||||
c.Service("brain", Service{OnStart: func() Result { return Result{OK: true} }))
|
||||
c.Service("brain", Service{OnStart: func() Result { return Result{OK: true} }})
|
||||
r := c.Service("brain")
|
||||
assert.True(t, r.OK)
|
||||
assert.NotNil(t, r.Value)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue