mirror of
https://github.com/lexmount/moli.git
synced 2026-10-30 08:07:38 +00:00
fix(layout): project scroll overflow in physical coordinates
Aggregate physical descendants once instead of treating Taffy's logical
scroll origin as a physical top-left corner. Share reachable scroll bounds
between scrollbar admission and final projection, including vertical writing
modes and reversed flex flows.
Preserve grid track areas, per-axis clipping, transformed borders, normal-flow
margins and end padding. Anchor vertical RTL block flow at the physical bottom
while keeping relative insets physical.
Add five native browser guards and a live Chromium differential covering
140 fixtures, two scrollbar modes and three fresh-document rounds.
Validation: root fmt, strict workspace/all-target/all-feature Clippy, and
Nextest (20380 passed, 18 skipped); Release; 840 Chromium comparisons;
960 flex geometry controls; MOL-463-468 probes; full 13631-case WPT Cross.
Three old/new control rounds found no new stable case, assertion or harness
regression. Cases are refreshed in the following commit.
Original-commit: d433653f0d
This commit is contained in:
+197
-39
@@ -1,8 +1,9 @@
|
||||
use std::{collections::VecDeque, fmt::Debug, hash::Hash};
|
||||
|
||||
use crate::{
|
||||
LayoutBoxId, LayoutPoint, LayoutRect, LayoutScrollbarAxis, LayoutTransform2D, LayoutViewport,
|
||||
LayoutWorld, style::ResolvedLayoutTransform,
|
||||
LayoutBoxId, LayoutDisplay, LayoutPoint, LayoutRect, LayoutScrollbarAxis, LayoutSize,
|
||||
LayoutTransform2D, LayoutViewport, LayoutWorld,
|
||||
style::{LayoutOverflowMode, ResolvedLayoutTransform},
|
||||
};
|
||||
|
||||
/// Geometry needed to resolve scrollable overflow and no other projection
|
||||
@@ -84,6 +85,26 @@ impl OverflowProjection {
|
||||
self.scrollable_overflow[id.index()]
|
||||
}
|
||||
|
||||
pub(crate) fn scroll_size<N>(&self, world: &LayoutWorld<N>, id: LayoutBoxId) -> LayoutSize
|
||||
where
|
||||
N: Copy + Debug + Eq + Hash,
|
||||
{
|
||||
let scrollport = self.geometry(id).local_scrollport;
|
||||
let overflow = self.scrollable_overflow(id);
|
||||
let reversed = world.boxes[id.index()].style.reversed_scroll_axes();
|
||||
let width = if reversed.x {
|
||||
scrollport.right() - overflow.x
|
||||
} else {
|
||||
overflow.right() - scrollport.x
|
||||
};
|
||||
let height = if reversed.y {
|
||||
scrollport.bottom() - overflow.y
|
||||
} else {
|
||||
overflow.bottom() - scrollport.y
|
||||
};
|
||||
LayoutSize::new(scrollport.width.max(width), scrollport.height.max(height))
|
||||
}
|
||||
|
||||
pub(crate) fn overflowing_axes<N>(
|
||||
&self,
|
||||
world: &LayoutWorld<N>,
|
||||
@@ -96,17 +117,9 @@ impl OverflowProjection {
|
||||
return (false, false);
|
||||
}
|
||||
let geometry = self.geometry(id);
|
||||
let overflow = self.scrollable_overflow(id);
|
||||
let horizontal_range = geometry
|
||||
.local_scrollport
|
||||
.width
|
||||
.max((overflow.right() - geometry.local_scrollport.x).max(0.0))
|
||||
- geometry.local_scrollport.width;
|
||||
let vertical_range = geometry
|
||||
.local_scrollport
|
||||
.height
|
||||
.max((overflow.bottom() - geometry.local_scrollport.y).max(0.0))
|
||||
- geometry.local_scrollport.height;
|
||||
let size = self.scroll_size(world, id);
|
||||
let horizontal_range = size.width - geometry.local_scrollport.width;
|
||||
let vertical_range = size.height - geometry.local_scrollport.height;
|
||||
(
|
||||
horizontal_range > f32::EPSILON,
|
||||
vertical_range > f32::EPSILON,
|
||||
@@ -224,32 +237,95 @@ impl OverflowProjection {
|
||||
{
|
||||
let mut overflow = self.geometries[id.index()].local_overflow;
|
||||
for child in self.children[id.index()].iter().copied() {
|
||||
overflow = overflow.union(self.child_contribution(world, child));
|
||||
overflow = overflow.union(self.child_contribution(world, id, child));
|
||||
}
|
||||
self.scrollable_overflow[id.index()] = overflow;
|
||||
}
|
||||
|
||||
fn child_contribution<N>(&self, world: &LayoutWorld<N>, child: LayoutBoxId) -> LayoutRect
|
||||
fn child_contribution<N>(
|
||||
&self,
|
||||
world: &LayoutWorld<N>,
|
||||
parent: LayoutBoxId,
|
||||
child: LayoutBoxId,
|
||||
) -> LayoutRect
|
||||
where
|
||||
N: Copy + Debug + Eq + Hash,
|
||||
{
|
||||
let geometry = self.geometries[child.index()];
|
||||
let visual_overflow = if clips_overflow(world, child) {
|
||||
geometry.border_box
|
||||
} else {
|
||||
self.scrollable_overflow[child.index()]
|
||||
};
|
||||
let mut visual_overflow = self.scrollable_overflow[child.index()];
|
||||
if clips_overflow(world, child) {
|
||||
let modes = world.boxes[child.index()].style.overflow_modes();
|
||||
// Clip/visible does not establish a scroll container. Clip just
|
||||
// that physical axis and keep overflow on the other axis. Native
|
||||
// controls and scrolling mechanisms still clip both axes.
|
||||
let independent_clip = !establishes_scroll_container(world, child)
|
||||
&& modes.contains(&LayoutOverflowMode::Clip);
|
||||
if !independent_clip || modes[0] == LayoutOverflowMode::Clip {
|
||||
visual_overflow.x = geometry.border_box.x;
|
||||
visual_overflow.width = geometry.border_box.width;
|
||||
}
|
||||
if !independent_clip || modes[1] == LayoutOverflowMode::Clip {
|
||||
visual_overflow.y = geometry.border_box.y;
|
||||
visual_overflow.height = geometry.border_box.height;
|
||||
}
|
||||
}
|
||||
let location = world.boxes[child.index()].final_layout.location;
|
||||
let layout_translation = LayoutTransform2D::translation(location.x, location.y);
|
||||
let local_to_parent = layout_translation.concatenate(geometry.resolved_transform.transform);
|
||||
local_to_parent
|
||||
.map_rect(visual_overflow)
|
||||
let parent_box = &world.boxes[parent.index()];
|
||||
let relative =
|
||||
if world.boxes[child.index()].style.position() == crate::LayoutPosition::Relative {
|
||||
crate::inline::relative_atomic_inset_offset(
|
||||
&world.boxes[child.index()].style.taffy,
|
||||
taffy::Size {
|
||||
width: self.geometries[parent.index()].content_box.width,
|
||||
height: self.geometries[parent.index()].content_box.height,
|
||||
},
|
||||
parent_box.style.direction(),
|
||||
)
|
||||
} else {
|
||||
taffy::Point::ZERO
|
||||
};
|
||||
let normal_flow_translation =
|
||||
LayoutTransform2D::translation(location.x - relative.x, location.y - relative.y);
|
||||
let parent_is_grid = matches!(
|
||||
parent_box.style.display(),
|
||||
LayoutDisplay::Grid | LayoutDisplay::InlineGrid
|
||||
);
|
||||
let margin_to_parent =
|
||||
if parent_is_grid && !world.boxes[child.index()].style.is_out_of_flow() {
|
||||
// A grid's untransformed floor is its grid area, not the original
|
||||
// border box of an item overflowing its track. Keep the item's
|
||||
// margins in the same transformed physical space as its border.
|
||||
local_to_parent
|
||||
} else {
|
||||
normal_flow_translation
|
||||
};
|
||||
let mut contribution = local_to_parent
|
||||
.map_rect(visual_overflow.union(geometry.border_box))
|
||||
.bounding_rect()
|
||||
.union(
|
||||
layout_translation
|
||||
margin_to_parent
|
||||
.map_rect(geometry.margin_box)
|
||||
.bounding_rect(),
|
||||
)
|
||||
);
|
||||
if establishes_scroll_container(world, parent)
|
||||
&& !world.boxes[child.index()].style.is_out_of_flow()
|
||||
&& !parent_is_grid
|
||||
{
|
||||
// End padding extends the direct in-flow margin box, not its
|
||||
// escaping descendants or transformed visual bounds. A grid
|
||||
// item's overflow does not grow its containing grid area.
|
||||
let padding = parent_box.final_layout.padding;
|
||||
let padded = extend_end_padding(
|
||||
geometry.margin_box,
|
||||
padding,
|
||||
parent_box.style.reversed_scroll_axes(),
|
||||
);
|
||||
contribution =
|
||||
contribution.union(normal_flow_translation.map_rect(padded).bounding_rect());
|
||||
}
|
||||
contribution
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,21 +401,50 @@ where
|
||||
horizontal_gutter,
|
||||
horizontal_leading_gutter,
|
||||
);
|
||||
let mut local_overflow = LayoutRect::new(
|
||||
local_scrollport.x,
|
||||
local_scrollport.y,
|
||||
local_scrollport
|
||||
.width
|
||||
.max(layout.scrollable_overflow_rect.right)
|
||||
.max(0.0),
|
||||
local_scrollport
|
||||
.height
|
||||
.max(layout.scrollable_overflow_rect.bottom)
|
||||
.max(0.0),
|
||||
);
|
||||
// Taffy's scrollable_overflow_rect is relative to a logical scroll origin,
|
||||
// not the physical top-left. It also already aggregates descendants.
|
||||
// Reusing its right/bottom here both mirrors mixed-direction overflow and
|
||||
// double counts it when resolve_one adds the physical descendants below.
|
||||
// Start with this box's scrollport and own inline fragments; aggregate
|
||||
// descendant bounds exactly once in the physical coordinate system.
|
||||
let mut local_overflow = local_scrollport;
|
||||
if is_root {
|
||||
local_overflow = local_overflow.union(padding_box);
|
||||
}
|
||||
if let Some(taffy::DetailedLayoutInfo::Grid(grid)) = world.native_grid_details.get(&id) {
|
||||
// Track positions already include physical border/padding/gaps and
|
||||
// alignment. Empty or undersized items must not erase a large grid
|
||||
// area, and overflowing items must not enlarge that untransformed area.
|
||||
let reversed = layout_box.style.reversed_scroll_axes();
|
||||
let padding = if establishes_scroll_container(world, id) {
|
||||
layout.padding
|
||||
} else {
|
||||
taffy::Rect::ZERO
|
||||
};
|
||||
let (left, right) = grid_track_overflow_axis(
|
||||
&grid.columns.positions,
|
||||
local_overflow.x,
|
||||
local_overflow.right(),
|
||||
if reversed.x {
|
||||
padding.left
|
||||
} else {
|
||||
padding.right
|
||||
},
|
||||
reversed.x,
|
||||
);
|
||||
let (top, bottom) = grid_track_overflow_axis(
|
||||
&grid.rows.positions,
|
||||
local_overflow.y,
|
||||
local_overflow.bottom(),
|
||||
if reversed.y {
|
||||
padding.top
|
||||
} else {
|
||||
padding.bottom
|
||||
},
|
||||
reversed.y,
|
||||
);
|
||||
local_overflow = LayoutRect::new(left, top, right - left, bottom - top);
|
||||
}
|
||||
if let Some(context) = layout_box.inline_layout.as_ref() {
|
||||
let origin = LayoutPoint::new(
|
||||
layout.border.left
|
||||
@@ -357,14 +462,26 @@ where
|
||||
horizontal_leading_gutter
|
||||
},
|
||||
);
|
||||
let inline_bounds = |rect| {
|
||||
let rect = offset_rect(rect, origin);
|
||||
if establishes_scroll_container(world, id) {
|
||||
extend_end_padding(
|
||||
rect,
|
||||
layout.padding,
|
||||
layout_box.style.reversed_scroll_axes(),
|
||||
)
|
||||
} else {
|
||||
rect
|
||||
}
|
||||
};
|
||||
for line in &context.fragments.lines {
|
||||
local_overflow = local_overflow.union(offset_rect(line.rect, origin));
|
||||
local_overflow = local_overflow.union(inline_bounds(line.rect));
|
||||
}
|
||||
for fragment in &context.fragments.text {
|
||||
local_overflow = local_overflow.union(offset_rect(fragment.rect, origin));
|
||||
local_overflow = local_overflow.union(inline_bounds(fragment.rect));
|
||||
}
|
||||
for fragment in &context.fragments.boxes {
|
||||
local_overflow = local_overflow.union(offset_rect(fragment.rect, origin));
|
||||
local_overflow = local_overflow.union(inline_bounds(fragment.rect));
|
||||
}
|
||||
}
|
||||
OverflowBoxGeometry {
|
||||
@@ -382,6 +499,47 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn grid_track_overflow_axis(
|
||||
tracks: &[taffy::Line<f32>],
|
||||
start: f32,
|
||||
end: f32,
|
||||
end_padding: f32,
|
||||
reversed: bool,
|
||||
) -> (f32, f32) {
|
||||
tracks.iter().fold((start, end), |(start, end), track| {
|
||||
(
|
||||
start.min(track.start - if reversed { end_padding } else { 0.0 }),
|
||||
end.max(track.end + if reversed { 0.0 } else { end_padding }),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn extend_end_padding(
|
||||
mut rect: LayoutRect,
|
||||
padding: taffy::Rect<f32>,
|
||||
reversed: taffy::Point<bool>,
|
||||
) -> LayoutRect {
|
||||
let x = if reversed.x {
|
||||
padding.left
|
||||
} else {
|
||||
padding.right
|
||||
};
|
||||
let y = if reversed.y {
|
||||
padding.top
|
||||
} else {
|
||||
padding.bottom
|
||||
};
|
||||
if reversed.x {
|
||||
rect.x -= x;
|
||||
}
|
||||
if reversed.y {
|
||||
rect.y -= y;
|
||||
}
|
||||
rect.width += x;
|
||||
rect.height += y;
|
||||
rect
|
||||
}
|
||||
|
||||
fn scrollbar_gutter_thickness<N>(
|
||||
world: &LayoutWorld<N>,
|
||||
id: LayoutBoxId,
|
||||
|
||||
@@ -493,30 +493,23 @@ where
|
||||
} else {
|
||||
local_scrollport
|
||||
};
|
||||
let scroll_size = LayoutSize::new(
|
||||
local_scrollport
|
||||
.width
|
||||
.max((overflow.right() - local_scrollport.x).max(0.0)),
|
||||
local_scrollport
|
||||
.height
|
||||
.max((overflow.bottom() - local_scrollport.y).max(0.0)),
|
||||
);
|
||||
let scroll_size = overflow_projection.scroll_size(self.world, id);
|
||||
let horizontal_range = (scroll_size.width - scrollport.width).max(0.0);
|
||||
let vertical_range = (scroll_size.height - scrollport.height).max(0.0);
|
||||
let is_scroll_container = self.establishes_scroll_container(id);
|
||||
let requested = finite_point(self.world.boxes[index].scroll_offset);
|
||||
let (minimum_offset, maximum_offset) = if is_scroll_container {
|
||||
if self.world.boxes[index].style.direction() == crate::style::InlineDirection::Rtl {
|
||||
(
|
||||
LayoutPoint::new(-horizontal_range, 0.0),
|
||||
LayoutPoint::new(0.0, vertical_range),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
LayoutPoint::ZERO,
|
||||
LayoutPoint::new(horizontal_range, vertical_range),
|
||||
)
|
||||
}
|
||||
let reversed = self.world.boxes[index].style.reversed_scroll_axes();
|
||||
(
|
||||
LayoutPoint::new(
|
||||
if reversed.x { -horizontal_range } else { 0.0 },
|
||||
if reversed.y { -vertical_range } else { 0.0 },
|
||||
),
|
||||
LayoutPoint::new(
|
||||
if reversed.x { 0.0 } else { horizontal_range },
|
||||
if reversed.y { 0.0 } else { vertical_range },
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(LayoutPoint::ZERO, LayoutPoint::ZERO)
|
||||
};
|
||||
|
||||
@@ -1968,6 +1968,37 @@ impl ResolvedLayoutStyle {
|
||||
self.writing_mode.is_horizontal()
|
||||
}
|
||||
|
||||
/// Physical axes whose reachable scroll direction points toward negative coordinates.
|
||||
pub(crate) fn reversed_scroll_axes(&self) -> taffy::Point<bool> {
|
||||
let rtl = self.direction == InlineDirection::Rtl;
|
||||
let mut reversed = match self.writing_mode {
|
||||
LayoutWritingMode::HorizontalTb => taffy::Point { x: rtl, y: false },
|
||||
LayoutWritingMode::VerticalRl => taffy::Point { x: true, y: rtl },
|
||||
LayoutWritingMode::VerticalLr => taffy::Point { x: false, y: rtl },
|
||||
};
|
||||
if matches!(
|
||||
self.display,
|
||||
LayoutDisplay::Flex | LayoutDisplay::InlineFlex
|
||||
) {
|
||||
let row = matches!(
|
||||
self.taffy.flex_direction,
|
||||
taffy::FlexDirection::Row | taffy::FlexDirection::RowReverse
|
||||
);
|
||||
let main_x = row == self.uses_horizontal_writing_mode();
|
||||
let main_reverse = matches!(
|
||||
self.taffy.flex_direction,
|
||||
taffy::FlexDirection::RowReverse | taffy::FlexDirection::ColumnReverse
|
||||
);
|
||||
let cross_reverse = matches!(
|
||||
self.taffy.flex_wrap,
|
||||
taffy::FlexWrap::WrapReverse | taffy::FlexWrap::BalanceReverse
|
||||
);
|
||||
reversed.x ^= if main_x { main_reverse } else { cross_reverse };
|
||||
reversed.y ^= if main_x { cross_reverse } else { main_reverse };
|
||||
}
|
||||
reversed
|
||||
}
|
||||
|
||||
pub(crate) const fn vertical_block_start_is_right(&self) -> Option<bool> {
|
||||
self.writing_mode.vertical_block_start_is_right()
|
||||
}
|
||||
|
||||
@@ -254,15 +254,14 @@ where
|
||||
world.finish_numeric_layout_tracking()
|
||||
}
|
||||
|
||||
/// Converts Taffy's horizontal block-start anchor into the physical edge used
|
||||
/// by a vertical writing mode.
|
||||
/// Converts Taffy's horizontal block anchors into the physical edges used by
|
||||
/// a vertical writing mode.
|
||||
///
|
||||
/// Taffy deliberately has no writing-mode input yet. Its block algorithm does
|
||||
/// still produce the right border-box size, margins and physical relative
|
||||
/// inset, but it chooses the x anchor from CSS `direction`: left for LTR and
|
||||
/// Taffy's block algorithm produces physical sizes, margins and relative
|
||||
/// insets, but it still chooses the x anchor from CSS `direction`: left for LTR and
|
||||
/// right for RTL. In a vertical formatting context, direction controls the
|
||||
/// vertical inline axis instead; block-start is always left for `vertical-lr`
|
||||
/// and right for `vertical-rl`.
|
||||
/// and right for `vertical-rl`. RTL also anchors the inline axis at the bottom.
|
||||
///
|
||||
/// Apply this adapter immediately after numeric layout so positioned, sticky,
|
||||
/// scroll-overflow, CSSOM and paint all observe the same real box coordinates.
|
||||
@@ -271,7 +270,7 @@ fn physicalize_vertical_block_flow<N>(world: &mut LayoutWorld<N>)
|
||||
where
|
||||
N: Copy + Debug + Eq + Hash,
|
||||
{
|
||||
let mut adjusted_x = Vec::new();
|
||||
let mut adjusted_locations = Vec::new();
|
||||
|
||||
for parent_index in 0..world.boxes.len() {
|
||||
let parent = LayoutBoxId::from_index(parent_index);
|
||||
@@ -292,6 +291,12 @@ where
|
||||
- parent_layout.border.right
|
||||
- parent_layout.padding.right
|
||||
- scrollbar.right;
|
||||
let content_top = parent_layout.border.top + parent_layout.padding.top + scrollbar.top;
|
||||
let content_bottom = parent_layout.size.height
|
||||
- parent_layout.border.bottom
|
||||
- parent_layout.padding.bottom
|
||||
- scrollbar.bottom;
|
||||
let direction = world.boxes[parent_index].style.direction();
|
||||
let taffy_anchored_right =
|
||||
world.boxes[parent_index].style.taffy.direction == taffy::Direction::Rtl;
|
||||
|
||||
@@ -317,12 +322,42 @@ where
|
||||
} else {
|
||||
left_anchor
|
||||
};
|
||||
adjusted_x.push((child, physical_anchor + (layout.location.x - taffy_anchor)));
|
||||
let y = if direction == InlineDirection::Rtl {
|
||||
let relative_y = if child_box.style.position() == crate::LayoutPosition::Relative {
|
||||
relative_atomic_inset_offset(
|
||||
&child_box.style.taffy,
|
||||
Size {
|
||||
width: content_right - content_left,
|
||||
height: content_bottom - content_top,
|
||||
},
|
||||
direction,
|
||||
)
|
||||
.y
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Mirror the normal-flow inline anchor, while physical top /
|
||||
// bottom margins and relative insets retain their own edges.
|
||||
content_top + content_bottom + layout.margin.top
|
||||
- layout.margin.bottom
|
||||
- layout.location.y
|
||||
- layout.size.height
|
||||
+ 2.0 * relative_y
|
||||
} else {
|
||||
layout.location.y
|
||||
};
|
||||
adjusted_locations.push((
|
||||
child,
|
||||
Point {
|
||||
x: physical_anchor + (layout.location.x - taffy_anchor),
|
||||
y,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for (child, x) in adjusted_x {
|
||||
world.boxes[child.index()].unrounded_layout.location.x = x;
|
||||
for (child, location) in adjusted_locations {
|
||||
world.boxes[child.index()].unrounded_layout.location = location;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,238 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn chromium_mixed_direction_overflow_keeps_physical_edges() {
|
||||
// Live oracle: scripts/layout-rtl-overflow-chromium-differential.py.
|
||||
// An opposite-direction visible descendant overflows before the parent's
|
||||
// scroll origin; it must not create a scrollbar on the opposite edge.
|
||||
run_page_vm_async_test(async move {
|
||||
for display in ["block", "flex", "grid"] {
|
||||
for (outer, inner, width) in [
|
||||
("ltr", "ltr", 277),
|
||||
("ltr", "rtl", 133),
|
||||
("rtl", "ltr", 133),
|
||||
("rtl", "rtl", 271),
|
||||
] {
|
||||
let mut page = page_with_size_fixture(&format!(
|
||||
r#"<style>
|
||||
#scroller{{position:absolute;left:40px;top:30px;width:120px;height:90px;
|
||||
border-style:solid;border-width:2px 5px 13px 19px;padding:3px 11px 7px 17px;
|
||||
overflow:auto;direction:{outer};display:{display};
|
||||
grid-template-columns:80px;grid-template-rows:50px}}
|
||||
#child{{flex:none;min-width:0;min-height:0;width:80px;height:50px;
|
||||
direction:{inner};overflow:visible}}#descendant{{width:260px;height:180px}}
|
||||
</style><div id=scroller><div id=child><div id=descendant></div></div></div>"#,
|
||||
))?;
|
||||
publish_size_layout(&mut page)?;
|
||||
assert_eq!(
|
||||
read_without_layout(&mut page,
|
||||
"[scroller.scrollWidth,scroller.scrollHeight,scroller.clientLeft,scroller.clientWidth]")?,
|
||||
json!([width, 183, if outer == "rtl" { 34 } else { 19 }, 133]),
|
||||
"{display}, {outer} outer / {inner} inner"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await
|
||||
.expect("physical overflow bounds must match Chromium for mixed directions");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn chromium_clip_visible_overflow_preserves_the_unclipped_physical_axis() {
|
||||
run_page_vm_async_test(async move {
|
||||
for direction in ["ltr", "rtl"] {
|
||||
for (clip, expected) in [("x", [85, 180]), ("y", [260, 65])] {
|
||||
let mut page = page_with_size_fixture(&format!(
|
||||
r#"<style>
|
||||
#scroller{{width:100px;height:80px;overflow:auto;direction:{direction}}}
|
||||
#child{{width:80px;height:50px;overflow-{clip}:clip}}
|
||||
#descendant{{width:260px;height:180px}}
|
||||
</style><div id=scroller><div id=child><div id=descendant></div></div></div>"#,
|
||||
))?;
|
||||
publish_size_layout(&mut page)?;
|
||||
assert_eq!(
|
||||
read_without_layout(&mut page, "[scroller.scrollWidth,scroller.scrollHeight]")?,
|
||||
json!(expected),
|
||||
"{direction}, clip {clip} / visible other axis"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await
|
||||
.expect("single-axis clipping must preserve the visible axis");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn chromium_transformed_overflow_keeps_untransformed_padding_bounds() {
|
||||
run_page_vm_async_test(async move {
|
||||
for direction in ["ltr", "rtl"] {
|
||||
for display in ["block", "flex"] {
|
||||
for (transform, ltr, rtl) in [
|
||||
("translate(30px,20px)", [307, 203], [288, 203]),
|
||||
("translate(-30px,-20px)", [288, 190], [301, 190]),
|
||||
("scale(2)", [407, 273], [401, 273]),
|
||||
] {
|
||||
let mut page = page_with_size_fixture(&format!(
|
||||
r#"<style>
|
||||
#scroller{{position:absolute;left:40px;top:30px;width:120px;height:90px;
|
||||
border-style:solid;border-width:2px 5px 13px 19px;padding:3px 11px 7px 17px;
|
||||
overflow:auto;direction:{direction};display:{display}}}
|
||||
#child{{flex:none;width:260px;height:180px;transform:{transform}}}
|
||||
</style><div id=scroller><div id=child></div></div>"#,
|
||||
))?;
|
||||
publish_size_layout(&mut page)?;
|
||||
assert_eq!(
|
||||
read_without_layout(
|
||||
&mut page,
|
||||
"[scroller.scrollWidth,scroller.scrollHeight]"
|
||||
)?,
|
||||
json!(if direction == "rtl" { rtl } else { ltr }),
|
||||
"{direction}, {display}, {transform}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await
|
||||
.expect("transforms must extend overflow without transforming parent end padding");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn chromium_reversed_flex_flow_keeps_physical_scroll_origins() {
|
||||
run_page_vm_async_test(async move {
|
||||
// Native-scrollbar geometry recorded by the live Chromium differential.
|
||||
for (mode, direction, flex, wrap, minimum, maximum) in [
|
||||
(
|
||||
"horizontal-tb",
|
||||
"ltr",
|
||||
"row-reverse",
|
||||
"nowrap",
|
||||
[-155, 0],
|
||||
[0, 105],
|
||||
),
|
||||
(
|
||||
"horizontal-tb",
|
||||
"rtl",
|
||||
"row-reverse",
|
||||
"nowrap",
|
||||
[0, 0],
|
||||
[155, 105],
|
||||
),
|
||||
(
|
||||
"horizontal-tb",
|
||||
"ltr",
|
||||
"column-reverse",
|
||||
"nowrap",
|
||||
[0, -105],
|
||||
[155, 0],
|
||||
),
|
||||
(
|
||||
"horizontal-tb",
|
||||
"rtl",
|
||||
"column-reverse",
|
||||
"wrap-reverse",
|
||||
[0, -105],
|
||||
[155, 0],
|
||||
),
|
||||
(
|
||||
"vertical-rl",
|
||||
"rtl",
|
||||
"row-reverse",
|
||||
"wrap-reverse",
|
||||
[0, 0],
|
||||
[155, 105],
|
||||
),
|
||||
(
|
||||
"vertical-lr",
|
||||
"ltr",
|
||||
"column-reverse",
|
||||
"wrap-reverse",
|
||||
[-155, -105],
|
||||
[0, 0],
|
||||
),
|
||||
] {
|
||||
let mut page = page_with_size_fixture(&format!(
|
||||
r#"<style>
|
||||
#scroller{{display:flex;width:120px;height:90px;overflow:auto;
|
||||
writing-mode:{mode};direction:{direction};flex-direction:{flex};flex-wrap:{wrap};
|
||||
align-items:flex-start;align-content:flex-start}}
|
||||
#child{{flex:none;width:260px;height:180px}}
|
||||
</style><div id=scroller><div id=child></div></div>"#,
|
||||
))?;
|
||||
publish_size_layout(&mut page)?;
|
||||
assert_eq!(
|
||||
read_without_layout(&mut page, "[scroller.scrollWidth,scroller.scrollHeight]")?,
|
||||
json!([260, 180])
|
||||
);
|
||||
for (value, expected) in [(-10000, minimum), (10000, maximum)] {
|
||||
page.vm_mut().eval(&format!(
|
||||
"scroller.scrollLeft={value};scroller.scrollTop={value};'scrolled'"
|
||||
))?;
|
||||
publish_size_layout(&mut page)?;
|
||||
assert_eq!(
|
||||
read_without_layout(&mut page, "[scroller.scrollLeft,scroller.scrollTop]")?,
|
||||
json!(expected),
|
||||
"{mode}, {direction}, {flex}, {wrap}, requested {value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await
|
||||
.expect("flex main/cross reversal must be projected onto physical scrolling axes");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn chromium_grid_overflow_keeps_track_area_and_transformed_item_bounds() {
|
||||
run_page_vm_async_test(async move {
|
||||
for direction in ["ltr", "rtl"] {
|
||||
for child in ["width:40px;height:30px", "display:none"] {
|
||||
let mut page = page_with_size_fixture(&format!(
|
||||
r#"<style>
|
||||
#scroller{{position:absolute;left:40px;top:30px;display:grid;width:120px;height:90px;
|
||||
border-style:solid;border-width:2px 5px 13px 19px;padding:3px 11px 7px 17px;
|
||||
overflow:auto;direction:{direction};grid-template-columns:260px;grid-template-rows:180px}}
|
||||
#child{{{child}}}
|
||||
</style><div id=scroller><div id=child></div></div>"#,
|
||||
))?;
|
||||
publish_size_layout(&mut page)?;
|
||||
assert_eq!(
|
||||
read_without_layout(&mut page, "[scroller.scrollWidth,scroller.scrollHeight]")?,
|
||||
json!([288, 190]),
|
||||
"{direction}, {child}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for (direction, transform, expected) in [
|
||||
("ltr", "translate(-30px,-20px)", [247, 163]),
|
||||
("rtl", "translate(30px,20px)", [241, 203]),
|
||||
("rtl", "translate(-30px,-20px)", [301, 163]),
|
||||
] {
|
||||
let mut page = page_with_size_fixture(&format!(
|
||||
r#"<style>
|
||||
#scroller{{position:absolute;left:40px;top:30px;display:grid;width:120px;height:90px;
|
||||
border-style:solid;border-width:2px 5px 13px 19px;padding:3px 11px 7px 17px;
|
||||
overflow:auto;direction:{direction};grid-template-columns:80px;grid-template-rows:50px}}
|
||||
#child{{width:260px;height:180px;transform:{transform}}}
|
||||
</style><div id=scroller><div id=child></div></div>"#,
|
||||
))?;
|
||||
publish_size_layout(&mut page)?;
|
||||
assert_eq!(
|
||||
read_without_layout(&mut page, "[scroller.scrollWidth,scroller.scrollHeight]")?,
|
||||
json!(expected),
|
||||
"{direction}, {transform}"
|
||||
);
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await
|
||||
.expect("grid area and item visual overflow have distinct physical bounds");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn computed_size_chromium_resolved_value_display_and_unit_matrix() {
|
||||
// fast/css/getComputedStyle/getComputedStyle-resolved-values.html and its
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare physical RTL overflow geometry and scrolling against live Chromium.
|
||||
|
||||
uv run --project moli-benchmark python \
|
||||
scripts/layout-rtl-overflow-chromium-differential.py --moli-bin target/release/moli
|
||||
|
||||
Runs with hidden and native scrollbars. Every round navigates fresh documents;
|
||||
scroll writes and geometry reads cross rendering/task boundaries. A mismatch
|
||||
is an error, including when both engines would otherwise match an old snapshot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"rtl_overflow_cdp", ROOT / "scripts/layout-phase5-chromium-differential.py"
|
||||
)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
cdp = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = cdp
|
||||
SPEC.loader.exec_module(cdp)
|
||||
|
||||
|
||||
def fixture_cases() -> dict[str, str]:
|
||||
cases = {}
|
||||
for direction, display, shape in itertools.product(
|
||||
("ltr", "rtl"), ("block", "flex", "grid"),
|
||||
("fit", "wide", "nested-ltr", "nested-rtl", "translated-positive", "translated-negative", "scaled", "scaled-border"),
|
||||
):
|
||||
wide = shape != "fit"
|
||||
# Unequal physical edges detect mirroring, axis swaps, and
|
||||
# adding the leading scrollbar gutter a second time.
|
||||
css = f"""
|
||||
html,body{{margin:0;padding:0}}
|
||||
#scroller{{position:absolute;left:40px;top:30px;width:120px;height:90px;
|
||||
border-style:solid;border-width:2px 5px 13px 19px;padding:3px 11px 7px 17px;
|
||||
overflow:auto;direction:{direction};display:{display}}}
|
||||
#child{{flex:none;min-width:0;min-height:0;width:{260 if wide else 40}px;
|
||||
height:{180 if wide else 30}px}}
|
||||
"""
|
||||
inner = ""
|
||||
if shape.startswith("nested-"):
|
||||
inner_direction = shape.removeprefix("nested-")
|
||||
css += f"""
|
||||
#child{{width:80px;height:50px;direction:{inner_direction};overflow:visible}}
|
||||
#descendant{{width:260px;height:180px}}
|
||||
"""
|
||||
inner = '<div id="descendant"></div>'
|
||||
if display == "grid":
|
||||
css += "#scroller{grid-template-columns:80px;grid-template-rows:50px}"
|
||||
transforms = {"translated-positive": "translate(30px,20px)",
|
||||
"translated-negative": "translate(-30px,-20px)",
|
||||
"scaled": "scale(2)", "scaled-border": "scale(2)"}
|
||||
if shape in transforms:
|
||||
css += f"#child{{transform:{transforms[shape]}}}"
|
||||
if shape == "scaled-border":
|
||||
css += "#child{border-style:solid;border-width:2px 4px 6px 8px}"
|
||||
cases[f"horizontal-tb/{direction}-{display}-{shape}"] = (
|
||||
f'<!doctype html><style>{css}</style>'
|
||||
f'<div id="scroller"><div id="child">{inner}</div></div>'
|
||||
)
|
||||
# Ordinary vertical block flow also needs to anchor RTL at physical bottom;
|
||||
# otherwise correct overflow reachability can hide a necessary scrollbar.
|
||||
for mode, direction, shape in itertools.product(
|
||||
("vertical-rl", "vertical-lr"), ("ltr", "rtl"), ("fit", "wide", "relative-margin"),
|
||||
):
|
||||
wide = shape != "fit"
|
||||
extra = "position:relative;top:9px;margin:7px 5px 11px 13px" if shape == "relative-margin" else ""
|
||||
cases[f"{mode}/{direction}-block-flow-{shape}"] = f"""<!doctype html><style>
|
||||
html,body{{margin:0;padding:0}}
|
||||
#scroller{{position:absolute;left:40px;top:30px;width:120px;height:90px;
|
||||
border-style:solid;border-width:2px 5px 13px 19px;padding:3px 11px 7px 17px;
|
||||
overflow:auto;writing-mode:{mode};direction:{direction}}}
|
||||
#child{{width:{260 if wide else 40}px;height:{180 if wide else 30}px;{extra}}}
|
||||
</style><div id="scroller"><div id="child"></div></div>"""
|
||||
# Explicit physical insets isolate vertical scroll-origin conversion from
|
||||
# the separate normal-flow/grid writing-mode layout algorithms. Exercise
|
||||
# overflow on both physical axes, including negative reachable extents.
|
||||
shapes = (
|
||||
("fit", 20, 10, 40, 30),
|
||||
("positive", 20, 10, 260, 180),
|
||||
("negative-x", -160, 10, 260, 180),
|
||||
("negative-y", 20, -130, 260, 180),
|
||||
("negative-both", -160, -130, 260, 180),
|
||||
("end-corner", 80, 60, 40, 30),
|
||||
)
|
||||
for mode, direction, (shape, x, y, width, height) in itertools.product(
|
||||
("vertical-rl", "vertical-lr"), ("ltr", "rtl"), shapes,
|
||||
):
|
||||
cases[f"{mode}/{direction}-positioned-{shape}"] = f"""<!doctype html><style>
|
||||
html,body{{margin:0;padding:0}}
|
||||
#scroller{{position:absolute;left:40px;top:30px;width:120px;height:90px;
|
||||
border-style:solid;border-width:2px 5px 13px 19px;padding:3px 11px 7px 17px;
|
||||
overflow:auto;writing-mode:{mode};direction:{direction}}}
|
||||
#child{{position:absolute;left:{x}px;top:{y}px;width:{width}px;height:{height}px}}
|
||||
</style><div id="scroller"><div id="child"></div></div>"""
|
||||
for direction, clip in itertools.product(("ltr", "rtl"), ("x", "y")):
|
||||
cases[f"horizontal-tb/{direction}-clip-{clip}-visible"] = f"""<!doctype html><style>
|
||||
html,body{{margin:0;padding:0}}
|
||||
#scroller{{width:100px;height:80px;overflow:auto;direction:{direction}}}
|
||||
#child{{width:80px;height:50px;overflow-{clip}:clip}}
|
||||
#descendant{{width:260px;height:180px}}
|
||||
</style><div id="scroller"><div id="child"><div id="descendant"></div></div></div>"""
|
||||
for mode, direction, flex, wrap in itertools.product(
|
||||
("horizontal-tb", "vertical-rl", "vertical-lr"), ("ltr", "rtl"),
|
||||
("row", "row-reverse", "column", "column-reverse"), ("nowrap", "wrap-reverse"),
|
||||
):
|
||||
cases[f"{mode}/{direction}-flow-{flex}-{wrap}"] = f"""<!doctype html><style>
|
||||
html,body{{margin:0;padding:0}}
|
||||
#scroller{{display:flex;width:120px;height:90px;overflow:auto;
|
||||
writing-mode:{mode};direction:{direction};flex-direction:{flex};flex-wrap:{wrap};
|
||||
align-items:flex-start;align-content:flex-start}}
|
||||
#child{{flex:none;width:260px;height:180px}}
|
||||
</style><div id="scroller"><div id="child"></div></div>"""
|
||||
for direction, empty in itertools.product(("ltr", "rtl"), (False, True)):
|
||||
child = "display:none" if empty else "width:40px;height:30px"
|
||||
cases[f"horizontal-tb/{direction}-grid-area-{'empty' if empty else 'small-item'}"] = f"""<!doctype html><style>
|
||||
html,body{{margin:0;padding:0}}
|
||||
#scroller{{position:absolute;left:40px;top:30px;display:grid;width:120px;height:90px;
|
||||
padding:3px 11px 7px 17px;border-style:solid;border-width:2px 5px 13px 19px;
|
||||
overflow:auto;direction:{direction};grid-template-columns:260px;grid-template-rows:180px}}
|
||||
#child{{{child}}}
|
||||
</style><div id="scroller"><div id="child"></div></div>"""
|
||||
return cases
|
||||
|
||||
|
||||
SNAPSHOT = """(() => {
|
||||
const s = document.getElementById('scroller');
|
||||
const rect = e => { const r=e.getBoundingClientRect();
|
||||
return [r.x,r.y,r.width,r.height]; };
|
||||
return {
|
||||
scroll: [s.scrollWidth,s.scrollHeight,s.scrollLeft,s.scrollTop],
|
||||
client: [s.clientLeft,s.clientTop,s.clientWidth,s.clientHeight],
|
||||
scroller: rect(s), child: rect(document.getElementById('child')),
|
||||
descendant: document.getElementById('descendant')
|
||||
? rect(document.getElementById('descendant')) : null
|
||||
};
|
||||
})()"""
|
||||
|
||||
|
||||
async def evaluate(client, session: str, expression: str) -> Any:
|
||||
result, _ = await cdp.command(client, "Runtime.evaluate", {
|
||||
"expression": expression, "returnByValue": True, "awaitPromise": True,
|
||||
}, session_id=session)
|
||||
if result.get("exceptionDetails"):
|
||||
raise RuntimeError(result["exceptionDetails"])
|
||||
return result["result"].get("value")
|
||||
|
||||
|
||||
async def settle(client, session: str) -> None:
|
||||
# Moli publishes layout at rendering boundaries. Reading inside the same
|
||||
# script as a scroll mutation would test its synchronous sampling contract.
|
||||
await evaluate(client, session, "new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))")
|
||||
# getLayoutMetrics deliberately reuses Moli's last published frame. A
|
||||
# capture explicitly publishes fresh visual geometry after each mutation.
|
||||
await cdp.command(client, "Page.captureScreenshot", {
|
||||
"clip": {"x": 0, "y": 0, "width": 1, "height": 1, "scale": 1},
|
||||
}, session_id=session)
|
||||
|
||||
|
||||
async def measure(client, session: str, html: str) -> dict[str, Any]:
|
||||
_, messages = await cdp.command(client, "Page.navigate", {
|
||||
"url": "data:text/html;charset=utf-8," + urllib.parse.quote(html),
|
||||
}, session_id=session)
|
||||
await cdp.wait_for_load(client, session, messages)
|
||||
await settle(client, session)
|
||||
values = {"initial": await evaluate(client, session, SNAPSHOT)}
|
||||
for name, x, y in (("negative", -10000, -10000), ("positive", 10000, 10000)):
|
||||
await evaluate(client, session, f"""(() => {{
|
||||
const s=document.getElementById('scroller');
|
||||
s.scrollLeft={x};s.scrollTop={y};
|
||||
}})()""")
|
||||
await settle(client, session)
|
||||
values[name] = await evaluate(client, session, SNAPSHOT)
|
||||
json.dumps(values, allow_nan=False)
|
||||
return values
|
||||
|
||||
|
||||
async def run_engine(binary: Path, engine: str, scrollbars: bool, rounds: int,
|
||||
output: Path) -> dict[str, Any]:
|
||||
port = cdp.reserve_port()
|
||||
endpoint = f"http://127.0.0.1:{port}"
|
||||
label = f"{engine}-{'native' if scrollbars else 'hidden'}"
|
||||
with tempfile.TemporaryDirectory(prefix=f"moli-rtl-{label}-", ignore_cleanup_errors=True) as profile:
|
||||
if engine == "moli":
|
||||
command = [str(binary), "serve", "--host", "127.0.0.1", "--port", str(port), "--layout"]
|
||||
if scrollbars:
|
||||
command.append("--scrollbars")
|
||||
else:
|
||||
command = [str(binary), "--headless=new", "--disable-gpu", "--no-sandbox",
|
||||
"--disable-background-networking", "--no-first-run",
|
||||
"--remote-debugging-address=127.0.0.1", f"--remote-debugging-port={port}",
|
||||
f"--user-data-dir={profile}", "about:blank"]
|
||||
if not scrollbars:
|
||||
command.append("--hide-scrollbars")
|
||||
env = {k: v for k, v in os.environ.items()
|
||||
if k.lower() not in {"http_proxy", "https_proxy", "all_proxy", "no_proxy",
|
||||
"moli_scrollbars", "moli_layout"}}
|
||||
with (output / f"{label}.log").open("w") as log:
|
||||
process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, env=env)
|
||||
client = None
|
||||
try:
|
||||
deadline = time.monotonic() + 15
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(f"{label} exited: {process.returncode}; see {log.name}")
|
||||
try:
|
||||
client = await cdp.connect_raw_cdp(endpoint)
|
||||
break
|
||||
except (OSError, TimeoutError, ValueError):
|
||||
await asyncio.sleep(0.05)
|
||||
if client is None:
|
||||
raise TimeoutError(f"{label}: CDP startup timed out")
|
||||
version, _ = await cdp.command(client, "Browser.getVersion")
|
||||
target, _ = await cdp.command(client, "Target.createTarget", {"url": "about:blank"})
|
||||
attached, _ = await cdp.command(client, "Target.attachToTarget", {
|
||||
"targetId": target["targetId"], "flatten": True,
|
||||
})
|
||||
session = attached["sessionId"]
|
||||
await cdp.command(client, "Page.enable", session_id=session)
|
||||
await cdp.command(client, "Emulation.setDeviceMetricsOverride", {
|
||||
"width": 800, "height": 600, "deviceScaleFactor": 1, "mobile": False,
|
||||
}, session_id=session)
|
||||
observations = []
|
||||
for number in range(1, rounds + 1):
|
||||
cases = {name: await measure(client, session, html)
|
||||
for name, html in fixture_cases().items()}
|
||||
observations.append(cases)
|
||||
print(f"{label}: round {number}, {len(cases)} cases", flush=True)
|
||||
return {"command": command, "version": version,
|
||||
"binary_sha256": hashlib.sha256(binary.read_bytes()).hexdigest(),
|
||||
"rounds": observations}
|
||||
finally:
|
||||
if client is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await cdp.command(client, "Browser.close")
|
||||
with contextlib.suppress(Exception):
|
||||
await client.websocket.close()
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
|
||||
|
||||
async def run(args, output: Path) -> bool:
|
||||
report: dict[str, Any] = {"tolerance_css_px": cdp.TOLERANCE,
|
||||
"fixtures": fixture_cases(), "engines": {}, "differences": []}
|
||||
for scrollbars in (False, True):
|
||||
mode = "native" if scrollbars else "hidden"
|
||||
expected = await run_engine(args.chromium, "chromium", scrollbars, args.rounds, output)
|
||||
report["engines"][f"chromium-{mode}"] = expected
|
||||
(output / "comparison.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
actual = await run_engine(args.moli_bin, "moli", scrollbars, args.rounds, output)
|
||||
report["engines"][f"moli-{mode}"] = actual
|
||||
for number, (moli_round, chrome_round) in enumerate(zip(actual["rounds"], expected["rounds"]), 1):
|
||||
for name, oracle in chrome_round.items():
|
||||
try:
|
||||
cdp.assert_value(f"{mode}/{name}", moli_round[name], oracle)
|
||||
except AssertionError as error:
|
||||
report["differences"].append({"mode": mode, "case": name, "round": number,
|
||||
"error": str(error)})
|
||||
(output / "comparison.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
report["passed"] = not report["differences"]
|
||||
report["complete"] = True
|
||||
(output / "comparison.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
print(json.dumps({"passed": report["passed"], "differences": report["differences"],
|
||||
"report": str(output / "comparison.json")}, indent=2))
|
||||
return report["passed"]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--moli-bin", type=Path, required=True)
|
||||
parser.add_argument("--chromium", type=Path, default=Path(shutil.which("chromium") or cdp.DEFAULT_CHROMIUM))
|
||||
parser.add_argument("--rounds", type=int, default=3)
|
||||
parser.add_argument("--output-dir", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.rounds < 1:
|
||||
parser.error("--rounds must be positive")
|
||||
args.moli_bin = args.moli_bin.expanduser().resolve(strict=True)
|
||||
args.chromium = args.chromium.expanduser().resolve(strict=True)
|
||||
output = args.output_dir or ROOT / "moli-benchmark/results/rtl-overflow" / time.strftime("%Y%m%d-%H%M%S")
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
raise SystemExit(0 if asyncio.run(run(args, output)) else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user