From d5725d0a3d442f7d2987fef582b382fd698ba443 Mon Sep 17 00:00:00 2001 From: Anders McCarthy Date: Fri, 29 Sep 2023 11:05:27 +0200 Subject: [PATCH] Initial commit --- .circleci/config.yml | 24 +++++ .github/CODE_OF_CONDUCT.md | 74 ++++++++++++++ .github/ISSUE_TEMPLATE.md | 16 +++ .github/PULL_REQUEST_TEMPLATE.md | 11 +++ CONTRIBUTING.md | 38 +++++++ LICENSE.txt | 21 ++++ README.md | 19 +++- collection.go | 125 +++++++++++++++++++++++ collection_test.go | 69 +++++++++++++ doc.go | 23 +++++ go.mod | 3 + monitor.go | 105 ++++++++++++++++++++ monitor_test.go | 165 +++++++++++++++++++++++++++++++ nested.go | 43 ++++++++ nested_test.go | 30 ++++++ 15 files changed, 764 insertions(+), 2 deletions(-) create mode 100644 .circleci/config.yml create mode 100644 .github/CODE_OF_CONDUCT.md create mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE.txt create mode 100644 collection.go create mode 100644 collection_test.go create mode 100644 doc.go create mode 100644 go.mod create mode 100644 monitor.go create mode 100644 monitor_test.go create mode 100644 nested.go create mode 100644 nested_test.go diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..f6ab5f8 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,24 @@ +version: 2.1 + +orbs: + ta-go: travelaudience/go@0.9 + +executors: + golang-executor: + docker: + - image: cimg/go:1.19 + environment: + GO111MODULE: "on" + +workflows: + build_and_test: + jobs: + + - ta-go/checks: + name: check + exec: golang-executor + run-static-analysis: true + + - ta-go/test_and_coverage: + name: test + exec: golang-executor diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..2a9ce23 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ +# travel audience Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +### Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at opensource@travelaudience.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [https://contributor-covenant.org/version/1/4][version] + +[homepage]: https://contributor-covenant.org +[version]: https://contributor-covenant.org/version/1/4/ diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..0bb61b8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,16 @@ + + +### Expected Behaviour + +### Actual Behaviour + +### Steps to Reproduce + + +### Optional additional info: + +#### Platform and Version + +#### Sample Code that illustrates the problem + +#### Logs taken while reproducing problem diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..27d1103 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ + + +**What this PR does / why we need it**: + +**Special notes for your reviewer**: + +**If applicable**: +- [ ] this PR contains documentation +- [ ] this PR contains unit tests +- [ ] this PR has been tested for backwards compatibility diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..498f37f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,38 @@ +# Contributing + +Thanks for choosing to contribute! + +The following are a set of guidelines to follow when contributing to this project. + +## Have A Question? + +Start by filing an issue. The existing committers on this project work to reach +consensus around project direction and issue solutions within issue threads +(when appropriate). + +## How to Contribute Code + +1. Fork the repo, develop and test your code changes. +1. Submit a pull request. + +Lastly, please follow the [pull request template](.github/PULL_REQUEST_TEMPLATE.md) when +submitting a pull request! + +#### Documentation PRs + +Documentation PRs will follow the same lifecycle as other PRs. They should also be labeled with the +`docs` label. For documentation, special attention will be paid to spelling, grammar, and clarity +(whereas those things don't matter *as* much for comments in code). + + + +## Code Of Conduct + +This project adheres to the travel audience [code of conduct](.github/CODE_OF_CONDUCT.md). By participating, +you are expected to uphold this code. Please report unacceptable behavior to +[opensource@travelaudience.com](mailto:opensource@travelaudience.com). diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..13c6d2f --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +© Copyright 2023 travel audience. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index a654e11..c3d2dcf 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,17 @@ -# go-nested -Nested services for Go +# Nested Services + +[![Go Reference](https://pkg.go.dev/badge/github.com/travelaudience/go-nested.svg)](https://pkg.go.dev/github.com/travelaudience/go-nested) +[![CircleCI](https://circleci.com/gh/travelaudience/go-nested.svg?style=svg)](https://circleci.com/gh/travelaudience/go-nested) + +**go-nested** provides a simple library to simplify implementing nested services. + +A nested service is a service that runs in the background, independently of the main program, and exposes an API +for communication with the main program or other services. It functions much like a microservice, except that it +runs on the same machine and is compiled into the same binary. + +A typical example of a nested service would be a caching layer, where the cache needs to be refreshed at some +regular interval an some external source. The nested service abstracts away the logic of maintaining the cache, +exposing only an API to read from it. + +This library provides a simple mechanism for the main program to monitor the nested service and take appropriate +action when the nested service is in an error state. diff --git a/collection.go b/collection.go new file mode 100644 index 0000000..b950adc --- /dev/null +++ b/collection.go @@ -0,0 +1,125 @@ +package nested + +import ( + "math/rand" + "strconv" + "sync" +) + +// A Collection monitors multiple services and keeps track of the overall state. The overall state is defined as: +// - Ready if all of the services are ready. +// - Stopped if ANY of the services are stopped. +// - Not Ready otherwise. +// +// A Collection implements the Service interface but does not set the error states. +// +// Services to be monitored are added using the Add() method. Services cannot be removed once added. +// +// An empty Collection is ready to use and in the Not Ready state. A Collection must not be copied after first use. +type Collection struct { + Monitor + sync.Mutex + services []Service + id string + updates chan Notification +} + +// Verifies that a Monitor implements the Service interface. +var _ Service = &Collection{} + +// Add adds a service to be monitored. Panics if the service has already been added. +func (c *Collection) Add(s Service) { + c.Lock() + defer c.Unlock() + + // Initialize the update channel if this is the first service to be added. + if c.updates == nil { + c.updates = make(chan Notification) + go func() { + for range c.updates { + c.Monitor.SetState(c.getOverallState(), nil) + } + }() + // Using the same ID to subscribe to all monitored services means that Subscribe will panic below if a service + // is added twice. + c.id = "collection-" + strconv.Itoa(rand.Int()) + } + + c.services = append(c.services, s) + s.Subscribe(c.id, c.updates) +} + +// StateCount returns the number of monitored services currently in the given state. +func (c *Collection) StateCount(state State) int { + c.Lock() + defer c.Unlock() + + var n int + for _, service := range c.services { + if s, _ := service.GetState(); s == state { + n++ + } + } + return n +} + +// Stop stops the collection and all monitored services and releases all of the resources. Neither the collection nor +// any of the services should be used after calling stop. +func (c *Collection) Stop() { + + // Start stopping all of the member services, and then release the lock. + u := func() chan Notification { + + // Initialize the wait group first so that wg.Wait() runs after the lock is released. That way, if we block + // on any of the Stop() calls, we do so without holding the lock. + wg := sync.WaitGroup{} + defer wg.Wait() + + c.Lock() + defer c.Unlock() + + wg.Add(len(c.services)) + for _, service := range c.services { + // Unsubscribe first so that we can close the notifications channel. Note that a side effect of + // unsubscribing here is that we need to explicitly set the monitor to stopped when we're done. + service.Unsubscribe(c.id) + go func(s Service) { + s.Stop() + wg.Done() + }(service) + } + c.services = nil + + // Return the update channel so that we don't have to grab the lock again to get it. + return c.updates + }() + + // Close the update channel to release the goroutine in Add() above. If u is nil, that means that this collection + // hasn't been used, which is unexpected but not our concern. + if u != nil { + close(u) + } + + // Need to explicitly set the monitor to stopped, since we unsubscribed already above. + c.Monitor.Stop() +} + +// getOverallState computes the overall state of the collection: ready if all of the services are ready, stopped +// if any of the services are stopped, and not ready otherwise. getOverallState should not be called on an empty +// collection, as it will give the incorrect state. +func (c *Collection) getOverallState() State { + c.Lock() + defer c.Unlock() + + we := State(Ready) + for _, service := range c.services { + state, _ := service.GetState() + switch state { + case Stopped: + return Stopped + case NotReady: + we = NotReady + } + } + return we +} diff --git a/collection_test.go b/collection_test.go new file mode 100644 index 0000000..051319b --- /dev/null +++ b/collection_test.go @@ -0,0 +1,69 @@ +package nested + +import ( + "testing" + "time" +) + +func TestCollection(t *testing.T) { + + co := Collection{} + + // A new collection is not ready. + s, e := co.GetState() + assertEqual(t, NotReady, s) + assertEqual(t, nil, e) + + // Add services. + s0, s1 := &Monitor{}, &Monitor{} + co.Add(s0) + co.Add(s1) + time.Sleep(10 * time.Millisecond) + s, e = co.GetState() + assertEqual(t, NotReady, s) + assertEqual(t, nil, e) + + // One service is ready. + s0.SetState(Ready, nil) + time.Sleep(10 * time.Millisecond) + s, e = co.GetState() + assertEqual(t, NotReady, s) + assertEqual(t, nil, e) + + // Both services are ready. + s1.SetState(Ready, nil) + time.Sleep(10 * time.Millisecond) + s, e = co.GetState() + assertEqual(t, Ready, s) + assertEqual(t, nil, e) + + assertEqual(t, 2, co.StateCount(Ready)) + assertEqual(t, 0, co.StateCount(NotReady)) + assertEqual(t, 0, co.StateCount(Stopped)) + + // One service is stopped. + s0.Stop() + time.Sleep(10 * time.Millisecond) + s, e = co.GetState() + assertEqual(t, Stopped, s) + assertEqual(t, nil, e) + + assertEqual(t, 1, co.StateCount(Ready)) + assertEqual(t, 0, co.StateCount(NotReady)) + assertEqual(t, 1, co.StateCount(Stopped)) + + // One service is stopped, and the other is not ready. + s1.SetState(NotReady, nil) + time.Sleep(10 * time.Millisecond) + s, e = co.GetState() + assertEqual(t, Stopped, s) + assertEqual(t, nil, e) + + // Stop all services. + co.Stop() + assertEqual(t, 0, co.StateCount(Ready)) + assertEqual(t, 0, co.StateCount(NotReady)) + + // We also have no stopped services because the service list has been emptied. + assertEqual(t, 0, co.StateCount(Stopped)) +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..445f0ad --- /dev/null +++ b/doc.go @@ -0,0 +1,23 @@ +// Package nested provides support for the nested service pattern, where a service runs independently from a process +// that uses it but runs on the same machine and is compiled into the same binary. +// +// One important use of nested services is to abstract the details of interfacing with external components. +// +// A nested service is modelled here as a finite state machine, with a Service interface that all nested services +// should implement. +// +// The state machine has the following states: +// - Ready. The service is running normally. +// - Not ready. The service is temporarily unavailable. +// - Stopped. The service is permanently unavailable. +// +// Additionally, an error state is exposed. +// - When ready, the error state should always be nil. +// - When not ready, the error state may indicate a reason for being not ready. Not ready with a nil error state +// implies that the service is initializing. +// - When stopped, the error state may indicate a reason for being stopped. Stopped with a nil error state implies +// that the service was stopped by the calling process with Stop(). +// +// This package also provides a Monitor type, which implements the state machine. A Monitor can be embedded in +// any service to make it a nested service. +package nested diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3198de4 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/travelaudience/go-nested + +go 1.19 diff --git a/monitor.go b/monitor.go new file mode 100644 index 0000000..775d4d0 --- /dev/null +++ b/monitor.go @@ -0,0 +1,105 @@ +package nested + +import ( + "fmt" + "sync" +) + +// A Monitor is a basic implementation of the nested service finite state machine. +// +// An empty Monitor is ready to use and in the Not Ready state. A Monitor must not be copied after first use. +type Monitor struct { + sync.Mutex + state State // current state + err error // current error state + subscriptions map[string]chan<- Notification +} + +// Verifies that a Monitor implements the Service interface. Note that the Service interface does NOT include the +// SetState() method. This is by design, as SetState() should only be called by the packiage implementing the service +// and not by consumers of the service. +var _ Service = &Monitor{} + +// GetState returns the current state of the service. +func (m *Monitor) GetState() (State, error) { + m.Lock() + defer m.Unlock() + return m.state, m.err +} + +// Stop sets the service to stopped and cancels all subsriptions. Does nothing if the service is already stopped +// or failed. +func (m *Monitor) Stop() { + m.setState(Stopped, nil, true) +} + +// Subscribe creates a subscription to state changes, and will send all subsequent state changes to the channel provided. +func (m *Monitor) Subscribe(id string, channel chan<- Notification) { + m.Lock() + defer m.Unlock() + if m.subscriptions == nil { + m.subscriptions = make(map[string]chan<- Notification) + } + m.subscriptions[id] = channel +} + +// Unsubscribe removes the subscription with the id provided. Does nothing if the subscription doesn't exist. +func (m *Monitor) Unsubscribe(id string) { + m.Lock() + defer m.Unlock() + delete(m.subscriptions, id) +} + +// SetState sets the state and error state. SetState should only be set by the package implementing the service. +// If there are subscriptions, SetState returns after the notifications are consumed. +// +// SetState can be used to change either the state or the error state, or both. If a call to SetState results +// in no change, then the result is a no-op. +// +// SetState panics on an attempt to change the state or error state of a stopped service. (It won't panic if +// there's no change.) +func (m *Monitor) SetState(newState State, newErr error) { + m.setState(newState, newErr, false) +} + +func (m *Monitor) setState(newState State, newErr error, ignoreStopped bool) { + + if _, ok := names[newState]; !ok { + panic(fmt.Sprintf("state %d is undefined", newState)) + } + + // Initialize the wait group first so that wg.Wait() runs after the lock is released. That way, if we block + // on any of the subscription channels, we do so without holding the lock. + var wg sync.WaitGroup + defer wg.Wait() + + m.Lock() + defer m.Unlock() + + if newState == m.state && newErr == m.err { + return // nothing to do + } + + if m.state == Stopped { + if ignoreStopped { + return + } + panic("cannot transition from stopped state") + } + + m.state, m.err = newState, newErr + + // Notify all subscribers. + wg.Add(len(m.subscriptions)) + for id, ch := range m.subscriptions { + // Run these in the background so as not to block while holding the lock. + go func(id string, ch chan<- Notification) { + ch <- Notification{ID: id, State: newState, Error: newErr} + wg.Done() + }(id, ch) + } + + if newState == Stopped { + m.subscriptions = nil + } +} diff --git a/monitor_test.go b/monitor_test.go new file mode 100644 index 0000000..7709e1b --- /dev/null +++ b/monitor_test.go @@ -0,0 +1,165 @@ +package nested + +import ( + "errors" + "fmt" + "strings" + "testing" +) + +func assertPanic(t *testing.T, f func(), msg string) { + t.Helper() + defer func() { + r := recover() + if r == nil { + t.Errorf("want panic %q, got no panic", msg) + return + } + got := fmt.Sprint(r) + if !strings.Contains(got, msg) { + t.Errorf("want panic containing %q, got panic %q", msg, got) + return + } + }() + f() +} + +func assertReceived[X any](t *testing.T, ch <-chan X) (x X) { + select { + case x = <-ch: + default: + t.Errorf("no message recevied") + } + return +} + +func TestMonitor(t *testing.T) { + + // A new Monitor is not ready. + mon := Monitor{} + s, e := mon.GetState() + assertEqual(t, NotReady, s) + assertEqual(t, nil, e) + + // Set to ready. + mon.SetState(Ready, nil) + s, e = mon.GetState() + assertEqual(t, Ready, s) + assertEqual(t, nil, e) + + // Set to not ready with a reason. + reason := errors.New("some reason") + mon.SetState(NotReady, reason) + s, e = mon.GetState() + assertEqual(t, NotReady, s) + assertEqual(t, reason, e) + + // Can't set to an undefined state. + assertPanic(t, func() { mon.SetState(-1, nil) }, "undefined") + + // Set ready again. + mon.SetState(Ready, nil) + s, e = mon.GetState() + assertEqual(t, Ready, s) + assertEqual(t, nil, e) + + // Stop. + mon.Stop() + s, e = mon.GetState() + assertEqual(t, Stopped, s) + assertEqual(t, nil, e) + + // Can't restart. + assertPanic(t, func() { mon.SetState(Ready, nil) }, "cannot transition from stopped state") +} + +func TestMonitor2(t *testing.T) { + + mon := Monitor{} + + // Failure on initialization. + failure := errors.New("some failure") + mon.SetState(Stopped, failure) + s, e := mon.GetState() + assertEqual(t, Stopped, s) + assertEqual(t, failure, e) + + // Now Stop() should be a no-op + mon.Stop() + s, e = mon.GetState() + assertEqual(t, Stopped, s) + assertEqual(t, failure, e) // note that the error condition is still there +} + +func TestMonitorNotifications(t *testing.T) { + + mon := Monitor{} + ch := make(chan Notification, 1) + mon.Subscribe("foo", ch) + + // Set to ready. + mon.SetState(Ready, nil) + n := assertReceived(t, ch) + assertEqual(t, Ready, n.State) + assertEqual(t, nil, n.Error) + + // Set to ready again, and there's not an additional notification. + mon.SetState(Ready, nil) + if len(ch) > 0 { + t.Error("unexpected notification") + } + + // Set to not ready with a reason. + reason := errors.New("some reason") + mon.SetState(NotReady, reason) + n = assertReceived(t, ch) + assertEqual(t, NotReady, n.State) + assertEqual(t, reason, n.Error) + + // Set ready again. + mon.SetState(Ready, nil) + n = assertReceived(t, ch) + assertEqual(t, Ready, n.State) + assertEqual(t, nil, n.Error) + + // Stop. + mon.Stop() + n = assertReceived(t, ch) + assertEqual(t, Stopped, n.State) + assertEqual(t, nil, n.Error) + + // Stop again, and there's not an additional notification. + mon.Stop() + if len(ch) > 0 { + t.Error("unexpected notification") + } + + close(ch) +} + +func TestUnsubscribe(t *testing.T) { + + mon := Monitor{} + + // Unsubscribing something that doesn't exist is not an error. + mon.Unsubscribe("bar") + + ch := make(chan Notification, 1) + mon.Subscribe("foo", ch) + + // Set to ready. + mon.SetState(Ready, nil) + n := assertReceived(t, ch) + assertEqual(t, n.State, Ready) + assertEqual(t, n.Error, nil) + + mon.Unsubscribe("foo") + + // No more notifications. + mon.SetState(NotReady, nil) + if len(ch) > 0 { + t.Error("unexpected notification") + } + + close(ch) +} diff --git a/nested.go b/nested.go new file mode 100644 index 0000000..8fa9527 --- /dev/null +++ b/nested.go @@ -0,0 +1,43 @@ +package nested + +type State int8 + +const ( + NotReady State = iota + Ready + Stopped +) + +var names = map[State]string{ + NotReady: "not ready", + Ready: "ready", + Stopped: "stopped", +} + +func (s State) String() string { + return names[s] +} + +type Service interface { + // GetState returns the current state and error state of the service. + GetState() (State, error) + // Stop stops the service, and releases all resources. After sending the final update to the stopped state, + // all subscriptions are unsubscribed. Future calls to GetState() will always return Stopped. + Stop() + // Subscribe starts sending all state changes to the channel provided. The ID must unique. Subscribe panics + // if the ID is already subscribed. + Subscribe(id string, channel chan<- Notification) + // Unsubscribe stops sending notifications. The caller must provide the same ID as was provided in the call + // to Subscribe(). Repeated calls to Unsubscribed() with the same ID are ignored. Calls to Unscrubscribe() + // with an unknown ID are also ignored. + Unsubscribe(id string) +} + +type Notification struct { + // The ID as provided by the call to Subscribe() + ID string + // The new state + State State + // The new error state + Error error +} diff --git a/nested_test.go b/nested_test.go new file mode 100644 index 0000000..c28dc5b --- /dev/null +++ b/nested_test.go @@ -0,0 +1,30 @@ +package nested + +import ( + "fmt" + "testing" +) + +/* +// Original version: Go 1.20+ required for type error to be comparable +func assertEqual[X comparable](t *testing.T, want, got X) { + t.Helper() + if want != got { + t.Errorf("want %v, got %v", want, got) + } +} +*/ + +// Go 1.19 workaround +func assertEqual[X any](t *testing.T, want, got X) { + t.Helper() + if w, g := fmt.Sprint(want), fmt.Sprint(got); w != g { + t.Errorf("want %v, got %v", w, g) + } +} + +func TestName(t *testing.T) { + assertEqual(t, "ready", Ready.String()) + assertEqual(t, "not ready", NotReady.String()) + assertEqual(t, "stopped", Stopped.String()) +}