Files
oak-editor/crates/oaktimeline/tests/marker_test.rs
T
Mike-Solar b36cbd6b6f refactor: purge CHandle from module internals (M14 R5)
Module-internal object references are Rust types now (values, Arc,
Mutex); CHandle remains only at the oakengine C-ABI boundary:

- oakundo: the global stack holds UndoStack/UndoCommand values
  directly (stack token is the static's address)
- oaktimeline: marker/workarea boxes carry Arc<Mutex<T>>; commands
  share the same allocation through Arc clones (readers in oakengine
  stubs and the app's graphops updated to lock)
- oaktask/oakstorage: sessions, write-through bindings and the
  database backend pass ProjectArc; the Session drops its manual
  release bookkeeping; nodeutil keeps the CHandle<->Arc boundary
  conversion (release_project restored for the app)
- oakcodec: handle.rs deleted outright (no facade entry needed it);
  texture/block placeholders are unit structs
- oakrender: copier's project handle is an identity u64; alive-count
  machinery removed; handle.rs is make_owned/get/get_mut only
- oakplugin: the instance registry is gone (its unregister key never
  matched, leaking weak entries); handle.rs is the RefBox boundary type
- oaknode/oakcommon: only dead guard/borrow helpers removed; external
  payload handles (texture/processor) documented as the boundary

Flake hunts landed along the way: the audio recording test serializes
on the shared manager lock with a normalized state; the autocacher
cancel test uses a slow producer so cancellation is deterministic.
2026-08-17 16:40:15 +08:00

513 lines
15 KiB
Rust

// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Contract tests for the marker domain (`src/marker.rs`):
//! `TimelineMarker` and `TimelineMarkerList` value types plus the five
//! marker undo commands. The list stays sorted by time (C++
//! `TimelineMarkerList` re-sorts on mutation), which `resort` enforces
//! after out-of-band edits. The XML load/save contract left with the
//! deleted C ABI export layer (single-lib unification).
use std::sync::{Arc, Mutex, MutexGuard};
use oakcore_rs::{Rational, TimeRange};
use oaktimeline::common::EditToInfo;
use oaktimeline::handle::{get, make_owned, CHandle};
use oaktimeline::marker::{
MarkerAddCommand, MarkerChangeColorCommand, MarkerChangeNameCommand, MarkerChangeTimeCommand,
MarkerRemoveCommand, TimelineMarker, TimelineMarkerList,
};
use oaktimeline::undocommon::Command;
/// Lock the shared marker list behind a live handle. Every marker-list
/// handle in these tests comes from `make_owned`, which boxes an
/// `Arc<Mutex<TimelineMarkerList>>`.
fn list_of(h: &CHandle) -> MutexGuard<'_, TimelineMarkerList> {
// SAFETY: as above; the handle is live.
unsafe { get::<Arc<Mutex<TimelineMarkerList>>>(h) }
.expect("live marker-list handle")
.lock()
.unwrap_or_else(|e| e.into_inner())
}
/// A default `TimelineMarker` starts at the null time with no name.
#[test]
fn marker_default_is_null_time() {
let m = TimelineMarker::new();
assert_eq!(m.color(), 0);
assert_eq!(m.time().in_(), Rational::new(0, 1));
assert_eq!(m.time().out(), Rational::new(0, 1));
assert_eq!(m.name(), "");
}
/// `TimelineMarker::with_time` stores color, time range and name as
/// given and exposes them through the getters.
#[test]
fn marker_with_time_stores_fields() {
let m = TimelineMarker::with_time(
3,
TimeRange::new(Rational::new(10, 1), Rational::new(20, 1)),
"hello",
);
assert_eq!(m.color(), 3);
assert_eq!(m.time().in_(), Rational::new(10, 1));
assert_eq!(m.time().out(), Rational::new(20, 1));
assert_eq!(m.name(), "hello");
}
/// `set_time_point` moves a range marker to a single point, preserving
/// its length (and hence name and color).
#[test]
fn marker_set_time_point_preserves_name_color() {
let mut m = TimelineMarker::with_time(
5,
TimeRange::new(Rational::new(10, 1), Rational::new(20, 1)),
"tag",
);
let t = Rational::new(30, 1);
m.set_time_point(t);
// Color and name survive the move.
assert_eq!(m.color(), 5);
assert_eq!(m.name(), "tag");
// The original length (10) is preserved.
assert_eq!(m.time().in_(), t);
assert_eq!(m.time().out(), Rational::new(40, 1));
}
/// `has_sibling_at_time` is a De-Qt simplification that always reports
/// `false`; sibling queries go through the list instead.
#[test]
fn marker_sibling_detection() {
let m = TimelineMarker::new();
assert!(!m.has_sibling_at_time(Rational::new(0, 1)));
assert!(!m.has_sibling_at_time(Rational::new(42, 1)));
}
/// The list starts empty and grows with each `add_marker`, preserving
/// sorted order (by in point) for markers at distinct times.
#[test]
fn marker_list_grows_with_adds() {
let mut list = TimelineMarkerList::new();
assert!(list.empty());
assert_eq!(list.size(), 0);
list.add_marker(TimelineMarker::with_time(
1,
TimeRange::new(Rational::new(10, 1), Rational::new(11, 1)),
"a",
));
list.add_marker(TimelineMarker::with_time(
2,
TimeRange::new(Rational::new(5, 1), Rational::new(6, 1)),
"b",
));
assert_eq!(list.size(), 2);
assert!(!list.empty());
// Sorted by in point: b (5) before a (10).
assert_eq!(list.front().unwrap().name(), "b");
assert_eq!(list.back().unwrap().name(), "a");
}
/// `at`/`back`/`front` expose the stored markers; `at` returns `None`
/// for an out-of-range index.
#[test]
fn marker_list_indexing() {
let mut list = TimelineMarkerList::new();
assert!(list.at(0).is_none());
assert!(list.front().is_none());
assert!(list.back().is_none());
list.add_marker(TimelineMarker::with_time(
1,
TimeRange::new(Rational::new(1, 1), Rational::new(2, 1)),
"one",
));
assert!(list.at(0).is_some());
assert_eq!(list.at(0).unwrap().name(), "one");
assert!(list.at(1).is_none());
assert!(list.front().is_some());
assert!(list.back().is_some());
}
/// `remove_marker` removes exactly the given marker and returns it;
/// removing a marker that is not present is a no-op returning `None`.
#[test]
fn marker_list_remove() {
let mut list = TimelineMarkerList::new();
list.add_marker(TimelineMarker::with_time(
1,
TimeRange::new(Rational::new(1, 1), Rational::new(2, 1)),
"one",
));
// `remove_marker` takes a reference into the list; drop the borrow by
// going through a raw pointer first (as `marker.rs` does internally).
let m = list.at(0).unwrap();
let mptr = m as *const TimelineMarker;
let removed = list.remove_marker(unsafe { &*mptr });
assert!(removed.is_some());
assert_eq!(removed.unwrap().name(), "one");
assert_eq!(list.size(), 0);
// Removing a marker not present returns None.
let stray = TimelineMarker::new();
assert!(list.remove_marker(&stray).is_none());
assert_eq!(list.size(), 0);
}
/// `get_marker_at_time` finds the marker at an exact time; adding a
/// marker and then asking at the same time finds it.
#[test]
fn marker_list_find_at_time() {
let mut list = TimelineMarkerList::new();
list.add_marker(TimelineMarker::with_time(
1,
TimeRange::new(Rational::new(7, 1), Rational::new(8, 1)),
"x",
));
let found = list.get_marker_at_time(Rational::new(7, 1));
assert!(found.is_some());
assert_eq!(found.unwrap().name(), "x");
assert!(list.get_marker_at_time(Rational::new(9, 1)).is_none());
}
/// `get_closest_marker_to_time` finds the nearest marker; an exact match
/// at the first marker early-exits once the distance starts increasing,
/// and queries before/after all markers exercise both signs of the
/// absolute-difference comparison.
#[test]
fn marker_list_closest_to_time() {
let mut list = TimelineMarkerList::new();
list.add_marker(TimelineMarker::with_time(
1,
TimeRange::new(Rational::new(5, 1), Rational::new(6, 1)),
"a",
));
list.add_marker(TimelineMarker::with_time(
2,
TimeRange::new(Rational::new(10, 1), Rational::new(11, 1)),
"b",
));
list.add_marker(TimelineMarker::with_time(
3,
TimeRange::new(Rational::new(20, 1), Rational::new(21, 1)),
"c",
));
// Empty list -> None.
assert!(TimelineMarkerList::new()
.get_closest_marker_to_time(Rational::new(0, 1))
.is_none());
// Exact match at the first marker (early-exit on the next).
assert_eq!(
list.get_closest_marker_to_time(Rational::new(5, 1))
.unwrap()
.name(),
"a"
);
// Query after all markers (negative differences).
assert_eq!(
list.get_closest_marker_to_time(Rational::new(100, 1))
.unwrap()
.name(),
"c"
);
// Query before all markers (positive differences).
assert_eq!(
list.get_closest_marker_to_time(Rational::new(1, 1))
.unwrap()
.name(),
"a"
);
}
/// A command's undo before any redo is a no-op.
#[test]
fn marker_add_command_undo_before_redo() {
let list_h = make_owned(TimelineMarkerList::new());
let mut cmd = MarkerAddCommand::new(
list_h.clone(),
TimeRange::new(Rational::new(1, 1), Rational::new(2, 1)),
"m",
0,
);
cmd.undo();
assert_eq!(list_of(&list_h).size(), 0);
}
/// `MarkerRemoveCommand` redo is idempotent (a second redo is a no-op).
#[test]
fn marker_remove_command_double_redo() {
let list_h = make_owned(TimelineMarkerList::new());
list_of(&list_h).add_marker(TimelineMarker::with_time(
1,
TimeRange::new(Rational::new(1, 1), Rational::new(2, 1)),
"m",
));
let mut cmd = MarkerRemoveCommand::new(list_h.clone(), 0);
cmd.redo();
cmd.redo();
assert_eq!(list_of(&list_h).size(), 0);
}
/// Every marker command dispatches through the `Command` trait, which is
/// how the undo stack's vtable invokes them.
#[test]
fn marker_commands_trait_dispatch() {
let list_h = make_owned(TimelineMarkerList::new());
list_of(&list_h).add_marker(TimelineMarker::with_time(
1,
TimeRange::new(Rational::new(1, 1), Rational::new(2, 1)),
"m",
));
let mut add = MarkerAddCommand::new(
list_h.clone(),
TimeRange::new(Rational::new(9, 1), Rational::new(10, 1)),
"n",
0,
);
Command::redo(&mut add);
assert_eq!(list_of(&list_h).size(), 2);
Command::undo(&mut add);
assert_eq!(list_of(&list_h).size(), 1);
let mut remove = MarkerRemoveCommand::new(list_h.clone(), 0);
Command::redo(&mut remove);
assert_eq!(list_of(&list_h).size(), 0);
Command::undo(&mut remove);
assert_eq!(list_of(&list_h).size(), 1);
let mut color = MarkerChangeColorCommand::new(list_h.clone(), 0, 7);
Command::redo(&mut color);
assert_eq!(list_of(&list_h).at(0).unwrap().color(), 7);
Command::undo(&mut color);
assert_eq!(list_of(&list_h).at(0).unwrap().color(), 1);
let mut name = MarkerChangeNameCommand::new(list_h.clone(), 0, "z");
Command::redo(&mut name);
assert_eq!(list_of(&list_h).at(0).unwrap().name(), "z");
Command::undo(&mut name);
assert_eq!(list_of(&list_h).at(0).unwrap().name(), "m");
let mut time = MarkerChangeTimeCommand::new(
list_h.clone(),
0,
TimeRange::new(Rational::new(50, 1), Rational::new(51, 1)),
);
Command::redo(&mut time);
assert_eq!(
list_of(&list_h).at(0).unwrap().time().in_(),
Rational::new(50, 1)
);
Command::undo(&mut time);
assert_eq!(
list_of(&list_h).at(0).unwrap().time().in_(),
Rational::new(1, 1)
);
}
/// Re-establishing time order after an out-of-band time change: the
/// list exposes markers immutably, so the edit is expressed as
/// detach + mutate a copy + re-insert, which re-sorts exactly as
/// `resort`/`MarkerChangeTimeCommand` do.
#[test]
fn marker_list_resort_after_time_change() {
let mut list = TimelineMarkerList::new();
list.add_marker(TimelineMarker::with_time(
1,
TimeRange::new(Rational::new(10, 1), Rational::new(11, 1)),
"a",
));
list.add_marker(TimelineMarker::with_time(
2,
TimeRange::new(Rational::new(5, 1), Rational::new(6, 1)),
"b",
));
// Order: b (5), a (10).
assert_eq!(list.at(0).unwrap().name(), "b");
// Detach `a`, move it to time 3, re-insert.
let mptr = list.at(1).unwrap() as *const TimelineMarker;
let a = list.remove_marker(unsafe { &*mptr }).unwrap();
let mut moved = a;
moved.set_time(TimeRange::new(Rational::new(3, 1), Rational::new(4, 1)));
list.add_marker(moved);
assert_eq!(list.size(), 2);
assert_eq!(list.front().unwrap().name(), "a");
assert_eq!(list.back().unwrap().name(), "b");
}
/// A `MarkerAddCommand` redo appends the marker; undo removes it
/// again, leaving the list as it was.
#[test]
fn marker_add_command_redo_undo() {
let list_h = make_owned(TimelineMarkerList::new());
let mut cmd = MarkerAddCommand::new(
list_h.clone(),
TimeRange::new(Rational::new(1, 1), Rational::new(2, 1)),
"m",
4,
);
cmd.redo();
let list = list_of(&list_h);
assert_eq!(list.size(), 1);
assert_eq!(list.at(0).unwrap().name(), "m");
assert_eq!(list.at(0).unwrap().color(), 4);
drop(list);
// Redo is idempotent.
cmd.redo();
assert_eq!(list_of(&list_h).size(), 1);
cmd.undo();
assert_eq!(list_of(&list_h).size(), 0);
}
/// A `MarkerRemoveCommand` redo drops the marker; undo re-inserts it
/// at its time position.
#[test]
fn marker_remove_command_redo_undo() {
let list_h = make_owned(TimelineMarkerList::new());
list_of(&list_h).add_marker(TimelineMarker::with_time(
1,
TimeRange::new(Rational::new(1, 1), Rational::new(2, 1)),
"m",
));
let mut cmd = MarkerRemoveCommand::new(list_h.clone(), 0);
cmd.redo();
assert_eq!(list_of(&list_h).size(), 0);
cmd.undo();
let list = list_of(&list_h);
assert_eq!(list.size(), 1);
assert_eq!(list.at(0).unwrap().name(), "m");
}
/// `MarkerChangeColorCommand` redo changes the color and undo restores
/// the original.
#[test]
fn marker_change_color_command_redo_undo() {
let list_h = make_owned(TimelineMarkerList::new());
list_of(&list_h).add_marker(TimelineMarker::with_time(
1,
TimeRange::new(Rational::new(1, 1), Rational::new(2, 1)),
"m",
));
let mut cmd = MarkerChangeColorCommand::new(list_h.clone(), 0, 9);
cmd.redo();
assert_eq!(list_of(&list_h).at(0).unwrap().color(), 9);
cmd.undo();
assert_eq!(list_of(&list_h).at(0).unwrap().color(), 1);
}
/// `MarkerChangeNameCommand` redo changes the name and undo restores
/// the original.
#[test]
fn marker_change_name_command_redo_undo() {
let list_h = make_owned(TimelineMarkerList::new());
list_of(&list_h).add_marker(TimelineMarker::with_time(
1,
TimeRange::new(Rational::new(1, 1), Rational::new(2, 1)),
"m",
));
let mut cmd = MarkerChangeNameCommand::new(list_h.clone(), 0, "renamed");
cmd.redo();
assert_eq!(list_of(&list_h).at(0).unwrap().name(), "renamed");
cmd.undo();
assert_eq!(list_of(&list_h).at(0).unwrap().name(), "m");
}
/// `MarkerChangeTimeCommand` redo moves the marker and undo restores
/// the original time.
#[test]
fn marker_change_time_command_redo_undo() {
let list_h = make_owned(TimelineMarkerList::new());
list_of(&list_h).add_marker(TimelineMarker::with_time(
1,
TimeRange::new(Rational::new(1, 1), Rational::new(2, 1)),
"m",
));
let new_t = TimeRange::new(Rational::new(50, 1), Rational::new(51, 1));
let mut cmd = MarkerChangeTimeCommand::new(list_h.clone(), 0, new_t);
cmd.redo();
assert_eq!(
list_of(&list_h).at(0).unwrap().time().in_(),
Rational::new(50, 1)
);
cmd.undo();
assert_eq!(
list_of(&list_h).at(0).unwrap().time().in_(),
Rational::new(1, 1)
);
}
/// `to_command` boxes a marker command into an oakundo `UndoCommand`
/// value for the undo stack (the old C ABI command handle is gone with
/// the single-lib unification); `redo_now`/`undo_now` drive the same
/// redo/undo bodies the stack would.
#[test]
fn marker_commands_box_to_undo_command() {
let list_h = make_owned(TimelineMarkerList::new());
list_of(&list_h).add_marker(TimelineMarker::with_time(
1,
TimeRange::new(Rational::new(1, 1), Rational::new(2, 1)),
"m",
));
let mut cmd = MarkerAddCommand::new(
list_h.clone(),
TimeRange::new(Rational::new(5, 1), Rational::new(6, 1)),
"n",
0,
)
.to_command();
cmd.redo_now();
assert_eq!(list_of(&list_h).size(), 2);
cmd.undo_now();
assert_eq!(list_of(&list_h).size(), 1);
}
/// Loading a marker with a `color`/`in`/`out` attribute equal to the
/// sentinel matches how `EditToInfo` consumers treat defaults.
#[test]
fn marker_defaults_map_to_edit_to_info() {
// A default marker carries the null time and default color (0).
let m = TimelineMarker::new();
assert_eq!(m.color(), 0);
assert_eq!(m.time().in_(), Rational::new(0, 1));
// `EditToInfo` defaults mirror those sentinels: null node references
// (`None`, the single-lib replacement for the null `CHandle`) and the
// null rational for `nearest_time`.
let info = EditToInfo {
track: None,
nearest_time: m.time().in_(),
nearest_block: None,
};
assert!(info.track.is_none());
assert!(info.nearest_block.is_none());
assert_eq!(info.nearest_time, Rational::new(0, 1));
}