Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,8 @@ de1plus/plugins/log_upload/settings.tdb
/de1plus/tmp/
/de1plus/profiles_v2/*.json
/de1plus/history_v2/*.json

# Test artifacts
/de1plus/tests/test_results.txt
/de1plus/tests/test_error.txt
/de1plus/tests/tmp_test_data/
5 changes: 4 additions & 1 deletion de1plus/machine.tcl
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,10 @@ array set ::de1_substate_type_description {

array set ::de1_substate_types_reversed [reverse_array ::de1_substate_types]

array set translation [encoding convertfrom utf-8 [read_binary_file "[homedir]/translation.tcl"]]
if {[catch {array set translation [encoding convertfrom utf-8 [read_binary_file "[homedir]/translation.tcl"]]}]} {
msg -ERROR "translation.tcl is corrupted or unreadable — using empty translations"
array set translation {}
}

proc de1_substate_text {} {
set num $::de1(substate)
Expand Down
62 changes: 62 additions & 0 deletions de1plus/safe_load.tcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# safe_load.tcl — Settings corruption detection and backup recovery
#
# Provides load_settings_recover. No package requires, no side effects at source time.
# Dependencies (read_binary_file, msg, popup) must be defined before calling.
#
# Sourced by utils.tcl. Also sourced directly by tests.

# load_settings_recover — Detect corruption in a settings file and recover from .bak
#
# Arguments:
# fn - path to the settings file
# contents - the already-read contents of the settings file (UTF-8 decoded)
#
# Side effects:
# - On successful load or recovery: populates ::settings array
# - On recovery: calls popup with notification, logs via msg
# - On total failure: leaves ::settings empty, logs error
#
# Returns a dict:
# corrupted - 1 if corruption was detected, 0 otherwise
# recovered - 1 if successfully recovered from .bak, 0 otherwise
# contents - the effective settings_file_contents (may be "" if recovery failed)

proc load_settings_recover {fn contents} {
set corrupted 0
set recovered 0
set settings_file_contents $contents

if {[file exists $fn] && [string length $settings_file_contents] == 0} {
# File exists but is empty — power loss during write
set corrupted 1
msg -WARNING "Settings file exists but is empty — likely power loss during write"
} elseif {[string length $settings_file_contents] > 0} {
if {[catch {array set ::settings $settings_file_contents} err]} {
# File exists but content is malformed
set corrupted 1
msg -WARNING "Settings file is corrupted: $err"
}
}
# Note: if file doesn't exist, settings_file_contents is "" and corrupted stays 0
# — falls through to existing fresh-defaults behavior

if {$corrupted} {
set bakfile "${fn}.bak"
if {[file exists $bakfile]} {
set bak_contents [encoding convertfrom utf-8 [read_binary_file $bakfile]]
if {[string length $bak_contents] > 0 && ![catch {array set ::settings $bak_contents}]} {
msg -WARNING "Settings recovered from backup file"
catch { popup "Settings recovered from backup" }
set recovered 1
} else {
msg -ERROR "Settings backup is also corrupted — using fresh defaults"
set settings_file_contents ""
}
} else {
msg -WARNING "Settings corrupted and no backup exists — using fresh defaults"
set settings_file_contents ""
}
}

return [list corrupted $corrupted recovered $recovered contents $settings_file_contents]
}
63 changes: 63 additions & 0 deletions de1plus/safe_write.tcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# safe_write.tcl — Atomic file write with backup retention
#
# Provides write_file and fast_write_open. No package requires, no side effects
# at source time. Dependencies (msg) must be defined before calling write_file.
#
# Sourced by updater.tcl. Also sourced directly by tests.

proc fast_write_open {fn parms} {
set success 0
set f 0
set errcode [catch {
set f [open $fn $parms]

# Michael argues that there's no need to go nonblocking if you have a write buffer defined.
# https://3.basecamp.com/3671212/buckets/7351439/messages/3033510129#__recording_3037579684
# so disabling for now, to see if he's right.
# fconfigure $f -blocking 0

# explicitly declare LF as the line feed character, as that's what it is on unix/android/macos - only windows doesn't and it causes issues
fconfigure $f -buffersize 1000000 -translation {lf lf}
set success 1
}]

if {$errcode != 0} {
catch {
msg -ERROR "fast_write_open $::errorInfo"
}
}

return $f
#return ""
}

proc write_file {filename data} {
set success 0
set tmpfile "${filename}.tmp"
set bakfile "${filename}.bak"

set errcode [catch {
# 1. Write to temp file
set fn [fast_write_open $tmpfile w]
puts $fn $data
close $fn

# 2. Copy current file to .bak (original stays in place)
if {[file exists $filename]} {
file copy -force $filename $bakfile
}

# 3. Rename .tmp over target (atomic on POSIX/Android)
file rename -force $tmpfile $filename

set success 1
}]

if {$errcode != 0} {
catch { msg -ERROR "write_file '$filename' $::errorInfo" }
# Clean up failed temp file if it exists
catch { file delete $tmpfile }
}

return $success
}
47 changes: 47 additions & 0 deletions de1plus/tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# DE1 App Tests

Test suite using Tcl's built-in `tcltest` package.

## Running Tests

From the project root (`decent de1/`):

```
undroidwish-win64.exe run_tests.tcl
```

On Windows, undroidwish is a GUI-only interpreter and doesn't write to the console.
Results are written to `de1app/de1plus/tests/test_results.txt`.

After running:

```
type de1app\de1plus\tests\test_results.txt
```

Exit code is 0 on success, 1 if any test fails.

## Test Files

| File | Sources | Covers |
|------|---------|--------|
| `test_safe_write.tcl` | `safe_write.tcl`, `safe_load.tcl` | Atomic write_file (R1), .bak retention (R2), corruption recovery (R3), translation defensive loading (R3) |

## Architecture

Production code that needs testing lives in small, side-effect-free files:

- `safe_write.tcl` — `fast_write_open` and `write_file` procs
- `safe_load.tcl` — `load_settings_recover` proc

These are sourced by the main app files (`updater.tcl`, `utils.tcl`) and also sourced
directly by the test suite.

## Adding Tests

1. Create a new `.tcl` file in this directory.
2. `package require tcltest 2.5` and `namespace import ::tcltest::*`
3. Source the production file(s) you're testing (define mocks for their dependencies first).
4. Use `tcltest::test` for each test case.
5. Call `cleanupTests` at the end.
6. Add a `source` call in `run_tests.tcl` if you want the wrapper to run it.
Loading