@@ -12,9 +12,10 @@ import AgentDeviceSnapshotPresentation
1212//
1313// The pure rule below is geometry on purpose — no XCUIApplication — so its exact decision is proven
1414// against the golden table shared with its TS twin, `clipScrollViewportAboveKeyboard` in
15- // packages/contracts/src/scroll-gesture.ts, asserted in that file's test beside this one. The table
16- // carries only frames representable in both languages: `CGRect` standardizes a negative extent into a
17- // positive height at a moved origin, so a negative `height` is tested on the TS side alone.
15+ // packages/contracts/src/scroll-gesture.ts, asserted in that file's test and in
16+ // UnitTests/RunnerTests+ScrollViewportPolicyTests.swift. The table carries only frames
17+ // representable in both languages: `CGRect` standardizes a negative extent into a positive height
18+ // at a moved origin, so a negative `height` is tested on the TS side alone.
1819//
1920// The `extension RunnerTests` below is the one impure caller: it reads the runner's own live keyboard
2021// frame, because a frame threaded from the daemon would predate the keyboard.
@@ -248,165 +249,3 @@ extension RunnerTests {
248249 }
249250#endif
250251}
251-
252- #if AGENT_DEVICE_RUNNER_UNIT_TESTS
253- private struct ScrollViewportPolicyFixture : Decodable {
254- struct Frame : Decodable {
255- let x : Double
256- let y : Double
257- let width : Double
258- let height : Double
259-
260- var cgRect : CGRect {
261- CGRect ( x : x , y : y , width : width , height : height )
262- }
263- }
264-
265- struct Constants : Decodable {
266- let minVisibleFraction : Double
267- let accessoryAllowance : Double
268- }
269-
270- struct Expected : Decodable {
271- let kind : String
272- let viewport : Frame ?
273- let keyboardMinY : Double ?
274- let visibleHeight : Double ?
275- }
276-
277- struct TestCase : Decodable {
278- let name : String
279- let viewport : Frame
280- let keyboard : Frame
281- let expected : Expected
282- }
283-
284- let constants : Constants
285- let cases : [ TestCase ]
286- }
287-
288- extension RunnerTests {
289- /// Golden parity table (#2500): every case in contracts/fixtures/scroll-keyboard-policy.json must
290- /// agree with the vitest twin. Add cases there, never fork the rule.
291- func testScrollViewportKeyboardClipMatchesGoldenParityTable ( ) throws {
292- let fixture = try loadScrollViewportPolicyFixture ( )
293- XCTAssertFalse ( fixture . cases . isEmpty , " parity table must not be empty " )
294- for testCase in fixture . cases {
295- let clip = ScrollViewportPolicy . clip (
296- viewport : testCase . viewport . cgRect ,
297- keyboard : testCase . keyboard . cgRect
298- )
299- switch testCase . expected . kind {
300- case " unobstructed " :
301- XCTAssertEqual ( clip , . unobstructed , testCase . name )
302- case " avoided " :
303- let expectedFrame = try XCTUnwrap ( testCase . expected . viewport , testCase . name ) . cgRect
304- let expectedMinY = try XCTUnwrap ( testCase . expected . keyboardMinY , testCase . name )
305- XCTAssertEqual (
306- clip ,
307- . avoided ( frame : expectedFrame , keyboardMinY : expectedMinY ) ,
308- testCase . name
309- )
310- case " occluded " :
311- let expectedMinY = try XCTUnwrap ( testCase . expected . keyboardMinY , testCase . name )
312- let expectedVisibleHeight = try XCTUnwrap ( testCase . expected . visibleHeight , testCase . name )
313- XCTAssertEqual (
314- clip ,
315- . occluded ( keyboardMinY : expectedMinY , visibleHeight : expectedVisibleHeight ) ,
316- testCase . name
317- )
318- default :
319- XCTFail ( " unknown expected kind ` \( testCase . expected . kind ) ` in \( testCase . name ) " )
320- }
321- }
322- }
323-
324- /// The thresholds are the table's, not this file's. The refusal reason and the runner code are
325- /// each one side's own vocabulary: the reason is what the host publishes, the code is what this
326- /// runner answers with, and neither is a shared clip constant.
327- func testScrollViewportPolicyUsesParityTableConstants ( ) throws {
328- let constants = try loadScrollViewportPolicyFixture ( ) . constants
329- XCTAssertEqual ( constants . minVisibleFraction , ScrollViewportPolicy . minVisibleFraction )
330- XCTAssertEqual ( constants . accessoryAllowance , ScrollViewportPolicy . accessoryAllowance )
331- }
332-
333- /// A clipped landscape band shortens the frame, and `CoordinateSpaceRotation.native(point:)` derives a
334- /// `landscapeRight` native x from the frame's HEIGHT. Rotating inside the band therefore moves the
335- /// dispatched path sideways by exactly what the keyboard took, off the lane the plan was built for,
336- /// so the plan band and the coordinate basis stay separate values through dispatch (#2500).
337- func testScrollViewportDispatchKeepsTheUnclippedFrameAsItsCoordinateRotationBasis ( ) throws {
338- let viewport = CGRect ( x : 0 , y : 0 , width : 1210 , height : 834 )
339- let keyboard = CGRect ( x : 0 , y : 588 , width : 1210 , height : 246 )
340- let clip = ScrollViewportPolicy . clip ( viewport : viewport , keyboard : keyboard )
341- guard case . avoided ( let band , let keyboardMinY ) = clip else {
342- return XCTFail ( " expected a landscape keyboard to be avoided, got \( clip ) " )
343- }
344- XCTAssertEqual ( band . height , 576 )
345-
346- guard case . gesture ( let gesture ) = ScrollViewportPolicy . frames (
347- referenceFrame : viewport ,
348- clip : clip
349- ) . gestureDispatch ( direction : . up , amount : nil , pixels : nil ) else {
350- return XCTFail ( " expected a gesture inside the clipped band " )
351- }
352- XCTAssertEqual ( gesture . planFrame , band )
353- XCTAssertEqual ( gesture . keyboardMinY , keyboardMinY )
354- XCTAssertEqual ( gesture . coordinateFrame , viewport , " the rotation basis must survive the clip " )
355- XCTAssertLessThanOrEqual (
356- max ( gesture . plan . y1 , gesture . plan . y2 ) ,
357- keyboard . minY - ScrollViewportPolicy . accessoryAllowance ,
358- " a landscape swipe must stay clear of the keys "
359- )
360-
361- let reported = gesture . attachingEvidence (
362- to : Response (
363- ok : true ,
364- data : DataPayload ( referenceWidth : viewport . width , referenceHeight : viewport . height ) ,
365- error : nil
366- )
367- )
368- XCTAssertEqual (
369- reported . data ? . referenceHeight ,
370- band . height ,
371- " the payload names the band the plan ran inside, not the synthesis frame "
372- )
373- XCTAssertEqual ( reported . data ? . referenceWidth , viewport . width )
374- XCTAssertEqual ( reported . data ? . keyboardMinY , keyboardMinY )
375- XCTAssertEqual ( reported . data ? . keyboardAvoided , true )
376-
377- let orientedStartY = gesture . planFrame . minY + gesture . plan . y1
378- let dispatchedFromViewport = CoordinateSpaceRotation . native (
379- point : CGPoint ( x : gesture . planFrame . minX + gesture . plan . x1 , y : orientedStartY ) ,
380- in : gesture . coordinateFrame ,
381- interfaceOrientation : RunnerInterfaceOrientation . landscapeRight
382- )
383- let dispatchedFromBand = CoordinateSpaceRotation . native (
384- point : CGPoint ( x : gesture . planFrame . minX + gesture . plan . x1 , y : orientedStartY ) ,
385- in : gesture . planFrame ,
386- interfaceOrientation : RunnerInterfaceOrientation . landscapeRight
387- )
388- XCTAssertEqual (
389- dispatchedFromViewport . x - dispatchedFromBand . x ,
390- viewport . height - band . height ,
391- accuracy : 0.001 ,
392- " rotating inside the clipped band would shift native x by what the keyboard took "
393- )
394- }
395-
396- private func loadScrollViewportPolicyFixture ( ) throws -> ScrollViewportPolicyFixture {
397- let fixtureURL = URL ( fileURLWithPath : #filePath )
398- . deletingLastPathComponent ( ) // AgentDeviceRunnerUITests
399- . deletingLastPathComponent ( ) // AgentDeviceRunner
400- . deletingLastPathComponent ( ) // runner
401- . deletingLastPathComponent ( ) // apple
402- . deletingLastPathComponent ( ) // repo root
403- . appendingPathComponent ( " contracts " )
404- . appendingPathComponent ( " fixtures " )
405- . appendingPathComponent ( " scroll-keyboard-policy.json " )
406- return try JSONDecoder ( ) . decode (
407- ScrollViewportPolicyFixture . self ,
408- from : Data ( contentsOf : fixtureURL )
409- )
410- }
411- }
412- #endif
0 commit comments