Files
radiantwave-legacy/internal/application/application_test.go
T
NeilClack 2ef826fe63 Fix double-close panic on DownloadDone: skip re-navigation between HOME-mapped states
Primary fix: checkState() now detects when both old and new states map
to the HOME page (DOWNLOADING_ASSETS and READY) and skips re-navigation.
This prevents destroying and recreating the Home page, which would
close the already-closed DownloadDone channel.

Defense-in-depth: Home.downloadDone flag prevents close if a new Home
page somehow reaches the close path.

12 application tests, 4 page tests, 18 mixer tests (34 total).
Tests cover: prerequisite priority ordering, state blocking on
downloads, non-blocking channel read, double-close guard, download
failure path, and repeated computeState calls.
2026-07-11 16:43:16 -05:00

476 lines
11 KiB
Go

package application
import (
"testing"
"time"
"gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/page"
)
// Regression: The app used to reach READY before audio downloads completed,
// showing "Status: READY" while "0 / 14 Files Downloaded" was still in progress.
// The fix gates READY on downloadsComplete being true.
// TestStateTransitionsToDownloadingAssets verifies that when all config
// prerequisites are met but downloads are not complete, the state transitions
// to DOWNLOADING_ASSETS instead of READY.
func TestStateTransitionsToDownloadingAssets(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = "test@example.com"
config.LicenseKey = "test-key-123"
app.DB.Save(config)
app.downloadsComplete = false
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
result := app.computeState()
if result != DOWNLOADING_ASSETS {
t.Errorf("state = %v, want DOWNLOADING_ASSETS (downloads not complete)", result)
}
}
// TestStateTransitionsToReadyWhenDownloadsComplete verifies that the state
// transitions to READY only after downloads are complete.
func TestStateTransitionsToReadyWhenDownloadsComplete(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = "test@example.com"
config.LicenseKey = "test-key-123"
app.DB.Save(config)
app.downloadsComplete = true
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
result := app.computeState()
if result != READY {
t.Errorf("state = %v, want READY", result)
}
}
// Regression: Download failure should prevent the app from reaching READY.
// If downloads fail, the user sees an error and must restart to retry.
func TestDownloadFailurePreventsReady(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = "test@example.com"
config.LicenseKey = "test-key-123"
app.DB.Save(config)
app.downloadsComplete = false
app.downloadFailed = true
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
result := app.computeState()
if result == READY {
t.Error("state should not be READY when download has failed")
}
}
// TestStateStaysReadyOnceSet verifies that once the state reaches READY,
// computeState() returns READY without checking other prerequisites.
func TestStateStaysReadyOnceSet(t *testing.T) {
app := &Application{
state: READY,
DB: setupTestDB(t),
}
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
result := app.computeState()
if result != READY {
t.Errorf("state = %v, want READY (should not change once set)", result)
}
}
// TestEmailMissingPreventsReady verifies that missing email blocks progress
// even when network is up and downloads are complete.
func TestEmailMissingPreventsReady(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = ""
config.LicenseKey = "test-key-123"
app.DB.Save(config)
app.downloadsComplete = true
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
result := app.computeState()
if result != NEEDS_EMAIL_ADDRESS {
t.Errorf("state = %v, want NEEDS_EMAIL_ADDRESS", result)
}
}
// TestLicenseMissingPreventsReady verifies that missing license key blocks
// progress even when email and network are present.
func TestLicenseMissingPreventsReady(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = "test@example.com"
config.LicenseKey = ""
app.DB.Save(config)
app.downloadsComplete = true
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
result := app.computeState()
if result != NEEDS_LICENSE_KEY {
t.Errorf("state = %v, want NEEDS_LICENSE_KEY", result)
}
}
// TestDownloadDoneChannelSetsDownloadsComplete verifies that receiving on
// the DownloadDone channel sets downloadsComplete to true.
func TestDownloadDoneChannelSetsDownloadsComplete(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = "test@example.com"
config.LicenseKey = "test-key-123"
app.DB.Save(config)
app.downloadsComplete = false
downloadDone := make(chan struct{})
close(downloadDone)
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
result := app.computeState()
if !app.downloadsComplete {
t.Error("downloadsComplete should be true after DownloadDone channel is closed")
}
if result != READY {
t.Errorf("state = %v, want READY after downloads complete", result)
}
}
// TestPrerequisitePriorityEmailBeforeLicense verifies that missing email
// is reported before missing license key, even when both are absent.
func TestPrerequisitePriorityEmailBeforeLicense(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = ""
config.LicenseKey = ""
app.DB.Save(config)
app.downloadsComplete = true
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
result := app.computeState()
if result != NEEDS_EMAIL_ADDRESS {
t.Errorf("state = %v, want NEEDS_EMAIL_ADDRESS (email should be checked before license)", result)
}
}
// TestPrerequisitePriorityLicenseAfterEmail verifies that when email is
// present but license is missing, the state is NEEDS_LICENSE_KEY.
func TestPrerequisitePriorityLicenseAfterEmail(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = "test@example.com"
config.LicenseKey = ""
app.DB.Save(config)
app.downloadsComplete = true
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
result := app.computeState()
if result != NEEDS_LICENSE_KEY {
t.Errorf("state = %v, want NEEDS_LICENSE_KEY", result)
}
}
// TestStateBlocksOnDownloadUntilChannelClosed verifies that computeState
// returns DOWNLOADING_ASSETS repeatedly until DownloadDone is closed.
func TestStateBlocksOnDownloadUntilChannelClosed(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = "test@example.com"
config.LicenseKey = "test-key-123"
app.DB.Save(config)
app.downloadsComplete = false
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
// Should return DOWNLOADING_ASSETS multiple times before channel closes.
for i := 0; i < 5; i++ {
result := app.computeState()
if result != DOWNLOADING_ASSETS {
t.Fatalf("iteration %d: state = %v, want DOWNLOADING_ASSETS", i, result)
}
}
// Now close the channel.
close(downloadDone)
result := app.computeState()
if result != READY {
t.Errorf("after close: state = %v, want READY", result)
}
}
// TestDownloadDoneNonBlocking verifies that computeState does not block
// when DownloadDone channel is open (non-blocking select).
func TestDownloadDoneNonBlocking(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = "test@example.com"
config.LicenseKey = "test-key-123"
app.DB.Save(config)
app.downloadsComplete = false
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
// computeState must return promptly even though DownloadDone is not closed.
done := make(chan struct{})
go func() {
result := app.computeState()
if result != DOWNLOADING_ASSETS {
t.Errorf("state = %v, want DOWNLOADING_ASSETS", result)
}
close(done)
}()
select {
case <-done:
// Good: returned promptly.
case <-time.After(2 * time.Second):
t.Fatal("computeState blocked on DownloadDone channel (should be non-blocking)")
}
}
// TestMultipleComputeStateCallsAfterDownloadComplete verifies that calling
// computeState repeatedly after downloads finish does not panic or change state.
func TestMultipleComputeStateCallsAfterDownloadComplete(t *testing.T) {
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
}
networkIsUp = true
config, err := db.GetConfig(app.DB)
if err != nil {
t.Fatal(err)
}
config.EmailAddress = "test@example.com"
config.LicenseKey = "test-key-123"
app.DB.Save(config)
downloadDone := make(chan struct{})
close(downloadDone)
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
// First call transitions to READY.
result := app.computeState()
if result != READY {
t.Fatalf("first call: state = %v, want READY", result)
}
app.state = result
// Subsequent calls should return READY without panic.
for i := 0; i < 10; i++ {
result = app.computeState()
if result != READY {
t.Fatalf("iteration %d: state = %v, want READY", i, result)
}
}
}
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
d, err := db.Connect(":memory:")
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(d); err != nil {
t.Fatal(err)
}
cfg := &db.AppConfig{
Model: gorm.Model{ID: 1},
DataDir: t.TempDir(),
AssetsDir: t.TempDir(),
SystemLevel: "BASIC",
SelectedAudioDevice: "default",
InitialFontSize: 128,
StandardFontSize: 32,
LinePattern: "fibonacci",
AudioEnabled: true,
SavedVolume: 1.0,
EmailAddress: "",
LicenseKey: "",
}
if err := d.Create(cfg).Error; err != nil {
t.Fatal(err)
}
return d
}