Files
oak-editor/crates/oakstorage/tests/common/mod.rs
T
Mike-Solar 025dc88c25 feat(storage,app): PostgreSQL backend (D3) + project manager window (D4)
D3: oakdb+pg:// fully wired (shared sea-orm entities, BIGSERIAL DDL,
connect-probe instead of pool retry on dead servers); Storage/Backend=pg
+ Storage/PgUrl config; 13 OAK_TEST_PG_URL-gated PG tests (verified
against a Docker postgres:16), always-on clean-error tests otherwise.

D4: DaVinci-style project manager — list with derived stats, create/
rename/duplicate/delete (confirm)/import/export (native dialogs,
ove/otio/fcpxml), shown at startup and from the file menu; facade
oakengine_library_* exports (list/create/delete/rename/duplicate/
import/export + project_load_library that binds write-through);
save/save-as menu becomes 'export project file', open splits into
from-library/from-file; status bar shows library write state; storage
activates on app start and flushes on exit; spawn_modal reentrancy
fixed (window-callback path) with a doc note.

Also: the P1 audio test's environment probe was lost in the ffi purge;
restored on cpal (the output device is cpal now).
2026-08-16 10:39:31 +08:00

526 lines
16 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/>.
//! Shared fixtures and helpers for the database-backend integration
//! tests (plan M13 D1/D3).
//!
//! Everything here is dialect-agnostic: the test bodies run against a
//! `oakdb+sqlite://…` or `oakdb+pg://…` uri string, and the helpers that
//! inspect rows behind the backend's back open a raw sea-orm connection
//! ([`inspect_sqlite`] / [`inspect_pg`]). `tests/database_test.rs` runs
//! the SQLite suite; `tests/database_pg_test.rs` runs the same behaviors
//! against a real PostgreSQL server gated on `OAK_TEST_PG_URL`.
// The module is compiled into two test binaries, each of which uses only
// the helpers for its own dialect, so the other dialect's helpers look
// dead to one binary while the other uses them.
#![allow(dead_code)]
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use sea_orm::entity::prelude::*;
use oakcore_rs::Rational;
use oaknode::block::ClipBlockBehavior;
use oaknode::footage::FootageBehavior;
use oaknode::id::NodeId;
use oaknode::keyframe::{Interpolation, Keyframe};
use oaknode::node::NodeCore;
use oaknode::project::Project;
use oaknode::sequence::SequenceBehavior;
use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType};
use oaknode::value::NodeValue;
use oakstorage::backend::StorageBackend;
use oakstorage::backends::database::entities::settings;
use oakstorage::backends::database::DatabaseBackend;
use oakstorage::error::OAKSTORAGE_OK;
use oakstorage::handle::CHandle;
use oakstorage::nodeutil::{make_project_owned, project_arc};
use oakstorage::uri::StorageUri;
// ---------------------------------------------------------------------------
// URIs and paths
// ---------------------------------------------------------------------------
/// A fresh, unique temp directory for one test.
pub(crate) fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("oakstorage_db_{}_{}", std::process::id(), tag));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// `oakdb+sqlite:///…` URI for a database file.
pub(crate) fn db_uri(path: &Path) -> String {
format!("oakdb+sqlite://{}", path.display())
}
/// `oakdb+sqlite:///…?project=<uuid>` URI selecting one library row.
pub(crate) fn project_uri(db: &str, uuid: &str) -> String {
format!("{db}?project={uuid}")
}
/// `file://…` URI for a plain file.
pub(crate) fn file_uri(path: &Path) -> String {
format!("file://{}", path.display())
}
// ---------------------------------------------------------------------------
// Save / load through the backend
// ---------------------------------------------------------------------------
/// Release an owned handle (refcount 1).
pub(crate) fn release(h: CHandle) {
if let Some(release) = h.release {
unsafe { release(h.ctx) };
}
}
/// Save an `Arc<Mutex<Project>>` through the database backend.
pub(crate) fn save_project(
backend: &DatabaseBackend,
project: &Arc<Mutex<Project>>,
uri: &str,
) -> oakstorage::error::Result<()> {
let parsed = StorageUri::parse(uri).unwrap();
let handle = make_project_owned(project.clone());
let result = backend.save(handle, &parsed, 0);
release(handle);
result
}
/// Load a project through a *new* database backend session (a fresh
/// connection pool = a fresh session), returning `(uuid, loaded)`.
pub(crate) fn load_project(backend: &DatabaseBackend, uri: &str) -> (String, Arc<Mutex<Project>>) {
let parsed = StorageUri::parse(uri).unwrap();
let result = backend.load(&parsed).unwrap();
assert_eq!(result.version_info, OAKSTORAGE_OK);
let handle = result.project;
let loaded = unsafe { project_arc(&handle) }.unwrap();
let uuid = loaded.lock().unwrap().uuid.clone();
release(handle);
(uuid, loaded)
}
/// Load the state at `seq` (undo to any point).
pub(crate) fn load_at(backend: &DatabaseBackend, uri: &str, uuid: &str, seq: i64) -> Arc<Mutex<Project>> {
let parsed = StorageUri::parse(uri).unwrap();
let handle = backend.load_at(&parsed, uuid, seq).unwrap();
let loaded = unsafe { project_arc(&handle) }.unwrap();
release(handle);
loaded
}
pub(crate) fn r_to_f(r: Rational) -> f64 {
r.numerator() as f64 / r.denominator() as f64
}
pub(crate) fn assert_close(a: f64, b: f64) {
assert!((a - b).abs() < 1e-6, "expected {a} close to {b}");
}
// ---------------------------------------------------------------------------
// Row inspection (raw sea-orm connections behind the backend's back)
// ---------------------------------------------------------------------------
/// Open a raw sea-orm SQLite connection to `path` and drive one future
/// against it on a private current-thread runtime.
pub(crate) fn inspect_sqlite<R, Fut>(path: &Path, f: impl FnOnce(sea_orm::DatabaseConnection) -> Fut) -> R
where
Fut: std::future::Future<Output = R>,
{
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let options = sea_orm::sqlx::sqlite::SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true)
.journal_mode(sea_orm::sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(Duration::from_secs(5))
.foreign_keys(true);
let pool = sea_orm::sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.unwrap();
f(sea_orm::DatabaseConnection::from(pool)).await
})
}
/// Open a raw sea-orm PostgreSQL connection to `url` and drive one
/// future against it on a private current-thread runtime.
pub(crate) fn inspect_pg<R, Fut>(url: &str, f: impl FnOnce(sea_orm::DatabaseConnection) -> Fut) -> R
where
Fut: std::future::Future<Output = R>,
{
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let pool = sea_orm::sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(url)
.await
.unwrap();
f(sea_orm::DatabaseConnection::from(pool)).await
})
}
/// The settings mirror rows of a project (SQLite).
pub(crate) fn settings_rows(path: &Path, project_id: i64) -> Vec<(String, String)> {
inspect_sqlite(path, |conn| async move {
settings::Entity::find()
.filter(settings::Column::ProjectId.eq(project_id))
.all(&conn)
.await
.unwrap()
.into_iter()
.map(|s| (s.key, s.value))
.collect()
})
}
/// The settings mirror rows of a project (PostgreSQL).
pub(crate) fn settings_rows_pg(url: &str, project_id: i64) -> Vec<(String, String)> {
inspect_pg(url, |conn| async move {
settings::Entity::find()
.filter(settings::Column::ProjectId.eq(project_id))
.all(&conn)
.await
.unwrap()
.into_iter()
.map(|s| (s.key, s.value))
.collect()
})
}
/// The project uuid.
pub(crate) fn uuid_of(project: &Arc<Mutex<Project>>) -> String {
project.lock().unwrap().uuid.clone()
}
/// Read a config value for the duration of a test (the config store is
/// process-global, so restore it afterwards).
pub(crate) fn with_config(group: &str, key: &str, value: i32, f: impl FnOnce()) {
let store = oakcommon::configstore::ConfigStore::instance();
let before = store.get_int(Some(group), key, 0);
store.set_int(Some(group), key, value);
f();
store.set_int(Some(group), key, before);
}
// ---------------------------------------------------------------------------
// Full-feature fixture (footage / sequence / track / clip / effect /
// keyframes) — the union of the ove round-trip and timeline fixtures.
// ---------------------------------------------------------------------------
pub(crate) const MATH: &str = "org.olivevideoeditor.Olive.math";
/// Build the round-trip fixture: root folder + two math nodes with
/// values/keyframes/label/color/link/connection + a sequence "My Seq"
/// with one video track carrying two clips (footage /a/b.mp4, /a/c.mp4).
pub(crate) fn build_full_project() -> Arc<Mutex<Project>> {
let project = Project::new();
let mut p = project.lock().unwrap();
p.initialize().unwrap();
let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)();
let a = p.graph.add_node(core, behavior);
{
let e = p.graph.get_mut(a).unwrap();
e.core.label = "Math A".to_string();
e.core.override_color = 2;
e.core.set_standard_value("param_a_in", -1, NodeValue::Float(2.5));
e.core
.keyframe_track_mut("param_a_in", -1)
.set_key(Keyframe {
time: Rational::new(0, 1),
value: NodeValue::Float(1.0),
interpolation: Interpolation::Linear,
bezier_in: (0.0, 0.0),
bezier_out: (0.0, 0.0),
});
e.core
.keyframe_track_mut("param_a_in", -1)
.set_key(Keyframe {
time: Rational::new(1, 1),
value: NodeValue::Float(3.0),
interpolation: Interpolation::Bezier,
bezier_in: (0.1, 0.2),
bezier_out: (0.3, 0.4),
});
}
let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)();
let b = p.graph.add_node(core, behavior);
p.graph
.get_mut(b)
.unwrap()
.core
.set_standard_value("param_a_in", -1, NodeValue::Float(4.0));
p.graph.connect(a, b, "param_b_in", -1).unwrap();
p.graph.link(a, b);
// Timeline: sequence + video track + two clips with footage.
let (seq_id, lists) = oakstorage::nodeutil::create_sequence(&mut p.graph);
p.graph.get_mut(seq_id).unwrap().core.label = "My Seq".to_string();
let mut tb = TrackBehavior::new(TrackType::Video);
tb.track_list = Some(lists[0]);
let track_id = p.graph.add_node(NodeCore::new(), Box::new(tb));
let foot1 = p
.graph
.add_node(NodeCore::new(), Box::new(FootageBehavior::new("/a/b.mp4")));
let clip1 = {
let (core, mut behavior) = oaknode::block::clip_create();
let clip = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
.unwrap();
clip.core.range = oakcore_rs::TimeRange::new(Rational::new(0, 1), Rational::new(100, 25));
clip.core.media_in = Rational::new(0, 1);
clip.core.track = Some(track_id);
clip.footage = Some(foot1);
p.graph.add_node(core, behavior)
};
let foot2 = p
.graph
.add_node(NodeCore::new(), Box::new(FootageBehavior::new("/a/c.mp4")));
let clip2 = {
let (core, mut behavior) = oaknode::block::clip_create();
let clip = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
.unwrap();
clip.core.range =
oakcore_rs::TimeRange::new(Rational::new(100, 25), Rational::new(150, 25));
clip.core.media_in = Rational::new(10, 25);
clip.core.track = Some(track_id);
clip.footage = Some(foot2);
p.graph.add_node(core, behavior)
};
if let Some(entry) = p.graph.get_mut(track_id) {
entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TrackBehavior>())
.unwrap()
.blocks = vec![clip1, clip2];
}
if let Some(entry) = p.graph.get_mut(lists[0]) {
entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TrackListBehavior>())
.unwrap()
.tracks = vec![track_id];
}
p.settings
.insert("projectname".to_string(), "full-fixture".to_string());
drop(p);
project
}
/// Field-by-field comparison of the full fixture after a round-trip
/// (uuid included).
pub(crate) fn assert_full_fields(orig: &Project, loaded: &Project) {
assert_eq!(loaded.uuid, orig.uuid, "uuid");
assert_full_state(orig, loaded);
}
/// Field-by-field comparison that ignores the uuid (a duplicated row
/// carries a fresh uuid by design).
pub(crate) fn assert_full_state(orig: &Project, loaded: &Project) {
assert_eq!(loaded.settings, orig.settings, "settings");
// Node set: count, type order (slot order preserved by the writer).
let o_ids = orig.graph.node_ids();
let l_ids = loaded.graph.node_ids();
assert_eq!(l_ids.len(), o_ids.len(), "node count");
let o_types: Vec<&str> = o_ids
.iter()
.map(|id| orig.graph.get(*id).unwrap().behavior.type_id())
.collect();
let l_types: Vec<&str> = l_ids
.iter()
.map(|id| loaded.graph.get(*id).unwrap().behavior.type_id())
.collect();
assert_eq!(l_types, o_types, "node types");
// Math A: label, color, value, keyframes.
let (a_o, a_l) = (o_ids[1], l_ids[1]);
assert_eq!(
loaded.graph.get(a_l).unwrap().core.label,
orig.graph.get(a_o).unwrap().core.label,
"label"
);
assert_eq!(
loaded.graph.get(a_l).unwrap().core.override_color,
orig.graph.get(a_o).unwrap().core.override_color,
"color"
);
assert_eq!(
loaded
.graph
.get(a_l)
.unwrap()
.core
.standard_value("param_a_in", -1),
orig.graph
.get(a_o)
.unwrap()
.core
.standard_value("param_a_in", -1),
"value a"
);
let keys_o = orig
.graph
.get(a_o)
.unwrap()
.core
.keyframe_track("param_a_in", -1)
.unwrap()
.keys()
.to_vec();
let keys_l = loaded
.graph
.get(a_l)
.unwrap()
.core
.keyframe_track("param_a_in", -1)
.unwrap()
.keys()
.to_vec();
assert_eq!(keys_l.len(), keys_o.len(), "keyframe count");
for (ko, kl) in keys_o.iter().zip(&keys_l) {
assert_eq!(kl.time, ko.time, "key time");
assert_eq!(kl.value.to_double(), ko.value.to_double(), "key value");
assert_eq!(kl.interpolation, ko.interpolation, "key interpolation");
assert_eq!(kl.bezier_in, ko.bezier_in, "key bezier in");
assert_eq!(kl.bezier_out, ko.bezier_out, "key bezier out");
}
// Connection a -> b.param_b_in and the link.
assert_eq!(
loaded.graph.connected_output(l_ids[2], "param_b_in", -1),
Some(a_l),
"connection"
);
assert!(loaded.graph.are_linked(a_l, l_ids[2]), "link");
// Timeline: sequence, track lists, track, clips, footage.
let mut seq: Option<(NodeId, Vec<NodeId>)> = None;
for id in loaded.graph.node_ids() {
let entry = loaded.graph.get(id).unwrap();
if entry.behavior.type_id() == "org.olivevideoeditor.Olive.sequence" {
let s = entry
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
.unwrap();
seq = Some((id, s.track_lists.clone()));
break;
}
}
let (_seq_id, lists) = seq.expect("sequence present");
assert_eq!(lists.len(), 3, "video/audio/subtitle lists");
let list = loaded
.graph
.get(lists[0])
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<TrackListBehavior>())
.unwrap();
assert_eq!(list.kind, TrackType::Video);
assert_eq!(list.array_base, 0);
assert_eq!(list.tracks.len(), 1);
let track = loaded
.graph
.get(list.tracks[0])
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<TrackBehavior>())
.unwrap();
assert_eq!(track.kind, TrackType::Video);
assert_eq!(track.blocks.len(), 2);
let c1 = loaded
.graph
.get(track.blocks[0])
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
.unwrap();
assert_close(r_to_f(c1.core.length()), 4.0);
assert_close(r_to_f(c1.core.media_in), 0.0);
let f1 = loaded
.graph
.get(c1.footage.unwrap())
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<FootageBehavior>())
.unwrap();
assert_eq!(f1.filename, "/a/b.mp4");
let c2 = loaded
.graph
.get(track.blocks[1])
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
.unwrap();
assert_close(r_to_f(c2.core.length()), 2.0);
assert_close(r_to_f(c2.core.media_in), 0.4);
let f2 = loaded
.graph
.get(c2.footage.unwrap())
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<FootageBehavior>())
.unwrap();
assert_eq!(f2.filename, "/a/c.mp4");
}
/// Build a small project with a `projectname` setting (fast saves).
pub(crate) fn build_named_project(name: &str) -> Arc<Mutex<Project>> {
let project = Project::new();
{
let mut p = project.lock().unwrap();
p.initialize().unwrap();
p.settings.insert("projectname".to_string(), name.to_string());
}
project
}
/// Save a fresh named project, returning its uuid.
pub(crate) fn save_named_project(backend: &DatabaseBackend, uri: &str, name: &str) -> String {
let project = build_named_project(name);
let uuid = uuid_of(&project);
save_project(backend, &project, uri).unwrap();
uuid
}