b09795ccff
- Add `persistence.rs` for stable serialization of dock layouts without runtime identifiers. - Introduce `preview.rs` for geometry handling of dock drop previews. - Create `sizing.rs` to manage content-aware sizing policies and constraint solving for dock panels. - Implement `welcome.rs` to provide a welcome surface when no documents are open, featuring New and Open actions.
349 lines
14 KiB
Rust
349 lines
14 KiB
Rust
//! Stable Cycle 0 feature identifiers and source-level regression gates.
|
|
//!
|
|
//! **Purpose:** Prevents the highest-risk placeholder behaviors from returning while broader GUI
|
|
//! parity work proceeds. **Logic & Workflow:** Each test owns a stable feature ID and inspects the
|
|
//! production source for architectural invariants that complement module-level behavior tests.
|
|
//! **Side Effects / Dependencies:** Reads source files from the crate at test time; performs no
|
|
//! writes and does not launch the GUI.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
const MENU_SEMANTIC_DISPATCH: &str = "shell.menu.semantic_dispatch";
|
|
const MENU_NO_CUT_COPY_ALIAS: &str = "clipboard.cut.no_copy_alias";
|
|
const SCREENSHOT_NATIVE_CAPTURE: &str = "audit.screenshot.native_capture";
|
|
const SCREENSHOT_PANEL_CROP: &str = "audit.screenshot.panel_crop";
|
|
const PANE_FULL_DROP_TARGETS: &str = "cycle1.pane.full_drop_targets";
|
|
const THEME_SYNC_ISOLATION: &str = "cycle1.theme.sync_isolation";
|
|
const SIDEBAR_MODE_PERSISTENCE: &str = "cycle1.sidebar.mode_persistence";
|
|
const VECTOR_SCREEN_CONSTANT_HANDLES: &str = "cycle2.vector.screen_constant_handles";
|
|
const VECTOR_CONSTRAINED_DRAG: &str = "cycle2.vector.constrained_drag";
|
|
const VECTOR_LAYER_BOUND_CANCEL: &str = "cycle2.vector.layer_bound_cancel";
|
|
const VECTOR_BOOLEAN_RESULT: &str = "cycle2.vector.boolean_result_selection";
|
|
const VECTOR_CLAMPED_CONTROLS: &str = "cycle2.vector.clamped_controls";
|
|
const SELECTION_IRREGULAR_EDGES: &str = "cycle3.selection.irregular_edges";
|
|
const CLIPBOARD_DISTINCT_CUT: &str = "cycle3.clipboard.distinct_cut";
|
|
const RASTER_TRANSFORM_PLACEMENT: &str = "cycle3.raster.transform_placement";
|
|
const CROP_LIFECYCLE: &str = "cycle3.crop.lifecycle";
|
|
const TEXT_MULTILINE: &str = "cycle3.text.multiline";
|
|
const TRUE_GRADIENT: &str = "cycle3.gradient.linear_radial";
|
|
const CANVAS_INPUT_PARITY: &str = "cycle3.canvas.input_parity";
|
|
const PEN_SPRAY_DISTINCT: &str = "cycle3.tools.pen_spray_distinct";
|
|
const LAYERS_HIERARCHY_CONTROLS: &str = "cycle4.layers.hierarchy_controls";
|
|
const LAYER_STYLES_CANCEL_RESTORE: &str = "cycle4.layer_styles.cancel_restore";
|
|
const IMMUTABLE_PIXEL_PREVIEW: &str = "cycle4.preview.immutable_baseline";
|
|
const FILTER_SCHEMA_COVERAGE: &str = "cycle4.filters.schema_coverage";
|
|
const DOCUMENT_PATH_IDENTITY: &str = "cycle4.documents.path_identity";
|
|
const CLOSE_SAVE_CONTINUATION: &str = "cycle4.documents.close_save_continuation";
|
|
const AI_REAL_STREAMING: &str = "cycle5.ai.real_streaming";
|
|
const AI_REASONING_TRANSCRIPT: &str = "cycle5.ai.reasoning_transcript";
|
|
const SCRIPT_VALIDATED_OUTPUT: &str = "cycle5.script.validated_output";
|
|
const VIEWER_DETERMINISTIC_NAV: &str = "cycle5.viewer.deterministic_navigation";
|
|
const VIEWER_SAFE_EDIT: &str = "cycle5.viewer.safe_edit_document";
|
|
|
|
/// Reads a crate-relative source file for structural audit assertions.
|
|
///
|
|
/// **Arguments:** `relative` is a path beneath the package manifest directory.
|
|
/// **Returns:** The complete UTF-8 source text.
|
|
/// **Side Effects / Dependencies:** Reads one local source file and panics on missing test input.
|
|
fn source(relative: &str) -> String {
|
|
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative);
|
|
std::fs::read_to_string(&path)
|
|
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()))
|
|
}
|
|
|
|
#[test]
|
|
fn semantic_menu_dispatch_has_no_positional_message() {
|
|
let app = source("src/app.rs");
|
|
let menus = source("src/panels/menus.rs");
|
|
assert!(
|
|
app.contains("Message::MenuCommand(command)"),
|
|
"{MENU_SEMANTIC_DISPATCH}"
|
|
);
|
|
assert!(
|
|
!app.contains("MenuAction(usize, usize)"),
|
|
"{MENU_SEMANTIC_DISPATCH}"
|
|
);
|
|
assert!(
|
|
menus.contains("command: Option<MenuCommand>"),
|
|
"{MENU_SEMANTIC_DISPATCH}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cut_does_not_dispatch_copy() {
|
|
let app = source("src/app.rs");
|
|
let menus = source("src/panels/menus.rs");
|
|
assert!(
|
|
app.contains("MenuCommand::Cut => return Task::perform(async {}, |_| Message::CutImage)"),
|
|
"{MENU_NO_CUT_COPY_ALIAS}"
|
|
);
|
|
assert!(
|
|
menus.contains("MenuCommand::Cut"),
|
|
"{MENU_NO_CUT_COPY_ALIAS}"
|
|
);
|
|
assert!(
|
|
!menus.contains("MenuItem::with_shortcut(\"Cut\", \"Ctrl+X\")"),
|
|
"{MENU_NO_CUT_COPY_ALIAS}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cycle_three_canvas_selection_clipboard_crop_text_paths_are_connected() {
|
|
let app = source("src/app.rs");
|
|
let canvas = source("src/canvas/mod.rs");
|
|
let shader = source("src/canvas/shader_canvas.rs");
|
|
let state = source("src/selection/state.rs");
|
|
let clipboard = source("src/selection/clipboard.rs");
|
|
let crop = source("src/selection/crop.rs");
|
|
let raster = source("src/raster.rs");
|
|
|
|
assert!(
|
|
state.contains("pub fn selected_spans")
|
|
&& canvas.contains("selection_fill_spans")
|
|
&& canvas.contains("Fill exact mask runs"),
|
|
"{SELECTION_IRREGULAR_EDGES}"
|
|
);
|
|
assert!(
|
|
app.contains("Message::CutImage")
|
|
&& app.contains("Message::ClearPixels")
|
|
&& app.contains("Some(Message::CutImage)"),
|
|
"{CLIPBOARD_DISTINCT_CUT}"
|
|
);
|
|
assert!(
|
|
clipboard.contains("pub fn centered_transform")
|
|
&& app.contains("placed.pos.x += 10.0")
|
|
&& app.contains("tr.sanitize_geometry(self.modifiers.shift())"),
|
|
"{RASTER_TRANSFORM_PLACEMENT}"
|
|
);
|
|
assert!(
|
|
crop.contains("start_drag_clamped") && app.contains("Message::CropConfirm"),
|
|
"{CROP_LIFECYCLE}"
|
|
);
|
|
assert!(
|
|
canvas.contains("iced::widget::text_editor(&draft.editor)")
|
|
&& app.contains("Message::TextEditorAction(action)"),
|
|
"{TEXT_MULTILINE}"
|
|
);
|
|
assert!(
|
|
raster.contains("pub fn apply_gradient")
|
|
&& app.contains("self.settings.tool_settings.gradient_type == 1"),
|
|
"{TRUE_GRADIENT}"
|
|
);
|
|
assert!(
|
|
app.contains("paint_tool && (self.modifiers.control()")
|
|
&& app.contains("paint_tool && self.modifiers.shift()")
|
|
&& app.contains("Message::FitToArea")
|
|
&& shader.contains("state.space_left_pan = true"),
|
|
"{CANVAS_INPUT_PARITY}"
|
|
);
|
|
assert!(
|
|
app.contains(".draw_pen_segment(")
|
|
&& app.contains("tip.spray_particle_size")
|
|
&& app.contains("tip.spray_density"),
|
|
"{PEN_SPRAY_DISTINCT}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn screenshot_cli_uses_native_capture_and_panel_crop() {
|
|
let app = source("src/app.rs");
|
|
let screenshot = source("src/screenshot.rs");
|
|
assert!(
|
|
app.contains("iced::window::screenshot(id)"),
|
|
"{SCREENSHOT_NATIVE_CAPTURE}"
|
|
);
|
|
assert!(
|
|
app.contains("crate::screenshot::crop_panel("),
|
|
"{SCREENSHOT_PANEL_CROP}"
|
|
);
|
|
assert!(
|
|
screenshot.contains(".layout()\n .pane_regions"),
|
|
"{SCREENSHOT_PANEL_CROP}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cycle_one_state_paths_are_complete_and_persisted() {
|
|
let app = source("src/app.rs");
|
|
let settings = source("src/settings.rs");
|
|
let manager = source("src/dock/manager.rs");
|
|
|
|
assert!(
|
|
app.contains("self.dock.apply_drop(pane, target)")
|
|
&& manager.contains("DockDropZone::Center")
|
|
&& manager.contains("DockDropZone::OuterEdge(_)")
|
|
&& manager.contains("DockDropZone::PaneEdge(_)")
|
|
&& manager.contains("Self::role(source) == DockRole::Tool"),
|
|
"{PANE_FULL_DROP_TARGETS}"
|
|
);
|
|
assert!(
|
|
!app.contains("Task::perform(async move { theme }, Message::ThemeChanged)"),
|
|
"{THEME_SYNC_ISOLATION}"
|
|
);
|
|
assert!(
|
|
app.contains("self.settings.theme_preset = theme"),
|
|
"{THEME_SYNC_ISOLATION}"
|
|
);
|
|
assert!(
|
|
settings.contains("pub theme_preset: crate::theme::ThemePreset"),
|
|
"{THEME_SYNC_ISOLATION}"
|
|
);
|
|
assert!(
|
|
app.contains("settings.panel_layout.sidebar_expanded"),
|
|
"{SIDEBAR_MODE_PERSISTENCE}"
|
|
);
|
|
assert!(
|
|
app.contains("self.settings.panel_layout.sidebar_expanded = self.sidebar_expanded"),
|
|
"{SIDEBAR_MODE_PERSISTENCE}"
|
|
);
|
|
}
|
|
|
|
/// Verifies the Cycle 2 vector completion paths remain connected to pure calculations and UI.
|
|
///
|
|
/// Purpose: Provides stable source-level scorecard evidence in addition to calculation unit tests.
|
|
/// Logic & Workflow: Checks semantic state/session types and reachable handlers rather than fragile
|
|
/// line positions. Side Effects / Dependencies: Reads local production sources only.
|
|
#[test]
|
|
fn cycle_two_vector_completion_paths_are_connected() {
|
|
let app = source("src/app.rs");
|
|
let canvas = source("src/canvas/mod.rs");
|
|
let edit = source("src/vector_edit.rs");
|
|
|
|
assert!(
|
|
edit.contains("HANDLE_HIT_HALF_PX / zoom") && edit.contains("ROTATE_HIT_RADIUS_PX / zoom"),
|
|
"{VECTOR_SCREEN_CONSTANT_HANDLES}"
|
|
);
|
|
assert!(
|
|
app.contains("crate::vector_edit::transform_shape(")
|
|
&& app.contains("self.modifiers.shift()")
|
|
&& app.contains("self.modifiers.alt()")
|
|
&& app.contains("engine.set_vector_shape_bounds(")
|
|
&& app.contains("engine.set_vector_shape_angle(")
|
|
&& edit.contains("MIN_VECTOR_SIZE"),
|
|
"{VECTOR_CONSTRAINED_DRAG}"
|
|
);
|
|
assert!(
|
|
app.contains("get_layer_shapes_direct(snapshot.layer_id)")
|
|
&& app.contains("self.vector_drag_session = None"),
|
|
"{VECTOR_LAYER_BOUND_CANCEL}"
|
|
);
|
|
assert!(
|
|
app.contains("let succeeded = valid")
|
|
&& app.contains("selected_vector_shape = Some(result_index)"),
|
|
"{VECTOR_BOOLEAN_RESULT}"
|
|
);
|
|
assert!(
|
|
canvas.contains("pane_w - controls_width") && canvas.contains("Message::VectorShapeDelete"),
|
|
"{VECTOR_CLAMPED_CONTROLS}"
|
|
);
|
|
}
|
|
|
|
/// Verifies Cycle 4 panel and document-lifecycle paths remain semantic and non-destructive.
|
|
///
|
|
/// Purpose: Records stable evidence for immutable previews, style cancellation, layer hierarchy,
|
|
/// filter schema coverage, and successful-save identity. Logic & Workflow: Inspects the GUI-only
|
|
/// implementations for their explicit state and engine-API routing. Side Effects / Dependencies:
|
|
/// Reads production source files only.
|
|
#[test]
|
|
fn cycle_four_panel_and_document_paths_are_connected() {
|
|
let app = source("src/app.rs");
|
|
let layers = source("src/panels/layers.rs");
|
|
let filters = source("src/panels/filters.rs");
|
|
let filter_params = source("src/panels/filter_params.rs");
|
|
let title_bar = source("src/panels/title_bar.rs");
|
|
|
|
assert!(
|
|
layers.contains("Message::LayerToggleCollapse(info.id)")
|
|
&& layers.contains("Message::LayerToggleVisibility(info.id)")
|
|
&& layers.contains("Message::LayerToggleLock(info.id)")
|
|
&& app.contains("set_layer_parent(active_id, Some(group_id))")
|
|
&& app.contains("move_layer(id, target_idx)"),
|
|
"{LAYERS_HIERARCHY_CONTROLS}"
|
|
);
|
|
assert!(
|
|
layers.contains("Fill opacity: unavailable in hcie-engine-api")
|
|
&& layers.contains("Unavailable: duplicate, mask, rasterize")
|
|
&& layers.contains("button(text(\"Duplicate\")")
|
|
&& layers.contains("button(text(\"Mask\")")
|
|
&& layers.contains("button(text(\"Rasterize\")"),
|
|
"{LAYERS_HIERARCHY_CONTROLS}: unsupported operations must be visibly disabled"
|
|
);
|
|
assert!(
|
|
app.contains("layer_style_baseline: Option<LayerStyleBaseline>")
|
|
&& app.contains("remove_layer_style_by_index(baseline.layer_id, index)")
|
|
&& app.contains("doc.engine.update_layer_style(baseline.layer_id, style)"),
|
|
"{LAYER_STYLES_CANCEL_RESTORE}"
|
|
);
|
|
assert!(
|
|
app.contains("preview_baseline: Option<PreviewBaseline>")
|
|
&& app.contains("reset_preview_to_baseline()")
|
|
&& app.contains("set_layer_pixels(baseline.layer_id, baseline.pixels.clone())"),
|
|
"{IMMUTABLE_PIXEL_PREVIEW}"
|
|
);
|
|
for variant in [
|
|
"BoxBlurGamma",
|
|
"GaussianBlurGamma",
|
|
"MedianFilter",
|
|
"SelectiveColor",
|
|
"NoisePattern",
|
|
] {
|
|
assert!(
|
|
filters.contains(variant) && filter_params.contains(variant),
|
|
"{FILTER_SCHEMA_COVERAGE}: missing {variant}"
|
|
);
|
|
}
|
|
assert!(
|
|
app.contains("fn save_document_to(")
|
|
&& app.contains("\"hcie\" => engine.save_native(&path_str)")
|
|
&& app.contains("finish_successful_save")
|
|
&& title_bar.contains("if modified"),
|
|
"{DOCUMENT_PATH_IDENTITY}"
|
|
);
|
|
assert!(
|
|
app.contains("PendingFileOperation::SaveAs { close_after: true }")
|
|
&& app.contains("continue_close_after_save()")
|
|
&& app.contains("Some(PendingFileOperation::Open)"),
|
|
"{CLOSE_SAVE_CONTINUATION}"
|
|
);
|
|
}
|
|
|
|
/// Verifies Cycle 5 AI, script, and viewer workflows have semantic handlers and safety gates.
|
|
#[test]
|
|
fn cycle_five_practical_paths_are_connected() {
|
|
let app = source("src/app.rs");
|
|
let chat = source("src/ai_chat.rs");
|
|
let ai_script = source("src/ai_script.rs");
|
|
let viewer = source("src/viewer/mod.rs");
|
|
|
|
assert!(
|
|
chat.contains("iced::stream::channel")
|
|
&& app.contains("Task::run(")
|
|
&& app.contains("task.abortable()")
|
|
&& app.contains("Message::AiChatStreamChunk(chunk)"),
|
|
"{AI_REAL_STREAMING}"
|
|
);
|
|
assert!(
|
|
app.contains("Message::AiChatReasoningChunk")
|
|
&& app.contains("Message::AiChatCopyTranscript")
|
|
&& chat.contains("pub fn transcript_text"),
|
|
"{AI_REASONING_TRANSCRIPT}"
|
|
);
|
|
assert!(
|
|
ai_script.contains("pub fn validate_script_output")
|
|
&& ai_script.contains("ANTHROPIC_API_KEY")
|
|
&& ai_script.contains("OPENAI_API_KEY"),
|
|
"{SCRIPT_VALIDATED_OUTPUT}"
|
|
);
|
|
assert!(
|
|
viewer.contains("Message::ViewerPrev")
|
|
&& viewer.contains("Message::ViewerNext")
|
|
&& app.contains("Message::ViewerSetMode(mode)"),
|
|
"{VIEWER_DETERMINISTIC_NAV}"
|
|
);
|
|
assert!(
|
|
app.contains("Open into a new editor document")
|
|
&& app.contains("self.documents.remove(document_index)"),
|
|
"{VIEWER_SAFE_EDIT}"
|
|
);
|
|
}
|