experiment: isolate read-only viewport comparison runner
This commit is contained in:
@@ -0,0 +1,744 @@
|
||||
//! Private read-only optical comparison. Normal startup never constructs this runner.
|
||||
mod inert;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use crate::{app::App, diagram::DiagramCanvas, style};
|
||||
use iced::{
|
||||
Element, Fill, Rectangle, Renderer, Size, Task, Theme, Vector, mouse,
|
||||
widget::{self, button, canvas, column, container, row, text},
|
||||
};
|
||||
use oa_core::{Id, Kind, Layer, Project, projection, routing};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
fs,
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
type Result<T> = std::result::Result<T, String>;
|
||||
type Mailbox = Rc<RefCell<Option<DrawStamp>>>;
|
||||
|
||||
pub(crate) fn requested(args: &[String]) -> bool {
|
||||
args.get(1).is_some_and(|arg| arg == "--focus-optical")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Variant {
|
||||
Baseline,
|
||||
Focus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
struct Camera {
|
||||
zoom: f32,
|
||||
pan: Vector,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
struct DrawStamp {
|
||||
project: Id,
|
||||
diagram: Id,
|
||||
variant: Variant,
|
||||
generation: u64,
|
||||
viewport: Size,
|
||||
camera: Camera,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Message {
|
||||
Variant(Variant),
|
||||
Fit,
|
||||
Capture,
|
||||
Measured(u64, Size),
|
||||
Drawn(DrawStamp),
|
||||
Header(u64, Size),
|
||||
Opened(iced::window::Id, Size),
|
||||
WindowSize(Size),
|
||||
Scale(f32),
|
||||
Close(iced::window::Id),
|
||||
UnexpectedPassive(String),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Frozen {
|
||||
diagram: Id,
|
||||
project: Project,
|
||||
projected: projection::ProjectedView,
|
||||
routed: routing::RoutedView,
|
||||
svg: String,
|
||||
report: Value,
|
||||
}
|
||||
|
||||
impl Frozen {
|
||||
fn new(project: Project, diagram: Id) -> Result<Self> {
|
||||
let Some(saved) = project.diagrams.get(&diagram) else {
|
||||
return Err("The named diagram does not exist.".into());
|
||||
};
|
||||
if !saved.projection.is_architecture() || saved.projection.scenario().is_some() {
|
||||
return Err("The optical comparison accepts saved Architecture diagrams only.".into());
|
||||
}
|
||||
let projected = projection::project_diagram(&project, saved);
|
||||
let routed = routing::route_diagram(&project, saved, &projected);
|
||||
let svg = crate::diagram::export_svg(&project, diagram).map_err(|e| e.to_string())?;
|
||||
let report = scene_report(&project, diagram, &projected, &routed);
|
||||
Ok(Self {
|
||||
diagram,
|
||||
project,
|
||||
projected,
|
||||
routed,
|
||||
svg,
|
||||
report,
|
||||
})
|
||||
}
|
||||
|
||||
fn check(&self, app: &App, diagram: Id) -> Result<()> {
|
||||
if app.project() != &self.project {
|
||||
return Err("Frozen complete Project changed.".into());
|
||||
}
|
||||
let saved = &app.project().diagrams[&diagram];
|
||||
let projected =
|
||||
projection::project_diagram_with_index(app.project(), saved, app.document.view_index());
|
||||
let routed = routing::route_diagram(app.project(), saved, &projected);
|
||||
let svg = crate::diagram::export_svg(app.project(), diagram).map_err(|e| e.to_string())?;
|
||||
if projected != self.projected || routed != self.routed || svg != self.svg {
|
||||
return Err("Frozen projection, complete routed geometry or SVG changed.".into());
|
||||
}
|
||||
if app.active_diagram != Some(diagram)
|
||||
|| app.layer != saved.layer
|
||||
|| app.page != crate::app::Page::Diagram
|
||||
|| app.selected != app.project().id
|
||||
|| app.diagram_selection.is_some()
|
||||
|| app.connection.is_some()
|
||||
|| app.architecture.is_some()
|
||||
{
|
||||
return Err("Optical presentation fixture is no longer neutral.".into());
|
||||
}
|
||||
if app.document.dirty()
|
||||
|| app.document.can_undo()
|
||||
|| app.document.can_redo()
|
||||
|| app.draft_dirty()
|
||||
|| app.source_dirty()
|
||||
|| app.context_help
|
||||
|| app.busy
|
||||
|| app.pending.is_some()
|
||||
|| app.model_menu.is_some()
|
||||
|| app.file_prompt.is_some()
|
||||
|| app.diagram_navigation_focus.is_some()
|
||||
|| app.diagram_usages.is_some()
|
||||
{
|
||||
return Err(
|
||||
"Optical fixture acquired draft, history, modal or navigation state.".into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn scene_report(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
projected: &projection::ProjectedView,
|
||||
routed: &routing::RoutedView,
|
||||
) -> Value {
|
||||
let label = |l: &routing::EdgeLabelGeometry| {
|
||||
json!({
|
||||
"text": l.text, "fraction": l.fraction, "anchor": l.anchor, "center": l.center,
|
||||
"bounds": {"position": l.bounds.position, "size": l.bounds.size}
|
||||
})
|
||||
};
|
||||
let edges: Vec<_> = routed
|
||||
.edges
|
||||
.values()
|
||||
.map(|edge| {
|
||||
let ends = project
|
||||
.relations
|
||||
.get(&edge.id)
|
||||
.map(|relation| {
|
||||
routing::association_end_geometry(relation, edge).iter().map(|end| json!({
|
||||
"source_end": end.source_end, "label": label(&end.label), "tip": end.tip,
|
||||
"inward": end.inward, "navigable": end.navigable
|
||||
})).collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
json!({"id": edge.id, "points": edge.points, "label": label(&edge.label),
|
||||
"pins": edge.pins, "pin_fractions": edge.pin_fractions,
|
||||
"warnings": edge.warnings.iter().map(|w| format!("{w:?}")).collect::<Vec<_>>(),
|
||||
"custom_suspended": edge.custom_suspended, "association_ends": ends})
|
||||
})
|
||||
.collect();
|
||||
json!({"diagram": diagram, "projected": projected,
|
||||
"routed": {"bounds": {"position": routed.bounds.position, "size": routed.bounds.size}, "edges": edges},
|
||||
"complete_routed_debug": format!("{routed:#?}"),
|
||||
"debug_scope": "Includes private derived routing fields; complete RoutedView PartialEq is also checked on every capture."})
|
||||
}
|
||||
|
||||
fn fit(bounds: routing::RoutingBounds, empty: bool, viewport: Size) -> Camera {
|
||||
if empty {
|
||||
return Camera {
|
||||
zoom: 1.,
|
||||
pan: Vector::new(30., 30.),
|
||||
};
|
||||
}
|
||||
let width = (viewport.width - 48.).max(1.);
|
||||
let height = (viewport.height - 48.).max(1.);
|
||||
// Preserve production f32 operation order, including origin addition/subtraction.
|
||||
let left = bounds.position.x;
|
||||
let top = bounds.position.y;
|
||||
let right = left + bounds.size.width;
|
||||
let bottom = top + bounds.size.height;
|
||||
let zoom = (width / (right - left).max(1.))
|
||||
.min(height / (bottom - top).max(1.))
|
||||
.clamp(0.0001, 1.5);
|
||||
Camera {
|
||||
zoom,
|
||||
pan: Vector::new(
|
||||
24. + (width - (right - left) * zoom) / 2. - left * zoom,
|
||||
24. + (height - (bottom - top) * zoom) / 2. - top * zoom,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_size(size: Size) -> bool {
|
||||
size.width.is_finite() && size.height.is_finite() && size.width > 0. && size.height > 0.
|
||||
}
|
||||
|
||||
struct LayoutState {
|
||||
variant: Variant,
|
||||
generation: u64,
|
||||
expected: Option<DrawStamp>,
|
||||
acknowledged: Option<DrawStamp>,
|
||||
}
|
||||
impl Default for LayoutState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
variant: Variant::Baseline,
|
||||
generation: 0,
|
||||
expected: None,
|
||||
acknowledged: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl LayoutState {
|
||||
fn invalidate(&mut self) {
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
self.expected = None;
|
||||
self.acknowledged = None;
|
||||
}
|
||||
fn measured(&mut self, generation: u64, viewport: Size, frozen: &Frozen) -> bool {
|
||||
if generation != self.generation
|
||||
|| !valid_size(viewport)
|
||||
|| self
|
||||
.expected
|
||||
.is_some_and(|stamp| stamp.viewport == viewport)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// A genuine canvas resize can happen without a native WindowResized.
|
||||
if self.expected.is_some() {
|
||||
self.invalidate();
|
||||
}
|
||||
self.expected = Some(DrawStamp {
|
||||
project: frozen.project.id,
|
||||
diagram: frozen.diagram,
|
||||
variant: self.variant,
|
||||
generation: self.generation,
|
||||
viewport,
|
||||
camera: fit(
|
||||
frozen.routed.bounds,
|
||||
frozen.projected.nodes.is_empty(),
|
||||
viewport,
|
||||
),
|
||||
});
|
||||
self.acknowledged = None;
|
||||
true
|
||||
}
|
||||
fn drawn(&mut self, stamp: DrawStamp) -> bool {
|
||||
if Some(stamp) != self.expected || self.acknowledged == Some(stamp) {
|
||||
return false;
|
||||
}
|
||||
self.acknowledged = Some(stamp);
|
||||
true
|
||||
}
|
||||
fn ready(&self) -> bool {
|
||||
self.expected.is_some() && self.expected == self.acknowledged
|
||||
}
|
||||
}
|
||||
|
||||
/// No update override: no interactive canvas state can ever be mutated here.
|
||||
struct OpticalCanvas<'a> {
|
||||
program: DiagramCanvas<'a>,
|
||||
stamp: Option<DrawStamp>,
|
||||
drawn: Mailbox,
|
||||
}
|
||||
impl canvas::Program<crate::app::Message> for OpticalCanvas<'_> {
|
||||
type State = crate::diagram::State;
|
||||
fn draw(
|
||||
&self,
|
||||
state: &Self::State,
|
||||
renderer: &Renderer,
|
||||
theme: &Theme,
|
||||
bounds: Rectangle,
|
||||
_: mouse::Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let geometry = <DiagramCanvas<'_> as canvas::Program<crate::app::Message>>::draw(
|
||||
&self.program,
|
||||
state,
|
||||
renderer,
|
||||
theme,
|
||||
bounds,
|
||||
mouse::Cursor::Unavailable,
|
||||
);
|
||||
if let Some(stamp) = self.stamp.filter(|s| {
|
||||
s.viewport == bounds.size()
|
||||
&& s.camera.zoom == self.program.zoom
|
||||
&& s.camera.pan == self.program.pan
|
||||
}) {
|
||||
*self.drawn.borrow_mut() = Some(stamp);
|
||||
}
|
||||
geometry
|
||||
}
|
||||
}
|
||||
|
||||
struct Runner {
|
||||
fixture: App,
|
||||
frozen: Frozen,
|
||||
diagram: Id,
|
||||
input_path: PathBuf,
|
||||
input: Vec<u8>,
|
||||
output: PathBuf,
|
||||
layout: LayoutState,
|
||||
drawn: Mailbox,
|
||||
header: Option<(u64, Size)>,
|
||||
scale_factor: Option<f32>,
|
||||
capture: u32,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn fixture(project: Project, diagram: Id) -> Result<App> {
|
||||
// Preflight the same conversion used by with_project so invalid input is a visible error.
|
||||
let document =
|
||||
crate::document::Document::from_project(project.clone(), crate::source_workspace::LIMITS)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if document.project() != &project {
|
||||
return Err("Document admission changed the complete Project.".into());
|
||||
}
|
||||
let layer = project.diagrams[&diagram].layer;
|
||||
let mut app = App::with_project(project);
|
||||
app.active_diagram = Some(diagram);
|
||||
app.layer = layer;
|
||||
app.page = crate::app::Page::Diagram;
|
||||
app.explorer_index = crate::explorer::Index::new(app.project(), layer);
|
||||
app.create_kind = if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
app.architecture = None;
|
||||
app.architecture_palette_expanded = false;
|
||||
app.connection = None;
|
||||
app.selected = app.project().id;
|
||||
app.selected_node = None;
|
||||
app.diagram_selection = None;
|
||||
app.selected_attachment = None;
|
||||
app.selected_bend = None;
|
||||
app.draft = crate::forms::Draft::from_project(app.project(), app.selected);
|
||||
app.diagram_navigation_focus = None;
|
||||
app.diagram_usages = None;
|
||||
app.status =
|
||||
"READ-ONLY OPTICAL EXPERIMENT · Waiting for measured canvas and draw acknowledgment".into();
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
impl Runner {
|
||||
fn invalidate(&mut self) {
|
||||
self.layout.invalidate();
|
||||
self.header = None;
|
||||
*self.drawn.borrow_mut() = None;
|
||||
self.fixture.status =
|
||||
"READ-ONLY · Waiting for measured canvas and draw acknowledgment".into();
|
||||
}
|
||||
fn update(&mut self, message: Message) -> Task<Message> {
|
||||
// A fault is terminal for this evidence session. Queued redraw/capture
|
||||
// messages cannot restore readiness or write another successful capture.
|
||||
if self.error.is_some() && !matches!(&message, Message::Close(_)) {
|
||||
return Task::none();
|
||||
}
|
||||
match message {
|
||||
Message::Variant(variant) if variant != self.layout.variant => {
|
||||
self.layout.variant = variant;
|
||||
self.invalidate();
|
||||
}
|
||||
Message::Fit => self.invalidate(),
|
||||
Message::Measured(generation, size) => {
|
||||
if self.layout.measured(generation, size, &self.frozen) {
|
||||
let stamp = self.layout.expected.unwrap();
|
||||
self.fixture.zoom = stamp.camera.zoom;
|
||||
self.fixture.pan = stamp.camera.pan;
|
||||
*self.drawn.borrow_mut() = None;
|
||||
self.fixture.status =
|
||||
"READ-ONLY · Fit measured; waiting for matching canvas draw".into();
|
||||
}
|
||||
}
|
||||
Message::Drawn(stamp) => {
|
||||
if self.layout.drawn(stamp) {
|
||||
self.fixture.status = format!(
|
||||
"READ-ONLY · {:?} ready · {:.1} × {:.1} · Fit {:.5} · Capture records draw acknowledgment, not GPU completion",
|
||||
stamp.variant,
|
||||
stamp.viewport.width,
|
||||
stamp.viewport.height,
|
||||
stamp.camera.zoom
|
||||
);
|
||||
}
|
||||
}
|
||||
Message::Header(generation, size)
|
||||
if generation == self.layout.generation && valid_size(size) =>
|
||||
{
|
||||
self.header = Some((generation, size));
|
||||
}
|
||||
Message::WindowSize(size) if valid_size(size) && size != self.fixture.window_size => {
|
||||
self.fixture.window_size = size;
|
||||
self.invalidate();
|
||||
}
|
||||
Message::Opened(id, size) => {
|
||||
if valid_size(size) && size != self.fixture.window_size {
|
||||
self.fixture.window_size = size;
|
||||
self.invalidate();
|
||||
}
|
||||
return iced::window::scale_factor(id).map(Message::Scale);
|
||||
}
|
||||
Message::Scale(scale)
|
||||
if scale.is_finite() && scale > 0. && self.scale_factor != Some(scale) =>
|
||||
{
|
||||
self.scale_factor = Some(scale);
|
||||
self.invalidate();
|
||||
}
|
||||
Message::Capture => {
|
||||
if !self.layout.ready()
|
||||
|| *self.drawn.borrow() != self.layout.expected
|
||||
|| self.header.is_none_or(|(g, _)| g != self.layout.generation)
|
||||
|| self.scale_factor.is_none()
|
||||
{
|
||||
self.fixture.status =
|
||||
"READ-ONLY · Capture refused: waiting for matching fitted draw".into();
|
||||
} else if let Err(error) = self.capture() {
|
||||
self.fail(error);
|
||||
}
|
||||
}
|
||||
Message::UnexpectedPassive(reason) => self.fail(reason),
|
||||
Message::Close(id) => {
|
||||
let conservation = self.check_frozen();
|
||||
if let Err(error) = &conservation {
|
||||
self.fail(error.clone());
|
||||
}
|
||||
let succeeded = conservation.is_ok() && self.error.is_none();
|
||||
let result = write_json(
|
||||
&self.output.join("finished.json"),
|
||||
&json!({
|
||||
"status": if succeeded { "PASS" } else { "FAIL" },
|
||||
"complete_project_scene_svg_and_input_unchanged": conservation.is_ok(),
|
||||
"captures": self.capture, "error": self.error,
|
||||
"scope": "Optical runner close; failed conservation or an earlier fault cannot produce PASS. No readability acceptance."
|
||||
}),
|
||||
);
|
||||
if let Err(error) = result {
|
||||
self.fail(error);
|
||||
}
|
||||
// A changed fixture or occupied receipt must never trap the
|
||||
// user. Existing evidence is preserved; failures remain in stderr.
|
||||
return iced::window::close(id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
fn fail(&mut self, error: String) {
|
||||
eprintln!("Focus optical error: {error}");
|
||||
self.error = Some(error);
|
||||
self.layout.acknowledged = None;
|
||||
}
|
||||
fn check_frozen(&self) -> Result<()> {
|
||||
self.frozen.check(&self.fixture, self.diagram)?;
|
||||
if fs::read(&self.input_path).map_err(|e| e.to_string())? != self.input {
|
||||
return Err("The original fixture bytes changed during the comparison.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn capture(&mut self) -> Result<()> {
|
||||
if let Some(error) = &self.error {
|
||||
return Err(format!(
|
||||
"This optical evidence session has stopped: {error}"
|
||||
));
|
||||
}
|
||||
if !self.layout.ready()
|
||||
|| *self.drawn.borrow() != self.layout.expected
|
||||
|| self.header.is_none_or(|(g, _)| g != self.layout.generation)
|
||||
|| self.scale_factor.is_none()
|
||||
{
|
||||
return Err(
|
||||
"Capture is pending a matching measured viewport, header, scale factor and draw."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
self.check_frozen()?;
|
||||
let stamp = self.layout.expected.unwrap();
|
||||
let next = self.capture + 1;
|
||||
// One fresh directory makes partial failures visible without overwriting any capture.
|
||||
let directory = self.output.join(format!("capture-{next:03}"));
|
||||
fs::create_dir(&directory).map_err(|e| e.to_string())?;
|
||||
write_json(
|
||||
&directory.join("measurement.json"),
|
||||
&json!({
|
||||
"variant": format!("{:?}", stamp.variant), "generation": stamp.generation,
|
||||
"viewport": {"width": stamp.viewport.width, "height": stamp.viewport.height},
|
||||
"zoom": stamp.camera.zoom, "pan": {"x": stamp.camera.pan.x, "y": stamp.camera.pan.y},
|
||||
"logical_client": {"width": self.fixture.window_size.width, "height": self.fixture.window_size.height},
|
||||
"scale_factor": self.scale_factor, "scale_factor_source": "Iced window scale_factor task / Rescaled event",
|
||||
"measured_header_height": self.header.unwrap().1.height,
|
||||
"baseline_deviation": "Only shared header action group is replaced; original logo/project typography/padding and body chrome retained.",
|
||||
"draw_acknowledged": true, "gpu_completion_claimed": false,
|
||||
"screenshot": "Separate native capture required to establish displayed output.",
|
||||
"selection": "project (no projected object)", "hover": false,
|
||||
"palette_expanded": false, "diagram": self.diagram,
|
||||
"complete_project_projection_routed_svg_equal": true,
|
||||
"scope": "Fit-only read-only optical measurement; no reading or editing acceptance."
|
||||
}),
|
||||
)?;
|
||||
write_json(
|
||||
&directory.join("project.json"),
|
||||
&serde_json::to_value(&self.frozen.project).map_err(|e| e.to_string())?,
|
||||
)?;
|
||||
write_json(&directory.join("scene.json"), &self.frozen.report)?;
|
||||
write_new(&directory.join("diagram.svg"), self.frozen.svg.as_bytes())?;
|
||||
self.capture = next;
|
||||
self.fixture.status = format!(
|
||||
"READ-ONLY · Captured {next:03} · {:?} · native screenshot still required",
|
||||
stamp.variant
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn view(&self) -> Element<'_, Message> {
|
||||
let generation = self.layout.generation;
|
||||
let ready = self.layout.ready()
|
||||
&& self.header.is_some_and(|(g, _)| g == generation)
|
||||
&& self.scale_factor.is_some()
|
||||
&& self.error.is_none();
|
||||
let controls = row![
|
||||
text("READ ONLY · EXPERIMENT").size(11),
|
||||
button(text("Baseline").size(12))
|
||||
.padding([7, 9])
|
||||
.on_press(Message::Variant(Variant::Baseline)),
|
||||
button(text("Focus").size(12))
|
||||
.padding([7, 9])
|
||||
.on_press(Message::Variant(Variant::Focus)),
|
||||
button(text("Fit").size(12))
|
||||
.padding([7, 9])
|
||||
.on_press(Message::Fit),
|
||||
button(text("Capture").size(12))
|
||||
.padding([7, 9])
|
||||
.on_press_maybe(ready.then_some(Message::Capture)),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center);
|
||||
let header: Element<'_, Message> = if self.layout.variant == Variant::Baseline {
|
||||
crate::views::header(&self.fixture, controls.into())
|
||||
} else {
|
||||
container(
|
||||
row![
|
||||
text(crate::views::short(
|
||||
&self.frozen.project.diagrams[&self.diagram].name,
|
||||
38
|
||||
))
|
||||
.size(14),
|
||||
widget::Space::new().width(Fill),
|
||||
controls
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.padding([6, 12])
|
||||
.height(48)
|
||||
.style(style::panel)
|
||||
.into()
|
||||
};
|
||||
let header = widget::sensor(header)
|
||||
.key(generation)
|
||||
.on_show(move |size| Message::Header(generation, size))
|
||||
.on_resize(move |size| Message::Header(generation, size));
|
||||
if let Some(error) = &self.error {
|
||||
return column![header, container(column![text("Optical comparison stopped").size(22), text(error).size(16),
|
||||
text("No further capture is accepted. Inspect logs and preserve this failed session.").size(14)]
|
||||
.spacing(16)).padding(24)].into();
|
||||
}
|
||||
let program = DiagramCanvas {
|
||||
selection: None,
|
||||
allocation: None,
|
||||
project: self.fixture.project(),
|
||||
view_index: Some(self.fixture.document.view_index()),
|
||||
diagram: self.diagram,
|
||||
selected: self.fixture.project().id,
|
||||
navigation_focus: None,
|
||||
zoom: self.fixture.zoom,
|
||||
pan: self.fixture.pan,
|
||||
context: self.fixture.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: false,
|
||||
placing: false,
|
||||
connection: None,
|
||||
};
|
||||
let canvas: Element<'_, crate::app::Message> = widget::canvas(OpticalCanvas {
|
||||
program,
|
||||
stamp: self.layout.expected,
|
||||
drawn: self.drawn.clone(),
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into();
|
||||
let body = if self.layout.variant == Variant::Baseline {
|
||||
crate::views::optical_baseline(&self.fixture, canvas)
|
||||
} else {
|
||||
widget::sensor(canvas)
|
||||
.key(generation)
|
||||
.on_show(crate::app::Message::CanvasResized)
|
||||
.on_resize(crate::app::Message::CanvasResized)
|
||||
.into()
|
||||
};
|
||||
let body = inert::wrap(body, generation, self.layout.expected, self.drawn.clone());
|
||||
if self.layout.variant == Variant::Baseline {
|
||||
column![header, body].into()
|
||||
} else {
|
||||
column![
|
||||
header,
|
||||
body,
|
||||
container(
|
||||
text(&self.fixture.status)
|
||||
.size(11)
|
||||
.wrapping(widget::text::Wrapping::None)
|
||||
)
|
||||
.padding([6, 12])
|
||||
.height(28)
|
||||
.style(style::panel)
|
||||
]
|
||||
.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_new(path: &Path, data: &[u8]) -> Result<()> {
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)
|
||||
.and_then(|mut file| file.write_all(data))
|
||||
.map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
fn write_json(path: &Path, value: &Value) -> Result<()> {
|
||||
let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
|
||||
bytes.push(b'\n');
|
||||
write_new(path, &bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn run(args: &[String]) -> iced::Result {
|
||||
let prepare = || -> Result<Runner> {
|
||||
if args.len() != 3 {
|
||||
return Err(
|
||||
"usage: arcadia-studio --focus-optical PROJECT.json DIAGRAM_UUID FRESH_OUTPUT_DIR"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let input_path = fs::canonicalize(&args[0]).map_err(|e| e.to_string())?;
|
||||
let input = fs::read(&input_path).map_err(|e| e.to_string())?;
|
||||
let raw: Value = serde_json::from_slice(&input).map_err(|e| e.to_string())?;
|
||||
let decoded =
|
||||
oa_core::persistence::decode_with_report(&input).map_err(|e| e.to_string())?;
|
||||
let diagram: Id = args[1]
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid diagram UUID: {e}"))?;
|
||||
let frozen = Frozen::new(decoded.project, diagram)?;
|
||||
let fixture = fixture(frozen.project.clone(), diagram)?;
|
||||
frozen.check(&fixture, diagram)?;
|
||||
let output = PathBuf::from(&args[2]);
|
||||
fs::create_dir(&output).map_err(|e| format!("Fresh output directory required: {e}"))?;
|
||||
write_new(&output.join("input.json"), &input)?;
|
||||
write_json(
|
||||
&output.join("admission.json"),
|
||||
&json!({
|
||||
"input_path": input_path, "input_bytes": input.len(), "raw_schema": raw.get("schema_version"),
|
||||
"admitted_schema": frozen.project.schema_version, "migration": decoded.migration,
|
||||
"diagnostics": oa_core::validation::validate(&frozen.project),
|
||||
"diagram": diagram, "projection": frozen.project.diagrams[&diagram].projection,
|
||||
"layer": frozen.project.diagrams[&diagram].layer,
|
||||
"document_equals_admitted_complete_project": true,
|
||||
"hash_identity": "Parent pins input/executable hashes externally; original input bytes retained.",
|
||||
"scope": "Existing schema migration occurs once at admission; both variants use the same frozen admitted Project."
|
||||
}),
|
||||
)?;
|
||||
write_json(
|
||||
&output.join("project.json"),
|
||||
&serde_json::to_value(&frozen.project).map_err(|e| e.to_string())?,
|
||||
)?;
|
||||
write_json(&output.join("scene.json"), &frozen.report)?;
|
||||
write_new(&output.join("diagram.svg"), frozen.svg.as_bytes())?;
|
||||
Ok(Runner {
|
||||
fixture,
|
||||
frozen,
|
||||
diagram,
|
||||
input_path,
|
||||
input,
|
||||
output,
|
||||
layout: LayoutState::default(),
|
||||
drawn: Rc::new(RefCell::new(None)),
|
||||
header: None,
|
||||
scale_factor: None,
|
||||
capture: 0,
|
||||
error: None,
|
||||
})
|
||||
};
|
||||
let runner = match prepare() {
|
||||
Ok(runner) => runner,
|
||||
Err(error) => {
|
||||
eprintln!("Focus optical admission failed: {error}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let boot = RefCell::new(Some(runner));
|
||||
iced::application(
|
||||
move || {
|
||||
(
|
||||
boot.borrow_mut().take().expect("Optical runner boots once"),
|
||||
iced::window::oldest().and_then(|id| {
|
||||
iced::window::size(id).map(move |size| Message::Opened(id, size))
|
||||
}),
|
||||
)
|
||||
},
|
||||
Runner::update,
|
||||
Runner::view,
|
||||
)
|
||||
.title("ARCADIA · private read-only optical comparison")
|
||||
.theme(|_: &Runner| style::theme())
|
||||
.subscription(|_: &Runner| {
|
||||
iced::event::listen_with(|event, _, id| match event {
|
||||
iced::Event::Window(iced::window::Event::Opened { size, .. }) => {
|
||||
Some(Message::Opened(id, size))
|
||||
}
|
||||
iced::Event::Window(iced::window::Event::Resized(size)) => {
|
||||
Some(Message::WindowSize(size))
|
||||
}
|
||||
iced::Event::Window(iced::window::Event::Rescaled(scale)) => {
|
||||
Some(Message::Scale(scale))
|
||||
}
|
||||
iced::Event::Window(iced::window::Event::CloseRequested) => Some(Message::Close(id)),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.window(iced::window::Settings {
|
||||
size: Size::new(1440., 900.),
|
||||
min_size: Some(Size::new(1100., 700.)),
|
||||
exit_on_close_request: false,
|
||||
..Default::default()
|
||||
})
|
||||
.run()
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Pre-dispatch boundary: frozen chrome never receives interactive events/operations.
|
||||
use super::{DrawStamp, Mailbox, Message};
|
||||
use crate::app::Message as AppMessage;
|
||||
use iced::advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, renderer,
|
||||
widget::{Tree, tree},
|
||||
};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Theme, mouse};
|
||||
|
||||
pub(super) fn wrap<'a>(
|
||||
content: Element<'a, AppMessage>,
|
||||
generation: u64,
|
||||
expected: Option<DrawStamp>,
|
||||
drawn: Mailbox,
|
||||
) -> Element<'a, Message> {
|
||||
Element::new(Inert {
|
||||
content,
|
||||
generation,
|
||||
expected,
|
||||
drawn,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) struct Inert<'a, R: renderer::Renderer> {
|
||||
pub(super) content: Element<'a, AppMessage, Theme, R>,
|
||||
pub(super) generation: u64,
|
||||
pub(super) expected: Option<DrawStamp>,
|
||||
pub(super) drawn: Mailbox,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
generation: u64,
|
||||
acknowledged: Option<DrawStamp>,
|
||||
}
|
||||
|
||||
impl<R: renderer::Renderer> Widget<Message, Theme, R> for Inert<'_, R> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<State>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(State {
|
||||
generation: self.generation,
|
||||
acknowledged: None,
|
||||
})
|
||||
}
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
if state.generation != self.generation {
|
||||
// This rig owns no editable widget state. Remount so an unchanged-size
|
||||
// Baseline sensor still reports after explicit Fit/generation change.
|
||||
*state = State {
|
||||
generation: self.generation,
|
||||
acknowledged: None,
|
||||
};
|
||||
tree.children = self.children();
|
||||
} else {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
self.content.as_widget().size_hint()
|
||||
}
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
// Default operate/overlay/mouse_interaction do not delegate into hidden controls.
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
_: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if matches!(
|
||||
event,
|
||||
Event::Window(iced::window::Event::Resized(_) | iced::window::Event::Rescaled(_))
|
||||
) {
|
||||
*self.drawn.borrow_mut() = None;
|
||||
}
|
||||
if !matches!(
|
||||
event,
|
||||
Event::Window(iced::window::Event::RedrawRequested(_))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let mut messages = Vec::new();
|
||||
let mut passive = Shell::new(&mut messages);
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
mouse::Cursor::Unavailable,
|
||||
renderer,
|
||||
clipboard,
|
||||
&mut passive,
|
||||
viewport,
|
||||
);
|
||||
shell.request_redraw_at(passive.redraw_request());
|
||||
if passive.is_layout_invalid() {
|
||||
shell.invalidate_layout();
|
||||
}
|
||||
if passive.are_widgets_invalid() {
|
||||
shell.invalidate_widgets();
|
||||
}
|
||||
// Do not dispatch any production command, including an unexpected passive one.
|
||||
for message in messages {
|
||||
match message {
|
||||
AppMessage::CanvasResized(size) => {
|
||||
if self.expected.is_some_and(|stamp| stamp.viewport != size) {
|
||||
*self.drawn.borrow_mut() = None;
|
||||
}
|
||||
shell.publish(Message::Measured(self.generation, size));
|
||||
}
|
||||
other => shell.publish(Message::UnexpectedPassive(format!(
|
||||
"Frozen chrome emitted unexpected passive message {:?}",
|
||||
std::mem::discriminant(&other)
|
||||
))),
|
||||
}
|
||||
}
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
if let Some(stamp) = *self.drawn.borrow()
|
||||
&& Some(stamp) == self.expected
|
||||
&& state.acknowledged != Some(stamp)
|
||||
{
|
||||
state.acknowledged = Some(stamp);
|
||||
shell.publish(Message::Drawn(stamp));
|
||||
}
|
||||
// draw runs after this update. One further redraw lets us acknowledge its
|
||||
// exact receipt. Stop requesting once that receipt was delivered.
|
||||
if self.expected.is_some() && state.acknowledged != self.expected {
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
_: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
mouse::Cursor::Unavailable,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
use super::*;
|
||||
use iced::advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, renderer,
|
||||
widget::{Operation, Tree},
|
||||
};
|
||||
use iced::{Event, Length, Point};
|
||||
use std::cell::Cell;
|
||||
|
||||
fn sample() -> Frozen {
|
||||
let project = Project::demo();
|
||||
let id = project
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.projection.is_architecture())
|
||||
.unwrap()
|
||||
.id;
|
||||
Frozen::new(project, id).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_uses_both_axes_offsets_margin_clamp_and_empty_scene() {
|
||||
let bounds = routing::RoutingBounds {
|
||||
position: oa_core::Position { x: 54., y: 18. },
|
||||
size: oa_core::Size {
|
||||
width: 1506.,
|
||||
height: 814.,
|
||||
},
|
||||
};
|
||||
let width = fit(bounds, false, Size::new(826., 534.));
|
||||
assert_eq!(width.zoom, 778. / 1506.);
|
||||
let height = fit(bounds, false, Size::new(1406., 534.));
|
||||
assert_eq!(height.zoom, 486. / 814.);
|
||||
assert!((width.pan.x + 54. * width.zoom - 24.).abs() < 0.0001);
|
||||
assert!((height.pan.y + 18. * height.zoom - 24.).abs() < 0.0001);
|
||||
assert_eq!(fit(bounds, false, Size::new(10000., 10000.)).zoom, 1.5);
|
||||
assert_eq!(
|
||||
fit(bounds, true, Size::new(1., 1.)),
|
||||
Camera {
|
||||
zoom: 1.,
|
||||
pan: Vector::new(30., 30.)
|
||||
}
|
||||
);
|
||||
// Large nonzero origin makes algebraically simplified size differ in f32.
|
||||
let offset = routing::RoutingBounds {
|
||||
position: oa_core::Position {
|
||||
x: 1_000_000.,
|
||||
y: 500_000.,
|
||||
},
|
||||
size: oa_core::Size {
|
||||
width: 100.03,
|
||||
height: 80.02,
|
||||
},
|
||||
};
|
||||
let camera = fit(offset, false, Size::new(100., 200.));
|
||||
assert_eq!(
|
||||
camera.zoom,
|
||||
52. / ((offset.position.x + offset.size.width) - offset.position.x)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measurements_and_draw_acknowledgments_reject_stale_invalid_repeat_and_wrong_camera() {
|
||||
let frozen = sample();
|
||||
let mut layout = LayoutState::default();
|
||||
for size in [
|
||||
Size::ZERO,
|
||||
Size::new(f32::NAN, 10.),
|
||||
Size::new(10., f32::INFINITY),
|
||||
Size::new(-1., 20.),
|
||||
] {
|
||||
assert!(!layout.measured(0, size, &frozen));
|
||||
}
|
||||
assert!(layout.measured(0, Size::new(800., 500.), &frozen));
|
||||
assert!(
|
||||
!layout.ready(),
|
||||
"Sensor receipt is not a draw acknowledgment"
|
||||
);
|
||||
let first = layout.expected.unwrap();
|
||||
assert!(!layout.measured(0, first.viewport, &frozen));
|
||||
assert!(!layout.drawn(DrawStamp {
|
||||
camera: Camera {
|
||||
zoom: first.camera.zoom * 1.1,
|
||||
..first.camera
|
||||
},
|
||||
..first
|
||||
}));
|
||||
assert!(layout.drawn(first));
|
||||
assert!(layout.ready());
|
||||
assert!(!layout.drawn(first));
|
||||
assert!(layout.measured(0, Size::new(900., 600.), &frozen));
|
||||
assert_eq!(
|
||||
layout.generation, 1,
|
||||
"Same-variant real resize invalidates old authority"
|
||||
);
|
||||
assert!(!layout.drawn(first));
|
||||
assert!(!layout.measured(0, Size::new(800., 500.), &frozen));
|
||||
assert!(!layout.ready());
|
||||
layout.variant = Variant::Focus;
|
||||
layout.invalidate();
|
||||
assert!(!layout.drawn(first));
|
||||
assert!(!layout.measured(1, Size::new(1400., 800.), &frozen));
|
||||
assert!(layout.measured(2, Size::new(1400., 800.), &frozen));
|
||||
assert!(layout.drawn(layout.expected.unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_rejects_missing_state_and_scenario_views_without_project_mutation() {
|
||||
let original = sample();
|
||||
assert!(Frozen::new(original.project.clone(), Id::new_v4()).is_err());
|
||||
for projection in [
|
||||
oa_core::ProjectionKind::StateMachine {
|
||||
machine: Id::new_v4(),
|
||||
},
|
||||
oa_core::ProjectionKind::Scenario {
|
||||
scenario: Id::new_v4(),
|
||||
},
|
||||
] {
|
||||
let mut candidate = original.project.clone();
|
||||
candidate
|
||||
.diagrams
|
||||
.get_mut(&original.diagram)
|
||||
.unwrap()
|
||||
.projection = projection;
|
||||
assert!(Frozen::new(candidate, original.diagram).is_err());
|
||||
}
|
||||
let app = fixture(original.project.clone(), original.diagram).unwrap();
|
||||
original.check(&app, original.diagram).unwrap();
|
||||
assert!(!app.document.dirty() && !app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_schema_seven_and_migrated_schema_three_fixtures_admit_coherent_oa_sa_chrome() {
|
||||
let cases: &[(&[u8], &str)] = &[
|
||||
(
|
||||
include_bytes!(
|
||||
"../../../../harness/experiments/focus-optical-20261001/eight-card.json"
|
||||
),
|
||||
"00000000-0038-4000-8000-000000000001",
|
||||
),
|
||||
(
|
||||
include_bytes!("../../../../examples/routing-workshop.json"),
|
||||
"945a7c88-a352-5028-b808-73d852e5abe6",
|
||||
),
|
||||
(
|
||||
include_bytes!("../../../../examples/routing-workshop.json"),
|
||||
"cb21862d-79e1-5a9d-bdf0-ee344c2fb219",
|
||||
),
|
||||
(
|
||||
include_bytes!("../../../../examples/coastal-rescue.json"),
|
||||
"487c064d-df33-45f2-b4b7-6dd68d7b601f",
|
||||
),
|
||||
];
|
||||
for (bytes, diagram) in cases {
|
||||
let decoded = oa_core::persistence::decode_with_report(bytes).unwrap();
|
||||
let raw: Value = serde_json::from_slice(bytes).unwrap();
|
||||
assert_eq!(decoded.migration.is_some(), raw["schema_version"] == 3);
|
||||
let frozen = Frozen::new(decoded.project, diagram.parse().unwrap()).unwrap();
|
||||
let app = fixture(frozen.project.clone(), frozen.diagram).unwrap();
|
||||
frozen.check(&app, frozen.diagram).unwrap();
|
||||
assert_eq!(
|
||||
app.create_kind,
|
||||
if app.layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
}
|
||||
);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, app.project().id);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().kind,
|
||||
crate::forms::RecordKind::Project
|
||||
);
|
||||
assert!(
|
||||
app.architecture.is_none()
|
||||
&& app.connection.is_none()
|
||||
&& app.diagram_navigation_focus.is_none()
|
||||
);
|
||||
let expected_index = crate::explorer::Index::new(app.project(), app.layer);
|
||||
for group in crate::explorer::Group::ALL {
|
||||
assert_eq!(
|
||||
app.explorer_index.rows(group, "", &app.explorer_collapsed),
|
||||
expected_index.rows(group, "", &app.explorer_collapsed)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captures_require_a_matching_draw_and_preserve_complete_model_scene_svg() {
|
||||
let frozen = sample();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let input = serde_json::to_vec(&frozen.project).unwrap();
|
||||
let input_path = dir.path().join("fixture.json");
|
||||
fs::write(&input_path, &input).unwrap();
|
||||
let mut runner = Runner {
|
||||
fixture: fixture(frozen.project.clone(), frozen.diagram).unwrap(),
|
||||
diagram: frozen.diagram,
|
||||
frozen,
|
||||
input_path,
|
||||
input,
|
||||
output: dir.path().to_owned(),
|
||||
layout: LayoutState::default(),
|
||||
drawn: Rc::new(RefCell::new(None)),
|
||||
header: None,
|
||||
scale_factor: Some(1.),
|
||||
capture: 0,
|
||||
error: None,
|
||||
};
|
||||
let original_bundle = runner.fixture.document.bundle().clone();
|
||||
assert!(runner.capture().is_err());
|
||||
for (index, variant) in [Variant::Baseline, Variant::Focus, Variant::Baseline]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
runner.layout.variant = variant;
|
||||
runner.invalidate();
|
||||
let generation = runner.layout.generation;
|
||||
let size = if variant == Variant::Focus {
|
||||
Size::new(1400., 800.)
|
||||
} else {
|
||||
Size::new(826., 534.)
|
||||
};
|
||||
let _ = runner.update(Message::Measured(generation, size));
|
||||
runner.header = Some((generation, Size::new(1440., 67.4)));
|
||||
assert!(runner.capture().is_err());
|
||||
let stamp = runner.layout.expected.unwrap();
|
||||
*runner.drawn.borrow_mut() = Some(stamp);
|
||||
let _ = runner.update(Message::Drawn(stamp));
|
||||
runner.capture().unwrap();
|
||||
let captured = dir.path().join(format!("capture-{:03}", index + 1));
|
||||
assert_eq!(
|
||||
fs::read(captured.join("diagram.svg")).unwrap(),
|
||||
runner.frozen.svg.as_bytes()
|
||||
);
|
||||
let scene: Value =
|
||||
serde_json::from_slice(&fs::read(captured.join("scene.json")).unwrap()).unwrap();
|
||||
assert_eq!(scene, runner.frozen.report);
|
||||
let project: Project =
|
||||
serde_json::from_slice(&fs::read(captured.join("project.json")).unwrap()).unwrap();
|
||||
assert_eq!(project, runner.frozen.project);
|
||||
assert_eq!(runner.fixture.document.bundle(), &original_bundle);
|
||||
assert!(!runner.fixture.document.can_undo() && !runner.fixture.document.can_redo());
|
||||
}
|
||||
let mut altered = runner.frozen.project.clone();
|
||||
altered.name.push_str(" changed");
|
||||
let altered_app = fixture(altered, runner.diagram).unwrap();
|
||||
assert!(runner.frozen.check(&altered_app, runner.diagram).is_err());
|
||||
fs::write(&runner.input_path, b"changed").unwrap();
|
||||
assert!(runner.capture().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_flag_is_exact_and_other_launches_remain_opted_out() {
|
||||
for arguments in [
|
||||
vec!["app"],
|
||||
vec!["app", "--write-example", "out"],
|
||||
vec!["app", "--focus"],
|
||||
vec!["app", "--focus-optical-extra"],
|
||||
] {
|
||||
assert!(!requested(
|
||||
&arguments.into_iter().map(String::from).collect::<Vec<_>>()
|
||||
));
|
||||
}
|
||||
assert!(requested(&["app".into(), "--focus-optical".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_fault_rejects_queued_draw_capture_and_close_never_overwrites_or_traps() {
|
||||
let frozen = sample();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let input = serde_json::to_vec(&frozen.project).unwrap();
|
||||
let input_path = dir.path().join("fixture.json");
|
||||
fs::write(&input_path, &input).unwrap();
|
||||
let mut runner = Runner {
|
||||
fixture: fixture(frozen.project.clone(), frozen.diagram).unwrap(),
|
||||
diagram: frozen.diagram,
|
||||
frozen,
|
||||
input_path,
|
||||
input,
|
||||
output: dir.path().to_owned(),
|
||||
layout: LayoutState::default(),
|
||||
drawn: Rc::new(RefCell::new(None)),
|
||||
header: None,
|
||||
scale_factor: Some(1.),
|
||||
capture: 0,
|
||||
error: None,
|
||||
};
|
||||
let _ = runner.update(Message::Measured(0, Size::new(800., 500.)));
|
||||
let stamp = runner.layout.expected.unwrap();
|
||||
runner.header = Some((0, Size::new(1440., 67.4)));
|
||||
*runner.drawn.borrow_mut() = Some(stamp);
|
||||
let _ = runner.update(Message::Drawn(stamp));
|
||||
assert!(runner.layout.ready());
|
||||
runner.fail("Injected harness fault".into());
|
||||
let _ = runner.update(Message::Drawn(stamp));
|
||||
let _ = runner.update(Message::Capture);
|
||||
assert!(!runner.layout.ready());
|
||||
assert!(runner.capture().is_err());
|
||||
assert_eq!(runner.capture, 0);
|
||||
assert!(!dir.path().join("capture-001").exists());
|
||||
fs::write(&runner.input_path, b"changed after admission").unwrap();
|
||||
let close = runner.update(Message::Close(iced::window::Id::unique()));
|
||||
assert!(
|
||||
iced_runtime::task::into_stream(close).is_some(),
|
||||
"Failure still issues the exit task"
|
||||
);
|
||||
let receipt = dir.path().join("finished.json");
|
||||
let original = fs::read(&receipt).unwrap();
|
||||
let recorded: Value = serde_json::from_slice(&original).unwrap();
|
||||
assert_eq!(recorded["status"], "FAIL");
|
||||
assert_eq!(
|
||||
recorded["complete_project_scene_svg_and_input_unchanged"],
|
||||
false
|
||||
);
|
||||
let close_again = runner.update(Message::Close(iced::window::Id::unique()));
|
||||
assert!(
|
||||
iced_runtime::task::into_stream(close_again).is_some(),
|
||||
"Existing receipt cannot trap Close"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(receipt).unwrap(),
|
||||
original,
|
||||
"Never overwrite the first close receipt"
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SpyCounts {
|
||||
updates: Cell<usize>,
|
||||
operations: Cell<usize>,
|
||||
overlays: Cell<usize>,
|
||||
draws: Cell<usize>,
|
||||
}
|
||||
struct Spy {
|
||||
counts: Rc<SpyCounts>,
|
||||
unexpected: bool,
|
||||
}
|
||||
impl Widget<crate::app::Message, Theme, ()> for Spy {
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size::new(Fill, Fill)
|
||||
}
|
||||
fn layout(&mut self, _: &mut Tree, _: &(), _: &layout::Limits) -> layout::Node {
|
||||
layout::Node::new(Size::new(500., 400.))
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
_: &mut Tree,
|
||||
event: &Event,
|
||||
_: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_: &(),
|
||||
_: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, crate::app::Message>,
|
||||
_: &Rectangle,
|
||||
) {
|
||||
self.counts.updates.set(self.counts.updates.get() + 1);
|
||||
assert!(matches!(
|
||||
event,
|
||||
Event::Window(iced::window::Event::RedrawRequested(_))
|
||||
));
|
||||
assert_eq!(cursor, mouse::Cursor::Unavailable);
|
||||
shell.publish(if self.unexpected {
|
||||
crate::app::Message::Fit
|
||||
} else {
|
||||
crate::app::Message::CanvasResized(Size::new(500., 400.))
|
||||
});
|
||||
}
|
||||
fn operate(&mut self, _: &mut Tree, _: Layout<'_>, _: &(), _: &mut dyn Operation) {
|
||||
self.counts.operations.set(self.counts.operations.get() + 1);
|
||||
}
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
_: &'a mut Tree,
|
||||
_: Layout<'a>,
|
||||
_: &(),
|
||||
_: &Rectangle,
|
||||
_: Vector,
|
||||
) -> Option<iced::advanced::overlay::Element<'a, crate::app::Message, Theme, ()>> {
|
||||
self.counts.overlays.set(self.counts.overlays.get() + 1);
|
||||
None
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
_: &Tree,
|
||||
_: &mut (),
|
||||
_: &Theme,
|
||||
_: &renderer::Style,
|
||||
_: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_: &Rectangle,
|
||||
) {
|
||||
assert_eq!(cursor, mouse::Cursor::Unavailable);
|
||||
self.counts.draws.set(self.counts.draws.get() + 1);
|
||||
}
|
||||
}
|
||||
struct NoClipboard;
|
||||
impl Clipboard for NoClipboard {
|
||||
fn read(&self, _: iced::advanced::clipboard::Kind) -> Option<String> {
|
||||
panic!("Unexpected clipboard read")
|
||||
}
|
||||
fn write(&mut self, _: iced::advanced::clipboard::Kind, _: String) {
|
||||
panic!("Unexpected clipboard write")
|
||||
}
|
||||
}
|
||||
struct Traverse;
|
||||
impl Operation for Traverse {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inert_widget_blocks_all_input_operations_overlays_and_allows_only_redraw_measurement() {
|
||||
let counts = Rc::new(SpyCounts::default());
|
||||
let mut wrapper = inert::Inert {
|
||||
content: Element::new(Spy {
|
||||
counts: counts.clone(),
|
||||
unexpected: false,
|
||||
}),
|
||||
generation: 9,
|
||||
expected: None,
|
||||
drawn: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
let mut tree = Tree::new(&wrapper as &dyn Widget<Message, Theme, ()>);
|
||||
let node = wrapper.layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, Size::new(500., 400.)),
|
||||
);
|
||||
let layout = Layout::new(&node);
|
||||
let bounds = Rectangle::with_size(Size::new(500., 400.));
|
||||
let cursor = mouse::Cursor::Available(Point::new(40., 40.));
|
||||
let key = crate::behavior::canvas_workspace::tests::key;
|
||||
let mut events = vec![
|
||||
Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: Point::new(50., 50.),
|
||||
}),
|
||||
Event::Mouse(mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
|
||||
}),
|
||||
key(iced::keyboard::key::Named::Delete),
|
||||
key(iced::keyboard::key::Named::Insert),
|
||||
key(iced::keyboard::key::Named::Tab),
|
||||
Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
|
||||
iced::keyboard::Modifiers::SHIFT,
|
||||
)),
|
||||
Event::InputMethod(iced::advanced::input_method::Event::Preedit(
|
||||
"字".into(),
|
||||
None,
|
||||
)),
|
||||
Event::InputMethod(iced::advanced::input_method::Event::Commit("字".into())),
|
||||
Event::Touch(iced::touch::Event::FingerPressed {
|
||||
id: iced::touch::Finger(1),
|
||||
position: Point::ORIGIN,
|
||||
}),
|
||||
Event::Window(iced::window::Event::Resized(Size::new(600., 500.))),
|
||||
Event::Window(iced::window::Event::Rescaled(1.25)),
|
||||
Event::Window(iced::window::Event::Unfocused),
|
||||
Event::Window(iced::window::Event::FileDropped(PathBuf::from(
|
||||
"ignored.json",
|
||||
))),
|
||||
];
|
||||
for button in [
|
||||
mouse::Button::Left,
|
||||
mouse::Button::Middle,
|
||||
mouse::Button::Right,
|
||||
] {
|
||||
events.push(Event::Mouse(mouse::Event::ButtonPressed(button)));
|
||||
events.push(Event::Mouse(mouse::Event::ButtonReleased(button)));
|
||||
}
|
||||
let mut messages = vec![];
|
||||
for event in &events {
|
||||
wrapper.update(
|
||||
&mut tree,
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
&(),
|
||||
&mut NoClipboard,
|
||||
&mut Shell::new(&mut messages),
|
||||
&bounds,
|
||||
);
|
||||
}
|
||||
assert_eq!(counts.updates.get(), 0);
|
||||
assert!(messages.is_empty());
|
||||
wrapper.operate(&mut tree, layout, &(), &mut Traverse);
|
||||
assert!(
|
||||
wrapper
|
||||
.overlay(&mut tree, layout, &(), &bounds, Vector::ZERO)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
wrapper.mouse_interaction(&tree, layout, cursor, &bounds, &()),
|
||||
mouse::Interaction::None
|
||||
);
|
||||
assert_eq!(counts.operations.get() + counts.overlays.get(), 0);
|
||||
wrapper.draw(
|
||||
&tree,
|
||||
&mut (),
|
||||
&Theme::Light,
|
||||
&renderer::Style::default(),
|
||||
layout,
|
||||
cursor,
|
||||
&bounds,
|
||||
);
|
||||
assert_eq!(counts.draws.get(), 1);
|
||||
wrapper.update(
|
||||
&mut tree,
|
||||
&Event::Window(iced::window::Event::RedrawRequested(
|
||||
iced::time::Instant::now(),
|
||||
)),
|
||||
layout,
|
||||
cursor,
|
||||
&(),
|
||||
&mut NoClipboard,
|
||||
&mut Shell::new(&mut messages),
|
||||
&bounds,
|
||||
);
|
||||
assert!(
|
||||
matches!(messages.as_slice(), [Message::Measured(9, size)] if *size == Size::new(500., 400.))
|
||||
);
|
||||
assert_eq!(counts.updates.get(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpected_passive_message_is_a_fault_and_draw_only_adapter_never_emits_actions() {
|
||||
let counts = Rc::new(SpyCounts::default());
|
||||
let mut wrapper = inert::Inert {
|
||||
content: Element::new(Spy {
|
||||
counts,
|
||||
unexpected: true,
|
||||
}),
|
||||
generation: 0,
|
||||
expected: None,
|
||||
drawn: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
let mut tree = Tree::new(&wrapper as &dyn Widget<Message, Theme, ()>);
|
||||
let node = wrapper.layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, Size::new(500., 400.)),
|
||||
);
|
||||
let mut messages = vec![];
|
||||
wrapper.update(
|
||||
&mut tree,
|
||||
&Event::Window(iced::window::Event::RedrawRequested(
|
||||
iced::time::Instant::now(),
|
||||
)),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&(),
|
||||
&mut NoClipboard,
|
||||
&mut Shell::new(&mut messages),
|
||||
&Rectangle::with_size(Size::new(500., 400.)),
|
||||
);
|
||||
assert!(matches!(
|
||||
messages.as_slice(),
|
||||
[Message::UnexpectedPassive(_)]
|
||||
));
|
||||
let frozen = sample();
|
||||
let app = fixture(frozen.project.clone(), frozen.diagram).unwrap();
|
||||
let program = OpticalCanvas {
|
||||
program: DiagramCanvas {
|
||||
selection: None,
|
||||
allocation: None,
|
||||
project: app.project(),
|
||||
view_index: Some(app.document.view_index()),
|
||||
diagram: frozen.diagram,
|
||||
selected: app.project().id,
|
||||
navigation_focus: None,
|
||||
zoom: 1.,
|
||||
pan: Vector::ZERO,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: false,
|
||||
placing: false,
|
||||
connection: None,
|
||||
},
|
||||
stamp: None,
|
||||
drawn: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
let mut state = crate::diagram::State::default();
|
||||
use canvas::Program;
|
||||
for event in [
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: Point::new(300., 200.),
|
||||
}),
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
] {
|
||||
assert!(
|
||||
program
|
||||
.update(
|
||||
&mut state,
|
||||
&event,
|
||||
Rectangle::with_size(Size::new(500., 400.)),
|
||||
mouse::Cursor::Available(Point::new(200., 100.))
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
program.mouse_interaction(
|
||||
&state,
|
||||
Rectangle::with_size(Size::new(500., 400.)),
|
||||
mouse::Cursor::Available(Point::new(200., 100.))
|
||||
),
|
||||
mouse::Interaction::None
|
||||
);
|
||||
frozen.check(&app, frozen.diagram).unwrap();
|
||||
}
|
||||
@@ -15,6 +15,7 @@ mod diagram_usage;
|
||||
mod document;
|
||||
mod explorer;
|
||||
mod explorer_view;
|
||||
mod focus_optical;
|
||||
mod forms;
|
||||
mod guide;
|
||||
mod highlight;
|
||||
@@ -44,6 +45,9 @@ mod workflow;
|
||||
fn main() -> iced::Result {
|
||||
// Small headless entry points support reproducible examples and CI validation.
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
if focus_optical::requested(&args) {
|
||||
return focus_optical::run(&args[2..]);
|
||||
}
|
||||
if args.get(1).is_some_and(|s| s == "--write-example") {
|
||||
let path = args
|
||||
.get(2)
|
||||
|
||||
@@ -65,8 +65,18 @@ pub fn heading<'a>(title: &str, subtitle: &str) -> View<'a, Message> {
|
||||
}
|
||||
|
||||
pub fn view(app: &App) -> View<'_, Message> {
|
||||
workspace(app, None)
|
||||
}
|
||||
|
||||
/// Private optical rig: preserve actual Baseline chrome below the shared header.
|
||||
pub(crate) fn optical_baseline<'a>(app: &'a App, canvas: View<'a, Message>) -> View<'a, Message> {
|
||||
workspace(app, Some(canvas))
|
||||
}
|
||||
|
||||
/// Same logo, project typography, padding and intrinsic height in both runners.
|
||||
pub(crate) fn header<'a, M: 'a>(app: &App, actions: View<'a, M>) -> View<'a, M> {
|
||||
let p = app.project();
|
||||
let header = container(
|
||||
container(
|
||||
row![
|
||||
row![
|
||||
text("◈").size(28).color(style::TEAL),
|
||||
@@ -81,6 +91,22 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
column![text(short(&p.name, 46)).size(17), label(app.hosted_label())]
|
||||
.spacing(3)
|
||||
.width(Fill),
|
||||
actions
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.padding([14, 18])
|
||||
.style(style::panel)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn workspace<'a>(app: &'a App, optical_canvas: Option<View<'a, Message>>) -> View<'a, Message> {
|
||||
let p = app.project();
|
||||
let optical = optical_canvas.is_some();
|
||||
let header = header(
|
||||
app,
|
||||
row![
|
||||
action("New", Message::Request(Pending::New)),
|
||||
crate::hosted::view::header_action(),
|
||||
action("Open", Message::Request(Pending::Open)),
|
||||
@@ -90,10 +116,9 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
.on_press(Message::Save(false))
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.padding([14, 18])
|
||||
.style(style::panel);
|
||||
.align_y(iced::Center)
|
||||
.into(),
|
||||
);
|
||||
let mut nav = column![
|
||||
label("ENGINEERING PERSPECTIVE"),
|
||||
row![
|
||||
@@ -240,7 +265,7 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
&app.recovery,
|
||||
)
|
||||
.map(Message::Home),
|
||||
Page::Diagram => diagram(app),
|
||||
Page::Diagram => diagram(app, optical_canvas),
|
||||
Page::Catalog => crate::explorer_view::catalog(app),
|
||||
Page::Data => data_catalog(app),
|
||||
Page::Behavior => crate::behavior::view(app),
|
||||
@@ -295,6 +320,9 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
)
|
||||
.padding([4, 16])
|
||||
.style(style::panel);
|
||||
if optical {
|
||||
return column![task_nav, body, footer].into();
|
||||
}
|
||||
let root = crate::allocation_input::root(app, column![header, task_nav, body, footer]);
|
||||
let root = crate::guide::protect_workspace(app.context_help, root);
|
||||
if app.context_help {
|
||||
@@ -439,7 +467,7 @@ fn modal_width<'a>(
|
||||
]
|
||||
.into()
|
||||
}
|
||||
fn diagram(app: &App) -> View<'_, Message> {
|
||||
fn diagram<'a>(app: &'a App, optical_canvas: Option<View<'a, Message>>) -> View<'a, Message> {
|
||||
if app
|
||||
.active_diagram
|
||||
.and_then(|id| app.project().diagrams.get(&id))
|
||||
@@ -569,6 +597,7 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
if let Some(editor) = crate::app::architecture_authoring::inline_editor(app) {
|
||||
scene = scene.push(editor);
|
||||
}
|
||||
let scene: View<'_, Message> = optical_canvas.unwrap_or_else(|| scene.into());
|
||||
let reveal = app.architecture_reveal_stamp(id);
|
||||
row![
|
||||
widget::sensor(
|
||||
|
||||
Reference in New Issue
Block a user