test(oak-app): UI panels, engines, dialogs and widgets
Mock/real engine boundaries, shell modals and menus, timeline/ inspector/node-editor/project-explorer panels, dialogs, and the editor controls, including the review remediation assertions.
This commit is contained in:
+2892
-9
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -510,4 +510,219 @@ pub fn apply_bgra8(data: &mut [u8], pixels: i64) {
|
||||
let _ = processor.convert_bgra8(data, pixels);
|
||||
}
|
||||
}
|
||||
let _ = processor.convert_bgra8(data, pixels);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Restores the tracked monitor fingerprint on drop: a failed
|
||||
/// assertion must not leak a fake monitor into the rest of the
|
||||
/// process (the key and generation of other tests depend on it).
|
||||
struct MonitorRestore(Option<String>);
|
||||
|
||||
impl MonitorRestore {
|
||||
fn capture() -> Self {
|
||||
Self(
|
||||
CURRENT_MONITOR
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MonitorRestore {
|
||||
fn drop(&mut self) {
|
||||
note_monitor(self.0.take());
|
||||
}
|
||||
}
|
||||
|
||||
/// The active-key tuple follows the monitor fingerprint and the
|
||||
/// content-space override, and resets cleanly when both are cleared.
|
||||
#[test]
|
||||
fn key_tracks_monitor_and_content_override() {
|
||||
let _lock = crate::oakui::graphops::test_lock();
|
||||
let _monitor = MonitorRestore::capture();
|
||||
let previous = ConfigStore::instance()
|
||||
.get(None, CONFIG_KEY_CONTENT_SPACE)
|
||||
.unwrap_or_default();
|
||||
|
||||
note_monitor(Some("x11:TEST-1".into()));
|
||||
let key = current_key();
|
||||
assert_eq!(key.0, display_policy());
|
||||
assert_eq!(key.1, configured_mode());
|
||||
assert_eq!(key.4, "x11:TEST-1");
|
||||
assert!(!key.3.is_empty());
|
||||
assert!(
|
||||
!key.3.starts_with("override:"),
|
||||
"no override installed by default: {}",
|
||||
key.3
|
||||
);
|
||||
|
||||
ConfigStore::instance().set(None, CONFIG_KEY_CONTENT_SPACE, "ACEScg");
|
||||
let key = current_key();
|
||||
assert_eq!(key.3, "override:ACEScg");
|
||||
|
||||
// Clearing the override returns to the project output spec, and an
|
||||
// unknown monitor falls back to the main display.
|
||||
ConfigStore::instance().set(None, CONFIG_KEY_CONTENT_SPACE, &previous);
|
||||
note_monitor(None);
|
||||
let key = current_key();
|
||||
assert_eq!(key.4, "");
|
||||
assert_eq!(key.3, format!(
|
||||
"{}:{}",
|
||||
pipeline_output_spec().gamut.as_setting(),
|
||||
pipeline_output_spec().transfer.as_setting()
|
||||
));
|
||||
}
|
||||
|
||||
/// `current()` caches per key: a rebuild only happens after a key
|
||||
/// change (and bumps the generation only when a prior state existed).
|
||||
#[test]
|
||||
fn current_caches_per_key() {
|
||||
let _lock = crate::oakui::graphops::test_lock();
|
||||
let _monitor = MonitorRestore::capture();
|
||||
let previous = ConfigStore::instance()
|
||||
.get(None, CONFIG_KEY_CONTENT_SPACE)
|
||||
.unwrap_or_default();
|
||||
|
||||
note_monitor(None);
|
||||
invalidate();
|
||||
let first = current().expect("the state always builds");
|
||||
let generation_after_first = generation();
|
||||
|
||||
// Same key: the cached state is cloned, not rebuilt — the
|
||||
// generation stands still and the chain Arcs are shared.
|
||||
let cached = current().expect("the cached state is cloned");
|
||||
assert_eq!(
|
||||
generation(),
|
||||
generation_after_first,
|
||||
"a same-key read does not rebuild"
|
||||
);
|
||||
assert_eq!(cached.key, first.key, "the cached state keeps the key");
|
||||
match (&first.f32, &cached.f32) {
|
||||
(Some(first_chain), Some(cached_chain)) => assert!(
|
||||
Arc::ptr_eq(first_chain, cached_chain),
|
||||
"the cached state shares the chain Arcs"
|
||||
),
|
||||
(None, None) => {}
|
||||
_ => panic!("a same-key read changed the chain availability"),
|
||||
}
|
||||
|
||||
// A monitor change rekeys: exactly one bump and a rebuilt state.
|
||||
note_monitor(Some("x11:OTHER".into()));
|
||||
let rekeyed = current().expect("the state rebuilds");
|
||||
assert_eq!(
|
||||
generation(),
|
||||
generation_after_first + 1,
|
||||
"a key change bumps the generation exactly once"
|
||||
);
|
||||
assert_ne!(rekeyed.key, first.key, "the key followed the monitor");
|
||||
assert_eq!(rekeyed.key.4, "x11:OTHER");
|
||||
if let (Some(first_chain), Some(rekeyed_chain)) = (&first.f32, &rekeyed.f32) {
|
||||
assert!(
|
||||
!Arc::ptr_eq(first_chain, rekeyed_chain),
|
||||
"a key change rebuilds the chain"
|
||||
);
|
||||
}
|
||||
|
||||
// The rekeyed state is now the cache: reading it again stands still.
|
||||
let _ = current().expect("the rekeyed state is cached");
|
||||
assert_eq!(
|
||||
generation(),
|
||||
generation_after_first + 1,
|
||||
"the rebuilt key is cached"
|
||||
);
|
||||
|
||||
// Clearing the monitor rekeys back: another single bump.
|
||||
note_monitor(None);
|
||||
let _ = current().expect("the state rebuilds again");
|
||||
assert_eq!(
|
||||
generation(),
|
||||
generation_after_first + 2,
|
||||
"each key change bumps exactly once"
|
||||
);
|
||||
|
||||
// invalidate() drops the cache: the next build is a first build
|
||||
// (no prior state), so it must not bump a third time.
|
||||
invalidate();
|
||||
let _ = current().expect("the state builds after invalidate");
|
||||
assert_eq!(
|
||||
generation(),
|
||||
generation_after_first + 2,
|
||||
"a first build after invalidate does not bump"
|
||||
);
|
||||
|
||||
ConfigStore::instance().set(None, CONFIG_KEY_CONTENT_SPACE, &previous);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
/// A clone of the cached state preserves the key and the chain slots.
|
||||
#[test]
|
||||
fn clone_state_preserves_the_chain_slots() {
|
||||
invalidate();
|
||||
let state = current().expect("state");
|
||||
let cloned = clone_state(&state).expect("clone");
|
||||
assert_eq!(cloned.key, state.key);
|
||||
assert_eq!(cloned.f32.is_some(), state.f32.is_some());
|
||||
assert_eq!(cloned.xyz.is_some(), state.xyz.is_some());
|
||||
assert_eq!(cloned.bgra.is_some(), state.bgra.is_some());
|
||||
}
|
||||
|
||||
/// Monitor fingerprints (known, unknown, empty) resolve or degrade
|
||||
/// without panicking, and the empty key field follows the noted
|
||||
/// fingerprint.
|
||||
#[test]
|
||||
fn monitor_profiles_resolve_or_degrade() {
|
||||
let _lock = crate::oakui::graphops::test_lock();
|
||||
let _monitor = MonitorRestore::capture();
|
||||
invalidate();
|
||||
|
||||
// An unparseable fingerprint degrades to the same profile as "no
|
||||
// fingerprint" (the main display), never to a per-monitor lookup
|
||||
// or a panic.
|
||||
assert_eq!(
|
||||
monitor_icc_path("garbage"),
|
||||
monitor_icc_path(""),
|
||||
"a malformed fingerprint falls back to the main display"
|
||||
);
|
||||
|
||||
for fingerprint in ["", "x11:NO-SUCH-OUTPUT", "garbage", "mac:1", "win:X"] {
|
||||
note_monitor(Some(fingerprint.to_string()));
|
||||
assert_eq!(
|
||||
current_key().4,
|
||||
fingerprint,
|
||||
"the effective key follows the noted fingerprint"
|
||||
);
|
||||
let _ = monitor_icc_path(fingerprint);
|
||||
}
|
||||
|
||||
// Clearing the fingerprint returns the key to the main-display
|
||||
// sentinel.
|
||||
note_monitor(None);
|
||||
assert_eq!(current_key().4, "");
|
||||
invalidate();
|
||||
}
|
||||
|
||||
/// Zero-pixel (or inactive) conversions never panic and produce only
|
||||
/// finite output samples.
|
||||
#[test]
|
||||
fn empty_conversions_are_safe() {
|
||||
let mut samples: [f32; 0] = [];
|
||||
apply_f32_rgba(&mut samples, 0);
|
||||
let mut bytes: [u8; 0] = [];
|
||||
apply_bgra8(&mut bytes, 0);
|
||||
|
||||
// A one-pixel conversion either transforms or passes through; it
|
||||
// must never produce NaN.
|
||||
let mut one = [0.25f32, 0.5, 0.75, 1.0];
|
||||
apply_f32_rgba(&mut one, 1);
|
||||
assert!(one.iter().all(|v| v.is_finite()));
|
||||
assert_eq!(one[3], 1.0, "alpha is untouched");
|
||||
|
||||
// The active flag and the content-space declaration agree with the
|
||||
// policy and never panic.
|
||||
let _ = is_active();
|
||||
let _ = content_colorspace();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1675,4 +1675,451 @@ pub struct ExportSession { /// The event receiver (the background thread's sende
|
||||
/// Cancels the running export as soon as possible.
|
||||
pub cancel: Box<dyn Fn() + Send>,
|
||||
}
|
||||
/// Cancels the running export as soon as possible.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::MockClock;
|
||||
use gpui::effect_stack::EffectData;
|
||||
use gpui::{AppContext as _, TestAppContext};
|
||||
use gpui::node_graph::PortId;
|
||||
use gpui_widgets::project_explorer::ProjectEntry;
|
||||
|
||||
#[test]
|
||||
fn node_category_key_covers_every_category() {
|
||||
use oak_node::node::Category as C;
|
||||
for category in [
|
||||
C::Output,
|
||||
C::Effect,
|
||||
C::Generator,
|
||||
C::Input,
|
||||
C::Math,
|
||||
C::Color,
|
||||
C::Distort,
|
||||
C::Filter,
|
||||
C::Keying,
|
||||
C::OpenFx,
|
||||
C::Group,
|
||||
] {
|
||||
let key = node_category_key(category).expect("category has an i18n key");
|
||||
assert!(key.starts_with("node.category."), "{category:?} -> {key}");
|
||||
}
|
||||
// Timeline-structural nodes never show in the Add menu.
|
||||
assert_eq!(node_category_key(C::Timeline), None);
|
||||
}
|
||||
|
||||
/// A minimal engine that implements only the required methods, so every
|
||||
/// `AppEngine` default body (the "not supported" / no-op fallbacks)
|
||||
/// executes in the tests below.
|
||||
struct NullEngine {
|
||||
source_clock: Entity<MockClock>,
|
||||
program_clock: Entity<MockClock>,
|
||||
}
|
||||
|
||||
impl NullEngine {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let rate = FrameRate { num: 25, den: 1 };
|
||||
NullEngine {
|
||||
source_clock: cx.new(|_| MockClock::new(rate)),
|
||||
program_clock: cx.new(|_| MockClock::new(rate)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EngineGateway for NullEngine {
|
||||
fn project(&self) -> Option<&Project> {
|
||||
None
|
||||
}
|
||||
fn current_sequence(&self) -> Option<&Sequence> {
|
||||
None
|
||||
}
|
||||
fn open_project(&mut self, _path: PathBuf, _cx: &mut Context<Self>) {}
|
||||
fn request_frame(&mut self, _m: Monitor, _f: Frame, _cx: &mut Context<Self>) {}
|
||||
fn play(&mut self, _m: Monitor, _cx: &mut Context<Self>) {}
|
||||
fn pause(&mut self, _m: Monitor, _cx: &mut Context<Self>) {}
|
||||
fn step(&mut self, _m: Monitor, _d: i64, _cx: &mut Context<Self>) {}
|
||||
fn tick(&mut self, _cx: &mut Context<Self>) {}
|
||||
}
|
||||
|
||||
impl TimelineDataSource for NullEngine {
|
||||
type Track = <crate::oakui::MockEngine as TimelineDataSource>::Track;
|
||||
fn frame_rate(&self) -> FrameRate {
|
||||
FrameRate { num: 25, den: 1 }
|
||||
}
|
||||
fn sequence_length(&self) -> Frame {
|
||||
Frame::ZERO
|
||||
}
|
||||
fn track_count(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn track(&self, _index: usize) -> Option<Self::Track> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl EffectStackDataSource for NullEngine {
|
||||
fn effects(&self) -> Vec<Arc<dyn EffectData>> {
|
||||
Vec::new()
|
||||
}
|
||||
fn target_label(&self) -> Option<SharedString> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeGraphDataSource for NullEngine {
|
||||
type Node = <crate::oakui::MockEngine as NodeGraphDataSource>::Node;
|
||||
type Edge = <crate::oakui::MockEngine as NodeGraphDataSource>::Edge;
|
||||
fn nodes(&self) -> Vec<Self::Node> {
|
||||
Vec::new()
|
||||
}
|
||||
fn edges(&self) -> Vec<Self::Edge> {
|
||||
Vec::new()
|
||||
}
|
||||
fn can_connect(&self, _from: PortId, _to: PortId) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl ProjectDataSource for NullEngine {
|
||||
fn roots(&self) -> Vec<ProjectEntry> {
|
||||
Vec::new()
|
||||
}
|
||||
fn children(&self, _parent: u64) -> Vec<ProjectEntry> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioMeterDataSource for NullEngine {
|
||||
fn levels(&self) -> Vec<f32> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppEngine for NullEngine {
|
||||
type Clock = MockClock;
|
||||
|
||||
fn create(cx: &mut Context<Self>) -> Self {
|
||||
NullEngine::new(cx)
|
||||
}
|
||||
fn source_clock(&self) -> &Entity<Self::Clock> {
|
||||
&self.source_clock
|
||||
}
|
||||
fn program_clock(&self) -> &Entity<Self::Clock> {
|
||||
&self.program_clock
|
||||
}
|
||||
fn clock_frame(&self, _m: Monitor, _cx: &App) -> Frame {
|
||||
Frame::ZERO
|
||||
}
|
||||
fn cpu_frame(&self, _m: Monitor, _cx: &App) -> Arc<RenderImage> {
|
||||
// A 2×2 transparent frame, so frame-consuming defaults
|
||||
// (save_frame) run instead of panicking.
|
||||
let samples = [0.0f32; 16];
|
||||
Arc::new(crate::oakui::frames::f32_rgba_to_bgra_image(2, 2, &samples))
|
||||
}
|
||||
fn scope_data(&self, _m: Monitor, _cx: &App) -> ScopeData {
|
||||
ScopeData::default()
|
||||
}
|
||||
fn add_track(&mut self, _kind: TrackKind, _cx: &mut Context<Self>) {}
|
||||
fn remove_track(&mut self, _index: usize, _cx: &mut Context<Self>) {}
|
||||
fn set_track_height(&mut self, _height: Pixels, _cx: &mut Context<Self>) {}
|
||||
fn select_item(&mut self, _id: u64, _cx: &mut Context<Self>) {}
|
||||
fn apply_effect_event(&mut self, _e: &EffectStackEvent, _cx: &mut Context<Self>) {}
|
||||
fn apply_node_graph_event(&mut self, _e: &NodeGraphEvent, _cx: &mut Context<Self>) {}
|
||||
fn apply_timeline_event(&mut self, _e: &TimelineEvent, _cx: &mut Context<Self>) {}
|
||||
fn split_clip(&mut self, _clip: ClipId, _time: Frame, _cx: &mut Context<Self>) {}
|
||||
fn split_at_playhead(&mut self, _cx: &mut Context<Self>) {}
|
||||
fn delete_clip(&mut self, _clip: ClipId, _ripple: bool, _cx: &mut Context<Self>) {}
|
||||
fn can_undo(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn can_redo(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn undo(&mut self, _cx: &mut Context<Self>) {}
|
||||
fn redo(&mut self, _cx: &mut Context<Self>) {}
|
||||
fn new_project(&mut self, _cx: &mut Context<Self>) {}
|
||||
fn open_project_path(
|
||||
&mut self,
|
||||
_path: PathBuf,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
fn export_project_path(
|
||||
&mut self,
|
||||
_path: PathBuf,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
fn close_project(&mut self, _cx: &mut Context<Self>) {}
|
||||
fn start_export(&mut self, _format: i32, _path: PathBuf) -> Result<ExportSession, String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
fn start_export_with(
|
||||
&mut self,
|
||||
_settings: &ExportSettings,
|
||||
_path: PathBuf,
|
||||
) -> Result<ExportSession, String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
fn add_default_transition(
|
||||
&mut self,
|
||||
_clips: Vec<ClipId>,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Result<usize, String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
fn backend_name(&self) -> &'static str {
|
||||
"null"
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn app_engine_defaults_degrade_gracefully(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = app.new(NullEngine::create);
|
||||
|
||||
// Gateway defaults.
|
||||
assert_eq!(engine.read(app).source_media_name(), "");
|
||||
|
||||
// Add-menu / inspector / OFX defaults.
|
||||
assert_eq!(engine.read(app).selected_graph_node(), None);
|
||||
assert!(engine.read(app).protected_graph_nodes().is_empty());
|
||||
assert!(engine.read(app).addable_effects().is_empty());
|
||||
assert!(engine.read(app).effect_params(EffectId(0)).is_none());
|
||||
assert_eq!(engine.read(app).ofx_interact_target(app), None);
|
||||
assert!(engine.read(app).addable_effects().is_empty());
|
||||
|
||||
// History defaults.
|
||||
assert!(engine.read(app).history_entries().is_empty());
|
||||
assert_eq!(engine.read(app).history_index(), 0);
|
||||
assert!(!engine.read(app).storage_bound());
|
||||
assert_eq!(engine.read(app).storage_last_error(), None);
|
||||
assert_eq!(engine.read(app).waveform_cache().map(|_| ()), None);
|
||||
|
||||
// Footage defaults.
|
||||
assert_eq!(engine.read(app).entry_path(1), None);
|
||||
assert_eq!(engine.read(app).project_entry_name(1).map(|s| s.to_string()), None);
|
||||
assert_eq!(engine.read(app).footage_length_frames(1), None);
|
||||
assert_eq!(engine.read(app).footage_video_params(1), None);
|
||||
assert_eq!(engine.read(app).workarea(), None);
|
||||
|
||||
// Unsupported / no-op mutators return their error strings.
|
||||
engine.update(app, |engine, cx| {
|
||||
assert!(engine.add_adjustment_layer(0, Frame::ZERO, cx).is_err());
|
||||
assert!(engine.add_effect(0, "no.such.effect", cx).is_err());
|
||||
assert!(engine
|
||||
.set_effect_param(EffectId(0), "p", oak_node::value::NodeValue::None, cx)
|
||||
.is_err());
|
||||
assert!(engine.effect_push_button(EffectId(0), "p", cx).is_err());
|
||||
assert!(engine.add_node_at("no.such.node", Point::default(), cx).is_err());
|
||||
assert!(engine
|
||||
.replace_footage(1, PathBuf::from("/tmp/x.mp4"), cx)
|
||||
.is_err());
|
||||
assert!(engine
|
||||
.library_projects()
|
||||
.is_err());
|
||||
assert!(engine.library_create_project("x", cx).is_err());
|
||||
assert!(engine.library_open_project("u", cx).is_err());
|
||||
assert!(engine.library_delete_project("u").is_err());
|
||||
assert!(engine.library_rename_project("u", "n").is_err());
|
||||
assert!(engine.library_duplicate_project("u").is_err());
|
||||
assert!(engine.save_project_as("x", cx).is_err());
|
||||
assert!(engine.library_import_project(PathBuf::from("/tmp/x.ove")).is_err());
|
||||
assert!(engine.library_export_project("u", PathBuf::from("/tmp/x.ove")).is_err());
|
||||
assert!(engine.import_footage(PathBuf::from("/tmp/x.mp4"), cx).is_ok());
|
||||
|
||||
// All panelless operations are silent no-ops.
|
||||
engine.set_selected_clips(vec![ClipId(1), ClipId(2)], cx);
|
||||
engine.rename_entry(1, "name".into(), cx);
|
||||
engine.delete_entry(1, cx);
|
||||
engine.add_marker_at_playhead(cx);
|
||||
engine.remove_marker_at_playhead(cx);
|
||||
engine.set_workarea_preview(Frame(1), Frame(2), cx);
|
||||
engine.commit_workarea(Frame(0), Frame(1), Frame(2), Frame(3), cx);
|
||||
engine.clear_workarea(cx);
|
||||
engine.clipboard_copy(vec![ClipId(1)], cx);
|
||||
engine.clipboard_cut(vec![ClipId(1)], cx);
|
||||
engine.clipboard_paste(cx);
|
||||
engine.jump_history(-1, cx);
|
||||
engine.drop_footage(1, TrackKind::Video, 0, Frame::ZERO, cx);
|
||||
});
|
||||
|
||||
// `delete_empty_tracks` walks the (empty) track list and stops.
|
||||
engine.update(app, |engine, cx| engine.delete_empty_tracks(cx));
|
||||
|
||||
// The node library comes from the global factory; every entry
|
||||
// carries an Add-menu category key.
|
||||
let library = engine.read(app).node_library();
|
||||
assert!(!library.is_empty(), "the factory has creatable entries");
|
||||
for entry in &library {
|
||||
assert!(entry.category_key.starts_with("node.category."));
|
||||
assert!(!entry.type_id.is_empty());
|
||||
assert!(!entry.name.is_empty());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_params_ui_round_trips_through_the_codec_type() {
|
||||
let codec = oak_codec::proxymanager::ProxyParams {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
divider: 2,
|
||||
crf: 23,
|
||||
include_audio: 0,
|
||||
..oak_codec::proxymanager::ProxyParams::default()
|
||||
};
|
||||
let ui = ProxyParamsUi::from_codec(&codec);
|
||||
assert_eq!(ui.width, 1280);
|
||||
assert_eq!(ui.height, 720);
|
||||
assert_eq!(ui.divider, 2);
|
||||
assert_eq!(ui.crf, 23);
|
||||
assert!(!ui.include_audio);
|
||||
let back = ui.to_codec();
|
||||
assert_eq!(back.width, 1280);
|
||||
assert_eq!(back.divider, 2);
|
||||
assert_eq!(back.include_audio, 0);
|
||||
|
||||
// A long preset is truncated to the 31-byte codec field.
|
||||
let long = ProxyParamsUi {
|
||||
preset: "x".repeat(40),
|
||||
include_audio: true,
|
||||
..ui
|
||||
};
|
||||
let codec = long.to_codec();
|
||||
assert_eq!(&codec.preset[..31], "x".repeat(31).as_bytes());
|
||||
assert_eq!(codec.preset[31], 0);
|
||||
assert_eq!(codec.include_audio, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_params_from_config_returns_the_defaults() {
|
||||
// No Proxy* config is installed by the test environment: the codec
|
||||
// defaults come back.
|
||||
let params = proxy_params_from_config();
|
||||
let codec_defaults = oak_codec::proxymanager::ProxyManager::proxy_params_default();
|
||||
assert_eq!(params.width, codec_defaults.width);
|
||||
assert_eq!(params.height, codec_defaults.height);
|
||||
assert_eq!(params.divider, codec_defaults.divider);
|
||||
assert_eq!(params.crf, codec_defaults.crf);
|
||||
}
|
||||
|
||||
/// The remaining `AppEngine` defaults: project/library/proxy/sync/
|
||||
/// multicam/eyedropper fallbacks that a minimal engine must survive.
|
||||
#[gpui::test]
|
||||
async fn app_engine_remaining_defaults_are_safe(cx: &mut TestAppContext) {
|
||||
let _lock = crate::oakui::graphops::test_lock();
|
||||
cx.update(|app| {
|
||||
let engine = app.new(NullEngine::create);
|
||||
|
||||
// Read-only fallbacks.
|
||||
assert!(engine.read(app).sequence_entries().is_empty());
|
||||
assert_eq!(engine.read(app).current_sequence_id(), None);
|
||||
assert_eq!(engine.read(app).sequence_parameters(1), None);
|
||||
assert!(!engine.read(app).entry_is_sequence(1));
|
||||
assert!(engine.read(app).proxy_rows().is_empty());
|
||||
assert_eq!(engine.read(app).proxy_state(1), None);
|
||||
assert!(engine.read(app).proxy_row(1).is_none());
|
||||
assert_eq!(engine.read(app).proxy_task_progress(), None);
|
||||
assert_eq!(engine.read(app).proxy_custom_params(1), None);
|
||||
assert!(engine.read(app).clip_footage_entries(&[]).is_empty());
|
||||
assert_eq!(engine.read(app).multicam_state(), None);
|
||||
assert!(!engine.read(app).multicam_eligible(&[]));
|
||||
assert!(!engine.read(app).multicam_enabled_on_selection(&[]));
|
||||
assert_eq!(engine.read(app).multicam_wizard_footage(), None);
|
||||
assert!(engine.read(app).multicam_wizard_sync_offsets(&[]).is_err());
|
||||
assert!(!engine.read(app).eyedropper_armed(app));
|
||||
let _ = engine
|
||||
.read(app)
|
||||
.save_frame(Monitor::Program, PathBuf::from("/tmp/oak-null.png"), app);
|
||||
|
||||
// Config-backed getters; the setters write their compiled-in
|
||||
// defaults back so the process config stays neutral.
|
||||
let _ = engine.read(app).use_proxy_media();
|
||||
let _ = engine.read(app).playback_divider();
|
||||
let _ = engine.read(app).stop_on_last();
|
||||
let _ = engine.read(app).waveform_mode();
|
||||
let _ = engine.read(app).project_ocio_config();
|
||||
let _ = engine.read(app).project_cache_location();
|
||||
let _ = engine.read(app).project_color_settings();
|
||||
let _ = engine.read(app).sync_eligibility(&[]);
|
||||
let _ = engine.read(app).proxy_effective_params(1);
|
||||
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.set_use_proxy_media(false, cx);
|
||||
engine.set_playback_divider(1, cx);
|
||||
engine.set_stop_on_last(false, cx);
|
||||
engine.set_waveform_mode(0, cx);
|
||||
assert!(engine.set_project_ocio_config(String::new(), cx).is_ok());
|
||||
engine.set_project_cache_location(0, String::new(), cx);
|
||||
engine.set_project_color_settings(String::new(), String::new(), String::new(), cx);
|
||||
|
||||
// Unsupported operations report errors instead of panicking.
|
||||
assert!(engine
|
||||
.start_export_of(1, &ExportSettings::default(), PathBuf::from("/tmp/x.mp4"))
|
||||
.is_err());
|
||||
assert!(engine
|
||||
.create_sequence_with_params(
|
||||
"s".into(),
|
||||
VideoFormat::hd_1080p25(),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.is_err());
|
||||
assert!(engine.create_folder("f".into(), cx).is_err());
|
||||
assert!(engine.create_text_footage(cx).is_err());
|
||||
assert!(engine.drop_generator_clip("t", 0, Frame::ZERO, cx).is_err());
|
||||
assert!(engine.drop_transition_at("t", 0, Frame::ZERO, cx).is_err());
|
||||
assert!(engine
|
||||
.update_sequence_parameters(
|
||||
1,
|
||||
"n".into(),
|
||||
VideoFormat::hd_1080p25(),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.is_err());
|
||||
engine.open_sequence_id(1, cx);
|
||||
|
||||
assert!(engine.proxy_generate(1, cx).is_err());
|
||||
engine.proxy_delete(1, cx);
|
||||
engine.proxy_set_enabled(1, true, cx);
|
||||
engine.proxy_reveal(1);
|
||||
engine.proxy_set_custom_params(
|
||||
1,
|
||||
ProxyParamsUi {
|
||||
width: 0,
|
||||
height: 0,
|
||||
divider: 1,
|
||||
crf: 0,
|
||||
preset: String::new(),
|
||||
include_audio: false,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
engine.proxy_clear_custom_params(1, cx);
|
||||
|
||||
engine.sync_clips_by_source_time(Vec::new(), cx);
|
||||
engine.sync_clips_by_waveform(Vec::new(), false, cx);
|
||||
engine.toggle_clip_links(Vec::new(), cx);
|
||||
engine.multicam_enable_selected(Vec::new(), true, cx);
|
||||
engine.multicam_enable_selected(Vec::new(), false, cx);
|
||||
engine.multicam_switch_to(0, false, cx);
|
||||
assert!(engine.multicam_angle_frame(0, cx).is_none());
|
||||
assert!(engine
|
||||
.multicam_create_sequence(Vec::new(), Vec::new(), "m".into(), cx)
|
||||
.is_err());
|
||||
|
||||
// Eyedropper mailbox round trip.
|
||||
engine.set_eyedropper_armed(true, cx);
|
||||
assert!(engine.eyedropper_armed(cx));
|
||||
engine.eyedropper_picked(gpui::rgba(0x11223344), cx);
|
||||
assert!(engine.take_eyedropper_result(cx).is_some());
|
||||
engine.set_eyedropper_armed(false, cx);
|
||||
assert!(engine.take_eyedropper_result(cx).is_none());
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,4 +274,87 @@ pub fn register_texture(
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Malformed uploads are rejected before any GPU context is touched
|
||||
/// (review §10.2: the validation arms of the M5 display path had no
|
||||
/// tests at all).
|
||||
#[test]
|
||||
fn rgba16f_upload_rejects_malformed_arguments() {
|
||||
// Zero dimensions.
|
||||
assert!(upload_rgba16f(0, 1, &[]).is_none());
|
||||
assert!(upload_rgba16f(1, 0, &[]).is_none());
|
||||
// Wrong sample counts: short, long and empty for a 2x1 frame.
|
||||
assert!(upload_rgba16f(2, 1, &[]).is_none());
|
||||
assert!(upload_rgba16f(2, 1, &[0.0; 7]).is_none());
|
||||
assert!(upload_rgba16f(2, 1, &[0.0; 9]).is_none());
|
||||
// Valid arguments without a registered window context decline
|
||||
// cleanly (the boolean is context-dependent, so only the
|
||||
// no-panic contract is asserted here).
|
||||
let _ = upload_rgba16f(2, 1, &[0.0; 8]);
|
||||
}
|
||||
|
||||
/// The half-float packer clamps to [0, 1], writes little-endian f16
|
||||
/// values and pads rows to the 256-byte copy pitch the upload needs.
|
||||
#[test]
|
||||
fn rgba16f_pack_clamps_pins_values_and_pads_rows() {
|
||||
let samples = [1.0, 0.5, 0.0, 1.0, -1.0, 2.0, 0.25, 0.5];
|
||||
let (bytes, pitch) = super::super::frames::f32_rgba_to_16f_bytes(2, 1, &samples)
|
||||
.expect("valid samples");
|
||||
assert_eq!(pitch, 256, "rows are 256-byte aligned for write_texture");
|
||||
assert_eq!(bytes.len(), 256);
|
||||
let word = |i: usize| u16::from_le_bytes([bytes[i], bytes[i + 1]]);
|
||||
assert_eq!(word(0), half::f16::from_f32(1.0).to_bits());
|
||||
assert_eq!(word(2), half::f16::from_f32(0.5).to_bits());
|
||||
assert_eq!(word(4), half::f16::from_f32(0.0).to_bits());
|
||||
assert_eq!(word(6), half::f16::from_f32(1.0).to_bits());
|
||||
// Clamping: -1 → 0, 2 → 1.
|
||||
assert_eq!(word(8), half::f16::from_f32(0.0).to_bits());
|
||||
assert_eq!(word(10), half::f16::from_f32(1.0).to_bits());
|
||||
assert_eq!(word(12), half::f16::from_f32(0.25).to_bits());
|
||||
assert_eq!(word(14), half::f16::from_f32(0.5).to_bits());
|
||||
// Wrong sample counts decline.
|
||||
assert!(super::super::frames::f32_rgba_to_16f_bytes(2, 1, &[0.0; 7]).is_none());
|
||||
}
|
||||
|
||||
/// The display LUT covers the configured grid with finite, deterministic
|
||||
/// data (the CPU reference is the GPU path's ground truth).
|
||||
#[test]
|
||||
fn display_lut_grid_is_complete_finite_and_deterministic() {
|
||||
let _lock = crate::oakui::graphops::test_lock();
|
||||
let key = display_lut_key();
|
||||
let lut = build_display_lut();
|
||||
assert_eq!(lut.edge, oak_core::lut::Lut3d::DISPLAY_EDGE);
|
||||
let n = (lut.edge as usize).pow(3);
|
||||
assert_eq!(lut.data.len(), n * 3, "three channels per grid point");
|
||||
assert!(
|
||||
lut.data.iter().all(|v| v.is_finite()),
|
||||
"the LUT contains only finite samples"
|
||||
);
|
||||
assert!(
|
||||
lut.data.iter().any(|v| *v != 0.0),
|
||||
"the LUT is not an all-zero placeholder"
|
||||
);
|
||||
if display_lut_key() == key {
|
||||
let again = build_display_lut();
|
||||
assert_eq!(lut.data, again.data, "the build is deterministic");
|
||||
}
|
||||
}
|
||||
|
||||
/// The cache key follows the display-color generation and the project
|
||||
/// color settings.
|
||||
#[test]
|
||||
fn display_lut_key_tracks_color_settings() {
|
||||
let _lock = crate::oakui::graphops::test_lock();
|
||||
let key = display_lut_key();
|
||||
assert!(
|
||||
key.starts_with(&crate::oakui::displaycolor::generation().to_string()),
|
||||
"the generation is the first key component: {key}"
|
||||
);
|
||||
assert!(key.contains(&format!("{:?}", oak_core::color::pipeline_working_space())));
|
||||
assert!(key.contains(&format!("{:?}", oak_core::color::pipeline_output_spec())));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4057,4 +4057,768 @@ mod tests {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- settings / proxy / sync / multicam paths ------------------------
|
||||
|
||||
use crate::oakui::engine::ProxyMediaState;
|
||||
|
||||
/// Settings round trips: proxy toggle, OCIO config validation and the
|
||||
/// cache-location clamp/trim rules.
|
||||
#[gpui::test]
|
||||
async fn settings_and_cache_location_round_trip(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
engine.update(app, |e, cx| {
|
||||
let use_proxy = e.use_proxy_media();
|
||||
e.set_use_proxy_media(!use_proxy, cx);
|
||||
assert_eq!(e.use_proxy_media(), !use_proxy);
|
||||
|
||||
// An empty path clears the override; a bogus path is
|
||||
// rejected like the real engine's OCIO validation.
|
||||
assert!(e.set_project_ocio_config(String::new(), cx).is_ok());
|
||||
assert_eq!(e.project_ocio_config(), "");
|
||||
assert!(e
|
||||
.set_project_ocio_config("/definitely/not/a/config".into(), cx)
|
||||
.is_err());
|
||||
|
||||
// Setting 2 keeps the trimmed custom path; other settings
|
||||
// clear it, and out-of-range codes clamp to 0..=2.
|
||||
e.set_project_cache_location(2, " /tmp/oak-cache ".into(), cx);
|
||||
assert_eq!(e.project_cache_location(), (2, "/tmp/oak-cache".to_string()));
|
||||
// 9 clamps to 2 (custom) and keeps the trimmed path...
|
||||
e.set_project_cache_location(9, " /ignored ".into(), cx);
|
||||
assert_eq!(e.project_cache_location(), (2, "/ignored".to_string()));
|
||||
// ...while a negative code clamps to 0 and clears it.
|
||||
e.set_project_cache_location(-5, "/ignored".into(), cx);
|
||||
assert_eq!(e.project_cache_location(), (0, String::new()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Proxy rows/states/generation/deletion and the custom-parameter
|
||||
/// storage paths.
|
||||
#[gpui::test]
|
||||
async fn proxy_paths(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
engine.update(app, |e, cx| {
|
||||
let rows = e.proxy_rows();
|
||||
assert!(!rows.is_empty(), "the demo library lists footage");
|
||||
let video = rows
|
||||
.iter()
|
||||
.find(|row| row.can_generate)
|
||||
.cloned()
|
||||
.expect("a video row");
|
||||
let audio = rows.iter().find(|row| !row.can_generate).cloned();
|
||||
|
||||
assert_eq!(e.proxy_state(u64::MAX), None);
|
||||
assert!(e.proxy_generate(u64::MAX, cx).is_err());
|
||||
assert!(e.proxy_row(u64::MAX).is_none());
|
||||
if let Some(audio) = &audio {
|
||||
assert!(
|
||||
e.proxy_generate(audio.id, cx).is_err(),
|
||||
"audio-only footage cannot generate a proxy"
|
||||
);
|
||||
}
|
||||
|
||||
// Generate → ready/enabled, then flip and delete.
|
||||
assert_eq!(e.proxy_state(video.id), Some(ProxyMediaState::Missing));
|
||||
e.proxy_generate(video.id, cx).expect("proxy generates");
|
||||
assert_eq!(e.proxy_state(video.id), Some(ProxyMediaState::Ready));
|
||||
let row = e.proxy_row(video.id).expect("the row reflects the state");
|
||||
assert!(row.has_proxy && row.enabled);
|
||||
|
||||
let params = crate::oakui::engine::ProxyParamsUi {
|
||||
width: 320,
|
||||
height: 180,
|
||||
divider: 1,
|
||||
crf: 18,
|
||||
preset: "veryfast".into(),
|
||||
include_audio: true,
|
||||
};
|
||||
e.proxy_set_custom_params(video.id, params.clone(), cx);
|
||||
assert_eq!(e.proxy_custom_params(video.id), Some(params));
|
||||
assert!(e.proxy_row(video.id).unwrap().has_custom);
|
||||
e.proxy_clear_custom_params(video.id, cx);
|
||||
assert_eq!(e.proxy_custom_params(video.id), None);
|
||||
assert!(!e.proxy_row(video.id).unwrap().has_custom);
|
||||
|
||||
e.proxy_set_enabled(video.id, false, cx);
|
||||
assert!(!e.proxy_row(video.id).unwrap().enabled);
|
||||
e.proxy_reveal(video.id);
|
||||
|
||||
e.proxy_delete(video.id, cx);
|
||||
assert_eq!(e.proxy_state(video.id), Some(ProxyMediaState::Missing));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Project lifecycle mutators, generator/footage drops, text footage,
|
||||
/// the work area and every effect-stack event arm.
|
||||
#[gpui::test]
|
||||
async fn engine_mutator_and_effect_event_paths(cx: &mut TestAppContext) {
|
||||
let _lock = crate::oakui::graphops::test_lock();
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
engine.update(app, |e, cx| {
|
||||
// Project lifecycle.
|
||||
e.new_project(cx);
|
||||
assert!(e
|
||||
.open_project_path(std::path::PathBuf::from("/tmp/demo.ove"), cx)
|
||||
.is_ok());
|
||||
assert_eq!(e.project.path, std::path::PathBuf::from("/tmp/demo.ove"));
|
||||
assert!(e
|
||||
.import_footage(std::path::PathBuf::from("/tmp/clip.mp4"), cx)
|
||||
.is_ok());
|
||||
assert!(e.imported_footage.iter().any(|p| p.ends_with("clip.mp4")));
|
||||
|
||||
let text = e.create_text_footage(cx).expect("text footage");
|
||||
assert!(e.text_footages.contains(&text));
|
||||
|
||||
// Work area preview + clear.
|
||||
e.set_workarea_preview(Frame(1), Frame(9), cx);
|
||||
assert_eq!(e.workarea(), Some((Frame(1), Frame(9))));
|
||||
e.clear_workarea(cx);
|
||||
assert_eq!(e.workarea(), None);
|
||||
|
||||
// Generator drop: the label resolves through the effect
|
||||
// table and an out-of-range track auto-selects video.
|
||||
let generator = crate::oakui::effectchain::addable_effects()
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("an addable generator");
|
||||
e.drop_generator_clip(&generator.type_id, usize::MAX, Frame(3), cx)
|
||||
.expect("auto-selects a video track");
|
||||
assert!(e
|
||||
.tracks
|
||||
.iter()
|
||||
.any(|t| t.kind == TrackKind::Video && !t.clips.is_empty()));
|
||||
assert!(e.drop_transition_at("x", 0, Frame(0), cx).is_ok());
|
||||
|
||||
// Footage drops: an unknown id is a no-op; audio footage
|
||||
// auto-selects an audio track even when pointed at video.
|
||||
let rows = e.proxy_rows();
|
||||
let video = rows
|
||||
.iter()
|
||||
.find(|row| row.can_generate)
|
||||
.cloned()
|
||||
.expect("a video entry");
|
||||
let audio = rows.iter().find(|row| !row.can_generate).cloned();
|
||||
e.drop_footage(u64::MAX, TrackKind::Video, 0, Frame(0), cx);
|
||||
e.drop_footage(video.id, TrackKind::Video, usize::MAX, Frame(0), cx);
|
||||
if let Some(audio) = audio {
|
||||
e.drop_footage(audio.id, TrackKind::Video, 0, Frame(0), cx);
|
||||
}
|
||||
|
||||
// Effect-stack events.
|
||||
let before = e.effects.len();
|
||||
e.apply_effect_event(
|
||||
&EffectStackEvent::AddRequested { index: usize::MAX },
|
||||
cx,
|
||||
);
|
||||
assert_eq!(e.effects.len(), before + 1);
|
||||
let id = e.effects.last().unwrap().id;
|
||||
e.apply_effect_event(
|
||||
&EffectStackEvent::AddTypeRequested {
|
||||
index: 0,
|
||||
type_id: "Invert".into(),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
assert_eq!(e.effects.first().unwrap().title, "Invert");
|
||||
|
||||
e.apply_effect_event(
|
||||
&EffectStackEvent::EnableToggled {
|
||||
effect: id,
|
||||
enabled: false,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
assert!(!e.effects.last().unwrap().enabled);
|
||||
// Unknown ids are ignored.
|
||||
e.apply_effect_event(
|
||||
&EffectStackEvent::EnableToggled {
|
||||
effect: EffectId(9999),
|
||||
enabled: true,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
e.apply_effect_event(
|
||||
&EffectStackEvent::ExpansionToggled {
|
||||
effect: id,
|
||||
expanded: true,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
assert!(e.effects.last().unwrap().expanded);
|
||||
e.apply_effect_event(
|
||||
&EffectStackEvent::ExpansionToggled {
|
||||
effect: EffectId(9999),
|
||||
expanded: false,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
|
||||
e.apply_effect_event(&EffectStackEvent::CardSelected { effect: id }, cx);
|
||||
e.apply_effect_event(
|
||||
&EffectStackEvent::ReorderRequested {
|
||||
effect: id,
|
||||
new_index: usize::MAX,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
assert_eq!(e.effects.last().unwrap().id, id);
|
||||
e.apply_effect_event(
|
||||
&EffectStackEvent::ReorderRequested {
|
||||
effect: EffectId(9999),
|
||||
new_index: 0,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
e.apply_effect_event(
|
||||
&EffectStackEvent::ContextMenuRequested {
|
||||
effect: id,
|
||||
position: gpui::Point::new(px(0.0), px(0.0)),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
e.apply_effect_event(&EffectStackEvent::ParameterChanged { effect: id }, cx);
|
||||
// Unknown ids are ignored; a known removable card goes.
|
||||
e.apply_effect_event(&EffectStackEvent::RemoveRequested(EffectId(9999)), cx);
|
||||
e.apply_effect_event(&EffectStackEvent::RemoveRequested(id), cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Sync eligibility/commands, clip→footage mapping, the multicam
|
||||
/// surface and the demo's unsupported default transition.
|
||||
#[gpui::test]
|
||||
async fn sync_and_multicam_paths(cx: &mut TestAppContext) {
|
||||
let _lock = crate::oakui::graphops::test_lock();
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
engine.update(app, |e, cx| {
|
||||
let clips: Vec<ClipId> = e
|
||||
.track(0)
|
||||
.map(|track| track.clips().iter().map(|clip| clip.id()).collect())
|
||||
.unwrap_or_default();
|
||||
let eligibility = e.sync_eligibility(&clips);
|
||||
assert!(eligibility.source_time <= clips.len());
|
||||
e.sync_clips_by_source_time(clips.clone(), cx);
|
||||
e.sync_clips_by_waveform(clips.clone(), false, cx);
|
||||
e.sync_clips_by_waveform(clips.clone(), true, cx);
|
||||
let entries = e.clip_footage_entries(&clips);
|
||||
assert!(entries.len() <= clips.len());
|
||||
|
||||
if let Some(state) = e.multicam_state() {
|
||||
assert!(state.source_count >= 1);
|
||||
let _ = e.multicam_angle_frame(0, cx);
|
||||
let _ = e.multicam_angle_frame(-1, cx);
|
||||
assert!(e.multicam_eligible(&clips));
|
||||
assert!(e.multicam_enabled_on_selection(&clips));
|
||||
e.multicam_switch_to(0, false, cx);
|
||||
e.multicam_switch_to(99, false, cx);
|
||||
e.multicam_switch_to(-1, true, cx);
|
||||
e.multicam_enable_selected(vec![], true, cx);
|
||||
e.multicam_enable_selected(vec![], false, cx);
|
||||
}
|
||||
assert!(e.add_default_transition(vec![], cx).is_err());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The node editor's ignored events, background click, unmapped
|
||||
/// selections and the selection helpers.
|
||||
#[gpui::test]
|
||||
async fn node_graph_ignored_events_selection_and_reach_cycles(cx: &mut TestAppContext) {
|
||||
let _lock = crate::oakui::graphops::test_lock();
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
engine.update(app, |e, cx| {
|
||||
// Fire-and-forget events: preview, viewport, context menu.
|
||||
e.apply_node_graph_event(
|
||||
&NodeGraphEvent::NodeMovePreview {
|
||||
nodes: vec![NodeId(5)],
|
||||
delta: point(px(4.0), px(4.0)),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
e.apply_node_graph_event(
|
||||
&NodeGraphEvent::ViewChanged {
|
||||
offset: point(px(10.0), px(10.0)),
|
||||
zoom: 1.5,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
e.apply_node_graph_event(
|
||||
&NodeGraphEvent::NodeContextMenuRequested {
|
||||
node: NodeId(5),
|
||||
position: point(px(0.0), px(0.0)),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
// A background click is logged and ignored.
|
||||
e.apply_node_graph_event(
|
||||
&NodeGraphEvent::BackgroundClicked {
|
||||
position: point(px(3.0), px(3.0)),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
|
||||
// Selection helpers: no selection, then a node with no
|
||||
// matching effect card (NodeId 0 has no 第一稿.mp4 card).
|
||||
assert!(e.selected_node_ids().is_empty());
|
||||
assert_eq!(e.selected_effect(), None);
|
||||
assert_eq!(e.selected_graph_node(), None);
|
||||
e.apply_node_graph_event(
|
||||
&NodeGraphEvent::SelectionChanged {
|
||||
nodes: BTreeSet::from([NodeId(0)]),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
assert_eq!(e.selected_node_ids(), &BTreeSet::from([NodeId(0)]));
|
||||
assert_eq!(e.selected_graph_node(), Some(0));
|
||||
assert_eq!(e.selected_effect(), None, "no card shares the title");
|
||||
|
||||
// Two selected nodes have no single effect target.
|
||||
e.apply_node_graph_event(
|
||||
&NodeGraphEvent::SelectionChanged {
|
||||
nodes: BTreeSet::from([NodeId(2), NodeId(3)]),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
assert_eq!(e.selected_effect(), None);
|
||||
assert_eq!(e.selected_graph_node(), None);
|
||||
e.apply_node_graph_event(
|
||||
&NodeGraphEvent::SelectionChanged {
|
||||
nodes: BTreeSet::from([NodeId(2)]),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
assert_eq!(e.selected_effect(), Some(EffectId(1)), "变换 card");
|
||||
|
||||
// An invalid connection is rejected without touching the
|
||||
// edge list.
|
||||
let before = e.edges.len();
|
||||
e.apply_node_graph_event(
|
||||
&NodeGraphEvent::ConnectionRequested {
|
||||
from: PortId(999),
|
||||
to: PortId(20),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
assert_eq!(e.edges.len(), before, "unknown port rejected");
|
||||
|
||||
// Reachability: a manually-added back edge makes the DFS
|
||||
// revisit a node (the visited guard).
|
||||
e.edges.push(MockEdge {
|
||||
id: EdgeId(99),
|
||||
from_node: NodeId(5),
|
||||
from_port: PortId(50),
|
||||
to_node: NodeId(2),
|
||||
to_port: PortId(20),
|
||||
});
|
||||
assert!(!e.reaches(NodeId(0), NodeId(7)), "no path into the sink");
|
||||
assert!(e.reaches(NodeId(0), NodeId(2)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Every timeline edit event the widget can emit applies to the mock
|
||||
/// model: playhead, both trim edges, split, ripple trim, roll (valid and
|
||||
/// rejected), slide, slip, moves, track height and toggles.
|
||||
#[gpui::test]
|
||||
async fn timeline_edit_events_apply_to_the_mock_model(cx: &mut TestAppContext) {
|
||||
use gpui::timeline::TrackHeaderEvent;
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
let clip = |app: &App, track: usize, id: u64| -> Option<MockClip> {
|
||||
engine
|
||||
.read(app)
|
||||
.track(track)?
|
||||
.clips()
|
||||
.iter()
|
||||
.find(|c| c.id() == ClipId(id))
|
||||
.cloned()
|
||||
};
|
||||
|
||||
// PlayheadChanged: an equal frame is a no-op, a new one seeks.
|
||||
engine.update(app, |e, cx| {
|
||||
e.apply_timeline_event(&TimelineEvent::PlayheadChanged(Frame(0)), cx);
|
||||
assert_eq!(e.clock_frame(Monitor::Program, cx), Frame(0));
|
||||
e.apply_timeline_event(&TimelineEvent::PlayheadChanged(Frame(77)), cx);
|
||||
assert_eq!(e.clock_frame(Monitor::Program, cx), Frame(77));
|
||||
|
||||
// Trim the END edge of B-roll (id 12, 240–600).
|
||||
e.apply_timeline_event(
|
||||
&TimelineEvent::ClipTrimRequested {
|
||||
clip: ClipId(12),
|
||||
edge: TrimEdge::End,
|
||||
new_frame: Frame(700),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
assert_eq!(
|
||||
clip(app, 1, 12).expect("B-roll").range(),
|
||||
FrameRange::new(Frame(240), Frame(700))
|
||||
);
|
||||
|
||||
// Ripple-trim 开场 (id 11) in by 20: the following B-roll shifts
|
||||
// with the edge.
|
||||
engine.update(app, |e, cx| {
|
||||
e.apply_timeline_event(
|
||||
&TimelineEvent::ClipRippleTrimRequested {
|
||||
clip: ClipId(11),
|
||||
edge: TrimEdge::Start,
|
||||
new_frame: Frame(20),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
let clip11 = clip(app, 1, 11).expect("开场");
|
||||
assert_eq!(clip11.range().start, Frame(20));
|
||||
assert_eq!(clip11.media_in(), Frame(20), "media-in follows");
|
||||
assert_eq!(
|
||||
clip(app, 1, 12).expect("B-roll").range(),
|
||||
FrameRange::new(Frame(260), Frame(720)),
|
||||
"the later clip rippled with the edge"
|
||||
);
|
||||
|
||||
// Ripple-trim the B-roll's end out to 800: nothing follows it.
|
||||
engine.update(app, |e, cx| {
|
||||
e.apply_timeline_event(
|
||||
&TimelineEvent::ClipRippleTrimRequested {
|
||||
clip: ClipId(12),
|
||||
edge: TrimEdge::End,
|
||||
new_frame: Frame(800),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
assert_eq!(
|
||||
clip(app, 1, 12).expect("B-roll").range(),
|
||||
FrameRange::new(Frame(260), Frame(800))
|
||||
);
|
||||
|
||||
// Roll the shared boundary of 开场/B-roll to 300.
|
||||
engine.update(app, |e, cx| {
|
||||
e.apply_timeline_event(
|
||||
&TimelineEvent::ClipRollRequested {
|
||||
clip_a: ClipId(11),
|
||||
clip_b: ClipId(12),
|
||||
new_frame: Frame(300),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
// Rejected rolls: unknown clip, boundary outside the span.
|
||||
e.apply_timeline_event(
|
||||
&TimelineEvent::ClipRollRequested {
|
||||
clip_a: ClipId(999),
|
||||
clip_b: ClipId(12),
|
||||
new_frame: Frame(300),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
e.apply_timeline_event(
|
||||
&TimelineEvent::ClipRollRequested {
|
||||
clip_a: ClipId(11),
|
||||
clip_b: ClipId(12),
|
||||
new_frame: Frame(10),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
assert_eq!(clip(app, 1, 11).expect("开场").range().end, Frame(300));
|
||||
assert_eq!(clip(app, 1, 12).expect("B-roll").range().start, Frame(300));
|
||||
|
||||
// A roll across two different tracks is rejected.
|
||||
engine.update(app, |e, cx| {
|
||||
e.apply_timeline_event(
|
||||
&TimelineEvent::ClipRollRequested {
|
||||
clip_a: ClipId(11),
|
||||
clip_b: ClipId(13),
|
||||
new_frame: Frame(100),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
// Split request: split 开场 at 150.
|
||||
e.apply_timeline_event(
|
||||
&TimelineEvent::ClipSplitRequested {
|
||||
clip: ClipId(11),
|
||||
time: Frame(150),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
let v1_clips = engine.read(app).track(1).expect("V1").clips().to_vec();
|
||||
assert_eq!(v1_clips.len(), 3, "开场 split in two");
|
||||
assert!(v1_clips
|
||||
.iter()
|
||||
.any(|c| c.range() == FrameRange::new(Frame(150), Frame(300))));
|
||||
|
||||
// Slide: the clip keeps its length at the new start.
|
||||
engine.update(app, |e, cx| {
|
||||
e.apply_timeline_event(
|
||||
&TimelineEvent::ClipSlideRequested {
|
||||
clip: ClipId(12),
|
||||
new_start: Frame(500),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
// Unknown clip is ignored.
|
||||
e.apply_timeline_event(
|
||||
&TimelineEvent::ClipSlideRequested {
|
||||
clip: ClipId(999),
|
||||
new_start: Frame(0),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
// Slip: position stays, media offset moves.
|
||||
e.apply_timeline_event(
|
||||
&TimelineEvent::ClipSlipRequested {
|
||||
clip: ClipId(12),
|
||||
new_media_in: Frame(42),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
// Unknown clips are ignored by move too.
|
||||
e.apply_timeline_event(
|
||||
&TimelineEvent::ClipMoveRequested {
|
||||
clip: ClipId(999),
|
||||
new_track: 0,
|
||||
new_start: Frame(0),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
let b_roll = clip(app, 1, 12).expect("B-roll");
|
||||
assert_eq!(b_roll.range().start, Frame(500), "slid in time");
|
||||
assert_eq!(b_roll.media_in(), Frame(42), "slipped the media offset");
|
||||
|
||||
// Track height (valid + unknown index) and toggles (solo, and
|
||||
// the unknown-track guard).
|
||||
engine.update(app, |e, cx| {
|
||||
e.apply_timeline_event(
|
||||
&TimelineEvent::TrackHeightChanged {
|
||||
track: 1,
|
||||
height: px(80.0),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
e.apply_timeline_event(
|
||||
&TimelineEvent::TrackHeightChanged {
|
||||
track: 99,
|
||||
height: px(10.0),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
e.apply_timeline_event(
|
||||
&TimelineEvent::TrackToggleRequested {
|
||||
track: 99,
|
||||
toggle: TrackHeaderEvent::ToggleLock,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
e.apply_timeline_event(
|
||||
&TimelineEvent::TrackToggleRequested {
|
||||
track: 3,
|
||||
toggle: TrackHeaderEvent::ToggleSolo,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
assert_eq!(engine.read(app).track(1).expect("V1").height(), px(80.0));
|
||||
assert!(engine.read(app).track(3).expect("A2").is_solo());
|
||||
|
||||
// The direct split_clip API: unknown clip, locked track, an
|
||||
// out-of-range time and a real split.
|
||||
engine.update(app, |e, cx| {
|
||||
e.split_clip(ClipId(999), Frame(10), cx);
|
||||
e.apply_timeline_event(
|
||||
&TimelineEvent::TrackToggleRequested {
|
||||
track: 1,
|
||||
toggle: TrackHeaderEvent::ToggleLock,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
e.split_clip(ClipId(11), Frame(250), cx);
|
||||
assert_eq!(
|
||||
e.tracks[1].clips.len(),
|
||||
3,
|
||||
"a locked track rejects the split"
|
||||
);
|
||||
e.apply_timeline_event(
|
||||
&TimelineEvent::TrackToggleRequested {
|
||||
track: 1,
|
||||
toggle: TrackHeaderEvent::ToggleLock,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
e.split_clip(ClipId(11), Frame(0), cx);
|
||||
assert_eq!(
|
||||
e.tracks[1].clips.len(),
|
||||
3,
|
||||
"a split on the clip edge is a no-op"
|
||||
);
|
||||
e.split_clip(ClipId(11), Frame(100), cx);
|
||||
assert_eq!(e.tracks[1].clips.len(), 4, "the split landed");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Track helpers, clip linking, the sync guard/commands and eligibility.
|
||||
#[gpui::test]
|
||||
async fn track_link_and_sync_helpers(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
engine.update(app, |e, cx| {
|
||||
// add_track: every kind, including subtitles.
|
||||
let before = e.track_count();
|
||||
e.add_track(TrackKind::Subtitle, cx);
|
||||
assert_eq!(e.track_count(), before + 1);
|
||||
assert_eq!(e.tracks.last().unwrap().kind, TrackKind::Subtitle);
|
||||
assert_eq!(e.tracks.last().unwrap().height, px(32.0));
|
||||
|
||||
// set_track_height applies to every track.
|
||||
e.set_track_height(px(72.0), cx);
|
||||
assert!(e.tracks.iter().all(|t| t.height == px(72.0)));
|
||||
|
||||
// Clip links: a single clip is ignored; a pair links
|
||||
// (normalized both ways) and a fully-linked pair unlinks.
|
||||
e.toggle_clip_links(vec![ClipId(1)], cx);
|
||||
assert!(!e.clips_linked(1, 2));
|
||||
e.toggle_clip_links(vec![ClipId(1), ClipId(2)], cx);
|
||||
assert!(e.clips_linked(1, 2) && e.clips_linked(2, 1));
|
||||
e.toggle_clip_links(vec![ClipId(2), ClipId(1)], cx);
|
||||
assert!(!e.clips_linked(1, 2), "a linked pair unlinks");
|
||||
|
||||
// Sync: a locked track's clips are skipped; the unlocked
|
||||
// ones align to the earliest in point (id 13 is audio-only
|
||||
// and id 12 starts later).
|
||||
e.tracks[2].locked = true;
|
||||
let eligibility = e.sync_eligibility(&[ClipId(11), ClipId(13), ClipId(999)]);
|
||||
assert_eq!(
|
||||
eligibility.source_time, 1,
|
||||
"audio and unknown clips do not count"
|
||||
);
|
||||
e.sync_clips_by_source_time(vec![ClipId(11), ClipId(13), ClipId(12)], cx);
|
||||
assert_eq!(e.tracks[1].clips[1].range.start, Frame(0), "B-roll moved");
|
||||
e.tracks[2].locked = false;
|
||||
|
||||
// Sync eligibility for an audio clip alone.
|
||||
let eligibility = e.sync_eligibility(&[ClipId(13)]);
|
||||
assert_eq!(eligibility.source_time, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Adjustment layers, generator/footage drops without a target track,
|
||||
/// `add_effect`'s unknown type, `add_node_at`, the legacy `start_export`
|
||||
/// wrapper and the demo multicam's create/enable/disable paths.
|
||||
#[gpui::test]
|
||||
async fn drop_add_and_legacy_export_paths(cx: &mut TestAppContext) {
|
||||
let _lock = crate::oakui::graphops::test_lock();
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
engine.update(app, |e, cx| {
|
||||
// Adjustment layers: unknown track, non-video track, then a
|
||||
// video track (inserted in ascending start order).
|
||||
assert!(e.add_adjustment_layer(99, Frame(0), cx).is_err());
|
||||
assert!(e.add_adjustment_layer(2, Frame(0), cx).is_err());
|
||||
e.add_adjustment_layer(0, Frame(200), cx)
|
||||
.expect("video track accepts");
|
||||
assert!(e.tracks[0]
|
||||
.clips
|
||||
.iter()
|
||||
.any(|c| c.label == "调整图层"));
|
||||
|
||||
// Unknown effect type keeps the stack unchanged.
|
||||
let before = e.effects.len();
|
||||
assert!(e.add_effect(0, "no-such-effect", cx).is_err());
|
||||
assert_eq!(e.effects.len(), before);
|
||||
|
||||
// A generator drop with the factory table unknown falls back
|
||||
// to the raw type id as the label.
|
||||
e.drop_generator_clip("not-in-the-table", 0, Frame(0), cx)
|
||||
.expect("video track exists");
|
||||
assert!(e
|
||||
.tracks
|
||||
.iter()
|
||||
.any(|t| t.clips.iter().any(|c| c.label == "not-in-the-table")));
|
||||
|
||||
// No video track at all: both generator and video footage
|
||||
// drops are rejected.
|
||||
let keep: Vec<MockTrack> = e
|
||||
.tracks
|
||||
.iter()
|
||||
.filter(|t| t.kind != TrackKind::Video)
|
||||
.cloned()
|
||||
.collect();
|
||||
e.tracks = keep;
|
||||
assert!(e.drop_generator_clip("x", 0, Frame(0), cx).is_err());
|
||||
e.drop_footage(3, TrackKind::Video, 0, Frame(0), cx);
|
||||
assert!(e.footage_drops.is_empty());
|
||||
});
|
||||
|
||||
// Audio footage with no audio track reports and drops nothing.
|
||||
let engine2 = demo_engine(app);
|
||||
engine2.update(app, |e, cx| {
|
||||
e.tracks.retain(|t| t.kind != TrackKind::Audio);
|
||||
let before = e.footage_drops.len();
|
||||
e.drop_footage(20, TrackKind::Audio, 0, Frame(0), cx);
|
||||
assert_eq!(e.footage_drops.len(), before, "no audio track");
|
||||
// An unknown entry id is rejected too.
|
||||
e.drop_footage(u64::MAX, TrackKind::Video, 0, Frame(0), cx);
|
||||
});
|
||||
|
||||
// add_node_at: unknown type errors; a known one gets one video
|
||||
// input and one video output.
|
||||
engine.update(app, |e, cx| {
|
||||
assert!(e.add_node_at("no-such-node", point(px(0.0), px(0.0)), cx).is_err());
|
||||
let entry = e.node_library().into_iter().next().expect("a node type");
|
||||
e.add_node_at(&entry.type_id, point(px(12.0), px(34.0)), cx)
|
||||
.expect("known node type");
|
||||
let added = e.node(NodeId(100)).expect("the created node");
|
||||
assert_eq!(added.title().as_ref(), entry.name.as_str());
|
||||
assert_eq!(added.position(), point(px(12.0), px(34.0)));
|
||||
assert_eq!(added.inputs().len(), 1);
|
||||
assert_eq!(added.outputs().len(), 1);
|
||||
assert!(added.endpoint().is_none());
|
||||
|
||||
// The legacy start_export wrapper delegates and succeeds.
|
||||
let session = e.start_export(0, std::path::PathBuf::from("/tmp/x.mp4"));
|
||||
assert!(session.is_ok());
|
||||
drop(session);
|
||||
|
||||
// The multicam sequence create only renames an unnamed demo
|
||||
// sequence.
|
||||
e.sequence.name = String::new();
|
||||
assert_eq!(
|
||||
e.multicam_create_sequence(vec![], vec![], "Cam".into(), cx),
|
||||
Ok(1)
|
||||
);
|
||||
assert_eq!(e.sequence.name, "Cam");
|
||||
assert_eq!(
|
||||
e.multicam_create_sequence(vec![], vec![], "Other".into(), cx),
|
||||
Ok(1)
|
||||
);
|
||||
assert_eq!(e.sequence.name, "Cam", "a named sequence is kept");
|
||||
});
|
||||
|
||||
// Demo multicam enable/disable run the real commands (the demo
|
||||
// starts enabled, so the first enable is a no-op; disable and
|
||||
// re-enable cover both command paths).
|
||||
let engine3 = demo_engine(app);
|
||||
engine3.update(app, |e, cx| {
|
||||
assert!(e.multicam_state().is_some());
|
||||
e.multicam_enable_selected(vec![], true, cx);
|
||||
assert!(e.multicam_state().is_some(), "enable is a no-op");
|
||||
e.multicam_enable_selected(vec![], false, cx);
|
||||
assert!(e.multicam_state().is_none(), "disabled");
|
||||
e.multicam_enable_selected(vec![], true, cx);
|
||||
assert!(e.multicam_state().is_some(), "enabled again");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,6 +867,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::effectchain;
|
||||
use crate::oakui::graphops;
|
||||
use gpui::node_graph::NodeGraphEvent;
|
||||
use oak_node::track::TrackType;
|
||||
use oak_node::value::ValueType;
|
||||
|
||||
/// Serializes with the other app test modules (the process-global undo
|
||||
/// stack).
|
||||
@@ -874,6 +877,552 @@ mod tests {
|
||||
crate::oakui::graphops::test_lock()
|
||||
}
|
||||
|
||||
/// Places an already-created block node onto the sequence's first video
|
||||
/// track through the same undoable command the timeline drag uses (so
|
||||
/// the block really gets its outgoing structural edges).
|
||||
fn place_on_first_video_track(p: &ProjectRef, seq: DomainNodeId, block: DomainNodeId) {
|
||||
let (list, index, in_r) = {
|
||||
let g = graphops::lock(p);
|
||||
let track = graphops::track_ids(&g.graph, seq, TrackType::Video)
|
||||
.first()
|
||||
.copied()
|
||||
.expect("the sequence has a video track");
|
||||
let list = graphops::track_behavior(&g.graph, track)
|
||||
.and_then(|t| t.track_list)
|
||||
.expect("the track has a list");
|
||||
let index = graphops::track_list_behavior(&g.graph, list)
|
||||
.and_then(|l| l.tracks.iter().position(|&t| t == track))
|
||||
.expect("the track is in its list") as i32;
|
||||
let tb = graphops::sequence_time_base(&g.graph, seq).expect("the sequence timebase");
|
||||
(list, index, graphops::ts_to_rational(0, tb))
|
||||
};
|
||||
graphops::push_command(
|
||||
oak_timeline::undopointer::TrackPlaceBlockCommand::new(
|
||||
graphops::node_ref(p, list),
|
||||
index,
|
||||
graphops::node_ref(p, block),
|
||||
in_r,
|
||||
)
|
||||
.to_command(),
|
||||
"Place Clip",
|
||||
)
|
||||
.expect("place the block");
|
||||
}
|
||||
|
||||
/// The `NodeData` / `PortData` / `EdgeData` accessor impls expose their
|
||||
/// fields, and the port-id packing round-trips (including the output
|
||||
/// tag).
|
||||
#[test]
|
||||
fn data_traits_and_port_packing_expose_the_fields() {
|
||||
let node = RealNode {
|
||||
id: NodeId(7),
|
||||
title: "Card".into(),
|
||||
position: point(px(1.0), px(2.0)),
|
||||
inputs: Vec::new(),
|
||||
outputs: Vec::new(),
|
||||
header_color: Some(hsla(0.5, 0.5, 0.5, 1.0)),
|
||||
collapsed: true,
|
||||
enabled: false,
|
||||
endpoint: Some(GraphEndpoint::Input),
|
||||
};
|
||||
assert_eq!(NodeData::id(&node), NodeId(7));
|
||||
assert_eq!(NodeData::title(&node).as_ref(), "Card");
|
||||
assert_eq!(NodeData::position(&node), point(px(1.0), px(2.0)));
|
||||
assert!(NodeData::inputs(&node).is_empty());
|
||||
assert!(NodeData::outputs(&node).is_empty());
|
||||
assert_eq!(NodeData::header_color(&node), Some(hsla(0.5, 0.5, 0.5, 1.0)));
|
||||
assert!(NodeData::is_collapsed(&node));
|
||||
assert!(!NodeData::is_enabled(&node));
|
||||
|
||||
let port = RealPort {
|
||||
id: PortId(9),
|
||||
kind: PortKind::Input,
|
||||
input_id: "tex_in".into(),
|
||||
label: "Texture".into(),
|
||||
data_type: video_type(),
|
||||
connected: true,
|
||||
};
|
||||
assert_eq!(PortData::id(&port), PortId(9));
|
||||
assert_eq!(PortData::kind(&port), PortKind::Input);
|
||||
assert_eq!(PortData::label(&port).as_ref(), "Texture");
|
||||
assert_eq!(PortData::data_type(&port).name.as_ref(), "video");
|
||||
assert!(PortData::is_connected(&port));
|
||||
|
||||
let edge = RealEdge {
|
||||
id: EdgeId(3),
|
||||
from_node: NodeId(1),
|
||||
from_port: PortId(5),
|
||||
to_node: NodeId(2),
|
||||
to_port: PortId(11),
|
||||
};
|
||||
assert_eq!(EdgeData::id(&edge), EdgeId(3));
|
||||
assert_eq!(EdgeData::from_node(&edge), NodeId(1));
|
||||
assert_eq!(EdgeData::from_port(&edge), PortId(5));
|
||||
assert_eq!(EdgeData::to_node(&edge), NodeId(2));
|
||||
assert_eq!(EdgeData::to_port(&edge), PortId(11));
|
||||
|
||||
// Output ports pack `(ident << 4) | 1` (index 0), inputs keep their
|
||||
// declaration index.
|
||||
assert_eq!(
|
||||
unpack_port(port_id(7, PortKind::Output, 3)),
|
||||
(7, PortKind::Output, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
unpack_port(port_id(7, PortKind::Input, 3)),
|
||||
(7, PortKind::Input, 3)
|
||||
);
|
||||
assert!(is_output_wire(output_wire_id(7)));
|
||||
assert!(!is_output_wire(real_edge_id(1, 2, "tex_in")));
|
||||
|
||||
// Endpoint type ids round-trip; anything else is no endpoint.
|
||||
use oak_node::nodes::graphendpoints::{GRAPH_INPUT_TYPE_ID, GRAPH_OUTPUT_TYPE_ID};
|
||||
assert_eq!(
|
||||
endpoint_of_type_id(GRAPH_INPUT_TYPE_ID),
|
||||
Some(GraphEndpoint::Input)
|
||||
);
|
||||
assert_eq!(
|
||||
endpoint_of_type_id(GRAPH_OUTPUT_TYPE_ID),
|
||||
Some(GraphEndpoint::Output)
|
||||
);
|
||||
assert_eq!(endpoint_of_type_id(TYPE_ID_CLIP_BLOCK), None);
|
||||
assert_eq!(endpoint_color(GraphEndpoint::Output), hsla(0.85, 0.65, 0.45, 1.0));
|
||||
assert_eq!(node_color(9), node_color(1));
|
||||
assert!(role_of(TYPE_ID_FOOTAGE, false) == 0);
|
||||
assert!(role_of(TYPE_ID_CLIP_BLOCK, false) == 2);
|
||||
assert!(role_of("x", true) == 3);
|
||||
}
|
||||
|
||||
/// Builder rejection and placement arms: invalid sequence/clip ids
|
||||
/// yield empty graphs, persisted context positions win over the role
|
||||
/// grid, an empty sequence label falls back to the behavior name and
|
||||
/// the per-clip chain skips edges whose target is outside the view.
|
||||
#[test]
|
||||
fn build_graph_handles_placement_and_invalid_ids() {
|
||||
let _g = stack_lock();
|
||||
oak_undo::global::clear().unwrap();
|
||||
let (project, seq, clip, _effect, _footage) = project_with_chained_clip();
|
||||
|
||||
// Invalid sequence / clip identities: id_of accepts any non-sentinel
|
||||
// identity, the graph validity check rejects it.
|
||||
let bogus = graphops::id_of(12_345).expect("a syntactically valid id");
|
||||
assert!(build_graph(&project, bogus).0.is_empty());
|
||||
assert!(build_graph_for_clip(&project, seq, 12_345).0.is_empty());
|
||||
// The invalid sentinel has no domain id at all.
|
||||
assert!(build_graph_for_clip(&project, seq, u64::from(u32::MAX)).0.is_empty());
|
||||
|
||||
// Persist a context position: the builder must use it (not the
|
||||
// fallback grid), and the fallback skips placed nodes.
|
||||
graphops::push_command(
|
||||
graphops::set_context_position_command(&project, clip, seq, 120.0, 60.0)
|
||||
.expect("the position command"),
|
||||
"Set Position",
|
||||
)
|
||||
.expect("apply the position");
|
||||
let (nodes, _) = build_graph(&project, seq);
|
||||
let placed = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == NodeId(clip.identity()))
|
||||
.expect("the clip card");
|
||||
assert_eq!(placed.position, point(px(120.0), px(60.0)));
|
||||
let unplaced = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == NodeId(_footage.identity()))
|
||||
.expect("the footage card");
|
||||
assert_ne!(unplaced.position, point(px(0.0), px(0.0)));
|
||||
|
||||
// An empty sequence label falls back to the behavior name.
|
||||
{
|
||||
let mut g = graphops::lock(&project);
|
||||
g.graph.get_mut(seq).expect("the sequence node").core.label.clear();
|
||||
}
|
||||
let (nodes, _) = build_graph(&project, seq);
|
||||
let seq_card = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == NodeId(seq.identity()))
|
||||
.expect("the sequence card");
|
||||
assert!(!seq_card.title.is_empty());
|
||||
|
||||
// A placed clip's outgoing wire targets a track that is not part of
|
||||
// the per-clip view, so the edge is skipped.
|
||||
let placed_clip = {
|
||||
let mut g = graphops::lock(&project);
|
||||
let (core, behavior) = oak_node::block::clip_create();
|
||||
g.graph.add_node(core, behavior)
|
||||
};
|
||||
place_on_first_video_track(&project, seq, placed_clip);
|
||||
let (chain, chain_edges) = build_graph_for_clip(&project, seq, placed_clip.identity());
|
||||
assert!(!chain.is_empty(), "the placed clip card exists");
|
||||
assert!(
|
||||
chain_edges.iter().all(|e| e.to_node != NodeId(seq.identity())),
|
||||
"the structural clip -> track edge does not leak into the clip view"
|
||||
);
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// Stream-input port filtering: an empty display name falls back to the
|
||||
/// input id, an empty input id drops the port entirely.
|
||||
#[test]
|
||||
fn build_graph_filters_stream_input_ports() {
|
||||
let _g = stack_lock();
|
||||
oak_undo::global::clear().unwrap();
|
||||
let (project, seq, clip, _, _) = project_with_chained_clip();
|
||||
|
||||
// The clip's texture input (a stream input that is not hidden).
|
||||
let input_index = {
|
||||
let g = graphops::lock(&project);
|
||||
g.graph
|
||||
.get(clip)
|
||||
.expect("the clip node")
|
||||
.core
|
||||
.inputs
|
||||
.iter()
|
||||
.position(|i| {
|
||||
matches!(
|
||||
i.value_type,
|
||||
ValueType::Texture | ValueType::Samples
|
||||
) && i.flags & oak_node::input::flags::HIDDEN == 0
|
||||
})
|
||||
.expect("the clip has a stream input")
|
||||
};
|
||||
|
||||
// Empty display name: the label falls back to the input id.
|
||||
{
|
||||
let mut g = graphops::lock(&project);
|
||||
let input = &mut g.graph.get_mut(clip).expect("clip").core.inputs[input_index];
|
||||
input.display_name.clear();
|
||||
input.id = "tex_in".to_string();
|
||||
}
|
||||
let (nodes, _) = build_graph(&project, seq);
|
||||
let card = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == NodeId(clip.identity()))
|
||||
.expect("the clip card");
|
||||
let port = card
|
||||
.inputs
|
||||
.iter()
|
||||
.find(|p| p.input_id.as_ref() == "tex_in")
|
||||
.expect("the tex_in port");
|
||||
assert_eq!(port.label.as_ref(), "tex_in");
|
||||
|
||||
// Empty input id: the port is dropped.
|
||||
{
|
||||
let mut g = graphops::lock(&project);
|
||||
g.graph.get_mut(clip).expect("clip").core.inputs[input_index]
|
||||
.id
|
||||
.clear();
|
||||
}
|
||||
let (nodes, _) = build_graph(&project, seq);
|
||||
let card = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == NodeId(clip.identity()))
|
||||
.expect("the clip card");
|
||||
assert!(
|
||||
card.inputs.iter().all(|p| !p.input_id.is_empty()),
|
||||
"an input with an empty id is not exposed"
|
||||
);
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// The connection rules reject wrong directions, same-node pairs,
|
||||
/// unknown nodes, missing/empty target inputs and already-connected
|
||||
/// inputs, and accept a free stream input.
|
||||
#[test]
|
||||
fn can_connect_rejects_the_invalid_pairs() {
|
||||
let _g = stack_lock();
|
||||
oak_undo::global::clear().unwrap();
|
||||
let (project, _seq, _clip, effect, footage) = project_with_chained_clip();
|
||||
|
||||
let (effect_input_id, effect_input_index) = {
|
||||
let g = graphops::lock(&project);
|
||||
let entry = g.graph.get(effect).expect("the effect node");
|
||||
let index = entry
|
||||
.core
|
||||
.inputs
|
||||
.iter()
|
||||
.position(|i| i.id == entry.core.effect_input)
|
||||
.expect("the effect input");
|
||||
(entry.core.effect_input.clone(), index)
|
||||
};
|
||||
let effect_input = port_id(effect.identity(), PortKind::Input, effect_input_index as u32);
|
||||
let footage_out = port_id(footage.identity(), PortKind::Output, 0);
|
||||
let clip_out = port_id(_clip.identity(), PortKind::Output, 0);
|
||||
let clip_input = port_id(_clip.identity(), PortKind::Input, 0);
|
||||
|
||||
// Wrong direction and same-node pairs.
|
||||
assert!(!can_connect(&project, clip_input, effect_input));
|
||||
assert!(!can_connect(&project, clip_out, clip_input));
|
||||
// Unknown target node (a syntactically valid, non-arena identity).
|
||||
assert!(!can_connect(
|
||||
&project,
|
||||
clip_out,
|
||||
port_id(u64::from(u32::MAX) - 1, PortKind::Input, 0)
|
||||
));
|
||||
// Declaration index past the end of the target's inputs (the clip
|
||||
// declares far fewer than eight inputs).
|
||||
assert!(!can_connect(
|
||||
&project,
|
||||
clip_out,
|
||||
port_id(_clip.identity(), PortKind::Input, 7)
|
||||
));
|
||||
// The footage -> effect wire already exists: the target is busy.
|
||||
assert!(!can_connect(&project, footage_out, effect_input));
|
||||
assert!(!effect_input_id.is_empty());
|
||||
// A free clip -> sequence input is accepted (the sequence's tex_in).
|
||||
let seq_input = {
|
||||
let g = graphops::lock(&project);
|
||||
g.graph
|
||||
.get(_seq)
|
||||
.expect("the sequence node")
|
||||
.core
|
||||
.inputs
|
||||
.iter()
|
||||
.position(|i| {
|
||||
matches!(i.value_type, ValueType::Texture | ValueType::Samples)
|
||||
&& !i.id.is_empty()
|
||||
})
|
||||
.map(|index| port_id(_seq.identity(), PortKind::Input, index as u32))
|
||||
.expect("the sequence has a stream input")
|
||||
};
|
||||
assert!(can_connect(&project, clip_out, seq_input));
|
||||
|
||||
// Empty input id: rejected.
|
||||
{
|
||||
let mut g = graphops::lock(&project);
|
||||
g.graph.get_mut(effect).expect("effect").core.inputs
|
||||
[effect_input_index]
|
||||
.id
|
||||
.clear();
|
||||
}
|
||||
assert!(!can_connect(&project, clip_out, effect_input));
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// `apply_edit` covers every event: connection rejection/success,
|
||||
/// synthesized-wire disconnection, real-edge removal, node moves (both
|
||||
/// the fallback base and the persisted position) and node deletion
|
||||
/// (including the protected sequence and the no-op preview event).
|
||||
#[test]
|
||||
fn apply_edit_covers_connect_move_and_delete() {
|
||||
let _g = stack_lock();
|
||||
oak_undo::global::clear().unwrap();
|
||||
let (project, seq, clip, effect, _footage) = project_with_chained_clip();
|
||||
|
||||
// Wrong endpoint kinds.
|
||||
let err = apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::ConnectionRequested {
|
||||
from: port_id(clip.identity(), PortKind::Input, 0),
|
||||
to: port_id(seq.identity(), PortKind::Input, 0),
|
||||
},
|
||||
)
|
||||
.expect_err("input -> input is invalid");
|
||||
assert!(err.contains("output"));
|
||||
|
||||
// Unknown source node.
|
||||
assert!(apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::ConnectionRequested {
|
||||
from: port_id(123_456, PortKind::Output, 0),
|
||||
to: port_id(seq.identity(), PortKind::Input, 0),
|
||||
},
|
||||
)
|
||||
.is_err());
|
||||
|
||||
// Target input index missing (the clip declares fewer than eight
|
||||
// inputs).
|
||||
assert!(apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::ConnectionRequested {
|
||||
from: port_id(seq.identity(), PortKind::Output, 0),
|
||||
to: port_id(clip.identity(), PortKind::Input, 7),
|
||||
},
|
||||
)
|
||||
.is_err());
|
||||
|
||||
// A real connection: the clip output into the sequence's tex_in.
|
||||
let seq_input = {
|
||||
let g = graphops::lock(&project);
|
||||
g.graph
|
||||
.get(seq)
|
||||
.expect("the sequence node")
|
||||
.core
|
||||
.inputs
|
||||
.iter()
|
||||
.position(|i| {
|
||||
matches!(i.value_type, ValueType::Texture | ValueType::Samples)
|
||||
&& !i.id.is_empty()
|
||||
})
|
||||
.map(|index| port_id(seq.identity(), PortKind::Input, index as u32))
|
||||
.expect("the sequence has a stream input")
|
||||
};
|
||||
apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::ConnectionRequested {
|
||||
from: port_id(clip.identity(), PortKind::Output, 0),
|
||||
to: seq_input,
|
||||
},
|
||||
)
|
||||
.expect("the connection applies");
|
||||
|
||||
// Disconnecting the synthesized wire is a no-op.
|
||||
apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::DisconnectionRequested {
|
||||
edge: output_wire_id(clip.identity()),
|
||||
},
|
||||
)
|
||||
.expect("the structural wire is ignored");
|
||||
// An unknown edge id errors.
|
||||
assert!(apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::DisconnectionRequested {
|
||||
edge: EdgeId(999_999),
|
||||
},
|
||||
)
|
||||
.is_err());
|
||||
// The real edge disconnects by its stable id.
|
||||
let edge_id = real_edge_id(clip.identity(), seq.identity(), "tex_in");
|
||||
apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::DisconnectionRequested { edge: edge_id },
|
||||
)
|
||||
.expect("the real edge disconnects");
|
||||
|
||||
// A move of an unplaced node: the fallback grid base plus delta.
|
||||
apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::NodeMoveRequested {
|
||||
nodes: vec![NodeId(clip.identity())],
|
||||
delta: point(px(10.0), px(20.0)),
|
||||
},
|
||||
)
|
||||
.expect("the first move applies");
|
||||
let moved = {
|
||||
let g = graphops::lock(&project);
|
||||
g.graph
|
||||
.get(clip)
|
||||
.expect("the clip")
|
||||
.core
|
||||
.context_positions
|
||||
.iter()
|
||||
.find(|(c, _, _)| *c == seq)
|
||||
.map(|(_, pos, _)| *pos)
|
||||
.expect("the position was persisted")
|
||||
};
|
||||
assert_eq!(moved, (570.0, 60.0), "fallback column 2, row 0, plus delta");
|
||||
|
||||
// A move list with an unknown node applies the earlier entries and
|
||||
// still reports the error; the next move starts from the persisted
|
||||
// position (not the origin).
|
||||
apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::NodeMoveRequested {
|
||||
nodes: vec![NodeId(clip.identity()), NodeId(123_456)],
|
||||
delta: point(px(5.0), px(5.0)),
|
||||
},
|
||||
)
|
||||
.expect_err("the unknown node rejects the whole move");
|
||||
let moved = {
|
||||
let g = graphops::lock(&project);
|
||||
g.graph
|
||||
.get(clip)
|
||||
.expect("the clip")
|
||||
.core
|
||||
.context_positions
|
||||
.iter()
|
||||
.find(|(c, _, _)| *c == seq)
|
||||
.map(|(_, pos, _)| *pos)
|
||||
.expect("the position")
|
||||
};
|
||||
assert_eq!(moved, (575.0, 65.0), "the valid entry still moved");
|
||||
apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::NodeMoveRequested {
|
||||
nodes: vec![NodeId(clip.identity())],
|
||||
delta: point(px(5.0), px(5.0)),
|
||||
},
|
||||
)
|
||||
.expect("the persisted position is the next base");
|
||||
let moved = {
|
||||
let g = graphops::lock(&project);
|
||||
g.graph
|
||||
.get(clip)
|
||||
.expect("the clip")
|
||||
.core
|
||||
.context_positions
|
||||
.iter()
|
||||
.find(|(c, _, _)| *c == seq)
|
||||
.map(|(_, pos, _)| *pos)
|
||||
.expect("the position")
|
||||
};
|
||||
assert_eq!(moved, (580.0, 70.0));
|
||||
|
||||
// The preview event is a no-op.
|
||||
apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::NodeMovePreview {
|
||||
nodes: vec![NodeId(clip.identity())],
|
||||
delta: point(px(1.0), px(1.0)),
|
||||
},
|
||||
)
|
||||
.expect("the preview is inert");
|
||||
|
||||
// Delete: the sequence is protected, a real node is removed and an
|
||||
// unknown node errors.
|
||||
apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::DeleteRequested {
|
||||
nodes: vec![NodeId(seq.identity())],
|
||||
edges: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("the sequence cannot be deleted");
|
||||
{
|
||||
let g = graphops::lock(&project);
|
||||
assert!(g.graph.is_valid(seq), "the sequence survives");
|
||||
}
|
||||
apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::DeleteRequested {
|
||||
nodes: vec![NodeId(effect.identity())],
|
||||
edges: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("the effect is removed");
|
||||
{
|
||||
let g = graphops::lock(&project);
|
||||
assert!(!g.graph.is_valid(effect), "the effect is gone");
|
||||
}
|
||||
assert!(apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::DeleteRequested {
|
||||
nodes: vec![NodeId(123_456)],
|
||||
edges: Vec::new(),
|
||||
},
|
||||
)
|
||||
.is_err());
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
|
||||
/// A project with a sequence and a clip whose chain runs footage → one
|
||||
/// effect → clip: `(project, seq, clip, effect, footage)`.
|
||||
fn project_with_chained_clip() -> (
|
||||
|
||||
@@ -843,6 +843,28 @@ mod tests {
|
||||
/// tests (the plugin cache is a process singleton with no lock).
|
||||
static HOST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Saves the marker env var and restores it (including the was-unset
|
||||
/// case) on drop, so a panicking assertion cannot leak the redirect into
|
||||
/// the next serialized host test.
|
||||
struct MarkerEnvGuard(Option<std::ffi::OsString>);
|
||||
|
||||
impl MarkerEnvGuard {
|
||||
fn set(path: &std::path::Path) -> MarkerEnvGuard {
|
||||
let previous = std::env::var_os(MARKER_ENV);
|
||||
unsafe { std::env::set_var(MARKER_ENV, path) };
|
||||
MarkerEnvGuard(previous)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MarkerEnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
Some(value) => unsafe { std::env::set_var(MARKER_ENV, value) },
|
||||
None => unsafe { std::env::remove_var(MARKER_ENV) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The minimal test plugin's scan directory (the bundle is assembled
|
||||
/// from the dylib the app build script compiles into OUT_DIR), or `None`
|
||||
/// when the plugin is unavailable (the test skips).
|
||||
@@ -925,7 +947,7 @@ mod tests {
|
||||
}
|
||||
let marker = marker_path("e2e");
|
||||
let _ = std::fs::remove_file(&marker);
|
||||
unsafe { std::env::set_var(MARKER_ENV, &marker) };
|
||||
let _marker_env = MarkerEnvGuard::set(&marker);
|
||||
|
||||
// (1) app-layer creation: `sync_active_interact` on the plugin
|
||||
// instance handle creates the interact (new_interact → describe →
|
||||
@@ -986,7 +1008,6 @@ mod tests {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
};
|
||||
|
||||
unsafe { std::env::remove_var(MARKER_ENV) };
|
||||
let _ = std::fs::remove_file(&marker);
|
||||
|
||||
// Lifecycle reached the plugin, in order.
|
||||
@@ -1048,7 +1069,7 @@ mod tests {
|
||||
}
|
||||
let marker = marker_path("draw");
|
||||
let _ = std::fs::remove_file(&marker);
|
||||
unsafe { std::env::set_var(MARKER_ENV, &marker) };
|
||||
let _marker_env = MarkerEnvGuard::set(&marker);
|
||||
|
||||
let inst = oak_plugin::host::Host::global()
|
||||
.create_instance(INTERACT_PLUGIN_ID, None)
|
||||
@@ -1060,7 +1081,7 @@ mod tests {
|
||||
// A 64×64 opaque blue base frame.
|
||||
let (w, h) = (64u32, 64u32);
|
||||
let mut base_samples = vec![0.0f32; (w * h * 4) as usize];
|
||||
for px in base_samples.chunks_exact_mut(4) {
|
||||
for px in base_samples.chunks_mut(4) {
|
||||
px.copy_from_slice(&[0.0, 0.0, 1.0, 1.0]);
|
||||
}
|
||||
let base = Arc::new(super::super::frames::f32_rgba_to_bgra_image(
|
||||
@@ -1101,7 +1122,6 @@ mod tests {
|
||||
);
|
||||
|
||||
// The draw action reached the plugin with the viewport args.
|
||||
unsafe { std::env::remove_var(MARKER_ENV) };
|
||||
let lines = read_marker(&marker);
|
||||
let _ = std::fs::remove_file(&marker);
|
||||
assert!(
|
||||
@@ -1112,4 +1132,208 @@ mod tests {
|
||||
sync_active_interact(None);
|
||||
oak_plugin::host::Host::global().shutdown();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers / idle pump
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `InteractViewport::at_frame_size` is the 1:1 viewport of a frame.
|
||||
#[test]
|
||||
fn interact_viewport_at_frame_size_is_unit_scale() {
|
||||
let vp = InteractViewport::at_frame_size(1920, 1080);
|
||||
assert_eq!(vp.width, 1920.0);
|
||||
assert_eq!(vp.height, 1080.0);
|
||||
assert_eq!(vp.pixel_scale, (1.0, 1.0));
|
||||
}
|
||||
|
||||
/// A `RenderImage` without CPU bytes converts to `None` (the viewer
|
||||
/// shows the base frame unchanged).
|
||||
#[test]
|
||||
fn bgra_image_to_f32_rgba_returns_none_without_bytes() {
|
||||
let empty = RenderImage::new(smallvec::SmallVec::<[image::Frame; 1]>::new());
|
||||
assert!(bgra_image_to_f32_rgba(&empty).is_none());
|
||||
}
|
||||
|
||||
/// `read_image_f32` unpacks the plugin image's native-endian F32 RGBA
|
||||
/// bytes.
|
||||
#[test]
|
||||
fn read_image_f32_unpacks_native_float_samples() {
|
||||
use oak_plugin::image::{BitDepth, Components, Image};
|
||||
use oak_plugin::instance::OfxRectD;
|
||||
let mut img = Image::allocate(
|
||||
BitDepth::Float,
|
||||
Components::Rgba,
|
||||
OfxRectD {
|
||||
x1: 0.0,
|
||||
y1: 0.0,
|
||||
x2: 2.0,
|
||||
y2: 1.0,
|
||||
},
|
||||
);
|
||||
let samples: [f32; 8] = [0.25, 0.5, 0.75, 1.0, -1.0, 2.0, 0.0, 0.5];
|
||||
let bytes: Vec<u8> = samples.iter().flat_map(|v| v.to_ne_bytes()).collect();
|
||||
img.pixels_mut()[..bytes.len()].copy_from_slice(&bytes);
|
||||
assert_eq!(read_image_f32(&img), samples);
|
||||
}
|
||||
|
||||
/// `composite_overlay` rejects buffers that are not whole RGBA pixels
|
||||
/// and clamps out-of-range alpha.
|
||||
#[test]
|
||||
fn composite_overlay_rejects_unaligned_and_clamps_alpha() {
|
||||
assert!(
|
||||
composite_overlay(&[0.0, 0.0], &[0.0, 0.0]).is_none(),
|
||||
"a two-sample buffer is not an RGBA pixel"
|
||||
);
|
||||
// Alpha above one clamps to fully opaque (the overlay wins).
|
||||
let out = composite_overlay(&[1.0, 0.0, 0.0, 2.0], &[0.0, 0.0, 1.0, 1.0]).unwrap();
|
||||
assert_eq!(out, [1.0, 0.0, 0.0, 1.0]);
|
||||
// Alpha below zero clamps to fully transparent (the base stays).
|
||||
let out = composite_overlay(&[1.0, 0.0, 0.0, -1.0], &[0.0, 0.0, 1.0, 1.0]).unwrap();
|
||||
assert_eq!(out, [0.0, 0.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
/// The f-key mapping parses f1..f35; out-of-range or unparseable
|
||||
/// suffixes fall through to the unknown symbol.
|
||||
#[test]
|
||||
fn key_symbol_function_keys_and_unknown_multi_char_keys() {
|
||||
use oak_plugin::host as ofx_key;
|
||||
let ks = |key: &str| gpui::Keystroke::parse(key).unwrap();
|
||||
assert_eq!(
|
||||
key_symbol(&ks("f35")),
|
||||
(ofx_key::KEY_F1 + 34, String::new())
|
||||
);
|
||||
assert_eq!(key_symbol(&ks("f36")), (ofx_key::KEY_UNKNOWN, String::new()));
|
||||
assert_eq!(key_symbol(&ks("f0")), (ofx_key::KEY_UNKNOWN, String::new()));
|
||||
// A bare "f" is the printable character, not a function key.
|
||||
assert_eq!(key_symbol(&ks("f")), (b'f' as i32, "f".to_string()));
|
||||
assert_eq!(
|
||||
key_symbol(&ks("f99999999999999999999")),
|
||||
(ofx_key::KEY_UNKNOWN, String::new())
|
||||
);
|
||||
}
|
||||
|
||||
/// The idle pump is a no-op without an active interact and throttles
|
||||
/// repeat calls: two back-to-back calls must not run the idle action
|
||||
/// twice.
|
||||
#[test]
|
||||
fn pump_interact_idle_is_inert_without_an_interact() {
|
||||
let _lock = HOST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Neither call may panic; with no interact installed they only
|
||||
// exercise the throttle guard.
|
||||
pump_interact_idle();
|
||||
pump_interact_idle();
|
||||
}
|
||||
|
||||
/// `sync_active_interact`: an unknown instance handle and a cleared
|
||||
/// target are no-ops (no interact is installed).
|
||||
#[test]
|
||||
fn sync_active_interact_missing_instance_is_a_noop() {
|
||||
let _lock = HOST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
assert!(
|
||||
oak_plugin::node_factory::instance_from_id(u64::MAX - 7).is_none(),
|
||||
"the probe handle is not registered"
|
||||
);
|
||||
// The node/instance is gone: nothing to attach.
|
||||
sync_active_interact(Some(u64::MAX - 7));
|
||||
// A cleared target with a (possibly) empty slot: nothing to destroy.
|
||||
sync_active_interact(None);
|
||||
}
|
||||
|
||||
/// `init` is idempotent: a second scan/registration must not duplicate
|
||||
/// plugin node types, and it installs the app-side bridges.
|
||||
#[test]
|
||||
fn init_wiring_is_idempotent() {
|
||||
let _lock = HOST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let first = init();
|
||||
let second = init();
|
||||
assert_eq!(
|
||||
second, 0,
|
||||
"a second init must not register duplicates (the first registered {first} plugin type(s))"
|
||||
);
|
||||
assert!(
|
||||
oak_plugin::progress::has_reporter_factory(),
|
||||
"init installs the progress reporter factory"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interact lifecycle / composite early returns (needs the test plugin)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The app-layer interact management with a real plugin instance:
|
||||
/// re-targeting the same instance is a no-op, `note_interact_viewport`
|
||||
/// updates only the matching instance, and `draw_interact_composite`
|
||||
/// reports `None` for an inert viewport/frame (zero size, mismatch, no
|
||||
/// CPU bytes) without touching GL.
|
||||
#[test]
|
||||
fn interact_viewport_tracking_and_composite_early_returns() {
|
||||
let _lock = HOST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !scan_interact_plugin() {
|
||||
return;
|
||||
}
|
||||
let inst = oak_plugin::host::Host::global()
|
||||
.create_instance(INTERACT_PLUGIN_ID, None)
|
||||
.expect("interact variant instance");
|
||||
let handle = oak_plugin::node_factory::register_instance(inst);
|
||||
sync_active_interact(Some(handle));
|
||||
|
||||
// Re-targeting the same instance keeps the live interact (the
|
||||
// unchanged-target early return).
|
||||
sync_active_interact(Some(handle));
|
||||
let (active_handle, interact, _) = active_interact().expect("active interact");
|
||||
assert_eq!(active_handle, handle);
|
||||
|
||||
// The viewport tracker updates the matching instance...
|
||||
let vp = InteractViewport::at_frame_size(64, 36);
|
||||
note_interact_viewport(handle, vp);
|
||||
assert_eq!(active_interact().expect("active interact").2, vp);
|
||||
// ... and ignores other instances.
|
||||
note_interact_viewport(
|
||||
handle.wrapping_add(1),
|
||||
InteractViewport::at_frame_size(1, 1),
|
||||
);
|
||||
assert_eq!(active_interact().expect("active interact").2, vp);
|
||||
|
||||
// A 2×2 base frame.
|
||||
let samples = vec![0.25f32; 2 * 2 * 4];
|
||||
let base = super::super::frames::f32_rgba_to_bgra_image(2, 2, &samples);
|
||||
|
||||
// A zero-size viewport is inert before any GL work.
|
||||
let zero = InteractViewport {
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
pixel_scale: (1.0, 1.0),
|
||||
};
|
||||
assert!(draw_interact_composite(handle, &interact, &zero, 0.0, &base).is_none());
|
||||
|
||||
// Viewport/frame size mismatch.
|
||||
let mismatch = InteractViewport::at_frame_size(4, 4);
|
||||
assert!(draw_interact_composite(handle, &interact, &mismatch, 0.0, &base).is_none());
|
||||
|
||||
// A base image without CPU bytes.
|
||||
let empty = RenderImage::new(smallvec::SmallVec::<[image::Frame; 1]>::new());
|
||||
let matching = InteractViewport::at_frame_size(2, 2);
|
||||
assert!(draw_interact_composite(handle, &interact, &matching, 0.0, &empty).is_none());
|
||||
|
||||
// The in-process GL path: headless Linux has no GL implementation,
|
||||
// so this reports None; a GL-capable host may composite. Either way
|
||||
// it must not panic.
|
||||
let _ = draw_interact_composite(handle, &interact, &matching, 0.0, &base);
|
||||
|
||||
sync_active_interact(None);
|
||||
|
||||
// A plugin that does not declare an overlay interact reports
|
||||
// `new_interact() == None` and stays inert.
|
||||
let plain = oak_plugin::host::Host::global()
|
||||
.create_instance("org.oak.test-plugin", None)
|
||||
.expect("plain effect variant instance");
|
||||
let plain_handle = oak_plugin::node_factory::register_instance(plain);
|
||||
sync_active_interact(Some(plain_handle));
|
||||
assert!(
|
||||
active_interact().is_none(),
|
||||
"a plugin without an interact installs nothing"
|
||||
);
|
||||
|
||||
oak_plugin::host::Host::global().shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
+4997
-42
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -244,4 +244,136 @@ impl ClipDecorator for OakClipDecorator {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::timeline::Frame;
|
||||
use gpui::{TestAppContext, VisualTestContext};
|
||||
|
||||
/// Media tests share the codec/undo test lock (FFmpeg is not safe
|
||||
/// against concurrent decode sessions).
|
||||
fn media_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
crate::oakui::graphops::test_lock()
|
||||
}
|
||||
|
||||
fn test_media(tag: &str, media: bool) -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"oakapp_waveform_unit_{tag}_{}.mp4",
|
||||
std::process::id()
|
||||
));
|
||||
if media {
|
||||
oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test media");
|
||||
} else {
|
||||
std::fs::write(&path, b"not media").expect("write");
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// `extract` reads the first channel of a real audio stream and
|
||||
/// reports the media geometry; non-media and missing files yield None.
|
||||
#[test]
|
||||
fn extract_reads_audio_and_rejects_non_media() {
|
||||
let _lock = media_lock();
|
||||
let good = test_media("ok", true);
|
||||
let waveform = extract(&good.to_string_lossy(), 250, 25.0).expect("waveform");
|
||||
assert!(waveform.channel_count >= 1);
|
||||
assert!(!waveform.peaks.is_empty());
|
||||
assert_eq!(waveform.sample_rate, 48000);
|
||||
assert_eq!(waveform.samples_per_point, 256);
|
||||
assert_eq!(waveform.duration_frames, 250);
|
||||
// The peaks are finite min/max pairs.
|
||||
for p in &waveform.peaks {
|
||||
assert!(p.min.is_finite() && p.max.is_finite());
|
||||
assert!(p.min <= p.max);
|
||||
}
|
||||
|
||||
let not_media = test_media("bad", false);
|
||||
assert!(extract(¬_media.to_string_lossy(), 250, 25.0).is_none());
|
||||
assert!(extract("/definitely/missing.mp4", 250, 25.0).is_none());
|
||||
|
||||
let _ = std::fs::remove_file(&good);
|
||||
let _ = std::fs::remove_file(¬_media);
|
||||
}
|
||||
|
||||
/// The multicam wizard envelope windows the first-channel peaks
|
||||
/// (~100 ms windows) and degrades to None without audio.
|
||||
#[test]
|
||||
fn envelope_windows_peaks_and_degrades_without_audio() {
|
||||
let _lock = media_lock();
|
||||
let media = test_media("env", true);
|
||||
let envelope =
|
||||
extract_audio_envelope(&media.to_string_lossy(), 0).expect("envelope");
|
||||
assert!(!envelope.is_empty());
|
||||
assert!(
|
||||
envelope.len() >= 2,
|
||||
"a 0.4s clip yields several windows: {}",
|
||||
envelope.len()
|
||||
);
|
||||
for peak in &envelope {
|
||||
assert!(peak.is_finite() && (0.0..=1.5).contains(peak), "{peak}");
|
||||
}
|
||||
|
||||
assert!(extract_audio_envelope("/definitely/missing.mp4", 0).is_none());
|
||||
assert!(extract_audio_envelope(&media.to_string_lossy(), 99).is_none());
|
||||
let _ = std::fs::remove_file(&media);
|
||||
}
|
||||
|
||||
struct Probe;
|
||||
impl gpui::Render for Probe {
|
||||
fn render(
|
||||
&mut self,
|
||||
_window: &mut gpui::Window,
|
||||
_cx: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
gpui::div()
|
||||
}
|
||||
}
|
||||
|
||||
/// The timeline decorator's guard paths: a clip without a cache entry
|
||||
/// and zero-width bounds both return before touching the window (the
|
||||
/// drawing loop itself only runs inside a real paint pass, which the
|
||||
/// widget-level tests cover).
|
||||
#[gpui::test]
|
||||
async fn decorator_skips_missing_entries_and_empty_bounds(cx: &mut TestAppContext) {
|
||||
let _lock = media_lock();
|
||||
let media = test_media("paint", true);
|
||||
let cache = WaveformCache::new(25.0);
|
||||
cache.refresh(1, &media.to_string_lossy(), 250);
|
||||
assert!(cache.get(1).is_some(), "the clip has a waveform");
|
||||
assert!(cache.get(99).is_none());
|
||||
assert!(cache.version() >= 1);
|
||||
// A second refresh is a cache hit and does not bump the version.
|
||||
let version = cache.version();
|
||||
cache.refresh(1, &media.to_string_lossy(), 250);
|
||||
assert_eq!(cache.version(), version);
|
||||
|
||||
let window = cx.open_window(
|
||||
gpui::size(gpui::px(200.0), gpui::px(100.0)),
|
||||
|_, _| Probe,
|
||||
);
|
||||
cx.run_until_parked();
|
||||
let mut visual = VisualTestContext::from_window(window.into(), cx);
|
||||
visual.update(|window, _app| {
|
||||
let mut decorator = OakClipDecorator {
|
||||
cache: cache.clone(),
|
||||
};
|
||||
let bounds = Bounds {
|
||||
origin: gpui::point(gpui::px(0.0), gpui::px(0.0)),
|
||||
size: gpui::size(gpui::px(100.0), gpui::px(40.0)),
|
||||
};
|
||||
let range = FrameRange {
|
||||
start: Frame(0),
|
||||
end: Frame(250),
|
||||
};
|
||||
decorator.paint_waveform(window, ClipId(99), range, bounds);
|
||||
let empty = Bounds {
|
||||
origin: bounds.origin,
|
||||
size: gpui::size(gpui::px(0.0), gpui::px(40.0)),
|
||||
};
|
||||
decorator.paint_waveform(window, ClipId(1), range, empty);
|
||||
});
|
||||
|
||||
let _ = std::fs::remove_file(&media);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,4 +389,202 @@ pub fn viewer_transport<E: AppEngine>(
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::actions::{ActionId, REGISTRY};
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::timeline::{Frame, TimelineDataSource as _};
|
||||
use gpui::{AppContext as _, TestAppContext};
|
||||
|
||||
/// A panel that overrides nothing: every command falls through.
|
||||
struct DefaultPanel;
|
||||
impl PanelCommandHandler for DefaultPanel {}
|
||||
|
||||
/// A panel that overrides a couple of commands and records the
|
||||
/// multi-camera routing arguments.
|
||||
#[derive(Default)]
|
||||
struct RecordingPanel {
|
||||
value: bool,
|
||||
multicam: Option<(i32, bool)>,
|
||||
}
|
||||
|
||||
impl PanelCommandHandler for RecordingPanel {
|
||||
fn play_pause(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
self.value = true;
|
||||
self.value
|
||||
}
|
||||
fn multicam_switch(&mut self, source: i32, split_clip: bool, _cx: &mut Context<Self>) -> bool {
|
||||
self.multicam = Some((source, split_clip));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Every registry action dispatches onto the matching trait method,
|
||||
/// and a panel that implements none of them declines all of them —
|
||||
/// which also exercises every default body.
|
||||
#[gpui::test]
|
||||
async fn default_panel_declines_every_registered_action(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let panel = app.new(|_| DefaultPanel);
|
||||
for entry in REGISTRY {
|
||||
let handled =
|
||||
panel.update(app, |panel, cx| dispatch_to(panel, entry.action, cx));
|
||||
assert!(
|
||||
!handled,
|
||||
"the default panel must decline {:?}",
|
||||
entry.action
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A panel that handles `PlayPause` stops the fall-through for that
|
||||
/// action only; the multicam actions map their numbered ids onto
|
||||
/// `(source, split_clip)` pairs.
|
||||
#[gpui::test]
|
||||
async fn dispatch_propagates_handled_actions_and_multicam_arguments(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
cx.update(|app| {
|
||||
let panel = app.new(|_| RecordingPanel::default());
|
||||
|
||||
let handled = panel.update(app, |panel, cx| dispatch_to(panel, ActionId::PlayPause, cx));
|
||||
assert!(handled, "the overridden command handles the action");
|
||||
assert!(panel.read(app).value);
|
||||
|
||||
// Everything else still falls through to the shell.
|
||||
let handled = panel.update(app, |panel, cx| dispatch_to(panel, ActionId::Cut, cx));
|
||||
assert!(!handled);
|
||||
|
||||
let handled =
|
||||
panel.update(app, |panel, cx| dispatch_to(panel, ActionId::MulticamSwitch3, cx));
|
||||
assert!(handled);
|
||||
assert_eq!(panel.read(app).multicam, Some((2, true)));
|
||||
|
||||
let handled = panel.update(app, |panel, cx| {
|
||||
dispatch_to(panel, ActionId::MulticamSwitchNoSplit7, cx)
|
||||
});
|
||||
assert!(handled);
|
||||
assert_eq!(panel.read(app).multicam, Some((6, false)));
|
||||
|
||||
// Actions with no panel command at all also decline.
|
||||
let handled = panel.update(app, |panel, cx| dispatch_to(panel, ActionId::NewProject, cx));
|
||||
assert!(!handled);
|
||||
});
|
||||
}
|
||||
|
||||
/// The viewer transport maps actions onto the monitor's clock: play /
|
||||
/// pause toggling, stepping, jumps to start/end and the shuttle keys.
|
||||
#[gpui::test]
|
||||
async fn viewer_transport_drives_the_monitor_clock(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = app.new(MockEngine::create);
|
||||
let clock = engine.read(app).program_clock().clone();
|
||||
let monitor = Monitor::Program;
|
||||
|
||||
// Commands the viewers do not implement fall through.
|
||||
assert!(!viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::Cut,
|
||||
app
|
||||
));
|
||||
|
||||
// PlayPause toggles, and ShuttleStop forces the paused state.
|
||||
assert!(viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::PlayPause,
|
||||
app
|
||||
));
|
||||
assert!(clock.read(app).is_playing());
|
||||
assert!(viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::PlayPause,
|
||||
app
|
||||
));
|
||||
assert!(!clock.read(app).is_playing());
|
||||
assert!(viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::ShuttleRight,
|
||||
app
|
||||
));
|
||||
assert!(clock.read(app).is_playing());
|
||||
assert!(viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::ShuttleStop,
|
||||
app
|
||||
));
|
||||
assert!(!clock.read(app).is_playing());
|
||||
|
||||
// Frame stepping clamps at the sequence bounds.
|
||||
assert!(viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::GoToStart,
|
||||
app
|
||||
));
|
||||
assert_eq!(engine.read(app).clock_frame(monitor, app), Frame::ZERO);
|
||||
assert!(viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::NextFrame,
|
||||
app
|
||||
));
|
||||
assert_eq!(engine.read(app).clock_frame(monitor, app), Frame(1));
|
||||
assert!(viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::PrevFrame,
|
||||
app
|
||||
));
|
||||
assert_eq!(engine.read(app).clock_frame(monitor, app), Frame(0));
|
||||
assert!(viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::ShuttleLeft,
|
||||
app
|
||||
));
|
||||
assert_eq!(engine.read(app).clock_frame(monitor, app), Frame(0));
|
||||
|
||||
// GoToEnd requests one past the end; the seek clamps to the
|
||||
// last frame.
|
||||
let length = engine.read(app).sequence_length();
|
||||
assert!(viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::GoToEnd,
|
||||
app
|
||||
));
|
||||
assert_eq!(engine.read(app).clock_frame(monitor, app), Frame(length.0 - 1));
|
||||
|
||||
// Work-area commands still claim the action even when the
|
||||
// engine has no work area to jump to.
|
||||
for action in [
|
||||
ActionId::GoToIn,
|
||||
ActionId::GoToOut,
|
||||
ActionId::PlayInToOut,
|
||||
] {
|
||||
assert!(
|
||||
viewer_transport(&engine, &clock, monitor, action, app),
|
||||
"{action:?} is handled"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,4 +317,115 @@ mod tests {
|
||||
.expect("context menu opened on right-click");
|
||||
assert!(popup.size.height > px(20.0), "popup lists the items");
|
||||
}
|
||||
|
||||
/// A real engine's undo stack renders as rows (the row-list branch of
|
||||
/// `render`); the menu actions route to the engine, the blank-area
|
||||
/// right-click opens the menu, and the dock metadata is populated.
|
||||
#[gpui::test]
|
||||
async fn rows_render_and_interact(cx: &mut TestAppContext) {
|
||||
let _lock = crate::oakui::graphops::test_lock();
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(320.0), px(400.0)), |window, cx| {
|
||||
let engine = cx.new(crate::oakui::RealEngine::create);
|
||||
HistoryPanel::new(engine, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let panel = window.root(cx).expect("history panel root");
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
|
||||
// Seed a project and one undoable edit through the panel's engine:
|
||||
// the panel now renders the row list instead of the placeholder.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.engine.update(cx, |engine, cx| {
|
||||
engine.new_project(cx);
|
||||
engine.add_track(gpui::timeline::TrackKind::Video, cx);
|
||||
});
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let entries = cx.read(|app| panel.read(app).engine.read(app).history_entries().len());
|
||||
assert!(entries >= 2, "new project + add track: {entries}");
|
||||
let total = entries as i64;
|
||||
assert_eq!(
|
||||
cx.read(|app| panel.read(app).engine.read(app).history_index()),
|
||||
total,
|
||||
"the pointer starts at the top of the seeded stack"
|
||||
);
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
|
||||
// Dock metadata.
|
||||
cx.update(|_window, app| {
|
||||
assert!(!panel.read(app).title(app).is_empty());
|
||||
assert_eq!(panel.read(app).panel_id(), HISTORY, "the dock routes by id");
|
||||
// `tab_content` only builds an element with no mounted bounds to
|
||||
// inspect in this test, so the call is a no-panic smoke check.
|
||||
let _ = panel.read(app).tab_content(app);
|
||||
});
|
||||
|
||||
// Left click focuses the panel (the dock re-emits the event).
|
||||
cx.simulate_mouse_down(
|
||||
gpui::point(px(160.0), px(380.0)),
|
||||
MouseButton::Left,
|
||||
Modifiers::none(),
|
||||
);
|
||||
// Right-clicking the blank body opens the menu without a row.
|
||||
cx.simulate_mouse_down(
|
||||
gpui::point(px(160.0), px(380.0)),
|
||||
MouseButton::Right,
|
||||
Modifiers::none(),
|
||||
);
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
assert!(cx.debug_bounds("menu-popup").is_some(), "blank-area menu opens");
|
||||
|
||||
// Every menu action routes to the engine; unknown ids are ignored,
|
||||
// and show_menu toggles between row and blank-area targets.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
// `menu_row` is still None: jump-here is a no-op and the
|
||||
// pointer stays at the top.
|
||||
panel.on_menu(MENU_JUMP_HERE, cx);
|
||||
assert_eq!(panel.engine.read(cx).history_index(), total);
|
||||
|
||||
// Undo/redo route to the engine stack: the pointer moves,
|
||||
// the row count stays, and the top row joins/leaves the
|
||||
// redoable tail.
|
||||
panel.on_menu(MENU_UNDO, cx);
|
||||
assert_eq!(panel.engine.read(cx).history_index(), total - 1);
|
||||
let entries = panel.engine.read(cx).history_entries();
|
||||
assert_eq!(entries.len(), total as usize, "undo keeps rows listed");
|
||||
assert!(!entries.last().expect("rows").done, "the undone row is gray");
|
||||
panel.on_menu(MENU_REDO, cx);
|
||||
assert_eq!(panel.engine.read(cx).history_index(), total);
|
||||
assert!(
|
||||
panel
|
||||
.engine
|
||||
.read(cx)
|
||||
.history_entries()
|
||||
.last()
|
||||
.expect("rows")
|
||||
.done,
|
||||
"redo restores the top row"
|
||||
);
|
||||
|
||||
// Unknown ids are ignored (the pointer does not move).
|
||||
panel.on_menu(9999, cx);
|
||||
assert_eq!(panel.engine.read(cx).history_index(), total);
|
||||
|
||||
panel.show_menu(None, gpui::point(px(5.0), px(5.0)), cx);
|
||||
assert_eq!(panel.menu_row, None);
|
||||
panel.show_menu(Some(0), gpui::point(px(5.0), px(5.0)), cx);
|
||||
assert_eq!(panel.menu_row, Some(0));
|
||||
|
||||
// Jump-here targets the last right-clicked row: row 0 maps
|
||||
// to stack index 0 + 1 and undoes everything above it.
|
||||
panel.on_menu(MENU_JUMP_HERE, cx);
|
||||
assert_eq!(panel.engine.read(cx).history_index(), 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,4 +496,92 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::{Modifiers, px, size, TestAppContext, VisualTestContext};
|
||||
|
||||
/// The panel's context-menu routing (card menu target, every local
|
||||
/// item, no-target guard), the pending add-menu render and the dock
|
||||
/// metadata.
|
||||
#[gpui::test]
|
||||
async fn panel_context_and_add_menu_paths(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(420.0), px(500.0)), |window, cx| {
|
||||
let engine = cx.new(MockEngine::demo);
|
||||
InspectorPanel::new(engine, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let panel = window.root(cx).expect("inspector panel root");
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
|
||||
// Dock metadata.
|
||||
cx.update(|_window, app| {
|
||||
assert!(!panel.read(app).title(app).is_empty());
|
||||
let _ = panel.read(app).tab_content(app);
|
||||
assert_eq!(panel.read(app).panel_id(), INSPECTOR);
|
||||
});
|
||||
|
||||
// Right click targets the first effect card and opens the card menu.
|
||||
cx.simulate_mouse_down(
|
||||
gpui::point(px(200.0), px(200.0)),
|
||||
MouseButton::Right,
|
||||
Modifiers::none(),
|
||||
);
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
assert!(
|
||||
cx.debug_bounds("menu-popup").is_some(),
|
||||
"the card context menu opens"
|
||||
);
|
||||
|
||||
// Every local item routes; a missing target is a no-op.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.on_local_menu_item(LOCAL_ENABLE, cx);
|
||||
panel.on_local_menu_item(LOCAL_RENAME, cx);
|
||||
panel.on_local_menu_item(LOCAL_PROPERTIES, cx);
|
||||
panel.on_local_menu_item(9999, cx);
|
||||
panel.context_effect = None;
|
||||
panel.on_local_menu_item(LOCAL_ENABLE, cx);
|
||||
panel.context_effect = Some((EffectId(1), true, true));
|
||||
panel.on_local_menu_item(LOCAL_REMOVE, cx);
|
||||
});
|
||||
});
|
||||
|
||||
// The add-effect menu renders while an add is pending (the mock
|
||||
// always reports a stack target).
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.pending_add = Some(0);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
// The add-menu list itself carries an element id but no debug
|
||||
// selector; its group headers do, so the menu's presence is
|
||||
// asserted through the built-in group header it renders.
|
||||
let menu_rendered = [
|
||||
"add-effect-group-row-color",
|
||||
"add-effect-group-row-filter",
|
||||
"add-effect-group-row-distort",
|
||||
"add-effect-group-row-keying",
|
||||
"add-effect-group-row-generator",
|
||||
"add-effect-group-row-math",
|
||||
]
|
||||
.into_iter()
|
||||
.any(|selector| cx.debug_bounds(selector).is_some());
|
||||
assert!(
|
||||
menu_rendered,
|
||||
"the add menu renders while an add is pending"
|
||||
);
|
||||
assert_eq!(
|
||||
cx.update(|_window, app| panel.read(app).pending_add),
|
||||
Some(0),
|
||||
"the mock engine has a stack target, so the pending add stays open"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -944,4 +944,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Local menu routing (color labels, add-node entries, unknown ids),
|
||||
/// the fit/zoom viewport helpers and the dock metadata.
|
||||
#[gpui::test]
|
||||
async fn menu_fit_zoom_and_dock_paths(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx);
|
||||
|
||||
cx.update(|window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
// A color-label id takes the early return.
|
||||
let color_item = (0..4096)
|
||||
.find(|item| menu::color_label_index(*item).is_some())
|
||||
.expect("a color label id exists");
|
||||
panel.on_local_menu_item(color_item, cx);
|
||||
|
||||
// An add-node entry (the mock engine rejects the add) and an
|
||||
// unknown id both return without panicking.
|
||||
panel.add_menu_ids.push((
|
||||
LOCAL_ADD_NODE_BASE + 41,
|
||||
"oak:test-node".to_string(),
|
||||
));
|
||||
panel.on_local_menu_item(LOCAL_ADD_NODE_BASE + 41, cx);
|
||||
panel.on_local_menu_item(LOCAL_ADD_NODE_BASE + 42, cx);
|
||||
panel.on_local_menu_item(0, cx);
|
||||
|
||||
// Fit/zoom/viewport helpers.
|
||||
assert!(panel.graph_bounds(cx).is_some());
|
||||
let _ = panel.fit_viewport(window, cx);
|
||||
panel.fit_graph(window, cx);
|
||||
panel.zoom(1.25, window, cx);
|
||||
panel.zoom(0.5, window, cx);
|
||||
});
|
||||
});
|
||||
|
||||
cx.update(|_window, app| {
|
||||
assert!(!panel.read(app).title(app).is_empty());
|
||||
let _ = panel.read(app).tab_content(app);
|
||||
assert_eq!(panel.read(app).panel_id(), NODE_EDITOR);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -792,8 +792,351 @@ impl<E: AppEngine> DockPanel for ProgramViewerPanel<E> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::component::menu::ViewerMenuAction;
|
||||
use crate::oakui::graphops;
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::{size, TestAppContext, VisualTestContext};
|
||||
use gpui::timeline::Frame;
|
||||
use gpui::{size, Keystroke, Modifiers, Rgba, TestAppContext, VisualTestContext};
|
||||
use gpui_widgets::viewer::PlaybackClock;
|
||||
|
||||
/// Serializes the tests that mutate process-global state (HOME, the
|
||||
/// debug env var) and the undo stack.
|
||||
fn stack_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
graphops::test_lock()
|
||||
}
|
||||
|
||||
/// Saves `HOME` and restores it (including the was-unset case) on drop,
|
||||
/// so a panicking assertion cannot leave the process pointed at the
|
||||
/// scratch directory. Callers hold [`stack_lock`] across the mutation.
|
||||
struct HomeGuard(Option<std::ffi::OsString>);
|
||||
|
||||
impl HomeGuard {
|
||||
fn redirect(path: &std::path::Path) -> HomeGuard {
|
||||
let previous = std::env::var_os("HOME");
|
||||
std::env::set_var("HOME", path);
|
||||
HomeGuard(previous)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HomeGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
Some(value) => std::env::set_var("HOME", value),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a `ProgramViewerPanel` over the demo mock engine in a test
|
||||
/// window.
|
||||
fn panel_window(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (
|
||||
&'static mut VisualTestContext,
|
||||
Entity<ProgramViewerPanel<MockEngine>>,
|
||||
) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(640.0), px(360.0)), |window, cx| {
|
||||
let engine = cx.new(MockEngine::demo);
|
||||
let clock = engine.read(cx).program_clock().clone();
|
||||
let meter = cx.new(|cx| AudioLevelMeter::new(30, engine.clone(), window, cx));
|
||||
ProgramViewerPanel::new(engine, clock, meter, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let panel = window.root(cx).expect("program viewer panel root");
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
(cx, panel)
|
||||
}
|
||||
|
||||
/// The panel's viewer subscription routes every event family: the
|
||||
/// interact forwarders (no-op without an interact), the overlay toggles,
|
||||
/// the workarea re-emissions, the eyedropper mailbox and the transport
|
||||
/// requests against the program monitor's clock.
|
||||
#[gpui::test]
|
||||
async fn viewer_events_route_through_the_panel(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx);
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
let viewer = cx.update(|_, cx| panel.read(cx).viewer.clone());
|
||||
let engine = cx.update(|_, cx| panel.read(cx).engine.clone());
|
||||
let clock = cx.update(|_, cx| panel.read(cx).clock.clone());
|
||||
|
||||
for event in [
|
||||
ViewerEvent::InteractPointer {
|
||||
kind: InteractPointerKind::Move,
|
||||
position: Point { x: 1.0, y: 1.0 },
|
||||
button: Some(MouseButton::Left),
|
||||
pressed: false,
|
||||
},
|
||||
ViewerEvent::InteractKey {
|
||||
down: true,
|
||||
keystroke: Keystroke::parse("a").expect("parse"),
|
||||
},
|
||||
ViewerEvent::ToggleSafeFramesRequested { control: 3 },
|
||||
ViewerEvent::ToggleZoomRequested { control: 3 },
|
||||
ViewerEvent::InPointRequested { control: 3 },
|
||||
ViewerEvent::OutPointRequested { control: 3 },
|
||||
ViewerEvent::ClearRangeRequested { control: 3 },
|
||||
ViewerEvent::EyedropperPick {
|
||||
color: Rgba {
|
||||
r: 1.0,
|
||||
g: 0.0,
|
||||
b: 0.0,
|
||||
a: 1.0,
|
||||
},
|
||||
},
|
||||
] {
|
||||
cx.update(|_, cx| viewer.update(cx, |_viewer, cx| cx.emit(event.clone())));
|
||||
cx.run_until_parked();
|
||||
}
|
||||
let picked =
|
||||
cx.update(|_, cx| engine.update(cx, |engine, cx| engine.take_eyedropper_result(cx)));
|
||||
assert_eq!(
|
||||
picked,
|
||||
Some(Rgba {
|
||||
r: 1.0,
|
||||
g: 0.0,
|
||||
b: 0.0,
|
||||
a: 1.0,
|
||||
}),
|
||||
"the eyedropper pick lands in the engine's mailbox"
|
||||
);
|
||||
|
||||
// Transport requests reach the program clock.
|
||||
cx.update(|_, cx| {
|
||||
viewer.update(cx, |_viewer, cx| {
|
||||
cx.emit(ViewerEvent::PlayRequested { control: 3 })
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(cx.read(|app| clock.read(app).is_playing()));
|
||||
cx.update(|_, cx| {
|
||||
viewer.update(cx, |_viewer, cx| {
|
||||
cx.emit(ViewerEvent::PauseRequested { control: 3 })
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(!cx.read(|app| clock.read(app).is_playing()));
|
||||
cx.update(|_, cx| {
|
||||
viewer.update(cx, |_viewer, cx| {
|
||||
cx.emit(ViewerEvent::StepRequested {
|
||||
control: 3,
|
||||
delta: 5,
|
||||
})
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(cx.read(|app| clock.read(app).current_frame()), Frame(5));
|
||||
}
|
||||
|
||||
/// The context-menu resolver and every viewer menu action, including the
|
||||
/// Save Frame arm (which writes through the engine's capture path).
|
||||
#[gpui::test]
|
||||
async fn menu_actions_apply_to_viewer_and_engine(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx);
|
||||
let engine = cx.update(|_, cx| panel.read(cx).engine.clone());
|
||||
|
||||
// A valid local item id resolves through the shared table; an
|
||||
// unknown one logs and returns.
|
||||
let valid = (0..4096)
|
||||
.find(|&item| crate::oakui::component::menu::viewer_menu_action(item).is_some())
|
||||
.expect("the viewer menu has at least one item");
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.on_local_menu_item(999_999, cx);
|
||||
panel.on_local_menu_item(valid, cx);
|
||||
});
|
||||
});
|
||||
|
||||
// Every action variant.
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.apply_viewer_action(ViewerMenuAction::ZoomFit, cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::ZoomLevel(4), cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::Resolution(2), cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::SafeOff, cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::SafeOn, cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::SafeCustom, cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::StopOnLast, cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::Waveform(WaveformMode::Only), cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::Waveform(WaveformMode::Both), cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::ShowFps, cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::FullScreen, cx);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
// The viewer-local actions are observable; the divider/stop-on-last/
|
||||
// waveform actions write the process-global config store (asserted
|
||||
// by the engine's own tests, not here).
|
||||
cx.update(|_, cx| {
|
||||
let panel = panel.read(cx);
|
||||
assert_eq!(panel.viewer.read(cx).zoom(), ViewerZoom::Level(4));
|
||||
assert_eq!(
|
||||
panel.viewer.read(cx).safe_margins(),
|
||||
SafeMargins::Custom(0.9, 0.8)
|
||||
);
|
||||
assert!(panel.viewer.read(cx).show_fps());
|
||||
});
|
||||
let _ = engine;
|
||||
|
||||
// Save Frame: HOME points at a scratch directory so the capture
|
||||
// lands in `$HOME/Pictures` instead of the developer's home.
|
||||
let _lock = stack_lock();
|
||||
let home = std::env::temp_dir().join(format!("oakapp_viewer_home_{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
std::fs::create_dir_all(&home).expect("create the scratch home");
|
||||
let _home_guard = HomeGuard::redirect(&home);
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.apply_viewer_action(ViewerMenuAction::SaveFrame, cx)
|
||||
});
|
||||
});
|
||||
let saved = std::fs::read_dir(home.join("Pictures"))
|
||||
.map(|entries| {
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.any(|e| e.file_name().to_string_lossy().starts_with("oak-frame-program-"))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
assert!(saved, "Save Frame writes a PNG into $HOME/Pictures");
|
||||
|
||||
// Non-writable target: the engine's error arm logs instead of
|
||||
// panicking. (Only meaningful when the test user owns the mode
|
||||
// bits; a root run writes through and still passes.)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let pictures = home.join("Pictures");
|
||||
std::fs::set_permissions(&pictures, std::fs::Permissions::from_mode(0o555))
|
||||
.expect("make Pictures read-only");
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.apply_viewer_action(ViewerMenuAction::SaveFrame, cx)
|
||||
});
|
||||
});
|
||||
std::fs::set_permissions(&pictures, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("restore the mode");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
}
|
||||
|
||||
/// The panel's own mouse handlers: a left click focuses the panel, a
|
||||
/// right click builds the viewer menu state and opens the popup.
|
||||
#[gpui::test]
|
||||
async fn mouse_downs_focus_and_open_the_context_menu(cx: &mut TestAppContext) {
|
||||
let (cx, _panel) = panel_window(cx);
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
let center = gpui::point(px(320.0), px(180.0));
|
||||
cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
cx.simulate_mouse_down(center, MouseButton::Right, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
assert!(
|
||||
cx.debug_bounds("menu-popup").is_some(),
|
||||
"the viewer context menu renders"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every focused-panel transport hook, the tab button click and the
|
||||
/// dock tab content.
|
||||
#[gpui::test]
|
||||
async fn transport_hooks_and_dock_content(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx);
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
assert!(panel.play_pause(cx));
|
||||
assert!(panel.prev_frame(cx));
|
||||
assert!(panel.next_frame(cx));
|
||||
assert!(panel.go_to_start(cx));
|
||||
assert!(panel.go_to_end(cx));
|
||||
assert!(panel.play_in_to_out(cx));
|
||||
assert!(panel.go_to_in(cx));
|
||||
assert!(panel.go_to_out(cx));
|
||||
assert!(panel.shuttle_left(cx));
|
||||
assert!(panel.shuttle_stop(cx));
|
||||
assert!(panel.shuttle_right(cx));
|
||||
});
|
||||
});
|
||||
let _ = cx.update(|_, cx| panel.read(cx).tab_content(cx));
|
||||
assert_eq!(
|
||||
cx.update(|_, cx| panel.read(cx).panel_id()),
|
||||
crate::panels::ids::PROGRAM_VIEWER
|
||||
);
|
||||
|
||||
// The header's tab buttons switch the body. They are the rightmost
|
||||
// row elements; sweep from the right edge to find them.
|
||||
let mut switched = false;
|
||||
for step in 0..320 {
|
||||
let x = 639.0 - f64::from(step) * 2.0;
|
||||
cx.simulate_click(
|
||||
gpui::point(px(x as f32), px(14.0)),
|
||||
Modifiers::none(),
|
||||
);
|
||||
cx.run_until_parked();
|
||||
if cx.read(|app| panel.read(app).tab == ProgramViewTab::Scopes) {
|
||||
switched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(switched, "a click on the scopes tab switches the body");
|
||||
}
|
||||
|
||||
/// The frame-sync debug dump, the interact forwarders without an active
|
||||
/// interact, the playhead helper and the idle pump timer.
|
||||
#[gpui::test]
|
||||
async fn sync_frame_debug_and_interact_noops(cx: &mut TestAppContext) {
|
||||
let _lock = stack_lock();
|
||||
let (cx, panel) = panel_window(cx);
|
||||
|
||||
let prev_debug = std::env::var_os("OAK_DEBUG_VIEWER");
|
||||
std::env::set_var("OAK_DEBUG_VIEWER", "1");
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
cx.run_until_parked();
|
||||
std::fs::remove_file("/tmp/oak_viewer_frame.ppm").ok();
|
||||
match prev_debug {
|
||||
Some(value) => std::env::set_var("OAK_DEBUG_VIEWER", value),
|
||||
None => std::env::remove_var("OAK_DEBUG_VIEWER"),
|
||||
}
|
||||
|
||||
// The forwarders bail out before touching the picture when no OFX
|
||||
// interact is live.
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.forward_interact_pointer(
|
||||
InteractPointerKind::Down,
|
||||
Point { x: 1.0, y: 1.0 },
|
||||
Some(MouseButton::Left),
|
||||
true,
|
||||
cx,
|
||||
);
|
||||
let keystroke = Keystroke::parse("a").expect("parse");
|
||||
panel.forward_interact_key(true, &keystroke, cx);
|
||||
panel.forward_interact_key(false, &keystroke, cx);
|
||||
let _seconds = panel.playhead_seconds(cx);
|
||||
// `displayed_frame` returns the raw frame without an
|
||||
// overlay.
|
||||
let frame = panel.engine.read(cx).cpu_frame(Monitor::Program, cx);
|
||||
let displayed = panel.displayed_frame(&frame, cx);
|
||||
assert!(std::sync::Arc::ptr_eq(&displayed, &frame));
|
||||
});
|
||||
});
|
||||
|
||||
// The idle pump's 50 ms timer runs on the test clock.
|
||||
cx.executor()
|
||||
.advance_clock(std::time::Duration::from_millis(120));
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// The scopes tab renders from the mock engine's synthetic frame without
|
||||
/// crashing, and the scope state carries that frame's samples.
|
||||
|
||||
@@ -526,6 +526,422 @@ fn reveal_in_finder(path: &std::path::Path) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::{px, size, TestAppContext, VisualTestContext};
|
||||
use gpui_widgets::project_explorer::ProjectDataSource;
|
||||
|
||||
/// Serializes the tests that touch process-global state (the undo stack,
|
||||
/// the media/codec library and the panel's HOME override).
|
||||
fn stack_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
crate::oakui::graphops::test_lock()
|
||||
}
|
||||
|
||||
/// Builds a `ProjectExplorerPanel` over the demo mock engine in a test
|
||||
/// window.
|
||||
fn mock_panel_window(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (
|
||||
&'static mut VisualTestContext,
|
||||
Entity<ProjectExplorerPanel<MockEngine>>,
|
||||
) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(640.0), px(480.0)), |window, cx| {
|
||||
let engine = cx.new(MockEngine::demo);
|
||||
ProjectExplorerPanel::new(engine, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let panel = window.root(cx).expect("project explorer panel root");
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
(cx, panel)
|
||||
}
|
||||
|
||||
/// The widget's requests reach the panel's engine: open selects/opens
|
||||
/// the entry, dropped files import, and a right-click request opens the
|
||||
/// context-menu popup.
|
||||
#[gpui::test]
|
||||
async fn explorer_events_route_to_the_engine_and_open_the_menu(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = mock_panel_window(cx);
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
let explorer = cx.update(|_, cx| panel.read(cx).explorer.clone());
|
||||
let engine = cx.update(|_, cx| panel.read(cx).engine.clone());
|
||||
|
||||
// OpenRequested on a non-sequence entry selects it into the shop
|
||||
// window (the mock reports no sequences).
|
||||
explorer.update(cx, |_explorer, cx| {
|
||||
cx.emit(ProjectExplorerEvent::OpenRequested {
|
||||
control: 1,
|
||||
id: 3,
|
||||
name: "第一稿.mp4".into(),
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
cx.read(|app| engine.read(app).selected_item()),
|
||||
Some(3),
|
||||
"a footage open request selects the entry"
|
||||
);
|
||||
|
||||
// FileDropRequested imports every dropped path.
|
||||
let dropped = std::env::temp_dir().join("oakapp_explorer_drop.mp4");
|
||||
explorer.update(cx, |_explorer, cx| {
|
||||
cx.emit(ProjectExplorerEvent::FileDropRequested {
|
||||
control: 1,
|
||||
paths: vec![dropped.clone()],
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
cx.read(|app| engine.read(app).imported_footage().contains(&dropped)),
|
||||
"the dropped path reaches the engine's import"
|
||||
);
|
||||
|
||||
// ContextMenuRequested opens the popup for the requested entry.
|
||||
explorer.update(cx, |_explorer, cx| {
|
||||
cx.emit(ProjectExplorerEvent::ContextMenuRequested {
|
||||
control: 1,
|
||||
id: Some(3),
|
||||
position: gpui::point(px(20.0), px(20.0)),
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
assert!(
|
||||
cx.debug_bounds("menu-popup").is_some(),
|
||||
"the requested context menu renders"
|
||||
);
|
||||
}
|
||||
|
||||
/// The full context-menu selection: an empty-area click gets the blank
|
||||
/// menu, footage gets the footage menu (with its proxy state), a
|
||||
/// sequence gets the sequence menu and anything else the entry menu.
|
||||
#[gpui::test]
|
||||
async fn context_menu_selection_follows_the_engine(cx: &mut TestAppContext) {
|
||||
use crate::oakui::real::RealEngine;
|
||||
let _lock = stack_lock();
|
||||
oak_undo::global::clear().unwrap();
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let media = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/demo.mp4");
|
||||
let window = cx.open_window(size(px(640.0), px(480.0)), |window, cx| {
|
||||
let engine = cx.new(RealEngine::create);
|
||||
engine.update(cx, |engine, cx| {
|
||||
engine.new_project(cx);
|
||||
engine
|
||||
.create_sequence_with_params(
|
||||
"Context".into(),
|
||||
crate::oakui::VideoFormat::hd_1080p25(),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.expect("create the sequence");
|
||||
engine
|
||||
.import_footage(media.clone(), cx)
|
||||
.expect("import the fixture media");
|
||||
});
|
||||
ProjectExplorerPanel::new(engine, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let panel = window.root(cx).expect("project explorer panel root");
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
let engine = cx.update(|_, cx| panel.read(cx).engine.clone());
|
||||
|
||||
let (seq_id, footage_id) = cx.read(|app| {
|
||||
let engine = engine.read(app);
|
||||
let seq = engine.sequence_entries().first().expect("a sequence entry").0;
|
||||
let footage = engine
|
||||
.roots()
|
||||
.into_iter()
|
||||
.chain(
|
||||
engine
|
||||
.roots()
|
||||
.into_iter()
|
||||
.flat_map(|root| engine.children(root.id)),
|
||||
)
|
||||
.find(|entry| entry.name.as_ref() == "demo.mp4")
|
||||
.expect("the imported footage entry")
|
||||
.id;
|
||||
(seq, footage)
|
||||
});
|
||||
assert!(
|
||||
cx.read(|app| engine.read(app).entry_is_sequence(seq_id)),
|
||||
"the created sequence is an explorer sequence entry"
|
||||
);
|
||||
assert!(
|
||||
cx.read(|app| engine.read(app).entry_path(footage_id).is_some()),
|
||||
"the imported footage has an on-disk path"
|
||||
);
|
||||
|
||||
// A second sequence makes the open-request routing observable: the
|
||||
// engine's current sequence must follow a sequence OpenRequested.
|
||||
let second_seq = cx.update(|_, cx| {
|
||||
engine.update(cx, |engine, cx| {
|
||||
engine
|
||||
.create_sequence_with_params(
|
||||
"Second".into(),
|
||||
crate::oakui::VideoFormat::hd_1080p25(),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.expect("create the second sequence")
|
||||
})
|
||||
});
|
||||
assert_eq!(
|
||||
cx.read(|app| engine.read(app).current_sequence_id()),
|
||||
Some(second_seq),
|
||||
"creating a sequence opens it"
|
||||
);
|
||||
|
||||
// The widget's open / drop requests: a sequence opens, footage
|
||||
// selects, a bogus dropped path logs the first error and a real one
|
||||
// imports.
|
||||
let explorer = cx.update(|_, cx| panel.read(cx).explorer.clone());
|
||||
explorer.update(cx, |_explorer, cx| {
|
||||
cx.emit(ProjectExplorerEvent::OpenRequested {
|
||||
control: 1,
|
||||
id: seq_id,
|
||||
name: "Context".into(),
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
cx.read(|app| engine.read(app).current_sequence_id()),
|
||||
Some(seq_id),
|
||||
"a sequence open request opens that sequence"
|
||||
);
|
||||
explorer.update(cx, |_explorer, cx| {
|
||||
cx.emit(ProjectExplorerEvent::OpenRequested {
|
||||
control: 1,
|
||||
id: footage_id,
|
||||
name: "demo.mp4".into(),
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
cx.read(|app| engine.read(app).current_sequence_id()),
|
||||
Some(seq_id),
|
||||
"a footage open request selects instead of switching the sequence"
|
||||
);
|
||||
|
||||
// The import path counts what is actually in the project: a bogus
|
||||
// dropped path adds nothing (the engine rejects it before any node
|
||||
// lands), a real one adds exactly one entry.
|
||||
let entry_count = |app: &gpui::App| -> usize {
|
||||
let engine = engine.read(app);
|
||||
let roots = engine.roots();
|
||||
let children: usize = roots
|
||||
.iter()
|
||||
.map(|root| engine.children(root.id).len())
|
||||
.sum();
|
||||
roots.len() + children
|
||||
};
|
||||
let entries_before = cx.update(|_, app| entry_count(app));
|
||||
explorer.update(cx, |_explorer, cx| {
|
||||
cx.emit(ProjectExplorerEvent::FileDropRequested {
|
||||
control: 1,
|
||||
paths: vec![std::path::PathBuf::from("/nonexistent/oakapp-drop.mp4")],
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
cx.update(|_, app| entry_count(app)),
|
||||
entries_before,
|
||||
"a missing dropped path imports nothing"
|
||||
);
|
||||
explorer.update(cx, |_explorer, cx| {
|
||||
cx.emit(ProjectExplorerEvent::FileDropRequested {
|
||||
control: 1,
|
||||
paths: vec![media.clone()],
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
cx.update(|_, app| entry_count(app)),
|
||||
entries_before + 1,
|
||||
"the real dropped path imports exactly one entry"
|
||||
);
|
||||
let demo_entries = cx.read(|app| {
|
||||
let engine = engine.read(app);
|
||||
let roots = engine.roots();
|
||||
let mut names: Vec<String> = roots.iter().map(|e| e.name.to_string()).collect();
|
||||
for root in &roots {
|
||||
names.extend(
|
||||
engine
|
||||
.children(root.id)
|
||||
.into_iter()
|
||||
.map(|e| e.name.to_string()),
|
||||
);
|
||||
}
|
||||
names
|
||||
.into_iter()
|
||||
.filter(|name| name == "demo.mp4")
|
||||
.count()
|
||||
});
|
||||
assert_eq!(
|
||||
demo_entries, 2,
|
||||
"the dropped media is listed a second time under its file name"
|
||||
);
|
||||
|
||||
// Empty area -> the blank menu; footage -> footage menu (with proxy
|
||||
// state); sequence -> sequence menu; anything else -> entry menu.
|
||||
// Pin the engine data each choice keys off, then assert the open
|
||||
// state and the recorded context entry for every choice.
|
||||
assert!(cx.read(|app| engine.read(app).entry_path(footage_id).is_some()));
|
||||
assert!(!cx.read(|app| engine.read(app).entry_is_sequence(footage_id)));
|
||||
assert!(cx.read(|app| engine.read(app).entry_path(seq_id)).is_none());
|
||||
assert!(cx.read(|app| engine.read(app).entry_is_sequence(seq_id)));
|
||||
assert!(cx.read(|app| engine.read(app).entry_path(1)).is_none());
|
||||
assert!(!cx.read(|app| engine.read(app).entry_is_sequence(1)));
|
||||
for id in [None, Some(footage_id), Some(seq_id), Some(1)] {
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.open_context_menu(id, gpui::point(px(20.0), px(20.0)), cx);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
cx.update(|_, cx| panel.read(cx).context_entry),
|
||||
id,
|
||||
"the opened menu records its context entry"
|
||||
);
|
||||
let menu = cx.update(|_, cx| panel.read(cx).context_menu.widget());
|
||||
assert!(
|
||||
cx.read(|app| menu.read(app).is_open()),
|
||||
"the requested menu is open for {id:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// The properties item on a sequence emits the properties request
|
||||
// while the same item on a non-sequence is a logged no-op.
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.context_entry = Some(seq_id);
|
||||
panel.on_local_menu_item(LOCAL_PROPERTIES, cx);
|
||||
panel.context_entry = Some(footage_id);
|
||||
panel.on_local_menu_item(LOCAL_PROPERTIES, cx);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// Every local menu item path: the guard returns without a context
|
||||
/// entry, the emissions fire with one, the prompt-driven replace runs
|
||||
/// its async body (both a selection and a cancellation) and the proxy
|
||||
/// items apply to the mock's footage.
|
||||
#[gpui::test]
|
||||
async fn local_menu_items_apply_and_guard(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = mock_panel_window(cx);
|
||||
let engine = cx.update(|_, cx| panel.read(cx).engine.clone());
|
||||
|
||||
// No context entry: every item returns early (reveal has no path
|
||||
// either, so its inner branch is skipped too).
|
||||
for item in [
|
||||
LOCAL_REVEAL_IN_FINDER,
|
||||
LOCAL_REPLACE_FOOTAGE,
|
||||
LOCAL_RENAME,
|
||||
LOCAL_DELETE,
|
||||
LOCAL_PROPERTIES,
|
||||
LOCAL_EXPORT_SEQUENCE,
|
||||
LOCAL_PROXY_GENERATE,
|
||||
] {
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.context_entry = None;
|
||||
panel.on_local_menu_item(item, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// With a video footage entry: reveal (the mock has no path),
|
||||
// rename/delete/export emissions, the unimplemented tab entry, the
|
||||
// non-sequence properties log and the proxy actions.
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.context_entry = Some(10);
|
||||
panel.on_local_menu_item(LOCAL_REVEAL_IN_FINDER, cx);
|
||||
panel.on_local_menu_item(LOCAL_RENAME, cx);
|
||||
panel.on_local_menu_item(LOCAL_DELETE, cx);
|
||||
panel.on_local_menu_item(LOCAL_OPEN_IN_NEW_TAB, cx);
|
||||
panel.on_local_menu_item(LOCAL_PROPERTIES, cx);
|
||||
panel.on_local_menu_item(LOCAL_EXPORT_SEQUENCE, cx);
|
||||
panel.on_local_menu_item(LOCAL_PROXY_USE, cx);
|
||||
});
|
||||
});
|
||||
assert!(
|
||||
cx.read(|app| engine.read(app).proxy_row(10).expect("row").enabled),
|
||||
"the Use Proxy item toggles the footprint's proxy flag on"
|
||||
);
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.context_entry = Some(10);
|
||||
panel.on_local_menu_item(LOCAL_PROXY_USE, cx);
|
||||
panel.on_local_menu_item(LOCAL_PROXY_REVEAL, cx);
|
||||
panel.on_local_menu_item(LOCAL_PROXY_DELETE, cx);
|
||||
});
|
||||
});
|
||||
assert!(
|
||||
cx.read(|app| engine.read(app).proxy_row(10).is_some_and(|row| !row.enabled)),
|
||||
"the second Use Proxy toggles it back off"
|
||||
);
|
||||
|
||||
// Generate succeeds for video, fails for audio-only footage.
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.context_entry = Some(10);
|
||||
panel.on_local_menu_item(LOCAL_PROXY_GENERATE, cx);
|
||||
panel.context_entry = Some(20);
|
||||
panel.on_local_menu_item(LOCAL_PROXY_GENERATE, cx);
|
||||
});
|
||||
});
|
||||
assert!(
|
||||
cx.read(|app| engine.read(app).proxy_state(10).is_some()),
|
||||
"generating a video proxy lands a state"
|
||||
);
|
||||
|
||||
// The unknown item logs through the fallback arm.
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.on_local_menu_item(999_999, cx);
|
||||
});
|
||||
});
|
||||
|
||||
// Replace footage: cancel the prompt (the async body takes the
|
||||
// non-selection path), then answer it (the mock returns Err, the
|
||||
// logged failure path).
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.context_entry = Some(10);
|
||||
panel.on_local_menu_item(LOCAL_REPLACE_FOOTAGE, cx);
|
||||
});
|
||||
});
|
||||
assert!(cx.did_prompt_for_paths(), "the replace item prompts");
|
||||
cx.simulate_path_prompt_response(|_| None);
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.context_entry = Some(10);
|
||||
panel.on_local_menu_item(LOCAL_REPLACE_FOOTAGE, cx);
|
||||
});
|
||||
});
|
||||
cx.simulate_path_prompt_response(|_| {
|
||||
Some(vec![std::env::temp_dir().join("oakapp_explorer_replace.mp4")])
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// The panel's dock metadata and the platform reveal helper.
|
||||
#[gpui::test]
|
||||
async fn panel_metadata_and_reveal(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = mock_panel_window(cx);
|
||||
let _ = cx.update(|_, cx| panel.read(cx).tab_content(cx));
|
||||
let _ = cx.update(|_, cx| panel.read(cx).title(cx));
|
||||
reveal_in_finder(std::path::Path::new("/tmp"));
|
||||
}
|
||||
|
||||
/// The blank-area menu is New ▸ (the shared new section) + Import.
|
||||
#[test]
|
||||
|
||||
@@ -341,4 +341,150 @@ impl<E: AppEngine> DockPanel for SourceViewerPanel<E> {
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
.into_any_element()
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::{Modifiers, px, size, TestAppContext, VisualTestContext};
|
||||
use gpui_widgets::viewer::PlaybackClock as _;
|
||||
|
||||
/// Builds the panel in a window and returns a `VisualTestContext`.
|
||||
fn panel_window(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (
|
||||
&'static mut VisualTestContext,
|
||||
Entity<SourceViewerPanel<MockEngine>>,
|
||||
) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(640.0), px(360.0)), |window, cx| {
|
||||
let engine = cx.new(MockEngine::demo);
|
||||
let clock = engine.read(cx).source_clock().clone();
|
||||
SourceViewerPanel::new(engine, clock, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let panel = window.root(cx).expect("source viewer panel root");
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
(cx, panel)
|
||||
}
|
||||
|
||||
/// Every viewer context-menu action updates the viewer widget state or
|
||||
/// the engine config; a real menu item id resolves through the shared
|
||||
/// menu mapping, unknown ids are ignored.
|
||||
#[gpui::test]
|
||||
async fn menu_actions_update_viewer_and_engine_state(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx);
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.on_local_menu_item(9999, cx);
|
||||
|
||||
panel.apply_viewer_action(menu::ViewerMenuAction::ZoomFit, cx);
|
||||
panel.apply_viewer_action(menu::ViewerMenuAction::ZoomLevel(3), cx);
|
||||
assert_eq!(panel.viewer.read(cx).zoom(), ViewerZoom::Level(3));
|
||||
|
||||
// Resolution/stop-on-last/waveform persist through the
|
||||
// engine's config-backed defaults; exercise the reads and
|
||||
// writes without assuming a fresh config entry type.
|
||||
let _ = panel.engine.read(cx).playback_divider();
|
||||
panel.apply_viewer_action(menu::ViewerMenuAction::Resolution(4), cx);
|
||||
let _ = panel.engine.read(cx).playback_divider();
|
||||
|
||||
panel.apply_viewer_action(menu::ViewerMenuAction::SafeOff, cx);
|
||||
panel.apply_viewer_action(menu::ViewerMenuAction::SafeOn, cx);
|
||||
panel.apply_viewer_action(menu::ViewerMenuAction::SafeCustom, cx);
|
||||
|
||||
let _ = panel.engine.read(cx).stop_on_last();
|
||||
panel.apply_viewer_action(menu::ViewerMenuAction::StopOnLast, cx);
|
||||
let _ = panel.engine.read(cx).stop_on_last();
|
||||
|
||||
let _ = panel.engine.read(cx).waveform_mode();
|
||||
panel.apply_viewer_action(
|
||||
menu::ViewerMenuAction::Waveform(WaveformMode::Both),
|
||||
cx,
|
||||
);
|
||||
let _ = panel.engine.read(cx).waveform_mode();
|
||||
|
||||
let show_fps = panel.viewer.read(cx).show_fps();
|
||||
panel.apply_viewer_action(menu::ViewerMenuAction::ShowFps, cx);
|
||||
assert_eq!(panel.viewer.read(cx).show_fps(), !show_fps);
|
||||
|
||||
panel.apply_viewer_action(menu::ViewerMenuAction::FullScreen, cx);
|
||||
|
||||
// A real menu id from the shared builder resolves and applies.
|
||||
let state = menu::ViewerMenuState {
|
||||
playback_divider: panel.engine.read(cx).playback_divider(),
|
||||
zoom: panel.viewer.read(cx).zoom(),
|
||||
safe: panel.viewer.read(cx).safe_margins(),
|
||||
stop_on_last: panel.engine.read(cx).stop_on_last(),
|
||||
waveform: WaveformMode::from_config_value(
|
||||
panel.engine.read(cx).waveform_mode(),
|
||||
),
|
||||
show_fps: panel.viewer.read(cx).show_fps(),
|
||||
};
|
||||
let menu = menu::viewer_menu(&state);
|
||||
let item = menu.items[0].id;
|
||||
panel.on_local_menu_item(item, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The focused-panel transport commands drive the SOURCE monitor.
|
||||
#[gpui::test]
|
||||
async fn transport_commands_drive_the_source_clock(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx);
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
assert!(panel.play_pause(cx));
|
||||
assert!(panel.clock.read(cx).is_playing());
|
||||
assert!(panel.play_pause(cx));
|
||||
assert!(!panel.clock.read(cx).is_playing());
|
||||
|
||||
assert!(panel.next_frame(cx));
|
||||
assert!(panel.prev_frame(cx));
|
||||
assert!(panel.go_to_start(cx));
|
||||
assert!(panel.go_to_end(cx));
|
||||
assert!(panel.shuttle_left(cx));
|
||||
assert!(panel.shuttle_stop(cx));
|
||||
assert!(panel.shuttle_right(cx));
|
||||
assert!(panel.play_in_to_out(cx));
|
||||
assert!(panel.go_to_in(cx));
|
||||
assert!(panel.go_to_out(cx));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The panel renders, right-click opens the shared viewer menu, and the
|
||||
/// dock metadata is populated.
|
||||
#[gpui::test]
|
||||
async fn render_right_click_menu_and_dock_metadata(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx);
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
cx.update(|_window, app| {
|
||||
assert!(!panel.read(app).title(app).is_empty());
|
||||
let _ = panel.read(app).tab_content(app);
|
||||
});
|
||||
|
||||
// A right-click anywhere in the panel opens the viewer menu.
|
||||
cx.simulate_mouse_down(
|
||||
gpui::point(px(320.0), px(180.0)),
|
||||
MouseButton::Right,
|
||||
Modifiers::none(),
|
||||
);
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
assert!(
|
||||
cx.debug_bounds("menu-popup").is_some(),
|
||||
"the viewer context menu opens"
|
||||
);
|
||||
|
||||
// A left click focuses the panel (the dock re-emits the event).
|
||||
cx.simulate_mouse_down(
|
||||
gpui::point(px(320.0), px(180.0)),
|
||||
MouseButton::Left,
|
||||
Modifiers::none(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1530,8 +1530,66 @@ pub(crate) fn ruler_menu() -> Menu {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::engine::EngineGateway;
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::effect_stack::LibraryEffectDrag;
|
||||
use gpui::timeline::{FrameRange, TimelineDataSource};
|
||||
use gpui::StatefulInteractiveElement;
|
||||
|
||||
/// The empty ghost for the test drag sources.
|
||||
struct DropGhost;
|
||||
|
||||
impl Render for DropGhost {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size(px(10.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// A test host with draggable footage / effect sources above a real
|
||||
/// timeline panel, so the panel's `on_drag_move` / `on_drop` handlers can
|
||||
/// be exercised end to end.
|
||||
struct DropHost<E: AppEngine> {
|
||||
panel: Entity<TimelinePanel<E>>,
|
||||
}
|
||||
|
||||
impl<E: AppEngine> Render for DropHost<E> {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let source = |id: &'static str| {
|
||||
div()
|
||||
.id(id)
|
||||
.debug_selector(move || id.into())
|
||||
.h(px(24.0))
|
||||
.w_full()
|
||||
.flex_shrink_0()
|
||||
};
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
source("footage-drag-source")
|
||||
.on_drag(FootageDrag(3), |_drag, _offset, _window, cx| {
|
||||
cx.new(|_| DropGhost)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
source("footage-drag-source-missing").on_drag(
|
||||
FootageDrag(9999),
|
||||
|_drag, _offset, _window, cx| cx.new(|_| DropGhost),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
source("effect-drag-source").on_drag(
|
||||
LibraryEffectDrag {
|
||||
type_id: "org.olivevideoeditor.Olive.solidgenerator".into(),
|
||||
name: "Solid".into(),
|
||||
},
|
||||
|_drag, _offset, _window, cx| cx.new(|_| DropGhost),
|
||||
),
|
||||
)
|
||||
.child(self.panel.clone())
|
||||
}
|
||||
}
|
||||
use gpui::{px, size, Hsla, TestAppContext, VisualTestContext};
|
||||
|
||||
/// Builds a `TimelinePanel` in a window of the given logical size and
|
||||
@@ -2017,4 +2075,911 @@ mod tests {
|
||||
);
|
||||
assert!(menu.items.iter().all(|item| item.checked == Some(false)));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Panel behavior: subscriptions, context menus, commands, drops, render.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// The zoom/height sliders and the snap checkbox drive the view/engine
|
||||
/// through the panel's subscriptions.
|
||||
#[gpui::test]
|
||||
async fn slider_and_snap_events_update_the_view_and_engine(cx: &mut TestAppContext) {
|
||||
use crate::oakui::component::controls::SliderValue;
|
||||
let (cx, panel) = panel_window(cx, 1280.0, 720.0);
|
||||
|
||||
// Zoom slider -> timeline zoom.
|
||||
cx.update(|_window, app| {
|
||||
panel.read(app).zoom.clone().update(app, |_slider, cx| {
|
||||
cx.emit(SliderEvent::ValueChanged {
|
||||
control: 10,
|
||||
value: SliderValue::Float(3.5),
|
||||
});
|
||||
});
|
||||
});
|
||||
assert_eq!(
|
||||
cx.read(|app| panel.read(app).timeline.read(app).state.zoom),
|
||||
3.5
|
||||
);
|
||||
|
||||
// Track-height slider -> every engine track.
|
||||
cx.update(|_window, app| {
|
||||
panel.read(app).height.clone().update(app, |_slider, cx| {
|
||||
cx.emit(SliderEvent::ValueChanged {
|
||||
control: 11,
|
||||
value: SliderValue::Float(80.0),
|
||||
});
|
||||
});
|
||||
});
|
||||
cx.read(|app| {
|
||||
let engine = panel.read(app).engine.clone();
|
||||
let engine = engine.read(app);
|
||||
assert!(engine.track_count() > 0, "the demo has tracks");
|
||||
for index in 0..engine.track_count() {
|
||||
assert_eq!(engine.track(index).unwrap().height(), px(80.0));
|
||||
}
|
||||
});
|
||||
|
||||
// Snap checkbox -> view state.
|
||||
for (state, expected) in [(CheckState::Unchecked, false), (CheckState::Checked, true)] {
|
||||
cx.update(|_window, app| {
|
||||
panel.read(app).snap.clone().update(app, |_snap, cx| {
|
||||
cx.emit(CheckBoxEvent::Toggled { control: 12, state });
|
||||
});
|
||||
});
|
||||
assert_eq!(
|
||||
cx.read(|app| panel.read(app).timeline.read(app).state.snap_enabled),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `TimelineHit` variant opens its matching context menu; an
|
||||
/// unselected right-clicked clip is selected first.
|
||||
#[gpui::test]
|
||||
async fn context_menus_open_for_every_hit_kind(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx, 1600.0, 900.0);
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
let position = gpui::point(px(20.0), px(20.0));
|
||||
|
||||
panel.open_context_menu(position, TimelineHit::Clip(ClipId(10)), cx);
|
||||
assert!(
|
||||
panel.timeline.read(cx).selection().contains(&ClipId(10)),
|
||||
"an unselected right-clicked clip is selected first"
|
||||
);
|
||||
assert_eq!(panel.context_track, None);
|
||||
assert_eq!(panel.context_empty, None);
|
||||
|
||||
// A second hit on the already-selected clip takes the
|
||||
// no-reselect path.
|
||||
panel.open_context_menu(position, TimelineHit::Clip(ClipId(10)), cx);
|
||||
|
||||
panel.open_context_menu(
|
||||
position,
|
||||
TimelineHit::Empty {
|
||||
track: 2,
|
||||
frame: Frame(42),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
assert_eq!(panel.context_empty, Some((2, Frame(42))));
|
||||
assert_eq!(panel.context_track, None);
|
||||
|
||||
panel.open_context_menu(position, TimelineHit::TrackHead(3), cx);
|
||||
assert_eq!(panel.context_track, Some(3));
|
||||
assert_eq!(panel.context_empty, None);
|
||||
|
||||
panel.open_context_menu(position, TimelineHit::RulerMarker(Frame(7)), cx);
|
||||
assert_eq!(panel.context_track, None);
|
||||
assert_eq!(panel.context_empty, None);
|
||||
|
||||
panel.open_context_menu(position, TimelineHit::Ruler(Frame(9)), cx);
|
||||
assert_eq!(panel.context_track, None);
|
||||
assert_eq!(panel.context_empty, None);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The local (non-registry) menu items route to the engine; missing
|
||||
/// targets and unknown ids are benign no-ops.
|
||||
#[gpui::test]
|
||||
async fn local_menu_items_route_to_the_engine(cx: &mut TestAppContext) {
|
||||
// The Multi-Cam item runs real undoable commands; serialize with the
|
||||
// graph tests sharing the global undo stack.
|
||||
let _guard = crate::oakui::graphops::test_lock();
|
||||
let (cx, panel) = panel_window(cx, 1600.0, 900.0);
|
||||
|
||||
let tracks_before = cx.read(|app| panel.read(app).engine.read(app).track_count());
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.on_local_menu_item(LOCAL_ADD_VIDEO_TRACK, cx);
|
||||
panel.on_local_menu_item(LOCAL_ADD_AUDIO_TRACK, cx);
|
||||
});
|
||||
});
|
||||
assert_eq!(
|
||||
cx.read(|app| panel.read(app).engine.read(app).track_count()),
|
||||
tracks_before + 2
|
||||
);
|
||||
|
||||
// Delete one track (with a recorded track-head target). Without a
|
||||
// target the click is ignored.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.on_local_menu_item(LOCAL_DELETE_TRACK, cx);
|
||||
panel.context_track = Some(2);
|
||||
panel.on_local_menu_item(LOCAL_DELETE_TRACK, cx);
|
||||
});
|
||||
});
|
||||
assert_eq!(
|
||||
cx.read(|app| panel.read(app).engine.read(app).track_count()),
|
||||
tracks_before + 1
|
||||
);
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.on_local_menu_item(LOCAL_DELETE_ALL_EMPTY, cx);
|
||||
});
|
||||
});
|
||||
|
||||
// A color label only logs (the engine has no clip-color surface).
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.on_local_menu_item(menu::COLOR_LABEL_BASE + 3, cx);
|
||||
});
|
||||
});
|
||||
|
||||
// Add adjustment layer: without a recorded empty-area click it is a
|
||||
// no-op; with one the demo layer lands on the pointed track.
|
||||
let clips_before = cx.read(|app| {
|
||||
panel.read(app).engine.read(app).track(0).unwrap().clips().len()
|
||||
});
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.on_local_menu_item(LOCAL_ADD_ADJUSTMENT_LAYER, cx);
|
||||
panel.context_empty = Some((0, Frame(10)));
|
||||
panel.on_local_menu_item(LOCAL_ADD_ADJUSTMENT_LAYER, cx);
|
||||
});
|
||||
});
|
||||
cx.read(|app| {
|
||||
assert_eq!(
|
||||
panel.read(app).engine.read(app).track(0).unwrap().clips().len(),
|
||||
clips_before + 1,
|
||||
"the adjustment layer joined the video track"
|
||||
);
|
||||
});
|
||||
// An audio target is rejected (the error branch logs).
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.context_empty = Some((2, Frame(10)));
|
||||
panel.on_local_menu_item(LOCAL_ADD_ADJUSTMENT_LAYER, cx);
|
||||
});
|
||||
});
|
||||
|
||||
// The cache / timecode / thumbnail placeholders and an unknown id.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
for item in [
|
||||
LOCAL_CACHE_ALL,
|
||||
LOCAL_CACHE_IN_OUT,
|
||||
LOCAL_CACHE_DISCARD,
|
||||
LOCAL_CACHE_AUTO,
|
||||
LOCAL_TIMECODE_DROP_FRAME,
|
||||
LOCAL_TIMECODE_NON_DROP_FRAME,
|
||||
LOCAL_TIMECODE_SECONDS,
|
||||
LOCAL_TIMECODE_FRAMES,
|
||||
LOCAL_TIMECODE_MILLISECONDS,
|
||||
LOCAL_USE_AUDIO_TIME_UNITS,
|
||||
LOCAL_SHOW_WAVEFORMS,
|
||||
LOCAL_THUMBNAIL_OFF,
|
||||
LOCAL_THUMBNAIL_IN_OUT,
|
||||
LOCAL_THUMBNAIL_ON,
|
||||
LOCAL_MARKER_PROPERTIES,
|
||||
LOCAL_REVEAL_FOOTAGE_VIEWER,
|
||||
LOCAL_REVEAL_PROJECT,
|
||||
] {
|
||||
panel.on_local_menu_item(item, cx);
|
||||
}
|
||||
panel.on_local_menu_item(999_999, cx);
|
||||
});
|
||||
});
|
||||
|
||||
// The proxy actions over the selected demo clip (id 10 maps to the
|
||||
// explorer's intro.mov): generate first, then use / reveal / delete.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.timeline.update(cx, |view, cx| {
|
||||
view.state.selection.clear();
|
||||
view.state.selection.insert(ClipId(10));
|
||||
cx.notify();
|
||||
});
|
||||
panel.on_local_menu_item(LOCAL_PROXY_GENERATE, cx);
|
||||
panel.on_local_menu_item(LOCAL_PROXY_USE, cx);
|
||||
panel.on_local_menu_item(LOCAL_PROXY_REVEAL, cx);
|
||||
panel.on_local_menu_item(LOCAL_PROXY_DELETE, cx);
|
||||
});
|
||||
});
|
||||
cx.read(|app| {
|
||||
let engine = panel.read(app).engine.clone();
|
||||
let engine = engine.read(app);
|
||||
assert!(engine.proxy_row(10).is_some(), "the demo row exists");
|
||||
});
|
||||
|
||||
// Multi-Cam flips through the real graph commands.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.timeline.update(cx, |view, cx| {
|
||||
view.state.selection.insert(ClipId(10));
|
||||
cx.notify();
|
||||
});
|
||||
panel.on_local_menu_item(LOCAL_MULTICAM, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The panel command trait routes transport, in/out points, selection and
|
||||
/// editing commands to the engine without panicking.
|
||||
#[gpui::test]
|
||||
async fn panel_commands_cover_transport_edit_and_view(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx, 1280.0, 720.0);
|
||||
|
||||
// Transport: play/pause toggles the program clock.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
assert!(panel.play_pause(cx));
|
||||
});
|
||||
});
|
||||
assert!(
|
||||
cx.read(|app| panel
|
||||
.read(app)
|
||||
.engine
|
||||
.read(app)
|
||||
.program_clock()
|
||||
.read(app)
|
||||
.is_playing()),
|
||||
"play_pause starts the program monitor"
|
||||
);
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
assert!(panel.play_pause(cx));
|
||||
assert!(panel.prev_frame(cx));
|
||||
assert!(panel.next_frame(cx));
|
||||
assert!(panel.go_to_start(cx));
|
||||
assert!(panel.go_to_end(cx));
|
||||
assert!(panel.shuttle_left(cx));
|
||||
assert!(panel.shuttle_right(cx));
|
||||
assert!(panel.shuttle_stop(cx));
|
||||
});
|
||||
});
|
||||
assert!(
|
||||
!cx.read(|app| panel
|
||||
.read(app)
|
||||
.engine
|
||||
.read(app)
|
||||
.program_clock()
|
||||
.read(app)
|
||||
.is_playing()),
|
||||
"shuttle_stop pauses the program monitor"
|
||||
);
|
||||
|
||||
// In/out points.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.engine.update(cx, |engine, cx| {
|
||||
engine.request_frame(Monitor::Program, Frame(90), cx)
|
||||
});
|
||||
assert!(panel.set_in(cx));
|
||||
});
|
||||
});
|
||||
assert_eq!(
|
||||
cx.read(|app| panel.read(app).engine.read(app).workarea().map(|(s, _)| s)),
|
||||
Some(Frame(90))
|
||||
);
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
assert!(panel.set_out(cx));
|
||||
assert!(panel.reset_in(cx));
|
||||
});
|
||||
});
|
||||
assert_eq!(
|
||||
cx.read(|app| panel.read(app).engine.read(app).workarea().map(|(s, _)| s)),
|
||||
Some(Frame::ZERO),
|
||||
"reset_in returns the in point to the sequence start"
|
||||
);
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
assert!(panel.reset_out(cx));
|
||||
});
|
||||
});
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
assert!(panel.clear_in_out(cx));
|
||||
});
|
||||
});
|
||||
assert_eq!(cx.read(|app| panel.read(app).engine.read(app).workarea()), None);
|
||||
|
||||
// Reset with no work area set (the unwrap_or fallbacks).
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
assert!(panel.reset_in(cx));
|
||||
assert!(panel.reset_out(cx));
|
||||
});
|
||||
});
|
||||
assert!(cx.read(|app| panel.read(app).engine.read(app).workarea()).is_some());
|
||||
|
||||
// Selection + editing commands (the mock's clipboard methods are
|
||||
// no-ops; delete/split touch its demo tracks).
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
assert!(panel.select_all(cx));
|
||||
assert!(panel.deselect_all(cx));
|
||||
assert!(panel.delete_selected(cx), "an empty selection is handled");
|
||||
assert!(panel.ripple_delete(cx));
|
||||
assert!(panel.select_all(cx));
|
||||
assert!(panel.copy_selected(cx));
|
||||
assert!(panel.cut_selected(cx));
|
||||
assert!(panel.paste(cx));
|
||||
assert!(panel.delete_selected(cx));
|
||||
assert!(panel.ripple_delete(cx));
|
||||
assert!(panel.split_at_playhead(cx));
|
||||
assert!(panel.default_transition(cx));
|
||||
assert!(panel.set_marker(cx));
|
||||
assert!(panel.sync_by_source_time(cx));
|
||||
assert!(panel.sync_by_waveform(cx));
|
||||
assert!(panel.sync_by_waveform_speed(cx));
|
||||
assert!(panel.toggle_links(cx));
|
||||
assert!(panel.zoom_in(cx));
|
||||
assert!(panel.zoom_out(cx));
|
||||
assert!(panel.increase_track_height(cx));
|
||||
assert!(panel.decrease_track_height(cx));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// `delete_selection` ignores an empty selection and removes the selected
|
||||
/// clips through the engine; the work-area commits follow the playhead.
|
||||
#[gpui::test]
|
||||
async fn delete_selection_and_workarea_paths(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx, 1280.0, 720.0);
|
||||
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.timeline.update(cx, |view, cx| {
|
||||
view.state.selection.clear();
|
||||
cx.notify();
|
||||
});
|
||||
panel.delete_selection(false, cx);
|
||||
panel.delete_selection(true, cx);
|
||||
});
|
||||
});
|
||||
|
||||
let clips_before = cx.read(|app| {
|
||||
panel.read(app).engine.read(app).track(0).unwrap().clips().len()
|
||||
});
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.timeline.update(cx, |view, cx| {
|
||||
view.state.selection.insert(ClipId(10));
|
||||
cx.notify();
|
||||
});
|
||||
panel.delete_selection(false, cx);
|
||||
});
|
||||
});
|
||||
assert_eq!(
|
||||
cx.read(|app| panel.read(app).engine.read(app).track(0).unwrap().clips().len()),
|
||||
clips_before - 1,
|
||||
"the selected clip left its track"
|
||||
);
|
||||
|
||||
// A playhead at frame zero cannot form an out point: the commit is
|
||||
// ignored (and the in point falls back to the sequence length).
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.engine.update(cx, |engine, cx| {
|
||||
engine.request_frame(Monitor::Program, Frame::ZERO, cx)
|
||||
});
|
||||
panel.set_point_at_playhead(false, cx);
|
||||
panel.set_point_at_playhead(true, cx);
|
||||
});
|
||||
});
|
||||
assert_eq!(
|
||||
cx.read(|app| panel.read(app).engine.read(app).workarea().map(|(s, _)| s)),
|
||||
Some(Frame::ZERO)
|
||||
);
|
||||
}
|
||||
|
||||
/// `finish_footage_drop` and `finish_effect_drop` route their pending
|
||||
/// targets; a missing target and an unusable effect are benign no-ops.
|
||||
#[gpui::test]
|
||||
async fn finished_drops_route_to_the_engine(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx, 1280.0, 720.0);
|
||||
|
||||
let target = || FootageDropTarget {
|
||||
track_kind: TrackKind::Video,
|
||||
track_index: 1,
|
||||
time: Frame(20),
|
||||
length: 250,
|
||||
};
|
||||
|
||||
// No pending footage drop.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.finish_footage_drop(&FootageDrag(3), cx);
|
||||
});
|
||||
});
|
||||
assert!(cx.read(|app| panel.read(app).engine.read(app).footage_drops().is_empty()));
|
||||
|
||||
// A pending drop with an open sequence reaches the engine.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.footage_drop = Some(target());
|
||||
panel.finish_footage_drop(&FootageDrag(3), cx);
|
||||
});
|
||||
});
|
||||
cx.read(|app| {
|
||||
let panel = panel.read(app);
|
||||
assert!(panel.footage_drop.is_none(), "the target is consumed");
|
||||
assert_eq!(panel.engine.read(app).footage_drops().len(), 1);
|
||||
});
|
||||
|
||||
let generator =
|
||||
gpui::effect_stack::LibraryEffectDrag {
|
||||
type_id: "org.olivevideoeditor.Olive.solidgenerator".into(),
|
||||
name: "Solid".into(),
|
||||
};
|
||||
let transition =
|
||||
gpui::effect_stack::LibraryEffectDrag {
|
||||
type_id: "org.olivevideoeditor.Olive.transition".into(),
|
||||
name: "Dissolve".into(),
|
||||
};
|
||||
let non_generator = gpui::effect_stack::LibraryEffectDrag {
|
||||
type_id: "org.olivevideoeditor.Olive.blur".into(),
|
||||
name: "Blur".into(),
|
||||
};
|
||||
|
||||
// No pending effect drop.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.finish_effect_drop(&generator, cx);
|
||||
});
|
||||
});
|
||||
|
||||
// A transition type routes to the engine's transition drop.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.effect_drop = Some(target());
|
||||
panel.finish_effect_drop(&transition, cx);
|
||||
});
|
||||
});
|
||||
cx.read(|app| assert!(panel.read(app).effect_drop.is_none()));
|
||||
|
||||
// A non-generator effect is ignored.
|
||||
let clips_before = cx.read(|app| {
|
||||
panel.read(app).engine.read(app).track(1).unwrap().clips().len()
|
||||
});
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.effect_drop = Some(target());
|
||||
panel.finish_effect_drop(&non_generator, cx);
|
||||
});
|
||||
});
|
||||
assert_eq!(
|
||||
cx.read(|app| panel.read(app).engine.read(app).track(1).unwrap().clips().len()),
|
||||
clips_before,
|
||||
"a non-generator makes no clip"
|
||||
);
|
||||
|
||||
// A generator type creates a standalone clip on the pointed track.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.effect_drop = Some(target());
|
||||
panel.finish_effect_drop(&generator, cx);
|
||||
});
|
||||
});
|
||||
assert_eq!(
|
||||
cx.read(|app| panel.read(app).engine.read(app).track(1).unwrap().clips().len()),
|
||||
clips_before + 1,
|
||||
"the generator clip landed on the pointed track"
|
||||
);
|
||||
}
|
||||
|
||||
/// `resolve_drop_point` maps the cursor to track + frame, clamps above
|
||||
/// the ruler / below the tracks and falls back on an empty timeline.
|
||||
#[gpui::test]
|
||||
async fn resolve_drop_point_maps_and_clamps(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx, 1280.0, 720.0);
|
||||
|
||||
assert_eq!(
|
||||
cx.read(|app| panel
|
||||
.read(app)
|
||||
.resolve_drop_point(gpui::point(px(200.0), px(10.0)), app)),
|
||||
None,
|
||||
"above the ruler there is no clip area"
|
||||
);
|
||||
|
||||
// Inside the header column the frame clamps to zero; the y walk finds
|
||||
// the second track (64px tall rows).
|
||||
let (kind, index, frame) = cx
|
||||
.read(|app| {
|
||||
panel
|
||||
.read(app)
|
||||
.resolve_drop_point(gpui::point(px(10.0), px(100.0)), app)
|
||||
})
|
||||
.expect("below the ruler");
|
||||
assert_eq!((kind, index), (TrackKind::Video, 1));
|
||||
assert_eq!(frame, Frame::ZERO);
|
||||
|
||||
// Below every track clamps to the last one.
|
||||
let (kind, index, _) = cx
|
||||
.read(|app| {
|
||||
panel
|
||||
.read(app)
|
||||
.resolve_drop_point(gpui::point(px(600.0), px(4000.0)), app)
|
||||
})
|
||||
.expect("below the ruler");
|
||||
assert_eq!((kind, index), (TrackKind::Audio, 3));
|
||||
|
||||
// An empty timeline falls back to (Video, 0).
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
let engine = panel.engine.clone();
|
||||
engine.update(cx, |engine, cx| {
|
||||
while engine.track_count() > 0 {
|
||||
engine.remove_track(0, cx);
|
||||
}
|
||||
});
|
||||
let (kind, index, _) = panel
|
||||
.resolve_drop_point(gpui::point(px(600.0), px(100.0)), cx)
|
||||
.expect("still below the ruler");
|
||||
assert_eq!((kind, index), (TrackKind::Video, 0));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// `zoom_timeline` and `nudge_track_height` scale the view and clamp the
|
||||
/// engine's track heights to the slider range.
|
||||
#[gpui::test]
|
||||
async fn zoom_and_track_height_nudges(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx, 1280.0, 720.0);
|
||||
let before = cx.read(|app| panel.read(app).timeline.read(app).state.zoom);
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| panel.zoom_timeline(1.25, cx));
|
||||
});
|
||||
assert!(cx.read(|app| panel.read(app).timeline.read(app).state.zoom) > before);
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| panel.zoom_timeline(0.8, cx));
|
||||
});
|
||||
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| panel.nudge_track_height(1000.0, cx));
|
||||
});
|
||||
assert_eq!(
|
||||
cx.read(|app| panel.read(app).engine.read(app).track(0).unwrap().height()),
|
||||
px(160.0),
|
||||
"the height clamps at the slider maximum"
|
||||
);
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| panel.nudge_track_height(-1000.0, cx));
|
||||
});
|
||||
assert_eq!(
|
||||
cx.read(|app| panel.read(app).engine.read(app).track(0).unwrap().height()),
|
||||
px(24.0),
|
||||
"the height clamps at the slider minimum"
|
||||
);
|
||||
|
||||
// No tracks: the 64px default is used as the nudge base.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
let engine = panel.engine.clone();
|
||||
engine.update(cx, |engine, cx| {
|
||||
while engine.track_count() > 0 {
|
||||
engine.remove_track(0, cx);
|
||||
}
|
||||
});
|
||||
panel.nudge_track_height(0.0, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The toolbar's tool buttons and trailing controls run their `on_click`
|
||||
/// closures: tool selection, add-track buttons, zoom buttons and the
|
||||
/// snap icon.
|
||||
#[gpui::test]
|
||||
async fn toolbar_buttons_switch_tools_and_route_actions(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx, 1600.0, 900.0);
|
||||
let toolbar = cx.debug_bounds("timeline-toolbar").expect("toolbar rendered");
|
||||
let y = toolbar.origin.y + px(15.0);
|
||||
|
||||
// The eight tool buttons sit at 24px + 8px-gap offsets after the 8px
|
||||
// toolbar padding.
|
||||
for index in 0..8 {
|
||||
let x = toolbar.origin.x + px(8.0 + 32.0 * index as f32 + 12.0);
|
||||
cx.simulate_click(gpui::point(x, y), gpui::Modifiers::none());
|
||||
assert_eq!(
|
||||
cx.read(|app| panel.read(app).timeline.read(app).tool()),
|
||||
TimelineTool::from_index(index).unwrap(),
|
||||
"toolbar button {index} selects its tool"
|
||||
);
|
||||
}
|
||||
// Clicking the already-selected tool again hits the no-op guard.
|
||||
cx.simulate_click(
|
||||
gpui::point(toolbar.origin.x + px(8.0 + 7.0 * 32.0 + 12.0), y),
|
||||
gpui::Modifiers::none(),
|
||||
);
|
||||
|
||||
// Walk the trailing controls: each hit routes through an `on_click`
|
||||
// closure (add-track buttons, zoom buttons, the snap toggle).
|
||||
let mut saw_add_track = false;
|
||||
let mut saw_zoom = false;
|
||||
let mut saw_snap = false;
|
||||
let mut x = toolbar.origin.x + px(264.0);
|
||||
while x < toolbar.right() {
|
||||
let (tracks_before, zoom_before, snap_before) = cx.read(|app| {
|
||||
let panel = panel.read(app);
|
||||
let engine = panel.engine.read(app);
|
||||
(
|
||||
engine.track_count(),
|
||||
panel.timeline.read(app).state.zoom,
|
||||
panel.timeline.read(app).state.snap_enabled,
|
||||
)
|
||||
});
|
||||
cx.simulate_click(gpui::point(x, y), gpui::Modifiers::none());
|
||||
let (tracks_after, zoom_after, snap_after) = cx.read(|app| {
|
||||
let panel = panel.read(app);
|
||||
let engine = panel.engine.read(app);
|
||||
(
|
||||
engine.track_count(),
|
||||
panel.timeline.read(app).state.zoom,
|
||||
panel.timeline.read(app).state.snap_enabled,
|
||||
)
|
||||
});
|
||||
saw_add_track |= tracks_after != tracks_before;
|
||||
saw_zoom |= (zoom_after - zoom_before).abs() > f32::EPSILON;
|
||||
saw_snap |= snap_after != snap_before;
|
||||
x += px(4.0);
|
||||
}
|
||||
assert!(saw_add_track, "an add-track button was hit");
|
||||
assert!(saw_zoom, "a zoom button was hit");
|
||||
assert!(saw_snap, "the snap toggle was hit");
|
||||
}
|
||||
|
||||
/// The drop ghost renders (including the out-of-range row break), the
|
||||
/// dock metadata is populated and a left click focuses the panel.
|
||||
#[gpui::test]
|
||||
async fn drop_ghost_and_dock_metadata(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx, 1280.0, 720.0);
|
||||
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.footage_drop = Some(FootageDropTarget {
|
||||
track_kind: TrackKind::Video,
|
||||
track_index: 1,
|
||||
time: Frame(50),
|
||||
length: 250,
|
||||
});
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
assert!(cx.debug_bounds("timeline-canvas").is_some());
|
||||
|
||||
// A target beyond the last track breaks the row walk instead of
|
||||
// panicking; a zero-length ghost keeps its 4px minimum width.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.footage_drop = None;
|
||||
panel.effect_drop = Some(FootageDropTarget {
|
||||
track_kind: TrackKind::Audio,
|
||||
track_index: 999,
|
||||
time: Frame(10),
|
||||
length: 0,
|
||||
});
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
|
||||
// Dock metadata.
|
||||
cx.update(|_window, app| {
|
||||
let panel = panel.read(app);
|
||||
assert_eq!(panel.panel_id(), crate::panels::ids::TIMELINE);
|
||||
assert!(!panel.title(app).is_empty());
|
||||
let _ = panel.tab_content(app);
|
||||
});
|
||||
|
||||
// A left click focuses the panel (the root's mouse-down handler).
|
||||
cx.simulate_mouse_down(
|
||||
gpui::point(px(640.0), px(400.0)),
|
||||
gpui::MouseButton::Left,
|
||||
gpui::Modifiers::none(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Dispatches one complete drag gesture against the current rendered
|
||||
/// frame (a repaint between the events would consume the listeners).
|
||||
fn dispatch_drag(
|
||||
cx: &mut VisualTestContext,
|
||||
start: gpui::Point<Pixels>,
|
||||
hover: gpui::Point<Pixels>,
|
||||
ruler: gpui::Point<Pixels>,
|
||||
) {
|
||||
cx.update(|window, cx| {
|
||||
window.dispatch_event(
|
||||
gpui::PlatformInput::MouseDown(gpui::MouseDownEvent {
|
||||
position: start,
|
||||
modifiers: gpui::Modifiers::none(),
|
||||
button: gpui::MouseButton::Left,
|
||||
click_count: 1,
|
||||
first_mouse: false,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
let mut move_to = |position| {
|
||||
window.dispatch_event(
|
||||
gpui::PlatformInput::MouseMove(gpui::MouseMoveEvent {
|
||||
position,
|
||||
modifiers: gpui::Modifiers::none(),
|
||||
pressed_button: Some(gpui::MouseButton::Left),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
};
|
||||
// Past the drag threshold to start the drag.
|
||||
move_to(start + gpui::point(px(6.0), px(0.0)));
|
||||
// Over the ruler first (no target yet, so the clear is a no-op),
|
||||
// then hover the drop point, slide up to the ruler (clearing the
|
||||
// target) and hover it again.
|
||||
move_to(ruler);
|
||||
move_to(hover);
|
||||
move_to(ruler);
|
||||
move_to(hover);
|
||||
window.dispatch_event(
|
||||
gpui::PlatformInput::MouseUp(gpui::MouseUpEvent {
|
||||
position: hover,
|
||||
modifiers: gpui::Modifiers::none(),
|
||||
button: gpui::MouseButton::Left,
|
||||
click_count: 1,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// A full drag onto the timeline canvas resolves the drop target (and
|
||||
/// clears it over the ruler), then routes the payload to the engine.
|
||||
#[gpui::test]
|
||||
async fn drag_gestures_resolve_targets_and_route_drops(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(1280.0), px(720.0)), |window, cx| {
|
||||
let engine = cx.new(MockEngine::demo);
|
||||
let timeline = cx.new(|cx| TimelineView::new(engine.clone(), window, cx).zoom(2.0));
|
||||
let panel = cx.new(|cx| TimelinePanel::new(engine, timeline, window, cx));
|
||||
DropHost { panel }
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let host = window.root(cx).expect("drop host root");
|
||||
let mut cx = VisualTestContext::from_window(window.into(), cx);
|
||||
let panel = cx.read(|app| host.read(app).panel.clone());
|
||||
|
||||
let canvas = cx.debug_bounds("timeline-canvas").expect("canvas rendered");
|
||||
let hover = gpui::point(
|
||||
canvas.left() + px(HEADER_WIDTH + 100.0),
|
||||
canvas.top() + px(RULER_HEIGHT + 20.0),
|
||||
);
|
||||
let ruler = gpui::point(
|
||||
canvas.left() + px(HEADER_WIDTH + 100.0),
|
||||
canvas.top() + px(10.0),
|
||||
);
|
||||
|
||||
// An unknown footage id falls back to a one-frame ghost and the
|
||||
// engine rejects the drop.
|
||||
let source = cx
|
||||
.debug_bounds("footage-drag-source-missing")
|
||||
.expect("missing-footage source");
|
||||
dispatch_drag(&mut cx, source.center(), hover, ruler);
|
||||
cx.run_until_parked();
|
||||
cx.read(|app| {
|
||||
let panel = panel.read(app);
|
||||
assert!(panel.footage_drop.is_none(), "the drop consumed the target");
|
||||
assert!(
|
||||
panel.engine.read(app).footage_drops().is_empty(),
|
||||
"the unknown entry never placed a clip"
|
||||
);
|
||||
});
|
||||
|
||||
// The real footage entry places its clip.
|
||||
let source = cx
|
||||
.debug_bounds("footage-drag-source")
|
||||
.expect("footage source");
|
||||
dispatch_drag(&mut cx, source.center(), hover, ruler);
|
||||
cx.run_until_parked();
|
||||
cx.read(|app| {
|
||||
let panel = panel.read(app);
|
||||
let drops = panel.engine.read(app).footage_drops().to_vec();
|
||||
assert_eq!(drops.len(), 1, "the footage drop reached the engine");
|
||||
assert_eq!(drops[0].id, 3);
|
||||
assert_eq!(drops[0].track_kind, TrackKind::Video);
|
||||
assert_eq!(drops[0].track_index, 0);
|
||||
assert!(drops[0].time.0 >= 0);
|
||||
});
|
||||
|
||||
// The generator effect becomes a standalone clip on the pointed
|
||||
// track (the drop point sits on the first row).
|
||||
let clips_before = cx.read(|app| {
|
||||
panel.read(app).engine.read(app).track(0).unwrap().clips().len()
|
||||
});
|
||||
let source = cx
|
||||
.debug_bounds("effect-drag-source")
|
||||
.expect("effect source");
|
||||
dispatch_drag(&mut cx, source.center(), hover, ruler);
|
||||
cx.run_until_parked();
|
||||
cx.read(|app| {
|
||||
let panel = panel.read(app);
|
||||
assert!(panel.effect_drop.is_none(), "the effect drop is consumed");
|
||||
assert_eq!(
|
||||
panel.engine.read(app).track(0).unwrap().clips().len(),
|
||||
clips_before + 1,
|
||||
"the generator clip landed on the pointed track"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// With no open sequence the footage drop is handed back to the shell and
|
||||
/// the work-area helpers fall back to a one-frame sequence.
|
||||
#[gpui::test]
|
||||
async fn no_sequence_paths_use_the_fallbacks(cx: &mut TestAppContext) {
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(1280.0), px(720.0)), |window, cx| {
|
||||
let engine = cx.new(crate::oakui::RealEngine::create);
|
||||
let timeline = cx.new(|cx| TimelineView::new(engine.clone(), window, cx).zoom(2.0));
|
||||
let panel = cx.new(|cx| TimelinePanel::new(engine, timeline, window, cx));
|
||||
DropHost { panel }
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let host = window.root(cx).expect("host root");
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
let panel = cx.read(|app| host.read(app).panel.clone());
|
||||
|
||||
let emitted = Rc::new(Cell::new(false));
|
||||
cx.update(|_window, app| {
|
||||
let emitted = emitted.clone();
|
||||
app.subscribe(
|
||||
&panel,
|
||||
move |_panel: Entity<TimelinePanel<crate::oakui::RealEngine>>,
|
||||
_event: &FootageDropNeedsSequence,
|
||||
_cx| {
|
||||
emitted.set(true);
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.footage_drop = Some(FootageDropTarget {
|
||||
track_kind: TrackKind::Video,
|
||||
track_index: 0,
|
||||
time: Frame(5),
|
||||
length: 1,
|
||||
});
|
||||
panel.finish_footage_drop(&FootageDrag(3), cx);
|
||||
});
|
||||
});
|
||||
assert!(
|
||||
emitted.get(),
|
||||
"the shell is asked to set up a sequence first"
|
||||
);
|
||||
cx.read(|app| assert!(panel.read(app).footage_drop.is_none()));
|
||||
|
||||
// No sequence length is available: the fallbacks use the playhead.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.set_point_at_playhead(false, cx);
|
||||
panel.set_point_at_playhead(true, cx);
|
||||
assert!(panel.reset_in(cx));
|
||||
assert!(panel.reset_out(cx));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user