Gate READY on download completion; move audio to ~/.radiantwave/audio

State machine now uses DOWNLOADING_ASSETS before READY. Downloads closing
the DownloadDone channel signals checkState() to proceed. Download failure
sets downloadFailed flag which prevents READY. Audio files moved from
~/.local/share/rhemawave/audio to ~/.radiantwave/audio/.

Regression tests in application_test.go (linux build-tagged due to
network package D-Bus init). Version 0.6.
This commit is contained in:
NeilClack
2026-07-11 13:43:05 -05:00
parent 2cc5071731
commit 3f8144a708
7 changed files with 252 additions and 26 deletions
+34 -17
View File
@@ -45,14 +45,16 @@ const (
)
type Application struct {
Window *sdl.Window
License *license.Response
pageStack []page.Page
pendingAction func()
running bool
DB *gorm.DB
PageRouter *page.Router
state State
Window *sdl.Window
License *license.Response
pageStack []page.Page
pendingAction func()
running bool
DB *gorm.DB
PageRouter *page.Router
state State
downloadsComplete bool
downloadFailed bool
}
var lastNetworkCheckTime time.Time
@@ -153,12 +155,14 @@ func Run() {
app.initializeAudio()
// Create a buffered channel so log writes don't slow down downloads.
downloadProgressChan := make(chan download.DownloadStatus, 1000)
downloadDone := make(chan struct{})
app.PageRouter = page.NewRouter(page.PageCtx{
DbPtr: app.DB,
W: w,
H: h,
MessageChannel: downloadProgressChan,
DownloadDone: downloadDone,
})
// Sync the proclamations from the API to the Database
@@ -183,16 +187,11 @@ func Run() {
// "PinealGland.wav",
// }
config, err := db.GetConfig(app.DB)
if err != nil {
panic(err)
}
audioDir := filepath.Join(config.AssetsDir, "audio")
audioDir := filepath.Join(homeDir, ".radiantwave", "audio")
_, err = os.Stat(audioDir)
if os.IsNotExist(err) {
if err = os.MkdirAll(audioDir, 0740); err != nil {
if err = os.MkdirAll(audioDir, 0755); err != nil {
logger.Error("unable to create audio directory: %v", err)
}
}
@@ -203,13 +202,22 @@ func Run() {
}
// checkState keeps watch of the application state and sets pages
// accordingly. This function runs once per
// accordingly. This function runs once per frame.
func (app *Application) checkState() {
// app.state is READY so do nothing.
if app.state == READY {
return
}
// Check if downloads have finished (non-blocking)
if !app.downloadsComplete {
select {
case <-app.PageRouter.Context.DownloadDone:
app.downloadsComplete = true
default:
}
}
// Check if there is an internet connection, and if not, force the WiFi page
if !networkIsUp {
if lastNetworkCheckTime.IsZero() || time.Since(lastNetworkCheckTime) >= time.Second {
@@ -264,6 +272,15 @@ func (app *Application) checkState() {
return
}
// All config prerequisites met — now wait for downloads to finish
if !app.downloadsComplete {
if app.state != DOWNLOADING_ASSETS {
app.state = DOWNLOADING_ASSETS
app.PageRouter.NavigateTo(page.HOME)
}
return
}
app.state = READY
app.PageRouter.NavigateTo(page.HOME)
}
@@ -494,7 +511,7 @@ func (app *Application) initKeybinds() {
})
keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() {
if app.state != READY {
if app.state != READY && app.state != DOWNLOADING_ASSETS {
return
}
app.PageRouter.NavigateTo(page.HOME)
+186
View File
@@ -0,0 +1,186 @@
//go:build linux
package application
import (
"testing"
"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) {
// Set up minimal application state
app := &Application{
state: FIRST_RUN,
DB: setupTestDB(t),
running: true,
}
// Simulate: network is up, email and license are set
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)
// Downloads are NOT complete
app.downloadsComplete = false
// Create a minimal router with channels
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
// Run checkState
app.checkState()
// Should be in DOWNLOADING_ASSETS, not READY
if app.state != DOWNLOADING_ASSETS {
t.Errorf("state = %v, want DOWNLOADING_ASSETS (downloads not complete)", app.state)
}
}
// 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),
running: true,
}
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)
// Downloads ARE complete
app.downloadsComplete = true
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
app.checkState()
if app.state != READY {
t.Errorf("state = %v, want READY", app.state)
}
}
// 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),
running: true,
}
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)
// Downloads completed but failed
app.downloadsComplete = false
app.downloadFailed = true
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
app.checkState()
// Should NOT be READY — download failed
if app.state == READY {
t.Error("state should not be READY when download has failed")
}
}
// TestStateStaysReadyOnceSet verifies that once the state reaches READY,
// checkState() is a no-op (doesn't reset or re-navigate).
func TestStateStaysReadyOnceSet(t *testing.T) {
app := &Application{
state: READY,
DB: setupTestDB(t),
running: true,
}
downloadDone := make(chan struct{})
app.PageRouter = &page.Router{
Context: page.PageCtx{
DbPtr: app.DB,
DownloadDone: downloadDone,
},
}
app.checkState()
if app.state != READY {
t.Errorf("state = %v, want READY (should not change once set)", app.state)
}
}
// setupTestDB creates a temporary in-memory SQLite database for testing.
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)
}
// Create default config
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
}