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 2a748b0495ef84c4ceffed9e8508e08512b15a86
parent 172ead3b94babc7be63fb75c7a377a55f4ea7392
Author: triesap <tyson@radroots.org>
Date:   Fri, 28 Aug 2026 21:59:12 +0000

test: preserve persona retry evidence

- Model the governed relay failure as a dropped transport response.
- Bind retrieval evidence to media uploaded by the active persona.
- Ignore only the unlabeled full-frame contrast audit container.
- Add focused fixture regressions and verify the simulator build.

Diffstat:
MRadrootsUITests/RadrootsRemoteQualificationUITests.swift | 13++++++++++++-
Mscripts/local-social-fixture.py | 23+++++++++++++++--------
Mscripts/test_local_social_fixture.py | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 98 insertions(+), 9 deletions(-)

diff --git a/RadrootsUITests/RadrootsRemoteQualificationUITests.swift b/RadrootsUITests/RadrootsRemoteQualificationUITests.swift @@ -428,6 +428,14 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { } if issue.auditType == .contrast, let element = issue.element, + element.elementType == .other, + element.identifier.isEmpty, + element.label.isEmpty, + element.frame == app.frame { + return true + } + if issue.auditType == .contrast, + let element = issue.element, element.exists, !element.isEnabled { @@ -916,7 +924,10 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { throw QualificationError.missingProductSurface } let retry = relaunched.buttons["Retry"].firstMatch - XCTAssertTrue(retry.waitForExistence(timeout: 20)) + guard retry.waitForExistence(timeout: 20) else { + XCTFail("The transport-retry draft did not expose the Retry action") + throw QualificationError.missingProductSurface + } retry.tap() let retryCompleted = NSPredicate { _, _ in !retry.exists || !retry.isEnabled } let expectation = XCTNSPredicateExpectation(predicate: retryCompleted, object: relaunched) diff --git a/scripts/local-social-fixture.py b/scripts/local-social-fixture.py @@ -244,6 +244,9 @@ class FixtureState: self._identity_by_persona: dict[str, str] = {} self._persona_by_identity: dict[str, str] = {} self._accepted_uploads_by_persona = {alias: 0 for alias in PERSONA_ALIASES} + self._uploaded_digests_by_persona = { + alias: set() for alias in PERSONA_ALIASES + } self._retrievals_by_persona = {alias: set() for alias in PERSONA_ALIASES} self._unknown_attempts = 0 self._duplicate_attempts = 0 @@ -254,7 +257,7 @@ class FixtureState: self._unintended_publications = 0 self._write_evidence() - def publish(self, event: dict[str, Any]) -> bool: + def publish(self, event: dict[str, Any]) -> bool | None: if not valid_nostr_event(event) or not verify_nostr_signature(event): return False if self._suite is not None: @@ -267,7 +270,7 @@ class FixtureState: self._write_evidence_locked() return True - def _publish_persona_event(self, event: dict[str, Any]) -> bool: + def _publish_persona_event(self, event: dict[str, Any]) -> bool | None: attempt = classify_attempt(event, self._attempts) control = read_control(self.control) with self._lock: @@ -306,7 +309,7 @@ class FixtureState: self._transport_rejected_attempts.add(attempt_id) self._expected_failure_rejections += 1 self._write_evidence_locked() - return False + return None if attempt_id in self._accepted_attempts: self._duplicate_attempts += 1 self._write_evidence_locked() @@ -375,6 +378,7 @@ class FixtureState: self._accepted_uploads += 1 if persona is not None: self._accepted_uploads_by_persona[persona] += 1 + self._uploaded_digests_by_persona[persona].add(digest) self._write_evidence_locked() descriptor = { "url": f"http://127.0.0.1:{self.blossom_port}/{digest}.png", @@ -394,11 +398,12 @@ class FixtureState: self._retrievals += 1 elif control is not None: persona = control["active_persona"] - before = len(self._retrievals_by_persona[persona]) - self._retrievals_by_persona[persona].add(digest) - self._retrievals += ( - len(self._retrievals_by_persona[persona]) - before - ) + if digest in self._uploaded_digests_by_persona[persona]: + before = len(self._retrievals_by_persona[persona]) + self._retrievals_by_persona[persona].add(digest) + self._retrievals += ( + len(self._retrievals_by_persona[persona]) - before + ) self._write_evidence_locked() return value @@ -786,6 +791,8 @@ class RelayHandler(socketserver.BaseRequestHandler): ): event_id = message[1].get("id", "") accepted = self.state.publish(message[1]) + if accepted is None: + return send_json( self.request, ["OK", event_id, accepted, "" if accepted else "invalid"], diff --git a/scripts/test_local_social_fixture.py b/scripts/test_local_social_fixture.py @@ -150,6 +150,77 @@ class LocalSocialFixtureTests(unittest.TestCase): self.assertEqual(evidence["accepted_uploads"], 1) self.assertEqual(evidence["retrievals"], 1) + def test_persona_retrieval_counts_only_its_own_uploaded_digest(self) -> None: + body = b"persona-photo" + digest = fixture.hashlib.sha256(body).hexdigest() + _, suite = fixture.load_persona_suite( + Path("test-fixtures/local-social-personas.v1.json") + ) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + control = root / "control.json" + state = fixture.FixtureState( + root / "evidence.json", control, 21100, suite + ) + self.write_persona_control(control, "P01") + with mock.patch.object( + fixture, "valid_blossom_authorization", return_value=True + ): + accepted, _ = state.upload(body, "image/png", digest, "Nostr valid") + self.assertTrue(accepted) + self.assertIsNotNone(state.retrieve(digest)) + self.write_persona_control(control, "P02") + self.assertIsNotNone(state.retrieve(digest)) + + evidence = json.loads( + (root / "evidence.json").read_text(encoding="utf-8") + ) + self.assertEqual(evidence["retrievals"], 1) + self.assertEqual(evidence["personas"][0]["retrievals"], 1) + self.assertEqual(evidence["personas"][1]["retrievals"], 0) + + def test_transport_retry_drops_one_response_then_accepts(self) -> None: + _, suite = fixture.load_persona_suite( + Path("test-fixtures/local-social-personas.v1.json") + ) + attempt = fixture.persona_attempts(suite)["P05-A01"] + event = { + "id": "1" * 64, + "kind": fixture.FLOW_KINDS[attempt["flow"]], + "pubkey": "2" * 64, + } + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + control = root / "control.json" + self.write_persona_control(control, "P05") + state = fixture.FixtureState( + root / "evidence.json", control, 21100, suite + ) + with mock.patch.object( + fixture, "classify_attempt", return_value=attempt + ): + self.assertIsNone(state._publish_persona_event(event)) + self.assertTrue(state._publish_persona_event(event)) + + evidence = json.loads( + (root / "evidence.json").read_text(encoding="utf-8") + ) + self.assertEqual(evidence["expected_failure_rejections"], 1) + self.assertEqual(evidence["accepted_events"], 1) + + @staticmethod + def write_persona_control(path: Path, alias: str) -> None: + path.write_text( + json.dumps( + { + "schema": fixture.PERSONA_CONTROL_SCHEMA, + "active_persona": alias, + "blossom_enabled": True, + } + ), + encoding="utf-8", + ) + def test_result_contract_accepts_future_ios_and_rejects_drift(self) -> None: suite, result = self.persona_result() result_schema = json.loads(