Files
iOS/Tests/Shared/Database/DatabaseMigration.test.swift
nstefanelli 31b2db4853 Add kiosk mode core infrastructure (PR 1/5) (#4422)
## Summary

Reopening kiosk mode PR (previously #4218) after addressing all review
feedback from @bgoncal. Apologies for the delay — some family matters
pulled me away, but I'm excited to get this implemented and am fully
committed to seeing it through.

This PR adds the foundational infrastructure for **kiosk mode** — a
feature designed for wall-mounted iPad displays running Home Assistant
dashboards.

### What's included:
- **KioskModeManager** — Central coordinator singleton with
KioskModeObserver protocol (no NotificationCenter)
- **KioskSettings** — Comprehensive settings model with GRDB persistence
(camelCase columns, Codable)
- **Screensaver system** — Clock screensaver with 4 styles, pixel shift
for OLED burn-in prevention
- **Secret exit gesture** — Configurable corner tap to access settings
when locked down
- **Status bar hiding** — StatusBarForwardingNavigationController for
proper UIKit integration
- **Settings UI** — SwiftUI settings accessible via Settings menu and
secret gesture
- **28 unit tests** — Covering settings serialization, time logic,
orientations, enums

### Changes since #4218 (all review feedback addressed):
-  Fixed settings modal dismissal on iOS 15 (UINavigationController +
explicit onDismiss closure)
-  Fixed status bar hiding (StatusBarForwardingNavigationController +
statusBarView integration)
-  Removed non-functional settings (navigationLockdown,
TouchFeedbackManager, IconMapper)
-  Replaced `Date()` with `Current.date()` for testability
-  GRDB persistence follows CarPlayConfig/WatchConfig pattern with
DatabaseTableProtocol
-  All strings localized via L10n (SwiftGen)
-  SFSafeSymbols used throughout (no string-based systemName)
-  KioskModeObserver protocol (no NotificationCenter)
-  Kiosk files prefixed with `Kiosk`
-  UIKit screensaver container (required for status bar/home indicator
control)
-  macCatalyst filtered out
-  Merged with latest upstream/main (adapted to Frontend/ directory
restructure)
-  Idle timer pauses when settings view is open
-  TODO comments (no PR-specific comments)

### Testing Instructions
1. **Settings → Companion App → Kiosk Mode** → Enable "Enable Kiosk
Mode"
2. Set screensaver timeout to **30 seconds** for quick testing
3. Wait for screensaver → tap to wake
4. Use secret gesture (default: 3 taps in top-left corner) to access
settings

### Previous review context
All feedback from @bgoncal in #4218 has been addressed. See that PR for
full discussion history.

## Test plan
- [ ] Enable/disable kiosk mode from Settings
- [ ] Screensaver activates after timeout
- [ ] Tap to wake from screensaver
- [ ] Secret gesture opens settings
- [ ] Status bar hides when kiosk mode active (full-screen iPad only)
- [ ] Device authentication toggle works
- [ ] Settings persist across app restart
- [ ] Clock screensaver styles (large, minimal, digital, analog)
- [ ] Pixel shift moves clock position periodically
- [ ] Kiosk entry hidden on macCatalyst

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Nick Stefanelli <nstefanelli@users.noreply.github.com>
2026-03-13 13:03:54 +00:00

192 lines
7.2 KiB
Swift

import GRDB
@testable import Shared
import Testing
@Suite("Database Migration Tests")
struct DatabaseMigrationTests {
/// Helper table class for migration testing
class TestTable: DatabaseTableProtocol {
var tableName: String
var definedColumns: [String]
init(tableName: String, definedColumns: [String]) {
self.tableName = tableName
self.definedColumns = definedColumns
}
func createIfNeeded(database: DatabaseQueue) throws {
let shouldCreateTable = try database.read { db in
try !db.tableExists(tableName)
}
if shouldCreateTable {
try database.write { db in
try db.create(table: tableName) { t in
// Create all defined columns
for columnName in definedColumns {
t.column(columnName, .text)
}
}
}
} else {
try migrateColumns(database: database)
}
}
}
@Test("Add new columns to existing table")
func addNewColumns() throws {
let database = try DatabaseQueue(path: ":memory:")
// Create initial table with 2 columns
let initialTable = TestTable(tableName: "testTable", definedColumns: ["column1", "column2"])
try initialTable.createIfNeeded(database: database)
// Verify initial columns
var columns = try database.read { db in
try db.columns(in: "testTable").map(\.name)
}
#expect(columns.count == 2)
#expect(columns.contains("column1"))
#expect(columns.contains("column2"))
// Create updated table definition with 3 columns (added column3)
let updatedTable = TestTable(tableName: "testTable", definedColumns: ["column1", "column2", "column3"])
try updatedTable.createIfNeeded(database: database)
// Verify column was added
columns = try database.read { db in
try db.columns(in: "testTable").map(\.name)
}
#expect(columns.count == 3, "Should have 3 columns after adding column3")
#expect(columns.contains("column1"))
#expect(columns.contains("column2"))
#expect(columns.contains("column3"))
}
@Test("Skip already existing columns")
func skipExistingColumns() throws {
let database = try DatabaseQueue(path: ":memory:")
// Create table with 2 columns
let table = TestTable(tableName: "testTable", definedColumns: ["column1", "column2"])
try table.createIfNeeded(database: database)
// Try to create the same table again (should skip since columns already exist)
try table.createIfNeeded(database: database)
// Verify still only 2 columns
let columns = try database.read { db in
try db.columns(in: "testTable").map(\.name)
}
#expect(columns.count == 2)
#expect(columns.contains("column1"))
#expect(columns.contains("column2"))
}
@Test("Remove obsolete columns")
func removeObsoleteColumns() throws {
let database = try DatabaseQueue(path: ":memory:")
// Create initial table with 3 columns
let initialTable = TestTable(
tableName: "testTable",
definedColumns: ["column1", "column2", "obsoleteColumn"]
)
try initialTable.createIfNeeded(database: database)
// Verify initial columns
var columns = try database.read { db in
try db.columns(in: "testTable").map(\.name)
}
#expect(columns.count == 3)
#expect(columns.contains("obsoleteColumn"))
// Create updated table definition without obsoleteColumn
let updatedTable = TestTable(tableName: "testTable", definedColumns: ["column1", "column2"])
try updatedTable.createIfNeeded(database: database)
// Verify obsolete column was removed
columns = try database.read { db in
try db.columns(in: "testTable").map(\.name)
}
#expect(columns.count == 2, "Should have 2 columns after removing obsoleteColumn")
#expect(columns.contains("column1"))
#expect(columns.contains("column2"))
#expect(!columns.contains("obsoleteColumn"), "obsoleteColumn should have been removed")
}
@Test("Handle add and remove simultaneously")
func addAndRemoveSimultaneously() throws {
let database = try DatabaseQueue(path: ":memory:")
// Create initial table with columns: column1, column2, obsoleteColumn
let initialTable = TestTable(
tableName: "testTable",
definedColumns: ["column1", "column2", "obsoleteColumn"]
)
try initialTable.createIfNeeded(database: database)
// Create updated table definition: column1, column2, newColumn (removed obsoleteColumn, added newColumn)
let updatedTable = TestTable(tableName: "testTable", definedColumns: ["column1", "column2", "newColumn"])
try updatedTable.createIfNeeded(database: database)
// Verify columns
let columns = try database.read { db in
try db.columns(in: "testTable").map(\.name)
}
#expect(columns.count == 3, "Should have 3 columns")
#expect(columns.contains("column1"))
#expect(columns.contains("column2"))
#expect(columns.contains("newColumn"), "newColumn should have been added")
#expect(!columns.contains("obsoleteColumn"), "obsoleteColumn should have been removed")
}
@Test("Handle no changes needed")
func noChangesNeeded() throws {
let database = try DatabaseQueue(path: ":memory:")
// Create table with 2 columns
let table = TestTable(tableName: "testTable", definedColumns: ["column1", "column2"])
try table.createIfNeeded(database: database)
// Get initial columns
let initialColumns = try database.read { db in
try db.columns(in: "testTable").map(\.name)
}
// Run migration again with same columns
try table.createIfNeeded(database: database)
// Verify columns haven't changed
let finalColumns = try database.read { db in
try db.columns(in: "testTable").map(\.name)
}
#expect(initialColumns == finalColumns, "Columns should remain unchanged")
}
@Test("Migration with actual table: HAppEntityTable")
func realTableMigration() throws {
let database = try DatabaseQueue(path: ":memory:")
// Create HAppEntityTable
let table = HAppEntityTable()
try table.createIfNeeded(database: database)
// Verify table was created
let tableExists = try database.read { db in
try db.tableExists(table.tableName)
}
#expect(tableExists)
// Call createIfNeeded again (should trigger migration path, not creation)
try table.createIfNeeded(database: database)
// Verify table still exists and has correct columns
let columns = try database.read { db in
try db.columns(in: table.tableName).map(\.name)
}
let expectedColumns = DatabaseTables.AppEntity.allCases.map(\.rawValue)
#expect(Set(columns) == Set(expectedColumns))
}
}