field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit 90bd7c82fa0cda65917f96ac593ededa7b732a05
parent b3bd53e982ab254dc5baecdd38c56a78dbcb088e
Author: triesap <tyson@radroots.org>
Date:   Fri, 28 Aug 2026 18:58:19 +0000

test: bind photo personas to authored wire shape

- accept only the governed two-line localhost Photo update content
- require matching BUD11 digest and NIP-92 metadata in fixture evidence
- fail fast when Add navigation or Today projection evidence is absent
- cover exact acceptance and near-match rejection in fixture tests

Diffstat:
MRadrootsUITests/RadrootsRemoteQualificationUITests.swift | 35+++++++++++++++++++++++++----------
Mscripts/local-social-fixture.py | 37++++++++++++++++++++++++++++++++++++-
Mscripts/test_local_social_fixture.py | 43+++++++++++++++++++++++++++++++++++++++++++
3 files changed, 104 insertions(+), 11 deletions(-)

diff --git a/RadrootsUITests/RadrootsRemoteQualificationUITests.swift b/RadrootsUITests/RadrootsRemoteQualificationUITests.swift @@ -107,10 +107,10 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { try publishFood(app, marker: markers[3]) let expected = [marker] + markers - assertTodayContains(app, markers: expected) + try assertTodayContains(app, markers: expected) app.terminate() app = launchToRoot(configuration) - assertTodayContains(app, markers: expected) + try assertTodayContains(app, markers: expected) guard openAdd(app) != nil, openDrafts(app) else { return XCTFail("The durable outbox was unavailable after the final relaunch") @@ -189,13 +189,13 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { default: throw QualificationError.invalidPersonaFixture } - assertTodayContains(app, markers: [attempt.marker]) + try assertTodayContains(app, markers: [attempt.marker]) } let markers = persona.attempts.map(\.marker) app.terminate() app = launchPersona(configuration) - assertTodayContains(app, markers: markers) + try assertTodayContains(app, markers: markers) XCTAssertEqual(try readPublicKey(app), publicKey) app.terminate() } @@ -947,19 +947,31 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { @MainActor private func beginDraft(_ app: XCUIApplication, type: String) throws { - guard let picker = openAdd(app) else { + guard openAdd(app) != nil else { XCTFail("The Add bottom tab did not present the real Add store") throw QualificationError.missingProductSurface } let newDraft = app.buttons["radroots.add.new"] - XCTAssertTrue(newDraft.waitForExistence(timeout: 10)) + guard newDraft.waitForExistence(timeout: 10), waitUntilHittable(newDraft, timeout: 10) else { + XCTFail("The New draft action was unavailable") + throw QualificationError.missingProductSurface + } newDraft.tap() + scrollAddFormToTop(app) + let picker = app.descendants(matching: .any)["radroots.add.type"] + guard picker.waitForExistence(timeout: 10), waitUntilHittable(picker, timeout: 10) else { + XCTFail("The Add type picker was unavailable") + throw QualificationError.missingProductSurface + } if picker.value as? String == type { return } picker.tap() let option = app.buttons[type] - XCTAssertTrue(option.waitForExistence(timeout: 10)) + guard option.waitForExistence(timeout: 10), waitUntilHittable(option, timeout: 10) else { + XCTFail("The Add type picker did not present \(type)") + throw QualificationError.missingProductSurface + } option.tap() guard waitForValue(picker, value: type, timeout: 10) else { XCTFail("The Add type picker did not select \(type)") @@ -1019,7 +1031,7 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { } @MainActor - private func assertTodayContains(_ app: XCUIApplication, markers: [String]) { + private func assertTodayContains(_ app: XCUIApplication, markers: [String]) throws { app.tabBars.buttons["Today"].tap() let refresh = app.buttons["radroots.today.refresh"] if refresh.waitForExistence(timeout: 5) { @@ -1028,7 +1040,7 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { let feed = app.descendants(matching: .any)["radroots.today.feed"].firstMatch guard feed.waitForExistence(timeout: 30) else { XCTFail("The local relay refresh did not materialize the Today feed") - return + throw QualificationError.missingProductSurface } for marker in markers.reversed() { let card = app.descendants(matching: .any).matching( @@ -1037,7 +1049,10 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { for _ in 0..<6 where !card.exists { feed.swipeUp() } - XCTAssertTrue(card.waitForExistence(timeout: 30), "Missing Today card \(marker)") + guard card.waitForExistence(timeout: 30) else { + XCTFail("Missing Today card \(marker)") + throw QualificationError.missingProductSurface + } } } diff --git a/scripts/local-social-fixture.py b/scripts/local-social-fixture.py @@ -564,10 +564,45 @@ def classify_attempt( values = [event.get("content")] for tag in event.get("tags", []): values.extend(tag) - matches = [attempt for attempt in attempts.values() if attempt["marker"] in values] + matches = [ + attempt + for attempt in attempts.values() + if ( + photo_attempt_matches(event, attempt) + if attempt.get("flow") == "PhotoUpdate" + else attempt["marker"] in values + ) + ] return matches[0] if len(matches) == 1 else None +def photo_attempt_matches(event: dict[str, Any], attempt: dict[str, Any]) -> bool: + if attempt.get("flow") != "PhotoUpdate": + return False + content = event.get("content") + if not isinstance(content, str): + return False + lines = content.split("\n") + if len(lines) != 2 or lines[0] != attempt.get("marker"): + return False + url = lines[1] + match = re.fullmatch( + r"http://127\.0\.0\.1:([1-9][0-9]{0,4})/([0-9a-f]{64})\.png", url + ) + if match is None or int(match.group(1)) > 65535: + return False + digest = match.group(2) + imeta = [ + tag + for tag in event.get("tags", []) + if isinstance(tag, list) and tag[:1] == ["imeta"] + ] + return len(imeta) == 1 and all( + field in imeta[0] + for field in (f"url {url}", f"x {digest}", "m image/png") + ) + + def identity_digest(public_key: str | None) -> str: if public_key is None: return "0" * 64 diff --git a/scripts/test_local_social_fixture.py b/scripts/test_local_social_fixture.py @@ -209,6 +209,49 @@ class LocalSocialFixtureTests(unittest.TestCase): ) self.assertIsNone(fixture.read_control(path)) + def test_attempt_classification_accepts_only_exact_photo_wire_shape(self) -> None: + marker = "rr-p01-a02-photo" + digest = "a" * 64 + url = f"http://127.0.0.1:21101/{digest}.png" + attempt = {"flow": "PhotoUpdate", "marker": marker} + event = { + "content": f"{marker}\n{url}", + "tags": [ + [ + "imeta", + f"url {url}", + f"x {digest}", + "m image/png", + "dim 1x1", + "size 1", + "alt Local qualification image", + ] + ], + } + + self.assertIs( + fixture.classify_attempt(event, {"P01-A02": attempt}), attempt + ) + for mutation in ( + lambda value: value.update({"content": marker}), + lambda value: value.update({"content": f"prefix {marker}\n{url}"}), + lambda value: value.update( + {"content": f"{marker}\nhttps://example.com/{digest}.png"} + ), + lambda value: value["tags"][0].remove(f"x {digest}"), + ): + changed = copy.deepcopy(event) + mutation(changed) + self.assertIsNone( + fixture.classify_attempt(changed, {"P01-A02": attempt}) + ) + + text_attempt = {"flow": "Update", "marker": "rr-p01-a01-update"} + embedded = {"content": "prefix rr-p01-a01-update", "tags": []} + self.assertIsNone( + fixture.classify_attempt(embedded, {"P01-A01": text_attempt}) + ) + if __name__ == "__main__": unittest.main()