aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-20 18:36:54 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-20 18:36:54 +0200
commit479d7ba2e843d5ea93979f2eed68f4d38d146c25 (patch)
tree52b0bc58a7456e8675e977698db20e55515b7100
parent151bfda5d4c567ff909a61dc9c90bf8eece1db37 (diff)
test(settings): cover every form, symbology and Manage action; send the forms end to end in the UI flow
34 Monkey C tests (154 per device): validation messages, idempotency, the queue, the About scan, "Encrypt names" and the properties contract. The UI-test build fills in Add OTP, Add code and Manage like the phone; the flow checks the new TOTP (309 848 at the frozen time), decodes the Code 128 card and confirms the queued change to the locked entry with the PIN. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--source/app/Fixtures.mc32
-rw-r--r--test/golden/fenix7/about.pngbin4028 -> 4017 bytes
-rw-r--r--test/golden/fenix7/code_form.pngbin0 -> 3290 bytes
-rw-r--r--test/golden/fenix7/import_results.pngbin4462 -> 4466 bytes
-rw-r--r--test/golden/fenix7/list.pngbin4654 -> 4640 bytes
-rw-r--r--test/golden/fenix7/menu.pngbin2732 -> 3201 bytes
-rw-r--r--test/golden/fenix7/otp_form.pngbin0 -> 4912 bytes
-rw-r--r--test/golden/fenix7/queue.pngbin0 -> 3374 bytes
-rw-r--r--test/golden/fenix7/queue_results.pngbin0 -> 2445 bytes
-rw-r--r--test/golden/fr255s/about.pngbin2374 -> 2350 bytes
-rw-r--r--test/golden/fr255s/code_form.pngbin0 -> 2208 bytes
-rw-r--r--test/golden/fr255s/import_results.pngbin2864 -> 2919 bytes
-rw-r--r--test/golden/fr255s/list.pngbin3160 -> 3155 bytes
-rw-r--r--test/golden/fr255s/menu.pngbin1716 -> 2081 bytes
-rw-r--r--test/golden/fr255s/otp_form.pngbin0 -> 3783 bytes
-rw-r--r--test/golden/fr255s/queue.pngbin0 -> 2147 bytes
-rw-r--r--test/golden/fr255s/queue_results.pngbin0 -> 1838 bytes
-rw-r--r--test/golden/fr70/about.pngbin15478 -> 15225 bytes
-rw-r--r--test/golden/fr70/code_form.pngbin0 -> 13639 bytes
-rw-r--r--test/golden/fr70/import_results.pngbin13389 -> 13834 bytes
-rw-r--r--test/golden/fr70/list.pngbin21003 -> 20842 bytes
-rw-r--r--test/golden/fr70/menu.pngbin37900 -> 40469 bytes
-rw-r--r--test/golden/fr70/otp_form.pngbin0 -> 21190 bytes
-rw-r--r--test/golden/fr70/queue.pngbin0 -> 40918 bytes
-rw-r--r--test/golden/fr70/queue_results.pngbin0 -> 8003 bytes
-rw-r--r--test/golden/fr965/about.pngbin16053 -> 15787 bytes
-rw-r--r--test/golden/fr965/code_form.pngbin0 -> 12339 bytes
-rw-r--r--test/golden/fr965/import_results.pngbin15853 -> 16195 bytes
-rw-r--r--test/golden/fr965/list.pngbin21792 -> 21555 bytes
-rw-r--r--test/golden/fr965/menu.pngbin42767 -> 44626 bytes
-rw-r--r--test/golden/fr965/otp_form.pngbin0 -> 25215 bytes
-rw-r--r--test/golden/fr965/queue.pngbin0 -> 45574 bytes
-rw-r--r--test/golden/fr965/queue_results.pngbin0 -> 10438 bytes
-rw-r--r--test/golden/instinct3amoled45mm/about.pngbin13647 -> 13576 bytes
-rw-r--r--test/golden/instinct3amoled45mm/code_form.pngbin0 -> 11402 bytes
-rw-r--r--test/golden/instinct3amoled45mm/import_results.pngbin14894 -> 15020 bytes
-rw-r--r--test/golden/instinct3amoled45mm/list.pngbin15614 -> 15528 bytes
-rw-r--r--test/golden/instinct3amoled45mm/menu.pngbin41632 -> 42369 bytes
-rw-r--r--test/golden/instinct3amoled45mm/otp_form.pngbin0 -> 15403 bytes
-rw-r--r--test/golden/instinct3amoled45mm/queue.pngbin0 -> 42893 bytes
-rw-r--r--test/golden/instinct3amoled45mm/queue_results.pngbin0 -> 10331 bytes
-rw-r--r--test/golden/venusq2/code_form.pngbin0 -> 2919 bytes
-rw-r--r--test/golden/venusq2/import_results.pngbin4825 -> 4879 bytes
-rw-r--r--test/golden/venusq2/list.pngbin5160 -> 5183 bytes
-rw-r--r--test/golden/venusq2/menu.pngbin3126 -> 3328 bytes
-rw-r--r--test/golden/venusq2/otp_form.pngbin0 -> 4827 bytes
-rw-r--r--test/golden/venux1/code_form.pngbin0 -> 11404 bytes
-rw-r--r--test/golden/venux1/import_results.pngbin21995 -> 26227 bytes
-rw-r--r--test/golden/venux1/list.pngbin26735 -> 26488 bytes
-rw-r--r--test/golden/venux1/menu.pngbin49066 -> 50106 bytes
-rw-r--r--test/golden/venux1/otp_form.pngbin0 -> 16910 bytes
-rw-r--r--test/settings/CodeFormTest.mc176
-rw-r--r--test/settings/ManageTest.mc320
-rw-r--r--test/settings/OtpFormTest.mc182
-rw-r--r--test/settings/SettingsJobTest.mc175
-rw-r--r--test/settings/SettingsSupport.mc110
-rw-r--r--tools/ui_flow.py72
57 files changed, 1061 insertions, 6 deletions
diff --git a/source/app/Fixtures.mc b/source/app/Fixtures.mc
index 31d4021..463780c 100644
--- a/source/app/Fixtures.mc
+++ b/source/app/Fixtures.mc
@@ -1,5 +1,5 @@
// Author: cirakg & claude ai
-// Summary: Debug-only fake entries (and, in UI tests, a fake import via the settings form); release builds get empty stubs.
+// Summary: Debug-only fake entries (and, in UI tests, a fake import and filled-in forms via settings); release builds get empty stubs.
//
// All data is fabricated: the secrets are the RFC 4226/6238 test keys and the
// names are made up. The UI-test build (source/util/TestMode.mc) wipes the
@@ -67,9 +67,39 @@ module Fixtures {
vault.lock();
if (TestMode.ON) {
startImport();
+ sendForms(vault);
}
}
+ // UI-test build: the other forms, filled in as the phone would (README
+ // 16, M7 end-to-end): Add OTP (a TOTP whose code the screenshot shows),
+ // Add code (a Code 128 card whose screenshot must decode) and Manage
+ // entry (Bank's group renamed: Bank is locked, so the change waits for
+ // the PIN, which the UI flow confirms from the menu). They are applied
+ // after the import, in README 10.3 order, so the new entries end the
+ // list.
+ function sendForms(vault as Vault) as Void {
+ Application.Properties.setValue("otpType", Entry.OTP_TOTP);
+ Application.Properties.setValue("otpIssuer", "Mailbox");
+ Application.Properties.setValue("otpAccount", "fixture@example.com");
+ Application.Properties.setValue("otpSecret", "JBSW Y3DP EHPK 3PXP"); // lint: allow-literal (the classic "Hello!" demo key)
+ Application.Properties.setValue("otpAlgorithm", Hashes.SHA1);
+ Application.Properties.setValue("otpDigits", 6);
+ Application.Properties.setValue("otpPeriod", 30);
+ Application.Properties.setValue("otpGroup", "Personal");
+ Application.Properties.setValue("otpAdd", true);
+ Application.Properties.setValue("codeName", "Locker");
+ Application.Properties.setValue("codeSymbology", Codes.CODE128);
+ Application.Properties.setValue("codePayload", "FAKE-0042");
+ Application.Properties.setValue("codeGroup", "Personal");
+ Application.Properties.setValue("codeAdd", true);
+ var ids = vault.ids();
+ Application.Properties.setValue("mgId", ids[2]); // Bank
+ Application.Properties.setValue("mgAction", ManageAction.EDIT);
+ Application.Properties.setValue("mgGroup", "Money");
+ Application.Properties.setValue("mgApply", true);
+ }
+
// UI-test build: an Aegis import of 100 entries through the settings
// form, the same path as the phone: 97 new, one duplicate of GitHub, one
// error and one skipped type. The results screen opens when it's done.
diff --git a/test/golden/fenix7/about.png b/test/golden/fenix7/about.png
index 9df582a..0dbbb80 100644
--- a/test/golden/fenix7/about.png
+++ b/test/golden/fenix7/about.png
Binary files differ
diff --git a/test/golden/fenix7/code_form.png b/test/golden/fenix7/code_form.png
new file mode 100644
index 0000000..2f6de04
--- /dev/null
+++ b/test/golden/fenix7/code_form.png
Binary files differ
diff --git a/test/golden/fenix7/import_results.png b/test/golden/fenix7/import_results.png
index 34c1cf7..9a14885 100644
--- a/test/golden/fenix7/import_results.png
+++ b/test/golden/fenix7/import_results.png
Binary files differ
diff --git a/test/golden/fenix7/list.png b/test/golden/fenix7/list.png
index ffcc3ff..b456505 100644
--- a/test/golden/fenix7/list.png
+++ b/test/golden/fenix7/list.png
Binary files differ
diff --git a/test/golden/fenix7/menu.png b/test/golden/fenix7/menu.png
index 3ceb597..b6b7386 100644
--- a/test/golden/fenix7/menu.png
+++ b/test/golden/fenix7/menu.png
Binary files differ
diff --git a/test/golden/fenix7/otp_form.png b/test/golden/fenix7/otp_form.png
new file mode 100644
index 0000000..7990539
--- /dev/null
+++ b/test/golden/fenix7/otp_form.png
Binary files differ
diff --git a/test/golden/fenix7/queue.png b/test/golden/fenix7/queue.png
new file mode 100644
index 0000000..6422077
--- /dev/null
+++ b/test/golden/fenix7/queue.png
Binary files differ
diff --git a/test/golden/fenix7/queue_results.png b/test/golden/fenix7/queue_results.png
new file mode 100644
index 0000000..478dfd9
--- /dev/null
+++ b/test/golden/fenix7/queue_results.png
Binary files differ
diff --git a/test/golden/fr255s/about.png b/test/golden/fr255s/about.png
index 0198345..51dc136 100644
--- a/test/golden/fr255s/about.png
+++ b/test/golden/fr255s/about.png
Binary files differ
diff --git a/test/golden/fr255s/code_form.png b/test/golden/fr255s/code_form.png
new file mode 100644
index 0000000..f218b4d
--- /dev/null
+++ b/test/golden/fr255s/code_form.png
Binary files differ
diff --git a/test/golden/fr255s/import_results.png b/test/golden/fr255s/import_results.png
index d2cdc19..6f500d5 100644
--- a/test/golden/fr255s/import_results.png
+++ b/test/golden/fr255s/import_results.png
Binary files differ
diff --git a/test/golden/fr255s/list.png b/test/golden/fr255s/list.png
index df30245..c373315 100644
--- a/test/golden/fr255s/list.png
+++ b/test/golden/fr255s/list.png
Binary files differ
diff --git a/test/golden/fr255s/menu.png b/test/golden/fr255s/menu.png
index 047504c..fb804e5 100644
--- a/test/golden/fr255s/menu.png
+++ b/test/golden/fr255s/menu.png
Binary files differ
diff --git a/test/golden/fr255s/otp_form.png b/test/golden/fr255s/otp_form.png
new file mode 100644
index 0000000..3bba7b6
--- /dev/null
+++ b/test/golden/fr255s/otp_form.png
Binary files differ
diff --git a/test/golden/fr255s/queue.png b/test/golden/fr255s/queue.png
new file mode 100644
index 0000000..2101705
--- /dev/null
+++ b/test/golden/fr255s/queue.png
Binary files differ
diff --git a/test/golden/fr255s/queue_results.png b/test/golden/fr255s/queue_results.png
new file mode 100644
index 0000000..c447958
--- /dev/null
+++ b/test/golden/fr255s/queue_results.png
Binary files differ
diff --git a/test/golden/fr70/about.png b/test/golden/fr70/about.png
index 7d0b222..1765b65 100644
--- a/test/golden/fr70/about.png
+++ b/test/golden/fr70/about.png
Binary files differ
diff --git a/test/golden/fr70/code_form.png b/test/golden/fr70/code_form.png
new file mode 100644
index 0000000..9c0d502
--- /dev/null
+++ b/test/golden/fr70/code_form.png
Binary files differ
diff --git a/test/golden/fr70/import_results.png b/test/golden/fr70/import_results.png
index 2da3dcc..f3e3afa 100644
--- a/test/golden/fr70/import_results.png
+++ b/test/golden/fr70/import_results.png
Binary files differ
diff --git a/test/golden/fr70/list.png b/test/golden/fr70/list.png
index eaa657e..ace90e9 100644
--- a/test/golden/fr70/list.png
+++ b/test/golden/fr70/list.png
Binary files differ
diff --git a/test/golden/fr70/menu.png b/test/golden/fr70/menu.png
index b35e078..f4fdc4b 100644
--- a/test/golden/fr70/menu.png
+++ b/test/golden/fr70/menu.png
Binary files differ
diff --git a/test/golden/fr70/otp_form.png b/test/golden/fr70/otp_form.png
new file mode 100644
index 0000000..0156b6b
--- /dev/null
+++ b/test/golden/fr70/otp_form.png
Binary files differ
diff --git a/test/golden/fr70/queue.png b/test/golden/fr70/queue.png
new file mode 100644
index 0000000..ccd0747
--- /dev/null
+++ b/test/golden/fr70/queue.png
Binary files differ
diff --git a/test/golden/fr70/queue_results.png b/test/golden/fr70/queue_results.png
new file mode 100644
index 0000000..9bd2136
--- /dev/null
+++ b/test/golden/fr70/queue_results.png
Binary files differ
diff --git a/test/golden/fr965/about.png b/test/golden/fr965/about.png
index 211487d..f48bea7 100644
--- a/test/golden/fr965/about.png
+++ b/test/golden/fr965/about.png
Binary files differ
diff --git a/test/golden/fr965/code_form.png b/test/golden/fr965/code_form.png
new file mode 100644
index 0000000..ed6108c
--- /dev/null
+++ b/test/golden/fr965/code_form.png
Binary files differ
diff --git a/test/golden/fr965/import_results.png b/test/golden/fr965/import_results.png
index c827d20..4d57962 100644
--- a/test/golden/fr965/import_results.png
+++ b/test/golden/fr965/import_results.png
Binary files differ
diff --git a/test/golden/fr965/list.png b/test/golden/fr965/list.png
index d0904b2..10d8016 100644
--- a/test/golden/fr965/list.png
+++ b/test/golden/fr965/list.png
Binary files differ
diff --git a/test/golden/fr965/menu.png b/test/golden/fr965/menu.png
index ba1149d..71ba71e 100644
--- a/test/golden/fr965/menu.png
+++ b/test/golden/fr965/menu.png
Binary files differ
diff --git a/test/golden/fr965/otp_form.png b/test/golden/fr965/otp_form.png
new file mode 100644
index 0000000..41ff390
--- /dev/null
+++ b/test/golden/fr965/otp_form.png
Binary files differ
diff --git a/test/golden/fr965/queue.png b/test/golden/fr965/queue.png
new file mode 100644
index 0000000..97773db
--- /dev/null
+++ b/test/golden/fr965/queue.png
Binary files differ
diff --git a/test/golden/fr965/queue_results.png b/test/golden/fr965/queue_results.png
new file mode 100644
index 0000000..9f41421
--- /dev/null
+++ b/test/golden/fr965/queue_results.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/about.png b/test/golden/instinct3amoled45mm/about.png
index 72fde99..bbad66e 100644
--- a/test/golden/instinct3amoled45mm/about.png
+++ b/test/golden/instinct3amoled45mm/about.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/code_form.png b/test/golden/instinct3amoled45mm/code_form.png
new file mode 100644
index 0000000..0c3757c
--- /dev/null
+++ b/test/golden/instinct3amoled45mm/code_form.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/import_results.png b/test/golden/instinct3amoled45mm/import_results.png
index 0dbf559..b45e972 100644
--- a/test/golden/instinct3amoled45mm/import_results.png
+++ b/test/golden/instinct3amoled45mm/import_results.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/list.png b/test/golden/instinct3amoled45mm/list.png
index d4b9323..90535b3 100644
--- a/test/golden/instinct3amoled45mm/list.png
+++ b/test/golden/instinct3amoled45mm/list.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/menu.png b/test/golden/instinct3amoled45mm/menu.png
index 6c6f81e..0fab0ff 100644
--- a/test/golden/instinct3amoled45mm/menu.png
+++ b/test/golden/instinct3amoled45mm/menu.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/otp_form.png b/test/golden/instinct3amoled45mm/otp_form.png
new file mode 100644
index 0000000..14363ff
--- /dev/null
+++ b/test/golden/instinct3amoled45mm/otp_form.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/queue.png b/test/golden/instinct3amoled45mm/queue.png
new file mode 100644
index 0000000..0b3eef0
--- /dev/null
+++ b/test/golden/instinct3amoled45mm/queue.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/queue_results.png b/test/golden/instinct3amoled45mm/queue_results.png
new file mode 100644
index 0000000..15a4159
--- /dev/null
+++ b/test/golden/instinct3amoled45mm/queue_results.png
Binary files differ
diff --git a/test/golden/venusq2/code_form.png b/test/golden/venusq2/code_form.png
new file mode 100644
index 0000000..9d441d2
--- /dev/null
+++ b/test/golden/venusq2/code_form.png
Binary files differ
diff --git a/test/golden/venusq2/import_results.png b/test/golden/venusq2/import_results.png
index 647ca21..d908572 100644
--- a/test/golden/venusq2/import_results.png
+++ b/test/golden/venusq2/import_results.png
Binary files differ
diff --git a/test/golden/venusq2/list.png b/test/golden/venusq2/list.png
index f778d10..f0be24f 100644
--- a/test/golden/venusq2/list.png
+++ b/test/golden/venusq2/list.png
Binary files differ
diff --git a/test/golden/venusq2/menu.png b/test/golden/venusq2/menu.png
index 6393819..b0e82c1 100644
--- a/test/golden/venusq2/menu.png
+++ b/test/golden/venusq2/menu.png
Binary files differ
diff --git a/test/golden/venusq2/otp_form.png b/test/golden/venusq2/otp_form.png
new file mode 100644
index 0000000..433eff2
--- /dev/null
+++ b/test/golden/venusq2/otp_form.png
Binary files differ
diff --git a/test/golden/venux1/code_form.png b/test/golden/venux1/code_form.png
new file mode 100644
index 0000000..868919c
--- /dev/null
+++ b/test/golden/venux1/code_form.png
Binary files differ
diff --git a/test/golden/venux1/import_results.png b/test/golden/venux1/import_results.png
index 1d1251d..3f27b17 100644
--- a/test/golden/venux1/import_results.png
+++ b/test/golden/venux1/import_results.png
Binary files differ
diff --git a/test/golden/venux1/list.png b/test/golden/venux1/list.png
index 8ba6f1f..95aa309 100644
--- a/test/golden/venux1/list.png
+++ b/test/golden/venux1/list.png
Binary files differ
diff --git a/test/golden/venux1/menu.png b/test/golden/venux1/menu.png
index 6930419..5a81418 100644
--- a/test/golden/venux1/menu.png
+++ b/test/golden/venux1/menu.png
Binary files differ
diff --git a/test/golden/venux1/otp_form.png b/test/golden/venux1/otp_form.png
new file mode 100644
index 0000000..880fa66
--- /dev/null
+++ b/test/golden/venux1/otp_form.png
Binary files differ
diff --git a/test/settings/CodeFormTest.mc b/test/settings/CodeFormTest.mc
new file mode 100644
index 0000000..9d51797
--- /dev/null
+++ b/test/settings/CodeFormTest.mc
@@ -0,0 +1,176 @@
+// Author: cirakg & claude ai
+// Summary: Tests for the Add code form: a valid and invalid payload for every symbology, options, notes, size warnings and the cache.
+
+import Toybox.Lang;
+import Toybox.Test;
+
+(:test)
+module CodeFormCases {
+
+ // [symbology, payload, part of the status] for contents every
+ // symbology accepts (all fake).
+ const VALID = [
+ [Codes.QR, "https://example.com/card/0042", ""],
+ [Codes.CODE128, "Member-0042", ""],
+ [Codes.CODE39, "CARD 0042", ""],
+ [Codes.EAN13, " 590123412345 ", "check digit 7 added"],
+ [Codes.EAN8, "96385074", ""],
+ [Codes.UPCA, "03600029145", "check digit 2 added"],
+ [Codes.UPCE, "0123456", ""],
+ [Codes.ITF, "123456789", ""], // with its check digit: 10 digits
+ [Codes.CODABAR, "40156", "start and stop characters A...A added"],
+ [Codes.DATAMATRIX, "PARCEL-FAKE-0042", ""],
+ [Codes.PDF417, "Boarding pass FAKE 0042", ""],
+ [Codes.AZTEC, "RAIL-FAKE-0042", ""]
+ ];
+
+ // [symbology, payload, check-digit mode, expected status part].
+ function invalid() as Array<Array> {
+ return [
+ [Codes.EAN13, "5901234123450", Codes.CHECK_ADD_VERIFY,
+ "Not added: EAN-13: check digit should be 7, got 0. Choose 'Fix if wrong' to correct it"],
+ [Codes.EAN13, "59012341234", Codes.CHECK_ADD_VERIFY, "EAN-13 needs 12 or 13 digits, got 11"],
+ [Codes.EAN13, "590123412345", Codes.CHECK_OFF, "EAN-13 with the check digit off needs all 13 digits"],
+ [Codes.EAN8, "12A4567", Codes.CHECK_ADD_VERIFY, "EAN-8 takes digits only"],
+ [Codes.UPCA, "123", Codes.CHECK_ADD_VERIFY, "UPC-A needs 11 or 12 digits, got 3"],
+ [Codes.UPCE, "2123456", Codes.CHECK_ADD_VERIFY, "UPC-E: the number system must be 0 or 1, got 2"],
+ [Codes.CODE128, "Café", Codes.CHECK_ADD_VERIFY, "Code 128: 'é' (position 4) is not a printable ASCII character"],
+ [Codes.CODE39, "card", Codes.CHECK_ADD_VERIFY, "Code 39: 'c' (position 1)"],
+ [Codes.ITF, "123", Codes.CHECK_OFF, "ITF needs an even number of digits: add a leading 0"],
+ [Codes.CODABAR, "A12X3B", Codes.CHECK_ADD_VERIFY, "Codabar: 'X' isn't allowed there"],
+ [Codes.QR, SettingsFixtures.repeat("a", 2400), Codes.CHECK_ADD_VERIFY, "QR: too long for a QR code"],
+ [Codes.DATAMATRIX, SettingsFixtures.repeat("a", 1600), Codes.CHECK_ADD_VERIFY, "too long for Data Matrix"],
+ [Codes.PDF417, SettingsFixtures.repeat("a1", 1500), Codes.CHECK_ADD_VERIFY, "PDF417: too"],
+ [Codes.AZTEC, SettingsFixtures.repeat("é", 2000), Codes.CHECK_ADD_VERIFY, "Aztec: too long"]
+ ] as Array<Array>;
+ }
+}
+
+// Every symbology accepts a typical payload; 2D codes are cached encoded.
+(:test)
+function settingsAddsEverySymbology(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ var ok = true;
+ for (var i = 0; i < CodeFormCases.VALID.size(); i++) {
+ var c = CodeFormCases.VALID[i] as Array;
+ var symbology = c[0] as Number;
+ SettingsFixtures.code(s, symbology, "Card " + i, c[1] as String);
+ SettingsFixtures.run(vault, s);
+ var status = SettingsFixtures.text(s, CodeForm.STATUS);
+ var ids = vault.ids();
+ if (ids.size() != i + 1 || status.find("Added " + ids[i] + " Card " + i) != 0 || status.find(c[2] as String) == null) {
+ logger.error(Codes.name(symbology) + ": " + status);
+ ok = false;
+ continue;
+ }
+ var cached = vault.loadCodeCache(ids[i]);
+ if (Codes.isMatrix(symbology) != (cached != null && Codes.Matrix.fromBytes(cached) != null)) {
+ logger.error(Codes.name(symbology) + ": cache " + (cached != null));
+ ok = false;
+ }
+ if (!SettingsFixtures.text(s, CodeForm.PAYLOAD).equals("") || Forms.flag(s, CodeForm.SWITCH)) {
+ logger.error(Codes.name(symbology) + ": form not cleared");
+ ok = false;
+ }
+ }
+ // Linear contents are stored trimmed.
+ return ok && SettingsFixtures.secretOf(vault, vault.ids()[3]).equals("590123412345");
+}
+
+// Every symbology's typical mistakes give the encoder's message; nothing
+// is added and only the contents are cleared.
+(:test)
+function settingsRejectsInvalidCodes(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var cases = CodeFormCases.invalid();
+ var ok = true;
+ for (var i = 0; i < cases.size(); i++) {
+ var c = cases[i];
+ var s = SettingsFixtures.store();
+ SettingsFixtures.code(s, c[0] as Number, "Card", c[1] as String);
+ s.set("codeCheckDigit", c[2] as Number);
+ SettingsFixtures.run(vault, s);
+ var status = SettingsFixtures.text(s, CodeForm.STATUS);
+ if (status.find(c[3] as String) == null || vault.ids().size() != 0 || !SettingsFixtures.text(s, "codeName").equals("Card")
+ || !SettingsFixtures.text(s, CodeForm.PAYLOAD).equals("")) {
+ logger.error("case " + i + " (" + Codes.name(c[0] as Number) + "): " + status);
+ ok = false;
+ }
+ }
+ return ok;
+}
+
+(:test)
+function settingsChecksCodeFields(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ SettingsFixtures.code(s, Codes.QR, " ", "x");
+ SettingsFixtures.run(vault, s);
+ var ok = SettingsFixtures.contains(logger, s, CodeForm.STATUS, "Name: enter a name for the card");
+ SettingsFixtures.code(s, Codes.QR, "Card", " ");
+ SettingsFixtures.run(vault, s);
+ ok = ok && SettingsFixtures.contains(logger, s, CodeForm.STATUS, "Contents: missing");
+ SettingsFixtures.code(s, 12, "Card", "x");
+ SettingsFixtures.run(vault, s);
+ ok = ok && SettingsFixtures.contains(logger, s, CodeForm.STATUS, "Type: choose one of the 12 barcode types");
+ SettingsFixtures.code(s, Codes.QR, "Card", SettingsFixtures.repeat("é", 2049));
+ SettingsFixtures.run(vault, s);
+ return ok && SettingsFixtures.contains(logger, s, CodeForm.STATUS, "Contents: too long (4098 bytes, at most 4096)")
+ && vault.ids().size() == 0;
+}
+
+// The form's options end up in Entry.options; "Fix if wrong" corrects the
+// check digit and says so.
+(:test)
+function settingsStoresCodeOptions(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ SettingsFixtures.code(s, Codes.EAN13, "Gym", "5901234123450");
+ s.set("codeCheckDigit", Codes.CHECK_FIX);
+ s.set("codeShowText", false);
+ s.set("codeEcLevel", 3);
+ s.set("codeGroup", "Sport");
+ s.set("codeLocked", false);
+ SettingsFixtures.run(vault, s);
+ if (vault.ids().size() != 1) {
+ logger.error(SettingsFixtures.text(s, CodeForm.STATUS));
+ return false;
+ }
+ var e = vault.open(vault.ids()[0]);
+ var ok = e.options == (3 | (Codes.CHECK_FIX << Codes.OPT_CHECK_SHIFT)) && e.group.equals("Sport") && e.type == Codes.EAN13;
+ e.wipeSecrets();
+ return ok && SettingsFixtures.contains(logger, s, CodeForm.STATUS, "check digit fixed: 7 (was 0)");
+}
+
+// Codes that encode but can't be shown at the minimum module size on this
+// screen are added with the README 12.2 warning.
+(:test)
+function settingsWarnsWhenACodeIsTooLarge(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ // About version 39 at level L: over 360 px at 2 px per module.
+ SettingsFixtures.code(s, Codes.QR, "Big", SettingsFixtures.repeat("Ab", 1400));
+ s.set("codeEcLevel", 0);
+ SettingsFixtures.run(vault, s);
+ var qr = SettingsFixtures.contains(logger, s, CodeForm.STATUS, "Too large for this screen: needs 2 px per module");
+ SettingsFixtures.code(s, Codes.CODE128, "Wide", "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn");
+ SettingsFixtures.run(vault, s);
+ return qr && SettingsFixtures.contains(logger, s, CodeForm.STATUS, "Too large for this screen: needs") && vault.ids().size() == 2;
+}
+
+// A locked card sent while locked waits as pending; no cache is written
+// for it (its key isn't available).
+(:test)
+function settingsLockedCodeWaitsForThePin(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ StorageFixtures.setPin(vault, StorageFixtures.PIN);
+ vault.lock();
+ var s = SettingsFixtures.store();
+ SettingsFixtures.code(s, Codes.DATAMATRIX, "Parcel", "PARCEL-FAKE-0042");
+ s.set("codeLocked", true);
+ SettingsFixtures.run(vault, s);
+ var id = vault.pendingIds().size() == 1 ? vault.pendingIds()[0] : "";
+ return id.length() > 0 && vault.storage().getValue("x." + id) == null
+ && SettingsFixtures.contains(logger, s, CodeForm.STATUS, "It waits for your PIN on the watch");
+}
diff --git a/test/settings/ManageTest.mc b/test/settings/ManageTest.mc
new file mode 100644
index 0000000..0adf785
--- /dev/null
+++ b/test/settings/ManageTest.mc
@@ -0,0 +1,320 @@
+// Author: cirakg & claude ai
+// Summary: Tests for the Manage entry form: every action on unlocked entries, the PIN queue for locked ones, IDs, repeats and damage.
+
+import Toybox.Lang;
+import Toybox.Test;
+
+(:test)
+module ManageFixtures {
+
+ // A vault with GitHub (TOTP), VPN (HOTP, counter 5) and Gym (EAN-13),
+ // all unlocked, and a PIN set (session unlocked).
+ function vault() as Vault {
+ var v = SettingsFixtures.vault();
+ StorageFixtures.setPin(v, StorageFixtures.PIN);
+ v.add(StorageFixtures.totp("GitHub"));
+ v.add(StorageFixtures.hotp("VPN", 5l));
+ var gym = new Entry();
+ gym.kind = Entry.KIND_CODE;
+ gym.type = Codes.EAN13;
+ gym.name = "Gym";
+ gym.payload = Bytes.fromUtf8("590123412345");
+ gym.options = Codes.DEFAULT_OPTIONS;
+ v.add(gym);
+ return v;
+ }
+
+ // Add a locked entry (needs the session unlocked) and return its ID.
+ function addLocked(v as Vault, name as String) as String {
+ var e = StorageFixtures.totp(name);
+ e.locked = true;
+ return v.add(e);
+ }
+
+ function isLocked(v as Vault, id as String) as Boolean {
+ var h = v.header(id);
+ return h != null && h.locked;
+ }
+
+ // A Manage form for `id`, switched on (not run yet).
+ function send(v as Vault, id as String, action as Number) as Forms.MemoryStore {
+ var s = SettingsFixtures.store();
+ SettingsFixtures.manage(s, id, action);
+ return s;
+ }
+
+ // Confirm the waiting changes, as after a correct PIN.
+ function confirm(v as Vault) as ChangeQueueJob {
+ var job = new ChangeQueueJob(v);
+ Jobs.runToCompletion(job, 1);
+ return job;
+ }
+}
+
+// Edit details: blank keeps a field, "-" clears it; the form is cleared.
+(:test)
+function manageEditsDetails(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var id = vault.ids()[0];
+ var s = ManageFixtures.send(vault, id.toLower(), ManageAction.EDIT); // IDs are case-insensitive
+ s.set("mgName", "GitLab");
+ s.set("mgGroup", "-");
+ SettingsFixtures.run(vault, s);
+ var e = vault.open(id);
+ var ok = e.name.equals("GitLab") && e.account.equals("alice@example.com") && e.group.equals("");
+ e.wipeSecrets();
+ return ok && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Edited " + id + ": GitLab (alice@example.com)")
+ && SettingsFixtures.text(s, "mgId").equals("") && !Forms.flag(s, ManageForm.SWITCH);
+}
+
+(:test)
+function manageRejectsBadEdits(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var ids = vault.ids();
+ var s = ManageFixtures.send(vault, ids[0], ManageAction.EDIT);
+ SettingsFixtures.run(vault, s);
+ var ok = SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Edit details: fill in a new name, account or group");
+ s = ManageFixtures.send(vault, ids[2], ManageAction.EDIT);
+ s.set("mgAccount", "bob");
+ SettingsFixtures.run(vault, s);
+ ok = ok && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "is a barcode, which has no account");
+ s = ManageFixtures.send(vault, ids[2], ManageAction.EDIT);
+ s.set("mgName", "-");
+ SettingsFixtures.run(vault, s);
+ ok = ok && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Name: a barcode needs a name");
+ s = ManageFixtures.send(vault, ids[0], ManageAction.EDIT);
+ s.set("mgName", "-");
+ s.set("mgAccount", "-");
+ SettingsFixtures.run(vault, s);
+ return ok && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "An OTP entry needs an issuer or an account")
+ && SettingsFixtures.secretOf(vault, ids[0]).length() > 0 && vault.anyHeader(ids[0]) != null
+ && (vault.anyHeader(ids[0]) as Entry).name.equals("GitHub");
+}
+
+// Move to an absolute position (clamped); applying it twice changes nothing.
+(:test)
+function manageMovesEntries(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var ids = vault.ids();
+ var s = ManageFixtures.send(vault, ids[2], ManageAction.MOVE);
+ s.set("mgPosition", 1);
+ SettingsFixtures.run(vault, s);
+ var moved = vault.ids()[0].equals(ids[2]) && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "to position 1");
+ s = ManageFixtures.send(vault, ids[0], ManageAction.MOVE);
+ s.set("mgPosition", 99);
+ SettingsFixtures.run(vault, s);
+ var last = vault.ids()[2].equals(ids[0]) && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "to position 3");
+ s = ManageFixtures.send(vault, ids[0], ManageAction.MOVE);
+ s.set("mgPosition", 0);
+ SettingsFixtures.run(vault, s);
+ return moved && last && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Position: 1 or more");
+}
+
+(:test)
+function manageDeletesAndSetsCounters(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var ids = vault.ids();
+ var s = ManageFixtures.send(vault, ids[1], ManageAction.COUNTER);
+ s.set("mgCounter", 1000l);
+ SettingsFixtures.run(vault, s);
+ var vpn = vault.header(ids[1]);
+ var counter = vpn != null && vpn.counter == 1000l;
+ s = ManageFixtures.send(vault, ids[0], ManageAction.COUNTER);
+ s.set("mgCounter", 3l);
+ SettingsFixtures.run(vault, s);
+ var notHotp = SettingsFixtures.contains(logger, s, ManageForm.STATUS, "is not a HOTP entry");
+ s = ManageFixtures.send(vault, ids[0], ManageAction.DELETE);
+ SettingsFixtures.run(vault, s);
+ return counter && notHotp && vault.ids().size() == 2 && vault.ids().indexOf(ids[0]) < 0
+ && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Deleted " + ids[0] + " GitHub (alice@example.com)");
+}
+
+// Replace secret (OTP, Base32 checked) and contents (code, checked by the
+// encoder with the entry's own options).
+(:test)
+function manageReplacesSecretsAndContents(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var ids = vault.ids();
+ var s = ManageFixtures.send(vault, ids[0], ManageAction.REPLACE);
+ s.set("mgSecret", AegisFixtures.SECRET_3);
+ SettingsFixtures.run(vault, s);
+ var secret = SettingsFixtures.secretOf(vault, ids[0]).equals("abcdefghijklmnopqrst")
+ && SettingsFixtures.text(s, "mgSecret").equals("");
+ s = ManageFixtures.send(vault, ids[0], ManageAction.REPLACE);
+ s.set("mgSecret", "AB1");
+ SettingsFixtures.run(vault, s);
+ var badSecret = SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Secret: '1' is not a Base32 character (position 3)")
+ && SettingsFixtures.secretOf(vault, ids[0]).equals("abcdefghijklmnopqrst") && SettingsFixtures.text(s, "mgSecret").equals("");
+ s = ManageFixtures.send(vault, ids[2], ManageAction.REPLACE);
+ s.set("mgSecret", "400638133393");
+ SettingsFixtures.run(vault, s);
+ var contents = SettingsFixtures.secretOf(vault, ids[2]).equals("400638133393")
+ && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Replaced the contents of " + ids[2] + " Gym");
+ s = ManageFixtures.send(vault, ids[2], ManageAction.REPLACE);
+ s.set("mgSecret", "123");
+ SettingsFixtures.run(vault, s);
+ return secret && badSecret && contents && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "EAN-13 needs 12 or 13 digits")
+ && SettingsFixtures.secretOf(vault, ids[2]).equals("400638133393");
+}
+
+// Unknown, malformed, pending and damaged IDs.
+(:test)
+function manageChecksTheId(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var s = ManageFixtures.send(vault, "K7F", ManageAction.DELETE);
+ SettingsFixtures.run(vault, s);
+ var ok = SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Entry ID: 'K7F' is not an ID");
+ s = ManageFixtures.send(vault, "ZZZZ", ManageAction.DELETE);
+ SettingsFixtures.run(vault, s);
+ ok = ok && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "No entry ZZZZ");
+ s = ManageFixtures.send(vault, "", ManageAction.DELETE);
+ SettingsFixtures.run(vault, s);
+ ok = ok && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Entry ID: missing");
+ vault.lock();
+ var pending = StorageFixtures.totp("Bank");
+ pending.locked = true;
+ var pendingId = vault.add(pending);
+ s = ManageFixtures.send(vault, pendingId, ManageAction.DELETE);
+ SettingsFixtures.run(vault, s);
+ ok = ok && SettingsFixtures.contains(logger, s, ManageForm.STATUS, pendingId + " waits for your PIN on the watch");
+ // A damaged record can still be deleted, nothing else.
+ var damaged = vault.ids()[0];
+ vault.storage().setValue("e." + damaged, "garbage");
+ s = ManageFixtures.send(vault, damaged, ManageAction.MOVE);
+ SettingsFixtures.run(vault, s);
+ ok = ok && SettingsFixtures.contains(logger, s, ManageForm.STATUS, damaged + " is damaged");
+ s = ManageFixtures.send(vault, damaged, ManageAction.DELETE);
+ SettingsFixtures.run(vault, s);
+ return ok && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Deleted damaged entry " + damaged) && vault.ids().size() == 2;
+}
+
+// The same Manage form twice is applied once.
+(:test)
+function manageAppliesTheSameFormOnce(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var ids = vault.ids();
+ var s = ManageFixtures.send(vault, ids[1], ManageAction.COUNTER);
+ s.set("mgCounter", 9l);
+ SettingsFixtures.run(vault, s);
+ vault.setCounter(ids[1], 10l); // the user pressed "next" on the watch
+ SettingsFixtures.manage(s, ids[1], ManageAction.COUNTER);
+ s.set("mgCounter", 9l);
+ SettingsFixtures.run(vault, s);
+ var header = vault.header(ids[1]);
+ return header != null && header.counter == 10l && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Already applied");
+}
+
+// Sensitive actions on a locked entry wait for the PIN even while the
+// session is unlocked (README 7.9); confirming applies them.
+(:test)
+function manageQueuesLockedEntryChanges(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var bank = ManageFixtures.addLocked(vault, "Bank");
+ var safe = ManageFixtures.addLocked(vault, "Safe");
+ var s = ManageFixtures.send(vault, bank, ManageAction.DELETE);
+ SettingsFixtures.run(vault, s);
+ var queued = SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Waiting for your PIN on the watch: Delete: " + bank + " Bank")
+ && vault.ids().indexOf(bank) >= 0 && ChangeQueue.count(vault) == 1;
+ s = ManageFixtures.send(vault, safe, ManageAction.REPLACE);
+ s.set("mgSecret", AegisFixtures.SECRET_3);
+ SettingsFixtures.run(vault, s);
+ s = ManageFixtures.send(vault, safe, ManageAction.UNLOCK);
+ SettingsFixtures.run(vault, s);
+ var descriptions = ChangeQueue.descriptions(vault);
+ var listed = descriptions.size() == 3 && descriptions[1].equals("Replace contents: " + safe + " Safe (alice@example.com)");
+ var job = ManageFixtures.confirm(vault);
+ var header = vault.header(safe);
+ var applied = vault.ids().indexOf(bank) < 0 && header != null && !header.locked
+ && SettingsFixtures.secretOf(vault, safe).equals("abcdefghijklmnopqrst") && ChangeQueue.count(vault) == 0;
+ if (!applied) {
+ logger.error(job.lines.toString());
+ }
+ return queued && listed && applied && SettingsFixtures.hasLine(job.lines, "Deleted " + bank);
+}
+
+// "Require PIN" applies at once while unlocked, waits while locked.
+(:test)
+function manageLocksEntries(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var ids = vault.ids();
+ var s = ManageFixtures.send(vault, ids[0], ManageAction.LOCK);
+ SettingsFixtures.run(vault, s);
+ var now = ManageFixtures.isLocked(vault, ids[0]) && SettingsFixtures.contains(logger, s, ManageForm.STATUS, "now requires the PIN");
+ vault.lock();
+ s = ManageFixtures.send(vault, ids[1], ManageAction.LOCK);
+ SettingsFixtures.run(vault, s);
+ var waits = !ManageFixtures.isLocked(vault, ids[1]) && ChangeQueue.count(vault) == 1;
+ // In lower case: the same form again would just be "Already applied".
+ s = ManageFixtures.send(vault, ids[0].toLower(), ManageAction.LOCK);
+ SettingsFixtures.run(vault, s);
+ var already = SettingsFixtures.contains(logger, s, ManageForm.STATUS, "already requires the PIN");
+ StorageFixtures.unlock(vault, StorageFixtures.PIN);
+ ManageFixtures.confirm(vault);
+ return now && waits && already && ManageFixtures.isLocked(vault, ids[1]);
+}
+
+// Changes that arrive while the queue is being applied stay queued.
+(:test)
+function manageQueueKeepsLateArrivals(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var bank = ManageFixtures.addLocked(vault, "Bank");
+ var safe = ManageFixtures.addLocked(vault, "Safe");
+ var s = ManageFixtures.send(vault, bank, ManageAction.DELETE);
+ SettingsFixtures.run(vault, s);
+ var job = new ChangeQueueJob(vault);
+ job.step(1);
+ s = ManageFixtures.send(vault, safe, ManageAction.DELETE);
+ SettingsFixtures.run(vault, s);
+ Jobs.runToCompletion(job, 1);
+ var left = ChangeQueue.descriptions(vault);
+ return vault.ids().indexOf(bank) < 0 && vault.ids().indexOf(safe) >= 0 && left.size() == 1
+ && left[0].find(safe) != null;
+}
+
+// A queued change whose entry disappeared meanwhile is reported, not applied.
+(:test)
+function manageQueueSurvivesMissingEntries(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var bank = ManageFixtures.addLocked(vault, "Bank");
+ var s = ManageFixtures.send(vault, bank, ManageAction.UNLOCK);
+ SettingsFixtures.run(vault, s);
+ vault.remove(bank);
+ var job = ManageFixtures.confirm(vault);
+ return SettingsFixtures.hasLine(job.lines, "Not applied: Don't require PIN: " + bank) && ChangeQueue.count(vault) == 0;
+}
+
+// Queue items are sealed one by one: a tampered item is dropped, the
+// others survive; the queue holds at most MAX_ITEMS.
+(:test)
+function manageQueueIsSealedAndLimited(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var a = new ManageAction();
+ a.id = "K7F2";
+ a.action = ManageAction.REPLACE;
+ a.material = Bytes.fromUtf8("secret");
+ a.decoded = true;
+ a.label = "Bank";
+ ChangeQueue.add(vault, a);
+ var b = new ManageAction();
+ b.id = "M3P9";
+ b.action = ManageAction.DELETE;
+ ChangeQueue.add(vault, b);
+ var raw = (vault.storage() as MemoryBackend).raw(ChangeQueue.KEY) as Array<ByteArray>;
+ var box = raw[0];
+ box[20] = box[20] ^ 1;
+ var loaded = ChangeQueue.load(vault);
+ var sealed = loaded.size() == 1 && loaded[0].id.equals("M3P9") && loaded[0].action == ManageAction.DELETE;
+ var roundTrip = ManageAction.fromBytes(a.toBytes());
+ var same = roundTrip != null && roundTrip.decoded && roundTrip.label.equals("Bank")
+ && Bytes.equal(roundTrip.material as ByteArray, Bytes.fromUtf8("secret"));
+ ChangeQueue.clear(vault);
+ var limited = false;
+ for (var i = 0; i <= ChangeQueue.MAX_ITEMS; i++) {
+ try {
+ ChangeQueue.add(vault, b);
+ } catch (e instanceof VaultError) {
+ limited = i == ChangeQueue.MAX_ITEMS && e.code == VaultError.LIMIT;
+ }
+ }
+ return sealed && same && limited && ManageAction.fromBytes([1, 2, 3]b) == null;
+}
diff --git a/test/settings/OtpFormTest.mc b/test/settings/OtpFormTest.mc
new file mode 100644
index 0000000..9e6249e
--- /dev/null
+++ b/test/settings/OtpFormTest.mc
@@ -0,0 +1,182 @@
+// Author: cirakg & claude ai
+// Summary: Tests for the Add OTP form: every type, every validation message, clearing, warnings, idempotency, pending locked entries.
+
+import Toybox.Lang;
+import Toybox.Test;
+
+// A TOTP form is added with all its fields; the form is cleared (secret
+// included), the switch turned off and the status names the new ID.
+(:test)
+function settingsAddsTotpAndClearsTheForm(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice", AegisFixtures.SECRET_1);
+ s.set("otpAlgorithm", Hashes.SHA256);
+ s.set("otpDigits", 8);
+ s.set("otpPeriod", 60);
+ s.set("otpGroup", "Work");
+ var job = SettingsFixtures.run(vault, s);
+ if (vault.ids().size() != 1) {
+ logger.error("status: " + SettingsFixtures.text(s, OtpForm.STATUS));
+ return false;
+ }
+ var id = vault.ids()[0];
+ var e = vault.open(id);
+ var fields = e.name.equals("GitHub") && e.account.equals("alice") && e.group.equals("Work") && e.algorithm == Hashes.SHA256
+ && e.digits == 8 && e.period == 60 && e.source == Entry.SOURCE_MANUAL && !e.locked;
+ e.wipeSecrets();
+ return fields && SettingsFixtures.secretOf(vault, id).equals("12345678901234567890")
+ && SettingsFixtures.text(s, OtpForm.STATUS).equals("Added " + id + " GitHub (alice)")
+ && SettingsFixtures.text(s, "otpSecret").equals("") && SettingsFixtures.text(s, "otpIssuer").equals("")
+ && !Forms.flag(s, OtpForm.SWITCH) && Forms.number(s, "otpDigits") == 6 && job.forms == 1 && job.changed
+ && SettingsFixtures.hasLine(job.lines, "Add OTP: Added " + id);
+}
+
+// HOTP keeps its counter; Steam ignores algorithm, digits and period.
+(:test)
+function settingsAddsHotpAndSteam(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ SettingsFixtures.otp(s, Entry.OTP_HOTP, "VPN", "", AegisFixtures.SECRET_1);
+ s.set("otpCounter", 42l);
+ SettingsFixtures.run(vault, s);
+ SettingsFixtures.otp(s, Entry.OTP_STEAM, "Steam", "erin", AegisFixtures.SECRET_3);
+ s.set("otpAlgorithm", Hashes.SHA512);
+ s.set("otpDigits", 8);
+ s.set("otpPeriod", 10);
+ SettingsFixtures.run(vault, s);
+ if (vault.ids().size() != 2) {
+ logger.error("status: " + SettingsFixtures.text(s, OtpForm.STATUS));
+ return false;
+ }
+ var hotp = vault.open(vault.ids()[0]);
+ var steam = vault.open(vault.ids()[1]);
+ var ok = hotp.type == Entry.OTP_HOTP && hotp.counter == 42l && steam.type == Entry.OTP_STEAM
+ && steam.algorithm == Hashes.SHA1 && steam.period == Otp.STEAM_PERIOD;
+ hotp.wipeSecrets();
+ steam.wipeSecrets();
+ return ok;
+}
+
+// Each invalid field gives its message; nothing is added, only the secret
+// is cleared (so the rest can be corrected) and the switch goes off.
+(:test)
+function settingsRejectsInvalidOtpForms(logger as Test.Logger) as Boolean {
+ // [field, value, expected message part]
+ var cases = [
+ ["otpSecret", "JBSW1Y3D","Not added: Secret: '1' is not a Base32 character (position 5)"],
+ ["otpSecret", " ", "Secret: missing"],
+ ["otpSecret", "JBSWY3DP=A", "Secret: characters after '=' padding (position 10)"],
+ ["otpDigits", 9, "Digits: choose 6, 7 or 8"],
+ ["otpDigits", "eight", "Digits: choose 6, 7 or 8"],
+ ["otpPeriod", 0, "Period: 1 to 300 seconds"],
+ ["otpPeriod", 301, "Period: 1 to 300 seconds"],
+ ["otpAlgorithm", 5, "Algorithm: choose SHA1, SHA256 or SHA512"],
+ ["otpType", 7, "Type: choose TOTP, HOTP or Steam"],
+ ["otpIssuer", "Git\nHub", "Issuer: control character at position 4"],
+ ["otpGroup", SettingsFixtures.repeat("g", 33), "Group: too long (33 bytes, at most 32)"],
+ ["otpAccount", SettingsFixtures.repeat("é", 33), "Account: too long (66 bytes, at most 64)"]
+ ];
+ var ok = true;
+ for (var i = 0; i < cases.size(); i++) {
+ var c = cases[i] as Array;
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice", AegisFixtures.SECRET_2);
+ s.set("otpLocked", true); // a field no case changes: it must be kept
+ s.set(c[0] as String, c[1] as String or Number);
+ SettingsFixtures.run(vault, s);
+ var status = SettingsFixtures.text(s, OtpForm.STATUS);
+ if (status.find(c[2] as String) == null || vault.ids().size() != 0 || vault.pendingIds().size() != 0
+ || Forms.flag(s, OtpForm.SWITCH) || !SettingsFixtures.text(s, "otpSecret").equals("")
+ || !Forms.flag(s, "otpLocked")) {
+ logger.error("case " + i + ": status '" + status + "'");
+ ok = false;
+ }
+ }
+ return ok;
+}
+
+(:test)
+function settingsNeedsANameAndACounter(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ SettingsFixtures.otp(s, Entry.OTP_TOTP, " ", "", AegisFixtures.SECRET_2);
+ SettingsFixtures.run(vault, s);
+ var names = SettingsFixtures.contains(logger, s, OtpForm.STATUS, "Fill in the issuer or the account");
+ SettingsFixtures.otp(s, Entry.OTP_HOTP, "VPN", "", AegisFixtures.SECRET_2);
+ s.set("otpCounter", -1l);
+ SettingsFixtures.run(vault, s);
+ return names && SettingsFixtures.contains(logger, s, OtpForm.STATUS, "Counter: a whole number, 0 or more") && vault.ids().size() == 0;
+}
+
+// A secret below 80 bits is accepted with a warning (README 10.4).
+(:test)
+function settingsWarnsAboutShortSecrets(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ SettingsFixtures.otp(s, Entry.OTP_TOTP, "Old", "x", "JBSW Y3DP"); // 5 bytes; the space is ignored
+ var job = SettingsFixtures.run(vault, s);
+ return vault.ids().size() == 1 && SettingsFixtures.contains(logger, s, OtpForm.STATUS, "Warning: the secret is only 40 bits")
+ && SettingsFixtures.hasLine(job.lines, "Warning: the secret is only 40 bits");
+}
+
+// The same form sent twice is applied once (README 10.3, rule 3); any
+// change makes it a new request.
+(:test)
+function settingsAppliesTheSameOtpFormOnce(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice", AegisFixtures.SECRET_1);
+ SettingsFixtures.run(vault, s);
+ SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice", AegisFixtures.SECRET_1);
+ SettingsFixtures.run(vault, s);
+ var repeated = SettingsFixtures.contains(logger, s, OtpForm.STATUS, "Already added") && vault.ids().size() == 1
+ && SettingsFixtures.text(s, "otpSecret").equals("");
+ SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice (work)", AegisFixtures.SECRET_1);
+ SettingsFixtures.run(vault, s);
+ return repeated && vault.ids().size() == 2;
+}
+
+// A locked entry sent while the watch is locked waits as pending (README 7.7).
+(:test)
+function settingsLockedOtpWaitsForThePin(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ StorageFixtures.setPin(vault, StorageFixtures.PIN);
+ vault.lock();
+ var s = SettingsFixtures.store();
+ SettingsFixtures.otp(s, Entry.OTP_TOTP, "Bank", "carol", AegisFixtures.SECRET_1);
+ s.set("otpLocked", true);
+ SettingsFixtures.run(vault, s);
+ return vault.pendingIds().size() == 1 && vault.ids().size() == 0
+ && SettingsFixtures.contains(logger, s, OtpForm.STATUS, "It waits for your PIN on the watch");
+}
+
+// Nothing switched on: no form is looked at, nothing is written.
+(:test)
+function settingsIgnoresFormsThatAreOff(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ s.set("otpIssuer", "GitHub");
+ s.set("otpSecret", AegisFixtures.SECRET_1);
+ var job = SettingsFixtures.run(vault, s);
+ return job.forms == 0 && vault.ids().size() == 0 && SettingsFixtures.text(s, "otpSecret").equals(AegisFixtures.SECRET_1)
+ && job.lines.size() == 0;
+}
+
+// Field values of unexpected types are read safely (the phone promises
+// nothing): numbers as text, longs, whole floats.
+(:test)
+function settingsReadsLooselyTypedNumbers(logger as Test.Logger) as Boolean {
+ var s = new Forms.MemoryStore();
+ s.set("a", "42");
+ s.set("b", 7l);
+ s.set("c", 3.0f);
+ s.set("d", 3.5f);
+ s.set("e", "-12");
+ s.set("f", "x1");
+ s.set("g", true);
+ return Forms.number(s, "a") == 42 && Forms.number(s, "b") == 7 && Forms.number(s, "c") == 3 && Forms.number(s, "d") == null
+ && Forms.long(s, "e") == -12l && Forms.number(s, "f") == null && Forms.number(s, "g") == null
+ && Forms.number(s, "missing") == null && Forms.text(s, "g").equals("") && !Forms.flag(s, "a");
+}
diff --git a/test/settings/SettingsJobTest.mc b/test/settings/SettingsJobTest.mc
new file mode 100644
index 0000000..07dbc64
--- /dev/null
+++ b/test/settings/SettingsJobTest.mc
@@ -0,0 +1,175 @@
+// Author: cirakg & claude ai
+// Summary: Tests for the whole settings run: README order, the import through its form, the About fields and "Encrypt names".
+
+import Toybox.Application;
+import Toybox.Lang;
+import Toybox.Test;
+
+// All forms at once are applied in README 10.3 order: Import, Add OTP,
+// Add code, Manage.
+(:test)
+function settingsAppliesFormsInOrder(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var first = vault.ids()[0];
+ var s = SettingsFixtures.store();
+ SettingsFixtures.manage(s, first, ManageAction.DELETE);
+ SettingsFixtures.code(s, Codes.QR, "Member", "https://example.com/m/42");
+ SettingsFixtures.otp(s, Entry.OTP_TOTP, "Mail", "bob", AegisFixtures.SECRET_2);
+ s.set("impText1", ImporterFixtures.uri("Proton", "zoe", AegisFixtures.SECRET_3));
+ s.set("impApply", true);
+ var job = SettingsFixtures.run(vault, s);
+ var lines = job.lines;
+ var order = ["Import: Imported 1", "Add OTP: Added", "Add code: Added", "Manage: Deleted " + first];
+ var at = 0;
+ for (var i = 0; i < lines.size() && at < order.size(); i++) {
+ if (lines[i].find(order[at] as String) == 0) {
+ at++;
+ }
+ }
+ if (at != order.size() || job.forms != 4) {
+ logger.error(lines.toString());
+ return false;
+ }
+ // 3 fixtures + 3 added - 1 deleted.
+ return vault.ids().size() == 5 && SettingsFixtures.text(s, "impText1").equals("") && !Forms.flag(s, "impApply")
+ && SettingsFixtures.text(s, "impStatus").equals("Imported 1");
+}
+
+// The import form keeps its M5 behaviour: a repeat is ignored, a cancel
+// leaves the text.
+(:test)
+function settingsImportFormIsAppliedOnce(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ var uri = ImporterFixtures.uri("Proton", "zoe", AegisFixtures.SECRET_3);
+ s.set("impText1", uri);
+ s.set("impApply", true);
+ SettingsFixtures.run(vault, s);
+ s.set("impText1", uri);
+ s.set("impApply", true);
+ SettingsFixtures.run(vault, s);
+ var repeated = SettingsFixtures.text(s, "impStatus").equals("Already imported") && vault.ids().size() == 1;
+ s.set("impText1", "partial");
+ s.set("impMode", Importer.MODE_CANCEL);
+ s.set("impApply", true);
+ SettingsFixtures.run(vault, s);
+ return repeated && SettingsFixtures.text(s, "impStatus").equals("Waiting import cancelled")
+ && SettingsFixtures.text(s, "impText1").equals("partial");
+}
+
+// The About fields list IDs and names, the version and the storage use;
+// they are rewritten only when something changed.
+(:test)
+function settingsWritesTheAboutFields(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var ids = vault.ids();
+ var s = SettingsFixtures.store();
+ var job = SettingsFixtures.run(vault, s);
+ var catalog = SettingsFixtures.text(s, "catalog");
+ var expected = ids[0] + " GitHub (alice@example.com) · " + ids[1] + " VPN (alice@example.com) · " + ids[2] + " Gym";
+ if (!catalog.equals(expected)) {
+ logger.error("catalog: " + catalog);
+ return false;
+ }
+ var fresh = !AboutScan.needed(vault) && job.forms == 0 && job.lines.size() == 0;
+ vault.add(StorageFixtures.totp("New"));
+ return fresh && AboutScan.needed(vault) && SettingsFixtures.text(s, "version").equals(AppInfo.VERSION)
+ && SettingsFixtures.contains(logger, s, "storageUse", "3 of 150 entries, 1 KB of about 100 KB");
+}
+
+// A long catalog is cut with a count of what's left out.
+(:test)
+function settingsShortensTheCatalog(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ for (var i = 0; i < 60; i++) {
+ var e = StorageFixtures.totp("Service number " + i);
+ vault.add(e);
+ }
+ var s = SettingsFixtures.store();
+ SettingsFixtures.run(vault, s);
+ var catalog = SettingsFixtures.text(s, "catalog");
+ return catalog.length() <= AboutScan.CATALOG_MAX_CHARS + 60 && catalog.find("more (see Entry IDs on the watch)") != null;
+}
+
+// "Encrypt names" moves names into the encrypted part of every entry it
+// can reach, keeps them off the phone's catalog, and is undone when off.
+// Locked entries wait for the session.
+(:test)
+function settingsAppliesEncryptNames(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var bank = ManageFixtures.addLocked(vault, "Bank");
+ vault.lock();
+ vault.encryptNames = true;
+ var s = SettingsFixtures.store();
+ SettingsFixtures.run(vault, s);
+ var ids = vault.ids();
+ var unlockedDone = (vault.anyHeader(ids[0]) as Entry).namesEncrypted && (vault.anyHeader(ids[0]) as Entry).name.equals("")
+ && (vault.header(ids[0]) as Entry).name.equals("GitHub");
+ var lockedWaits = !(vault.anyHeader(bank) as Entry).namesEncrypted && AboutScan.needed(vault);
+ var catalog = SettingsFixtures.text(s, "catalog");
+ var namesHidden = catalog.find("GitHub") == null && catalog.find(ids[0] + " TOTP") != null && catalog.find(ids[2] + " EAN-13") != null;
+ StorageFixtures.unlock(vault, StorageFixtures.PIN);
+ SettingsFixtures.run(vault, s);
+ var lockedDone = (vault.anyHeader(bank) as Entry).namesEncrypted && !AboutScan.needed(vault);
+ vault.encryptNames = false;
+ SettingsFixtures.run(vault, s);
+ var back = !(vault.anyHeader(ids[0]) as Entry).namesEncrypted && (vault.anyHeader(ids[0]) as Entry).name.equals("GitHub")
+ && SettingsFixtures.text(s, "catalog").find("GitHub") != null;
+ // A new entry follows the option.
+ vault.encryptNames = true;
+ var id = vault.add(StorageFixtures.totp("Later"));
+ var follows = (vault.anyHeader(id) as Entry).namesEncrypted;
+ if (!(unlockedDone && lockedWaits && namesHidden && lockedDone && back && follows)) {
+ logger.error([unlockedDone, lockedWaits, namesHidden, lockedDone, back, follows].toString() + " " + catalog);
+ return false;
+ }
+ return true;
+}
+
+// hasWork: nothing to do once the About fields are current and every
+// switch is off.
+(:test)
+function settingsHasWorkOnlyWhenNeeded(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ var before = SettingsJob.hasWork(vault, s);
+ SettingsFixtures.run(vault, s);
+ var after = SettingsJob.hasWork(vault, s);
+ s.set(CodeForm.SWITCH, true);
+ return before && !after && SettingsJob.hasWork(vault, s);
+}
+
+// The watch's side of the properties contract (tools/check_resources.py
+// checks the phone's side): every field the forms read exists in
+// properties.xml (reading an undefined property crashes the app) and the
+// values a cleared form goes back to are the properties' defaults.
+(:test)
+function settingsDefaultsMatchProperties(logger as Test.Logger) as Boolean {
+ var forms = [OtpForm.defaults(), CodeForm.defaults(), ManageForm.defaults()] as Array<Dictionary>;
+ var ok = true;
+ for (var f = 0; f < forms.size(); f++) {
+ var d = forms[f];
+ var keys = d.keys();
+ for (var i = 0; i < keys.size(); i++) {
+ var key = keys[i] as String;
+ var actual = Application.Properties.getValue(key);
+ var expected = (d[key] as Object).toString();
+ if (actual == null || !actual.toString().equals(expected)) {
+ logger.error(key + ": property " + actual + ", form default " + expected);
+ ok = false;
+ }
+ }
+ }
+ var others = [OtpForm.SWITCH, OtpForm.STATUS, CodeForm.SWITCH, CodeForm.STATUS, ManageForm.SWITCH, ManageForm.STATUS,
+ "impApply", "impStatus", "impMode", "impLocked", "catalog", "version", "storageUse"];
+ for (var i = 0; i < others.size(); i++) {
+ if (Application.Properties.getValue(others[i] as String) == null) {
+ logger.error((others[i] as String) + " is missing");
+ ok = false;
+ }
+ }
+ for (var i = 1; i <= SettingsJob.IMPORT_TEXT_FIELDS; i++) {
+ ok = ok && Application.Properties.getValue("impText" + i) != null;
+ }
+ return ok;
+}
diff --git a/test/settings/SettingsSupport.mc b/test/settings/SettingsSupport.mc
new file mode 100644
index 0000000..66ec969
--- /dev/null
+++ b/test/settings/SettingsSupport.mc
@@ -0,0 +1,110 @@
+// Author: cirakg & claude ai
+// Summary: Helpers for the settings tests: a form store with every default, form builders and a job runner (fake data only).
+
+import Toybox.Application;
+import Toybox.Lang;
+import Toybox.Test;
+
+(:test)
+module SettingsFixtures {
+
+ function vault() as Vault {
+ return new Vault(new MemoryBackend(), new FakeTime());
+ }
+
+ // A store holding every form field at its default (as properties.xml).
+ function store() as Forms.MemoryStore {
+ var s = new Forms.MemoryStore();
+ copy(s, OtpForm.defaults() as Dictionary<String, Application.Properties.ValueType>);
+ copy(s, CodeForm.defaults() as Dictionary<String, Application.Properties.ValueType>);
+ copy(s, ManageForm.defaults() as Dictionary<String, Application.Properties.ValueType>);
+ for (var i = 1; i <= SettingsJob.IMPORT_TEXT_FIELDS; i++) {
+ s.set("impText" + i, "");
+ }
+ s.set("impMode", 0);
+ s.set("impLocked", false);
+ var off = ["otpAdd", "codeAdd", "mgApply", "impApply"];
+ for (var i = 0; i < off.size(); i++) {
+ s.set(off[i] as String, false);
+ }
+ return s;
+ }
+
+ function copy(s as Forms.MemoryStore, values as Dictionary<String, Application.Properties.ValueType>) as Void {
+ var keys = values.keys();
+ for (var i = 0; i < keys.size(); i++) {
+ s.set(keys[i], values[keys[i]] as Application.Properties.ValueType);
+ }
+ }
+
+ // Fill in Add OTP and turn its switch on.
+ function otp(s as Forms.Store, type as Number, issuer as String, account as String, secret as String) as Void {
+ s.set("otpType", type);
+ s.set("otpIssuer", issuer);
+ s.set("otpAccount", account);
+ s.set("otpSecret", secret);
+ s.set(OtpForm.SWITCH, true);
+ }
+
+ function code(s as Forms.Store, symbology as Number, name as String, payload as String) as Void {
+ s.set("codeName", name);
+ s.set("codeSymbology", symbology);
+ s.set("codePayload", payload);
+ s.set(CodeForm.SWITCH, true);
+ }
+
+ function manage(s as Forms.Store, id as String, action as Number) as Void {
+ s.set("mgId", id);
+ s.set("mgAction", action);
+ s.set(ManageForm.SWITCH, true);
+ }
+
+ // Run every form to the end, as the app's timer would.
+ function run(v as Vault, s as Forms.Store) as SettingsJob {
+ var job = new SettingsJob(v, s, 1800000000l);
+ Jobs.runToCompletion(job, 1);
+ return job;
+ }
+
+ function text(s as Forms.Store, key as String) as String {
+ var v = s.get(key);
+ return v instanceof String ? v : "";
+ }
+
+ // True when the field's text contains `part`; logs it otherwise.
+ function contains(logger as Test.Logger, s as Forms.Store, key as String, part as String) as Boolean {
+ var value = text(s, key);
+ if (value.find(part) != null) {
+ return true;
+ }
+ logger.error(key + " = '" + value + "', expected to contain '" + part + "'");
+ return false;
+ }
+
+ // True when some line starts with `prefix`.
+ function hasLine(lines as Array<String>, prefix as String) as Boolean {
+ for (var i = 0; i < lines.size(); i++) {
+ if (lines[i].find(prefix) == 0) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // A string of n copies of `unit`.
+ function repeat(unit as String, n as Number) as String {
+ var out = "";
+ for (var i = 0; i < n; i++) {
+ out += unit;
+ }
+ return out;
+ }
+
+ // The secret of an entry, as text (the fixtures use ASCII keys).
+ function secretOf(v as Vault, id as String) as String {
+ var e = v.open(id);
+ var text = Bytes.toUtf8(e.kind == Entry.KIND_OTP ? e.secret as ByteArray : e.payload as ByteArray);
+ e.wipeSecrets();
+ return text != null ? text : "";
+ }
+}
diff --git a/tools/ui_flow.py b/tools/ui_flow.py
index e54b530..edfc9a4 100644
--- a/tools/ui_flow.py
+++ b/tools/ui_flow.py
@@ -19,8 +19,13 @@ drawing each screen, so every step waits for the screen it expects instead
of sleeping. Menus are native Menu2 views that print nothing; for those the
script waits a fixed time.
-The flow covers: the results of an Aegis import sent through the settings
-form (the fixture sets the form, as the phone would), the list, a TOTP entry, a HOTP entry and its next code, the
+The flow covers: the results of an Aegis import and of the Add OTP, Add
+code and Manage entry forms, all sent through the settings (the fixture
+sets the forms, as the phone would), the two entries those forms added (a
+TOTP whose code the golden image shows, and a Code 128 card whose
+screenshot must decode), the Manage form's change to the locked Bank entry
+waiting for the PIN and confirmed from the menu (watches with buttons), the
+list, a TOTP entry, a HOTP entry and its next code, the
PIN screen, a wrong PIN, a correct PIN opening a locked entry, auto-lock
closing it, the menu, the About screen (watches with buttons; touch-only
menus have no focus to move), the lockout after 5 wrong PINs, and an EAN-13
@@ -49,6 +54,7 @@ import zxingcpp
EXIT_OK, EXIT_DIFFERENT, EXIT_FLOW = 0, 1, 2
SCREEN_TIMEOUT_S = 20.0
AUTO_LOCK_TIMEOUT_S = 25.0 # the UI-test build locks after 10 s without input
+AUTO_LOCK_IDLE_S = 13.0 # idle time after which that lock has surely happened
IMPORT_TIMEOUT_S = 90.0 # the 100-entry fixture import runs on a timer after start-up
IMPORT_MEMORY_BUDGET = 0.70 # README 18: peak memory at most 70 % of the watch-app memory
MENU_SETTLE_S = 1.5 # Menu2 has no "shown" line
@@ -66,6 +72,9 @@ MEMBER_QR = ( # source/app/Fixtures.mc: a version 6 QR
PARCEL_DM = "PARCEL-FAKE-0042-7731" # source/app/Fixtures.mc: Data Matrix
TICKET_PDF417 = "Boarding pass FAKE 0042 seat 12A" # source/app/Fixtures.mc: PDF417
RAIL_AZTEC = "RAIL-FAKE-0042 Zone 1-3" # source/app/Fixtures.mc: Aztec
+LOCKER_CODE128 = "FAKE-0042" # source/app/Fixtures.mc: the Add code form's Code 128 card
+# The Add OTP form's TOTP ("Hello!" demo key) at TestMode.NOW is 309848, next
+# 489290 (RFC 6238 computed in Python); the golden image otp_form shows them.
QR_TIMEOUT_S = 30.0 # the QR is encoded in timer steps the first time
@@ -350,6 +359,30 @@ def flow_import(f: Flow) -> None:
f.expect("list")
+def flow_forms(f: Flow) -> None:
+ """The entries the Add code and Add OTP forms added end the list: the
+ list wraps, so UP from the first row reaches them."""
+ f.prev() # the Code 128 card (added last)
+ f.act()
+ f.open_focused_row()
+ f.expect("code")
+ f.shot("code_form", expect_code=LOCKER_CODE128)
+ f.act()
+ f.back()
+ f.expect("list")
+ f.prev() # the TOTP from Add OTP
+ f.act()
+ f.open_focused_row()
+ f.expect("otp")
+ f.shot("otp_form")
+ f.act()
+ f.back()
+ f.expect("list")
+ f.next()
+ f.next() # back to the first row (GitHub)
+ time.sleep(SETTLE_S)
+
+
def flow_entries(f: Flow) -> None:
"""The list, a TOTP entry, a HOTP entry and its next code."""
f.shot("list")
@@ -403,9 +436,10 @@ def flow_menu(f: Flow) -> None:
f.shot("menu")
f.act()
if f.sim.has("down"):
- # Locked with a PIN: Change PIN, Reset PIN, Settings results, Entry
- # IDs, Clock status, About.
- for _ in range(5):
+ # Locked with a PIN and a phone change waiting: Phone changes,
+ # Change PIN, Reset PIN, Settings results, Entry IDs, Clock status,
+ # About.
+ for _ in range(6):
f.next()
time.sleep(MENU_SETTLE_S)
f.act()
@@ -419,6 +453,32 @@ def flow_menu(f: Flow) -> None:
f.expect("list")
+def flow_queue(f: Flow) -> None:
+ """The Manage form's edit of the locked Bank entry waits for the PIN:
+ menu, Phone changes, Confirm all, PIN, results. Touch-only
+ menus have no focus to select with, so those watches skip it."""
+ if not f.sim.has("down"):
+ return
+ f.menu()
+ time.sleep(MENU_SETTLE_S)
+ f.select() # Phone changes (the first item while one waits)
+ time.sleep(MENU_SETTLE_S)
+ f.shot("queue")
+ f.act()
+ f.select() # Confirm all
+ f.expect("pin")
+ f.opened_pin_screen()
+ f.act()
+ f.enter_pin(FIXTURE_PIN)
+ f.expect("results")
+ f.shot("queue_results")
+ f.act()
+ f.back()
+ f.expect("list")
+ # The PIN unlocked the session; the lockout flow needs it locked again.
+ time.sleep(AUTO_LOCK_IDLE_S)
+
+
def flow_lockout(f: Flow) -> None:
"""Five wrong PINs start the 30 s wait."""
f.act()
@@ -471,9 +531,11 @@ def flow_code(f: Flow) -> None:
def run_flow(f: Flow) -> None:
"""The scripted session; see the module docstring."""
flow_import(f)
+ flow_forms(f)
flow_entries(f)
flow_locked_entry(f)
flow_menu(f)
+ flow_queue(f)
flow_lockout(f)
flow_code(f)