FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

fix: address library bugs uncovered by the E2E suite · pythonnative/pythonnative@c7becef · GitHub

Commit c7becef

Browse files
committed
fix: address library bugs uncovered by the E2E suite
1 parent 38e3e01 commit c7becef

22 files changed

Lines changed: 562 additions & 61 deletions

File tree

‎.github/workflows/e2e.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
e2e-android:
2626
needs: coverage
2727
runs-on: ubuntu-latest
28-
timeout-minutes: 45
28+
timeout-minutes: 60
2929

3030
steps:
3131
- name: Checkout
@@ -66,7 +66,7 @@ jobs:
6666
e2e-ios:
6767
needs: coverage
6868
runs-on: macos-latest
69-
timeout-minutes: 45
69+
timeout-minutes: 60
7070

7171
steps:
7272
- name: Checkout

‎examples/e2e-suite/app/screens/components/text_input.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def TextInputDemo() -> pn.Element:
3838
placeholder="Type your name here",
3939
on_change=set_name,
4040
return_key_type="done",
41+
auto_correct=False,
4142
style=field_style,
4243
),
4344
result_text("Echo", name or "(empty)"),

‎examples/e2e-suite/app/screens/navigation/params_passing.py‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,8 @@
1515
@pn.component
1616
def ParamsPassingDemo() -> pn.Element:
1717
"""Render the active route's params using ``use_route``."""
18-
route = pn.use_route()
18+
params = pn.use_route()
1919
nav = pn.use_navigation()
20-
params = route.get("params") or {}
2120

