// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package lifecycle import ( "bytes" "context" "errors" "log/slog" "strings" "testing" ) func TestStartAndStopReverseOrder(t *testing.T) { var events []string runner, err := New( Service{ Name: "first", Start: func(context.Context) error { events = append(events, "start first"); return nil }, Stop: func(context.Context) error { events = append(events, "stop first"); return nil }, }, Service{ Name: "second", Start: func(context.Context) error { events = append(events, "start second"); return nil }, Stop: func(context.Context) error { events = append(events, "stop second"); return nil }, }, ) if err != nil { t.Fatalf("New: %v", err) } if err := runner.Start(context.Background()); err != nil { t.Fatalf("Start: %v", err) } if runner.State() != StateRunning { t.Fatalf("state after start = %s, want %s", runner.State(), StateRunning) } if err := runner.Stop(context.Background()); err != nil { t.Fatalf("Stop: %v", err) } want := strings.Join([]string{"start first", "start second", "stop second", "stop first"}, ",") if got := strings.Join(events, ","); got != want { t.Fatalf("events = %q, want %q", got, want) } if runner.State() != StateStopped { t.Fatalf("state after stop = %s, want %s", runner.State(), StateStopped) } } func TestStartupFailureStopsStartedServices(t *testing.T) { startFailure := errors.New("start failed") stopFailure := errors.New("stop failed") var stopped bool runner, err := New( Service{ Name: "ready", Start: func(context.Context) error { return nil }, Stop: func(context.Context) error { stopped = true return stopFailure }, }, Service{ Name: "broken", Start: func(context.Context) error { return startFailure }, Stop: func(context.Context) error { return nil }, }, ) if err != nil { t.Fatalf("New: %v", err) } err = runner.Start(context.Background()) if !errors.Is(err, startFailure) || !errors.Is(err, stopFailure) { t.Fatalf("Start error = %v, want both start and stop failures", err) } if !stopped { t.Fatal("started service was not stopped") } if runner.State() != StateStopped { t.Fatalf("state after failed start = %s, want %s", runner.State(), StateStopped) } } func TestCanceledStartDoesNotCallService(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() var called bool runner, err := New(Service{ Name: "service", Start: func(context.Context) error { called = true return nil }, Stop: func(context.Context) error { return nil }, }) if err != nil { t.Fatalf("New: %v", err) } err = runner.Start(ctx) if !errors.Is(err, context.Canceled) { t.Fatalf("Start error = %v, want context.Canceled", err) } if called { t.Fatal("service start was called after cancellation") } if runner.State() != StateStopped { t.Fatalf("state after canceled start = %s, want %s", runner.State(), StateStopped) } } func TestStopIsIdempotent(t *testing.T) { runner, err := New(Service{ Name: "service", Start: func(context.Context) error { return nil }, Stop: func(context.Context) error { return nil }, }) if err != nil { t.Fatalf("New: %v", err) } if err := runner.Start(context.Background()); err != nil { t.Fatalf("Start: %v", err) } if err := runner.Stop(context.Background()); err != nil { t.Fatalf("first Stop: %v", err) } if err := runner.Stop(context.Background()); err != nil { t.Fatalf("second Stop: %v", err) } } func TestStopLogsCompletedServiceStages(t *testing.T) { var logs bytes.Buffer logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) runner, err := NewWithLogger(logger, Service{ Name: "service", Start: func(context.Context) error { return nil }, Stop: func(context.Context) error { return nil }, }) if err != nil { t.Fatalf("NewWithLogger: %v", err) } if err := runner.Start(context.Background()); err != nil { t.Fatalf("Start: %v", err) } if err := runner.Stop(context.Background()); err != nil { t.Fatalf("Stop: %v", err) } output := logs.String() if !strings.Contains(output, "lifecycle service stopping") || !strings.Contains(output, "lifecycle service stopped") || !strings.Contains(output, "service") { t.Fatalf("stop logs = %q, want stopping and stopped records for service", output) } } func TestNewRejectsInvalidService(t *testing.T) { if _, err := New(Service{Name: "invalid"}); err == nil { t.Fatal("New accepted a service without functions") } }