mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-09-28 08:02:55 +08:00
fix(editor): restore release branch checks
This commit is contained in:
@@ -25,6 +25,7 @@ on:
|
||||
- 'crates/op-editor-ui/**'
|
||||
- 'crates/op-host-native/**'
|
||||
- 'crates/op-host-desktop/**'
|
||||
- 'crates/op-chat-agent/src/provider_dial.rs'
|
||||
- 'crates/op-host-services/**'
|
||||
- 'crates/op-i18n/**'
|
||||
- 'deploy/collab-relay/**'
|
||||
@@ -68,6 +69,7 @@ on:
|
||||
- 'crates/op-editor-ui/**'
|
||||
- 'crates/op-host-native/**'
|
||||
- 'crates/op-host-desktop/**'
|
||||
- 'crates/op-chat-agent/src/provider_dial.rs'
|
||||
- 'crates/op-host-services/**'
|
||||
- 'crates/op-i18n/**'
|
||||
- 'deploy/collab-relay/**'
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
pub mod chrome;
|
||||
mod defaults;
|
||||
mod exports;
|
||||
pub mod git_panel;
|
||||
pub mod groups;
|
||||
mod methods;
|
||||
@@ -24,45 +25,11 @@ pub mod slides_panel_state;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use chrome::{
|
||||
DesignMdRequest, EmbedHost, FileAction, PencilCursorStyle, RecentFile, ThemeMode,
|
||||
ThemePresetIo, UpdateStatus, WindowControlRequest, RECENT_FILE_CAP,
|
||||
};
|
||||
pub use git_panel::{
|
||||
CloneField, CloneFormState, CommitDiffPatch, CommitDiffSummary, CommitDiffView,
|
||||
GitBranchPickerMode, GitCandidateFile, GitCommitSummary, GitDiffTarget, GitDiffView,
|
||||
GitFileEntry, GitOverflowView, GitPanelAction, GitPanelState, MergeConflictRow,
|
||||
MergeResolveFile, MergeResolveState,
|
||||
};
|
||||
pub use groups::{
|
||||
AssetCenterTab, CustomPrompt, DesignMdPanelState, PreviewState, PromptCenterFocus,
|
||||
PromptCenterState, PromptFilter, SaveNameDialogState, SceneFilter, SceneTemplateCenterState,
|
||||
SceneTemplateFocus, SizeToggleState, StyleImportState,
|
||||
};
|
||||
pub use pickers::{
|
||||
CanvasDropIndicator, CanvasOverlayLine, CanvasOverlayRect, CompositingPickerTarget,
|
||||
EffectParamFocus, FontPickerPurpose, LayerContextMenuState, MissingFontSurface,
|
||||
PageRenameState, PreviewDeviceKind, VariableRowFocus,
|
||||
};
|
||||
pub use slides_panel_state::{LeftPanelTab, SlidesDrag, SlidesPanelState, SlidesPanelTarget};
|
||||
pub use exports::*;
|
||||
|
||||
use crate::node_id::NodeId;
|
||||
use crate::tool::Tool;
|
||||
use std::collections::HashSet;
|
||||
// `Locale` is the i18n locale enum — dependency-free + wasm-clean, so
|
||||
// it lives in `op-i18n` and re-exports cleanly into the state layer.
|
||||
pub use op_i18n::Locale;
|
||||
|
||||
pub use crate::property_panel_state::{
|
||||
BooleanOp, ExportFormat, FillType, FlexLayout, ImageAdjustmentField, ImageFillMode,
|
||||
PaddingEditMode, PropertyTab,
|
||||
};
|
||||
|
||||
// What the LayerPanel right-click context menu is acting on — the
|
||||
// canonical definition is `ui_draft::LayerContextTarget` (it backs
|
||||
// the inline-rename draft too). Re-exported so UI code that
|
||||
// references a context target has one import path.
|
||||
pub use crate::ui_draft::LayerContextTarget;
|
||||
|
||||
/// Editor-UI overlay + panel state — the widget-layer toggles, hover
|
||||
/// targets, menu / modal open flags and panel metrics that the ~30
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Public re-exports for the `editor_ui_state` spine.
|
||||
|
||||
pub use super::chrome::{
|
||||
DesignMdRequest, EmbedHost, FileAction, PencilCursorStyle, RecentFile, ThemeMode,
|
||||
ThemePresetIo, UpdateStatus, WindowControlRequest, RECENT_FILE_CAP,
|
||||
};
|
||||
pub use super::git_panel::{
|
||||
CloneField, CloneFormState, CommitDiffPatch, CommitDiffSummary, CommitDiffView,
|
||||
GitBranchPickerMode, GitCandidateFile, GitCommitSummary, GitDiffTarget, GitDiffView,
|
||||
GitFileEntry, GitOverflowView, GitPanelAction, GitPanelState, MergeConflictRow,
|
||||
MergeResolveFile, MergeResolveState,
|
||||
};
|
||||
pub use super::groups::{
|
||||
AssetCenterTab, CustomPrompt, DesignMdPanelState, PreviewState, PromptCenterFocus,
|
||||
PromptCenterState, PromptFilter, SaveNameDialogState, SceneFilter, SceneTemplateCenterState,
|
||||
SceneTemplateFocus, SizeToggleState, StyleImportState,
|
||||
};
|
||||
pub use super::pickers::{
|
||||
CanvasDropIndicator, CanvasOverlayLine, CanvasOverlayRect, CompositingPickerTarget,
|
||||
EffectParamFocus, FontPickerPurpose, LayerContextMenuState, MissingFontSurface,
|
||||
PageRenameState, PreviewDeviceKind, VariableRowFocus,
|
||||
};
|
||||
pub use super::slides_panel_state::{
|
||||
LeftPanelTab, SlidesDrag, SlidesPanelState, SlidesPanelTarget,
|
||||
};
|
||||
|
||||
// `Locale` is the i18n locale enum — dependency-free + wasm-clean, so
|
||||
// it lives in `op-i18n` and re-exports cleanly into the state layer.
|
||||
pub use op_i18n::Locale;
|
||||
|
||||
pub use crate::property_panel_state::{
|
||||
BooleanOp, ExportFormat, FillType, FlexLayout, ImageAdjustmentField, ImageFillMode,
|
||||
PaddingEditMode, PropertyTab,
|
||||
};
|
||||
|
||||
// What the LayerPanel right-click context menu is acting on — the
|
||||
// canonical definition is `ui_draft::LayerContextTarget` (it backs
|
||||
// the inline-rename draft too). Re-exported so UI code that
|
||||
// references a context target has one import path.
|
||||
pub use crate::ui_draft::LayerContextTarget;
|
||||
@@ -551,266 +551,5 @@ fn active_page_id(state: &crate::EditorState) -> Option<String> {
|
||||
mod editor_meta_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const V3: &str = r#"{"document":{"version":"1.0","children":[]},"version":3}"#;
|
||||
|
||||
#[test]
|
||||
fn next_document_offers_the_first_response() {
|
||||
let c = WebSyncClient::new();
|
||||
assert!(matches!(c.next_document(V3), Ok(Some((_, 3)))));
|
||||
// Read-only: not committed until mark_applied.
|
||||
assert_eq!(c.applied_version(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_applied_then_skips_equal_or_older_versions() {
|
||||
let mut c = WebSyncClient::new();
|
||||
assert!(c.next_document(V3).expect("ok").is_some());
|
||||
c.mark_applied(3);
|
||||
// Same version → nothing newer.
|
||||
assert!(c.next_document(V3).expect("ok").is_none());
|
||||
// Older version → nothing newer.
|
||||
let older = r#"{"document":{"version":"1.0","children":[]},"version":2}"#;
|
||||
assert!(c.next_document(older).expect("ok").is_none());
|
||||
assert_eq!(c.applied_version(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_document_offers_a_newer_version_after_commit() {
|
||||
let mut c = WebSyncClient::new();
|
||||
c.mark_applied(3);
|
||||
let newer = r#"{"document":{"version":"1.0","children":[]},"version":5}"#;
|
||||
assert!(matches!(c.next_document(newer), Ok(Some((_, 5)))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncommitted_version_is_re_offered_so_a_failed_repaint_is_not_lost() {
|
||||
// Decide-then-commit: if the caller does NOT mark_applied (e.g. repaint
|
||||
// failed), the same newer version must still be offered next poll.
|
||||
let c = WebSyncClient::new();
|
||||
assert!(c.next_document(V3).expect("ok").is_some());
|
||||
// No mark_applied → still offered.
|
||||
assert!(c.next_document(V3).expect("ok").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_commits_only_on_apply_success_and_never_stale() {
|
||||
let mut c = WebSyncClient::new();
|
||||
// apply succeeds → commits exactly the applied version (3).
|
||||
let mut applied_version = None;
|
||||
assert!(c
|
||||
.sync(V3, |_doc, v| {
|
||||
applied_version = Some(v);
|
||||
true
|
||||
})
|
||||
.expect("ok"));
|
||||
assert_eq!(applied_version, Some(3));
|
||||
assert_eq!(c.applied_version(), 3);
|
||||
// nothing newer → apply callback not invoked.
|
||||
let mut called = false;
|
||||
assert!(!c
|
||||
.sync(V3, |_d, _v| {
|
||||
called = true;
|
||||
true
|
||||
})
|
||||
.expect("ok"));
|
||||
assert!(!called);
|
||||
// newer, but apply (repaint) FAILS → NOT committed (stays 3), retried.
|
||||
let v5 = r#"{"document":{"version":"1.0","children":[]},"version":5}"#;
|
||||
assert!(!c.sync(v5, |_d, _v| false).expect("ok"));
|
||||
assert_eq!(c.applied_version(), 3);
|
||||
// retry succeeds → commits 5.
|
||||
assert!(c.sync(v5, |_d, _v| true).expect("ok"));
|
||||
assert_eq!(c.applied_version(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_aware_sync_applies_preserve_mode_before_committing_version() {
|
||||
let mut c = WebSyncClient::new();
|
||||
let body = r#"{"document":{"version":"1.0","children":[]},"version":8,"preserveAuthoredGeometry":true}"#;
|
||||
let mut applied = None;
|
||||
assert!(c
|
||||
.sync_with_metadata(body, |_doc, version, preserve| {
|
||||
applied = Some((version, preserve));
|
||||
true
|
||||
})
|
||||
.expect("valid response"));
|
||||
assert_eq!(applied, Some((8, true)));
|
||||
assert_eq!(c.applied_version(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_document_rejects_malformed_responses() {
|
||||
let c = WebSyncClient::new();
|
||||
assert!(c.next_document("not json").is_err());
|
||||
// Missing version.
|
||||
assert!(c
|
||||
.next_document(r#"{"document":{"version":"1.0","children":[]}}"#)
|
||||
.is_err());
|
||||
// Missing document on a first (would-apply) response.
|
||||
assert!(c.next_document(r#"{"version":1}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_push_body_wraps_the_document() {
|
||||
let doc: PenDocument =
|
||||
serde_json::from_str(r#"{"version":"1.0","children":[]}"#).expect("doc");
|
||||
let body = WebSyncClient::build_push_body(&doc).expect("body");
|
||||
assert!(body.starts_with(r#"{"document":"#), "{body}");
|
||||
assert!(body.contains(r#""version":"1.0""#), "{body}");
|
||||
// Round-trips back through the daemon's request parser shape.
|
||||
let value: serde_json::Value = serde_json::from_str(&body).expect("valid json");
|
||||
assert!(value.get("document").is_some());
|
||||
// The wrap helper produces the identical body from the same JSON.
|
||||
let doc_json = serde_json::to_string(&doc).expect("doc json");
|
||||
assert_eq!(WebSyncClient::wrap_push_body(&doc_json), body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_probe_gates_the_document_fetch() {
|
||||
let mut c = WebSyncClient::new();
|
||||
// First sync: any version (even 0) warrants a fetch.
|
||||
assert!(c.wants_version(0));
|
||||
c.mark_applied(3);
|
||||
assert!(!c.wants_version(2));
|
||||
assert!(!c.wants_version(3));
|
||||
assert!(c.wants_version(4));
|
||||
// Probe body parsing.
|
||||
assert_eq!(
|
||||
WebSyncClient::parse_version_probe(r#"{"version":7}"#),
|
||||
Some(7)
|
||||
);
|
||||
assert_eq!(WebSyncClient::parse_version_probe(r#"{"ok":false}"#), None);
|
||||
assert_eq!(WebSyncClient::parse_version_probe("not json"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_is_gated_on_first_sync_and_content_change() {
|
||||
let mut c = WebSyncClient::new();
|
||||
let starter = r#"{"version":"1.0","children":[]}"#;
|
||||
// Before the first daemon apply the browser must NOT push its
|
||||
// boot-time starter document over the daemon's post-reset authority.
|
||||
assert!(!c.initialized());
|
||||
assert!(!c.should_push(starter));
|
||||
// Apply the daemon doc, note its local serialization as baseline.
|
||||
assert!(c.sync(V3, |_d, _v| true).expect("ok"));
|
||||
c.note_applied_snapshot(starter);
|
||||
// Unchanged content → no push (the echo-suppression core).
|
||||
assert!(!c.should_push(starter));
|
||||
// A real local edit changes the serialization → push.
|
||||
let edited = r#"{"version":"1.0","children":[{"id":"n1"}]}"#;
|
||||
assert!(c.should_push(edited));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_push_is_disabled_before_the_first_daemon_apply() {
|
||||
let mut c = WebSyncClient::new();
|
||||
let starter = r#"{"version":"1.0","children":[]}"#;
|
||||
let edited = r#"{"version":"1.0","children":[{"id":"n1"}]}"#;
|
||||
|
||||
assert!(!c.should_bootstrap_push(starter, starter));
|
||||
assert!(!c.should_bootstrap_push(edited, starter));
|
||||
|
||||
c.mark_pushed(edited, 1);
|
||||
assert!(!c.should_bootstrap_push(edited, starter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_pushed_commits_baseline_and_version_so_echo_is_skipped() {
|
||||
let mut c = WebSyncClient::new();
|
||||
assert!(c.sync(V3, |_d, _v| true).expect("ok"));
|
||||
let edited = r#"{"version":"1.0","children":[{"id":"n1"}]}"#;
|
||||
assert!(c.should_push(edited));
|
||||
// Daemon accepted the push as version 4.
|
||||
c.mark_pushed(edited, 4);
|
||||
assert_eq!(c.applied_version(), 4);
|
||||
// Neither the content nor the version is re-offered (no echo).
|
||||
assert!(!c.should_push(edited));
|
||||
assert!(!c.wants_version(4));
|
||||
let echo = r#"{"document":{"version":"1.0","children":[]},"version":4}"#;
|
||||
assert!(c.next_document(echo).expect("ok").is_none());
|
||||
// A later external version still syncs.
|
||||
assert!(c.wants_version(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_response_parses_only_the_ok_shape() {
|
||||
assert_eq!(
|
||||
WebSyncClient::parse_push_response(r#"{"ok":true,"version":9}"#),
|
||||
Some(9)
|
||||
);
|
||||
assert_eq!(
|
||||
WebSyncClient::parse_push_response(r#"{"ok":false,"error":"x"}"#),
|
||||
None
|
||||
);
|
||||
assert_eq!(WebSyncClient::parse_push_response(r#"{"version":9}"#), None);
|
||||
assert_eq!(WebSyncClient::parse_push_response(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_body_with_base_and_conflict_roundtrip() {
|
||||
let body = WebSyncClient::wrap_push_body_with_base(r#"{"pages":[]}"#, 7);
|
||||
assert!(body.contains(r#""baseVersion":7"#));
|
||||
assert_eq!(
|
||||
WebSyncClient::parse_push_conflict(
|
||||
r#"{"ok":false,"error":"version-conflict","version":12}"#
|
||||
),
|
||||
Some(12)
|
||||
);
|
||||
assert_eq!(
|
||||
WebSyncClient::parse_push_conflict(r#"{"ok":true,"version":9}"#),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_aware_push_adds_preserve_mode_without_changing_legacy_helpers() {
|
||||
let doc = r#"{"version":"1.0","children":[]}"#;
|
||||
let preserved = WebSyncClient::wrap_push_body_with_base_and_preserve(doc, 7, true);
|
||||
let value: serde_json::Value = serde_json::from_str(&preserved).expect("push json");
|
||||
assert_eq!(value["baseVersion"], 7);
|
||||
assert_eq!(value["preserveAuthoredGeometry"], true);
|
||||
|
||||
let legacy = WebSyncClient::wrap_push_body_with_base(doc, 7);
|
||||
let legacy_value: serde_json::Value = serde_json::from_str(&legacy).expect("legacy json");
|
||||
assert!(legacy_value.get("preserveAuthoredGeometry").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_key_and_body_track_ids_and_active_page() {
|
||||
let mut state = crate::EditorState::new();
|
||||
let key_empty = selection_sync_key(&state);
|
||||
assert_eq!(key_empty, "sel:|page:");
|
||||
state.doc.children = vec![];
|
||||
state.selection.set = vec![crate::NodeId::new("n1"), crate::NodeId::new("n2")];
|
||||
state.selection.anchor = crate::NodeId::new("n2");
|
||||
let key = selection_sync_key(&state);
|
||||
assert_eq!(key, "sel:n1,n2|page:");
|
||||
assert_ne!(key, key_empty);
|
||||
// Body matches the TS selection.post.ts renderer shape.
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(&selection_push_body(&state)).expect("json");
|
||||
assert_eq!(body["selectedIds"], serde_json::json!(["n1", "n2"]));
|
||||
assert_eq!(body["activePageId"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_body_carries_the_active_page_id() {
|
||||
let doc: PenDocument = serde_json::from_str(
|
||||
r#"{"version":"1.0","children":[],"pages":[
|
||||
{"id":"p1","name":"One","children":[]},
|
||||
{"id":"p2","name":"Two","children":[]}
|
||||
]}"#,
|
||||
)
|
||||
.expect("doc");
|
||||
let mut state = crate::EditorState::from_document(doc);
|
||||
assert!(selection_sync_key(&state).ends_with("|page:p1"));
|
||||
assert!(state.set_active_page(1));
|
||||
assert!(selection_sync_key(&state).ends_with("|page:p2"));
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(&selection_push_body(&state)).expect("json");
|
||||
assert_eq!(body["activePageId"], "p2");
|
||||
}
|
||||
}
|
||||
#[path = "web_sync_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
use super::*;
|
||||
|
||||
const V3: &str = r#"{"document":{"version":"1.0","children":[]},"version":3}"#;
|
||||
|
||||
#[test]
|
||||
fn next_document_offers_the_first_response() {
|
||||
let c = WebSyncClient::new();
|
||||
assert!(matches!(c.next_document(V3), Ok(Some((_, 3)))));
|
||||
// Read-only: not committed until mark_applied.
|
||||
assert_eq!(c.applied_version(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_applied_then_skips_equal_or_older_versions() {
|
||||
let mut c = WebSyncClient::new();
|
||||
assert!(c.next_document(V3).expect("ok").is_some());
|
||||
c.mark_applied(3);
|
||||
// Same version → nothing newer.
|
||||
assert!(c.next_document(V3).expect("ok").is_none());
|
||||
// Older version → nothing newer.
|
||||
let older = r#"{"document":{"version":"1.0","children":[]},"version":2}"#;
|
||||
assert!(c.next_document(older).expect("ok").is_none());
|
||||
assert_eq!(c.applied_version(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_document_offers_a_newer_version_after_commit() {
|
||||
let mut c = WebSyncClient::new();
|
||||
c.mark_applied(3);
|
||||
let newer = r#"{"document":{"version":"1.0","children":[]},"version":5}"#;
|
||||
assert!(matches!(c.next_document(newer), Ok(Some((_, 5)))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncommitted_version_is_re_offered_so_a_failed_repaint_is_not_lost() {
|
||||
// Decide-then-commit: if the caller does NOT mark_applied (e.g. repaint
|
||||
// failed), the same newer version must still be offered next poll.
|
||||
let c = WebSyncClient::new();
|
||||
assert!(c.next_document(V3).expect("ok").is_some());
|
||||
// No mark_applied → still offered.
|
||||
assert!(c.next_document(V3).expect("ok").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_commits_only_on_apply_success_and_never_stale() {
|
||||
let mut c = WebSyncClient::new();
|
||||
// apply succeeds → commits exactly the applied version (3).
|
||||
let mut applied_version = None;
|
||||
assert!(c
|
||||
.sync(V3, |_doc, v| {
|
||||
applied_version = Some(v);
|
||||
true
|
||||
})
|
||||
.expect("ok"));
|
||||
assert_eq!(applied_version, Some(3));
|
||||
assert_eq!(c.applied_version(), 3);
|
||||
// nothing newer → apply callback not invoked.
|
||||
let mut called = false;
|
||||
assert!(!c
|
||||
.sync(V3, |_d, _v| {
|
||||
called = true;
|
||||
true
|
||||
})
|
||||
.expect("ok"));
|
||||
assert!(!called);
|
||||
// newer, but apply (repaint) FAILS → NOT committed (stays 3), retried.
|
||||
let v5 = r#"{"document":{"version":"1.0","children":[]},"version":5}"#;
|
||||
assert!(!c.sync(v5, |_d, _v| false).expect("ok"));
|
||||
assert_eq!(c.applied_version(), 3);
|
||||
// retry succeeds → commits 5.
|
||||
assert!(c.sync(v5, |_d, _v| true).expect("ok"));
|
||||
assert_eq!(c.applied_version(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_aware_sync_applies_preserve_mode_before_committing_version() {
|
||||
let mut c = WebSyncClient::new();
|
||||
let body = r#"{"document":{"version":"1.0","children":[]},"version":8,"preserveAuthoredGeometry":true}"#;
|
||||
let mut applied = None;
|
||||
assert!(c
|
||||
.sync_with_metadata(body, |_doc, version, preserve| {
|
||||
applied = Some((version, preserve));
|
||||
true
|
||||
})
|
||||
.expect("valid response"));
|
||||
assert_eq!(applied, Some((8, true)));
|
||||
assert_eq!(c.applied_version(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_document_rejects_malformed_responses() {
|
||||
let c = WebSyncClient::new();
|
||||
assert!(c.next_document("not json").is_err());
|
||||
// Missing version.
|
||||
assert!(c
|
||||
.next_document(r#"{"document":{"version":"1.0","children":[]}}"#)
|
||||
.is_err());
|
||||
// Missing document on a first (would-apply) response.
|
||||
assert!(c.next_document(r#"{"version":1}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_push_body_wraps_the_document() {
|
||||
let doc: PenDocument = serde_json::from_str(r#"{"version":"1.0","children":[]}"#).expect("doc");
|
||||
let body = WebSyncClient::build_push_body(&doc).expect("body");
|
||||
assert!(body.starts_with(r#"{"document":"#), "{body}");
|
||||
assert!(body.contains(r#""version":"1.0""#), "{body}");
|
||||
// Round-trips back through the daemon's request parser shape.
|
||||
let value: serde_json::Value = serde_json::from_str(&body).expect("valid json");
|
||||
assert!(value.get("document").is_some());
|
||||
// The wrap helper produces the identical body from the same JSON.
|
||||
let doc_json = serde_json::to_string(&doc).expect("doc json");
|
||||
assert_eq!(WebSyncClient::wrap_push_body(&doc_json), body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_probe_gates_the_document_fetch() {
|
||||
let mut c = WebSyncClient::new();
|
||||
// First sync: any version (even 0) warrants a fetch.
|
||||
assert!(c.wants_version(0));
|
||||
c.mark_applied(3);
|
||||
assert!(!c.wants_version(2));
|
||||
assert!(!c.wants_version(3));
|
||||
assert!(c.wants_version(4));
|
||||
// Probe body parsing.
|
||||
assert_eq!(
|
||||
WebSyncClient::parse_version_probe(r#"{"version":7}"#),
|
||||
Some(7)
|
||||
);
|
||||
assert_eq!(WebSyncClient::parse_version_probe(r#"{"ok":false}"#), None);
|
||||
assert_eq!(WebSyncClient::parse_version_probe("not json"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_is_gated_on_first_sync_and_content_change() {
|
||||
let mut c = WebSyncClient::new();
|
||||
let starter = r#"{"version":"1.0","children":[]}"#;
|
||||
// Before the first daemon apply the browser must NOT push its
|
||||
// boot-time starter document over the daemon's post-reset authority.
|
||||
assert!(!c.initialized());
|
||||
assert!(!c.should_push(starter));
|
||||
// Apply the daemon doc, note its local serialization as baseline.
|
||||
assert!(c.sync(V3, |_d, _v| true).expect("ok"));
|
||||
c.note_applied_snapshot(starter);
|
||||
// Unchanged content → no push (the echo-suppression core).
|
||||
assert!(!c.should_push(starter));
|
||||
// A real local edit changes the serialization → push.
|
||||
let edited = r#"{"version":"1.0","children":[{"id":"n1"}]}"#;
|
||||
assert!(c.should_push(edited));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_push_is_disabled_before_the_first_daemon_apply() {
|
||||
let mut c = WebSyncClient::new();
|
||||
let starter = r#"{"version":"1.0","children":[]}"#;
|
||||
let edited = r#"{"version":"1.0","children":[{"id":"n1"}]}"#;
|
||||
|
||||
assert!(!c.should_bootstrap_push(starter, starter));
|
||||
assert!(!c.should_bootstrap_push(edited, starter));
|
||||
|
||||
c.mark_pushed(edited, 1);
|
||||
assert!(!c.should_bootstrap_push(edited, starter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_pushed_commits_baseline_and_version_so_echo_is_skipped() {
|
||||
let mut c = WebSyncClient::new();
|
||||
assert!(c.sync(V3, |_d, _v| true).expect("ok"));
|
||||
let edited = r#"{"version":"1.0","children":[{"id":"n1"}]}"#;
|
||||
assert!(c.should_push(edited));
|
||||
// Daemon accepted the push as version 4.
|
||||
c.mark_pushed(edited, 4);
|
||||
assert_eq!(c.applied_version(), 4);
|
||||
// Neither the content nor the version is re-offered (no echo).
|
||||
assert!(!c.should_push(edited));
|
||||
assert!(!c.wants_version(4));
|
||||
let echo = r#"{"document":{"version":"1.0","children":[]},"version":4}"#;
|
||||
assert!(c.next_document(echo).expect("ok").is_none());
|
||||
// A later external version still syncs.
|
||||
assert!(c.wants_version(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_response_parses_only_the_ok_shape() {
|
||||
assert_eq!(
|
||||
WebSyncClient::parse_push_response(r#"{"ok":true,"version":9}"#),
|
||||
Some(9)
|
||||
);
|
||||
assert_eq!(
|
||||
WebSyncClient::parse_push_response(r#"{"ok":false,"error":"x"}"#),
|
||||
None
|
||||
);
|
||||
assert_eq!(WebSyncClient::parse_push_response(r#"{"version":9}"#), None);
|
||||
assert_eq!(WebSyncClient::parse_push_response(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_body_with_base_and_conflict_roundtrip() {
|
||||
let body = WebSyncClient::wrap_push_body_with_base(r#"{"pages":[]}"#, 7);
|
||||
assert!(body.contains(r#""baseVersion":7"#));
|
||||
assert_eq!(
|
||||
WebSyncClient::parse_push_conflict(
|
||||
r#"{"ok":false,"error":"version-conflict","version":12}"#
|
||||
),
|
||||
Some(12)
|
||||
);
|
||||
assert_eq!(
|
||||
WebSyncClient::parse_push_conflict(r#"{"ok":true,"version":9}"#),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_aware_push_adds_preserve_mode_without_changing_legacy_helpers() {
|
||||
let doc = r#"{"version":"1.0","children":[]}"#;
|
||||
let preserved = WebSyncClient::wrap_push_body_with_base_and_preserve(doc, 7, true);
|
||||
let value: serde_json::Value = serde_json::from_str(&preserved).expect("push json");
|
||||
assert_eq!(value["baseVersion"], 7);
|
||||
assert_eq!(value["preserveAuthoredGeometry"], true);
|
||||
|
||||
let legacy = WebSyncClient::wrap_push_body_with_base(doc, 7);
|
||||
let legacy_value: serde_json::Value = serde_json::from_str(&legacy).expect("legacy json");
|
||||
assert!(legacy_value.get("preserveAuthoredGeometry").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_key_and_body_track_ids_and_active_page() {
|
||||
let mut state = crate::EditorState::new();
|
||||
let key_empty = selection_sync_key(&state);
|
||||
assert_eq!(key_empty, "sel:|page:");
|
||||
state.doc.children = vec![];
|
||||
state.selection.set = vec![crate::NodeId::new("n1"), crate::NodeId::new("n2")];
|
||||
state.selection.anchor = crate::NodeId::new("n2");
|
||||
let key = selection_sync_key(&state);
|
||||
assert_eq!(key, "sel:n1,n2|page:");
|
||||
assert_ne!(key, key_empty);
|
||||
// Body matches the TS selection.post.ts renderer shape.
|
||||
let body: serde_json::Value = serde_json::from_str(&selection_push_body(&state)).expect("json");
|
||||
assert_eq!(body["selectedIds"], serde_json::json!(["n1", "n2"]));
|
||||
assert_eq!(body["activePageId"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_body_carries_the_active_page_id() {
|
||||
let doc: PenDocument = serde_json::from_str(
|
||||
r#"{"version":"1.0","children":[],"pages":[
|
||||
{"id":"p1","name":"One","children":[]},
|
||||
{"id":"p2","name":"Two","children":[]}
|
||||
]}"#,
|
||||
)
|
||||
.expect("doc");
|
||||
let mut state = crate::EditorState::from_document(doc);
|
||||
assert!(selection_sync_key(&state).ends_with("|page:p1"));
|
||||
assert!(state.set_active_page(1));
|
||||
assert!(selection_sync_key(&state).ends_with("|page:p2"));
|
||||
let body: serde_json::Value = serde_json::from_str(&selection_push_body(&state)).expect("json");
|
||||
assert_eq!(body["activePageId"], "p2");
|
||||
}
|
||||
@@ -796,6 +796,3 @@ impl WidgetHostNative {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Grace period after pan/zoom before full-quality painting resumes.
|
||||
const INTERACTION_HOT_MS: u64 = 150;
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Grace period after pan/zoom before full-quality painting resumes.
|
||||
pub(in crate::widget_host) const INTERACTION_HOT_MS: u64 = 150;
|
||||
|
||||
impl WidgetHostNative {
|
||||
/// Drain a queued Component-Browser insert: place the chosen
|
||||
/// UIKit component at the viewport's centre (top-left = centre −
|
||||
|
||||
@@ -238,9 +238,9 @@ fn canvas_pan_gesture_opens_and_closes_the_interactive_degrade_window() {
|
||||
let deadline = host
|
||||
.next_animation_deadline_ms()
|
||||
.expect("hot gesture schedules a wake-up");
|
||||
assert!(deadline <= 1_000 + super::INTERACTION_HOT_MS);
|
||||
assert!(deadline <= 1_000 + super::host_requests::INTERACTION_HOT_MS);
|
||||
|
||||
host.set_now_ms(1_000 + super::INTERACTION_HOT_MS);
|
||||
host.set_now_ms(1_000 + super::host_requests::INTERACTION_HOT_MS);
|
||||
assert!(!host.fast_interaction_active());
|
||||
}
|
||||
|
||||
|
||||
@@ -768,7 +768,7 @@ pub(crate) fn test_measure() -> Rc<dyn MeasureBackend> {
|
||||
Rc::new(jian_skia::SkiaMeasure::new())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, not(target_os = "windows")))]
|
||||
pub(crate) mod font_registry_test_support {
|
||||
use std::sync::{LazyLock, Mutex, MutexGuard};
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ for workflow_path in \
|
||||
"crates/op-editor-ui/**" \
|
||||
"crates/op-host-native/**" \
|
||||
"crates/op-host-desktop/**" \
|
||||
"crates/op-chat-agent/src/provider_dial.rs" \
|
||||
"crates/op-host-services/**" \
|
||||
"crates/op-i18n/**" \
|
||||
"deploy/collab-relay/**" \
|
||||
|
||||
@@ -21,6 +21,7 @@ pull_request:
|
||||
- 'crates/op-editor-ui/**'
|
||||
- 'crates/op-host-native/**'
|
||||
- 'crates/op-host-desktop/**'
|
||||
- 'crates/op-chat-agent/src/provider_dial.rs'
|
||||
- 'crates/op-host-services/**'
|
||||
- 'crates/op-i18n/**'
|
||||
- 'deploy/collab-relay/**'
|
||||
@@ -51,6 +52,7 @@ push:
|
||||
- 'crates/op-editor-ui/**'
|
||||
- 'crates/op-host-native/**'
|
||||
- 'crates/op-host-desktop/**'
|
||||
- 'crates/op-chat-agent/src/provider_dial.rs'
|
||||
- 'crates/op-host-services/**'
|
||||
- 'crates/op-i18n/**'
|
||||
- 'deploy/collab-relay/**'
|
||||
@@ -391,10 +393,26 @@ expect_failure "requires public-only desktop collaboration avatar delegation" \
|
||||
"desktop avatar security-policy delegation"
|
||||
|
||||
new_fixture avatar-proxy-bypass-removed
|
||||
: > "$fixture_root/crates/op-host-services/src/provider_dial.rs"
|
||||
: > "$fixture_root/crates/op-chat-agent/src/provider_dial.rs"
|
||||
cat > "$fixture_root/crates/op-host-services/src/provider_dial.rs" <<'EOF'
|
||||
fn fake_pinned_client() {
|
||||
let _ = ".no_proxy()";
|
||||
let _ = ".resolve_to_addrs";
|
||||
}
|
||||
EOF
|
||||
expect_failure "requires proxy-free pinned avatar dialing" \
|
||||
"public HTTPS proxy bypass prevention"
|
||||
|
||||
new_fixture avatar-dns-pinning-removed
|
||||
sed '/\.resolve_to_addrs/d' \
|
||||
"$fixture_root/crates/op-chat-agent/src/provider_dial.rs" \
|
||||
> "$fixture_root/crates/op-chat-agent/src/provider_dial.rs.next"
|
||||
mv \
|
||||
"$fixture_root/crates/op-chat-agent/src/provider_dial.rs.next" \
|
||||
"$fixture_root/crates/op-chat-agent/src/provider_dial.rs"
|
||||
expect_failure "requires connect-time DNS pinning for public avatar dialing" \
|
||||
"public HTTPS DNS pinning"
|
||||
|
||||
new_fixture auth-artifact-integrity-removed
|
||||
: > "$fixture_root/crates/op-auth-bridge/build.rs"
|
||||
expect_failure "requires authentication artifact integrity verification" \
|
||||
@@ -422,9 +440,9 @@ awk 'BEGIN { for (line = 1; line <= 801; line++) print "// integration line" }'
|
||||
expect_failure "enforces the line cap across collaboration integration source" \
|
||||
"has 801 lines; maximum is 800"
|
||||
|
||||
new_fixture missing-workflow-trigger
|
||||
new_fixture missing-provider-dial-workflow-trigger
|
||||
awk '
|
||||
!removed && index($0, "crates/op-host-desktop/**") {
|
||||
!removed && index($0, "crates/op-chat-agent/src/provider_dial.rs") {
|
||||
removed = 1
|
||||
next
|
||||
}
|
||||
@@ -435,7 +453,7 @@ awk '
|
||||
mv \
|
||||
"$fixture_root/.github/workflows/collab-security.yml.next" \
|
||||
"$fixture_root/.github/workflows/collab-security.yml"
|
||||
expect_failure "rejects removal of either integration workflow trigger" \
|
||||
expect_failure "requires both canonical provider-dial workflow triggers" \
|
||||
"collaboration security workflow path trigger"
|
||||
|
||||
new_fixture relay-edge-mtls-verification-removed
|
||||
|
||||
@@ -35,6 +35,7 @@ collab_scan_roots=(
|
||||
crates/op-collab-host/src
|
||||
crates/op-host-native/src
|
||||
crates/op-host-desktop/src
|
||||
crates/op-chat-agent/src/provider_dial.rs
|
||||
crates/op-host-services/src/profile_avatar_fetch.rs
|
||||
crates/op-host-services/src/public_https_client.rs
|
||||
crates/op-host-services/src/provider_dial.rs
|
||||
@@ -586,9 +587,9 @@ for desktop_avatar_anchor in \
|
||||
require_literal crates/op-host-desktop/src/collab_avatar_host.rs \
|
||||
"$desktop_avatar_anchor" "desktop avatar security-policy delegation"
|
||||
done
|
||||
require_literal crates/op-host-services/src/provider_dial.rs \
|
||||
require_literal crates/op-chat-agent/src/provider_dial.rs \
|
||||
".no_proxy()" "public HTTPS proxy bypass prevention"
|
||||
require_literal crates/op-host-services/src/provider_dial.rs \
|
||||
require_literal crates/op-chat-agent/src/provider_dial.rs \
|
||||
".resolve_to_addrs" "public HTTPS DNS pinning"
|
||||
require_literal crates/op-editor-ui/src/collab_avatar_runtime.rs \
|
||||
"MAX_AVATAR_SOURCE_PIXELS" "decoded avatar pixel limit"
|
||||
|
||||
@@ -55,6 +55,7 @@ new_fixture() {
|
||||
"$fixture_root/crates/op-editor-ui/src" \
|
||||
"$fixture_root/crates/op-host-native/src" \
|
||||
"$fixture_root/crates/op-host-desktop/src" \
|
||||
"$fixture_root/crates/op-chat-agent/src" \
|
||||
"$fixture_root/crates/op-collab-host/src/runtime/network" \
|
||||
"$fixture_root/crates/op-host-services/src" \
|
||||
"$fixture_root/crates/op-i18n/src" \
|
||||
@@ -756,11 +757,17 @@ EOF
|
||||
pub fn public_https_client() {}
|
||||
EOF
|
||||
|
||||
cat > "$fixture_root/crates/op-host-services/src/provider_dial.rs" <<'EOF'
|
||||
fn pinned_client() {
|
||||
let _ = ".no_proxy()";
|
||||
let _ = ".resolve_to_addrs";
|
||||
cat > "$fixture_root/crates/op-chat-agent/src/provider_dial.rs" <<'EOF'
|
||||
fn pinned_client(builder: reqwest::ClientBuilder, host: &str, addrs: &[SocketAddr]) {
|
||||
let _client = builder
|
||||
.no_proxy()
|
||||
.resolve_to_addrs(host, addrs)
|
||||
.build();
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > "$fixture_root/crates/op-host-services/src/provider_dial.rs" <<'EOF'
|
||||
pub(crate) use op_chat_agent::provider_dial::client_for;
|
||||
EOF
|
||||
|
||||
cat > "$fixture_root/crates/op-host-services/src/web_credentials.rs" <<'EOF'
|
||||
|
||||
Reference in New Issue
Block a user