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:
2026-09-22 20:54:04 +08:00
parent fe8fbff8a9
commit 4cc30d8711
22 changed files with 21111 additions and 72 deletions
+2892 -9
View File
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
+216 -1
View File
@@ -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();
}
}
+448 -1
View File
@@ -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());
});
});
}
}
+84 -1
View File
@@ -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
+764
View File
@@ -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");
});
});
}
}
+549
View File
@@ -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() -> (
+229 -5
View File
@@ -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();
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+133 -1
View File
@@ -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(&not_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(&not_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);
}
}
+199 -1
View File
@@ -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"
);
}
});
}
}
+111
View File
@@ -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);
});
});
}
}
+88
View File
@@ -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"
);
}
}
+40
View File
@@ -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
+344 -1
View File
@@ -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]
+147 -1
View File
@@ -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(),
);
}
}
+965
View File
@@ -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));
});
});
}
}