2221
def push_with(value: str) -> None:
2322
nav.navigate("params_passing", {"value": value})
@@ -27,7 +26,6 @@ def push_with(value: str) -> None:
2726
"use_route reads the active route's params; navigating with new params updates the readout.",
2827
section(
2928
"Route info",
30-
result_text("Route name", route.get("name") or "(none)"),
3129
result_text("Param 'value'", params.get("value") or "(none)"),
3230
buttons_row(
3331
pn.Button("Push value=alpha", on_click=lambda: push_with("alpha")),

‎scripts/run-e2e.sh‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
# The script:
2626
# 1. Builds + installs the e2e-suite app via `pn run <platform> --no-logs`.
2727
# 2. Picks the right Maestro YAML based on platform + suite.
28-
# 3. Runs `maestro test` and exits with Maestro's exit code.
28+
# 3. Runs `maestro test` up to ``MAESTRO_MAX_ATTEMPTS`` times (default
29+
# 2) and exits with the last attempt's exit code.
2930
#
3031
# A successful run prints "All E2E suites passed." at the end and exits 0.
3132
# Any failed flow is reported by Maestro in its standard format; see
@@ -87,11 +88,37 @@ pushd examples/e2e-suite > /dev/null
8788
pn run "$PLATFORM" --no-logs
8889
popd > /dev/null
8990

91+
run_maestro() {
92+
if [[ "$PLATFORM" == "ios" ]]; then
93+
maestro --platform ios test -e "APP_ID=$APP_ID" "$MAESTRO_TARGET"
94+
else
95+
maestro test -e "APP_ID=$APP_ID" "$MAESTRO_TARGET"
96+
fi
97+
}
98+
9099
printf "\n==> Running Maestro suite: %s\n" "$MAESTRO_TARGET"
91-
if [[ "$PLATFORM" == "ios" ]]; then
92-
maestro --platform ios test -e "APP_ID=$APP_ID" "$MAESTRO_TARGET"
93-
else
94-
maestro test -e "APP_ID=$APP_ID" "$MAESTRO_TARGET"
95-
fi
100+
101+
# Maestro's iOS XCUITest driver occasionally loses its connection to the
102+
# app during long suites and surfaces transient "Application is not
103+
# running" / "Request for viewHierarchy failed" errors that have nothing
104+
# to do with the test under test. Allow one automatic retry of the whole
105+
# suite (overridable via ``MAESTRO_MAX_ATTEMPTS``) so CI doesn't fail on
106+
# driver flakes. A retry can also mask a genuine race in the suite, so
107+
# treat the "retrying..." line as a signal to investigate, not just to
108+
# trust the second pass.
109+
MAX_ATTEMPTS="${MAESTRO_MAX_ATTEMPTS:-2}"
110+
attempt=1
111+
while (( attempt <= MAX_ATTEMPTS )); do
112+
if run_maestro; then
113+
break
114+
fi
115+
if (( attempt == MAX_ATTEMPTS )); then
116+
printf "\nMaestro suite failed after %d attempt(s).\n" "$attempt" >&2
117+
exit 1
118+
fi
119+
printf "\n==> Maestro suite failed (attempt %d/%d); retrying...\n" \
120+
"$attempt" "$MAX_ATTEMPTS" >&2
121+
attempt=$(( attempt + 1 ))
122+
done
96123

97124
printf "\nAll E2E suites passed.\n"

‎src/pythonnative/animated.py‎

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ async def fade_in():
6262
_TARGET_FPS = 60.0
6363
_FRAME_DT = 1.0 / _TARGET_FPS
6464

65+
# Upper bound on how much wall-clock time the animation loop will try to
66+
# catch up on in a single iteration after thread starvation. At 60 fps
67+
# this is ~333 ms of simulated motion; further drift is dropped to keep
68+
# the loop responsive.
69+
_MAX_CATCHUP_FRAMES = 20
70+
6571
_EASINGS: Dict[str, Callable[[float], float]] = {
6672
"linear": lambda t: t,
6773
"ease_in": lambda t: t * t,
@@ -199,6 +205,20 @@ def _ensure_thread_locked(self) -> None:
199205

200206
def _loop(self) -> None:
201207
last = time.monotonic()
208+
# Clamping the per-tick dt is important for numerical stability:
209+
# an underdamped spring with a 0.3 s step explodes immediately,
210+
# and on iOS/Android the animation thread can be starved for
211+
# several frames during render bursts. We integrate physics on a
212+
# clamped dt (max 2 target frames) and sub-step when wall-clock
213+
# has advanced more than that, so the perceived motion still
214+
# tracks real time at most a couple of frames behind. After an
215+
# extreme starvation (e.g. the app was backgrounded for seconds)
216+
# we cap the catch-up at ``_MAX_CATCHUP_FRAMES`` worth of
217+
# physics; any further wall-clock drift is dropped on the floor,
218+
# which keeps the loop responsive instead of spinning forward
219+
# through hundreds of substeps.
220+
max_step = _FRAME_DT * 2.0
221+
max_catchup = _FRAME_DT * _MAX_CATCHUP_FRAMES
202222
while not self._stopped:
203223
now = time.monotonic()
204224
dt = now - last
@@ -209,13 +229,19 @@ def _loop(self) -> None:
209229
time.sleep(0.05)
210230
last = time.monotonic()
211231
continue
212-
for anim in active:
213-
try:
214-
finished = anim.advance(dt)
215-
except Exception:
216-
finished = True
217-
if finished:
218-
self.remove(anim)
232+
remaining = min(dt, max_catchup)
233+
while remaining > 0.0:
234+
step = remaining if remaining <= max_step else max_step
235+
remaining -= step
236+
for anim in active:
237+
if getattr(anim, "_completed", False):
238+
continue
239+
try:
240+
finished = anim.advance(step)
241+
except Exception:
242+
finished = True
243+
if finished:
244+
self.remove(anim)
219245
time.sleep(_FRAME_DT)
220246

221247

‎src/pythonnative/layout.py‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,17 @@ class LayoutNode:
401401
height: Computed height in points.
402402
"""
403403

404-
__slots__ = ("style", "children", "measure", "user_data", "x", "y", "width", "height")
404+
__slots__ = (
405+
"style",
406+
"children",
407+
"measure",
408+
"user_data",
409+
"x",
410+
"y",
411+
"width",
412+
"height",
413+
"_pn_scroll_axis",
414+
)
405415

406416
def __init__(
407417
self,
@@ -418,6 +428,14 @@ def __init__(
418428
self.y: float = 0.0
419429
self.width: float = 0.0
420430
self.height: float = 0.0
431+
# ``"x"``/``"y"`` for scroll containers; ``None`` for everything
432+
# else. Consumed by ``_measure_container`` to clamp the node's
433+
# own main-axis size to the parent's available space while still
434+
# measuring children unbounded on the scroll axis (which is what
435+
# makes the native ``UIScrollView`` / Android ``ScrollView``
436+
# actually scroll). The reconciler stamps this when building the
437+
# layout tree for ``ScrollView`` elements.
438+
self._pn_scroll_axis: Optional[str] = None
421439

422440
def __repr__(self) -> str:
423441
return (
@@ -576,6 +594,22 @@ def _measure_container(
576594

577595
width = explicit_w if explicit_w is not None else (used_w + pad_x)
578596
height = explicit_h if explicit_h is not None else (used_h + pad_y)
597+
598+
# Scroll containers: clamp the container's own main-axis size to the
599+
# parent's available space when no explicit size was provided. The
600+
# children are still measured against an unbounded main-axis (handled
601+
# via the wrapper inserted in ``Reconciler._build_layout_tree``) so the
602+
# overflow becomes the scrollable region. Without this clamp, the
603+
# container would grow to fit its content and there would be no
604+
# overflow for the native ScrollView to scroll. Skipped when the
605+
# parent is itself unbounded, so nested scroll views still fall back
606+
# to natural sizing (the inner scroll is unscrollable in that case,
607+
# which matches the behavior in React Native).
608+
scroll_axis = getattr(node, "_pn_scroll_axis", None)
609+
if scroll_axis == "y" and explicit_h is None and math.isfinite(avail_h):
610+
height = avail_h
611+
elif scroll_axis == "x" and explicit_w is None and math.isfinite(avail_w):
612+
width = avail_w
579613
return width, height
580614

581615

‎src/pythonnative/native_views/android.py‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
_pn_view_visual_props: dict = {}
3636
_DRAWABLE_STYLE_KEYS = ("background_color", "border_radius", "border_width", "border_color")
3737

38+
3839
# ======================================================================
3940
# Shared helpers
4041
# ======================================================================
@@ -509,18 +510,27 @@ def onClick(self, view: Any) -> None:
509510
class ScrollViewHandler(AndroidViewHandler):
510511
"""Scroll container — wraps a single child whose height is unbounded.
511512
513+
Uses ``androidx.core.widget.NestedScrollView`` rather than the
514+
framework ``android.widget.ScrollView`` because the framework
515+
ScrollView always intercepts vertical gestures, even when it has
516+
no overflow. That breaks the common case of nesting a small
517+
fixed-height scroll view inside a screen-level scroll view (the
518+
outer steals every gesture and the inner never scrolls).
519+
``NestedScrollView`` implements the standard
520+
``NestedScrollingParent2`` / ``NestedScrollingChild2`` protocol so
521+
the outer cooperates with any nested scroll, only consuming
522+
leftover scroll when its child reaches its limit.
523+
512524
When a ``refresh_control`` prop is provided, wraps the scroll in
513525
a `SwipeRefreshLayout` and forwards the on-refresh callback.
514526
"""
515527

516528
def create(self, props: Dict[str, Any]) -> Any:
517-
sv = jclass("android.widget.ScrollView")(_ctx())
529+
try:
530+
sv = jclass("androidx.core.widget.NestedScrollView")(_ctx())
531+
except Exception:
532+
sv = jclass("android.widget.ScrollView")(_ctx())
518533
_apply_common_visual(sv, props)
519-
# Wrap the inner ScrollView in a SwipeRefreshLayout when
520-
# ``refresh_control`` is asked for. Implementing this cleanly
521-
# would require returning a different parent; for v1, we
522-
# attach the listener via a wrapper that we expose to
523-
# add_child callers below.
524534
return sv
525535

526536
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
@@ -536,6 +546,17 @@ def remove_child(self, parent: Any, child: Any) -> None:
536546
class TextInputHandler(AndroidViewHandler):
537547
def create(self, props: Dict[str, Any]) -> Any:
538548
et = jclass("android.widget.EditText")(_ctx())
549+
# Default to single-line so pressing Enter triggers IME_ACTION_DONE
550+
# (submit / dismiss) instead of inserting a newline. The
551+
# ``_apply`` path will override this if ``multiline=True`` is
552+
# set in props. Without this, every TextInput without an
553+
# explicit ``multiline`` value falls back to Android's
554+
# multi-line default and Enter inserts ``\n``.
555+
try:
556+
if not props.get("multiline"):
557+
et.setSingleLine(True)
558+
except Exception:
559+
pass
539560
self._apply(et, props)
540561
return et
541562

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL