From b55d626c0cab4ff7e58d9ed4d93e734b5cfa432b Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Wed, 5 Aug 2026 01:16:07 +0800 Subject: [PATCH 1/4] docs: dependency analysis and two-person split for issues 9-20 (#34-#45) --- docs/plans/issues-9-20-dependency-plan.md | 73 ++++++++++++++++++++ docs/zh/plans/issues-9-20-dependency-plan.md | 70 +++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 docs/plans/issues-9-20-dependency-plan.md create mode 100644 docs/zh/plans/issues-9-20-dependency-plan.md diff --git a/docs/plans/issues-9-20-dependency-plan.md b/docs/plans/issues-9-20-dependency-plan.md new file mode 100644 index 0000000000..3f12c35d8e --- /dev/null +++ b/docs/plans/issues-9-20-dependency-plan.md @@ -0,0 +1,73 @@ +# Issues 9–20 (#34–#45): Dependency Analysis & Two-Person Split + +Scope: the twelve "Structure migrations" sub-issues of #26 (Eliminating +EventBridge), GitHub issues #34–#45. Each is independently shippable; this +document captures the real ordering constraints so two people can work in +parallel without stepping on each other. + +## Hard dependencies + +Only two kinds exist: + +1. **The issue 7 signal (`Core::undo_index_changed`)** — required by issues + 9, 11, 15, 16 (and used by 10/12/13/17 for undo refresh). + ✅ Already landed in `a030f2da2` (issue 7 / #55). **No longer blocks + anything.** + +2. **The issue 15 "project load finished" hook** (`Core` broadcasts after + TaskDialog load success). Explicitly reused by: + - **issue 13 (#38)** — "re-read uniformly after undo/**load**" + - **issue 17 (#42)** — "undo and **load** rebuild uniformly" + - **issue 19 (#44)** — "on load completion do a uniform model reset + (**reuse the issue 15 hook**)" + +Everything else has **no ordering constraints**. + +## Dependency graph + +``` +issue 7 (done) ──┬─> 9, 10, 11, 12, 16 (undo refresh signal, available now) + └─> 15 ──┬─> 13 (project-load hook) + ├─> 17 + └─> 19 +14, 18, 20 — fully independent +``` + +## File-overlap (merge-conflict) risks + +| Pair | Overlap | Severity | +|------|---------|----------| +| 15 / 16 | both in `app/widget/nodeview/` (+ `mainwindow.cpp` in 16) | low — different files | +| 17 / 18 | both in `app/widget/timelinewidget/` (17 heavily edits `timelinewidget.cpp`) | medium — keep in the same person's queue | +| 12 / 13 / 14 | all under `app/widget/nodeparamview/` | low — different files | +| 9 / 10 | marker/workarea pattern is identical; not the same files | none, but cheap to do together | + +## Suggested split + +**Person A — "signals already available" batch (start immediately):** + +- issue 9 (#34) — seekablewidget marker/workarea +- issue 10 (#35) — resizabletimelinescrollbar marker/workarea (same pattern as 9) +- issue 11 (#36) — nodeviewitem label/color/message/array +- issue 12 (#37) — NodeParamViewItem / arraywidget / keyframecontrol +- issue 14 (#39) — NodeParamView group passthrough / context +- issue 20 (#45) — misc leftovers + +**Person B — "project-load hook" chain (15 first, then its dependents):** + +- issue 15 (#40) — nodeviewcontext structure core + **add the load hook** +- issue 16 (#41) — nodeview NODE_REMOVED_FROM_GRAPH (same area as 15) +- issue 13 (#38) — nodeparamviewwidgetbridge values (needs the hook) +- issue 17 (#42) — timelinewidget track/block structure (needs the hook; keeps 18's neighbor in one queue) +- issue 18 (#43) — trackviewitem index/muted +- issue 19 (#44) — projectviewmodel folder/label (needs the hook) + +Rationale: B owns everything that consumes the issue 15 hook, so the hook's +API is designed and used by one person with no cross-team blocking. A's +batch only needs the already-landed issue 7 signal, so both can start today. +Workload is ~6 × 0.5 day each side. + +Ground rules still apply per issue: `cmake --build cmake-build-debug -j8 && +cd cmake-build-debug && ctest -j4` must be green (122/122) before shipping, +and remove the matching `bridge_->subscribe` / `oakengine_event_subscribe` +calls when done. diff --git a/docs/zh/plans/issues-9-20-dependency-plan.md b/docs/zh/plans/issues-9-20-dependency-plan.md new file mode 100644 index 0000000000..151acf9a12 --- /dev/null +++ b/docs/zh/plans/issues-9-20-dependency-plan.md @@ -0,0 +1,70 @@ +# Issues 9–20(#34–#45):依赖关系梳理与两人分工建议 + +范围:#26(Eliminating EventBridge)的 12 个 "Structure migrations" +子任务,对应 GitHub issue #34–#45。每个 issue 都可以独立交付,本文档 +梳理真实的先后约束,让两个人可以并行推进而互不阻塞。 + +## 硬依赖 + +只有两类: + +1. **issue 7 的信号(`Core::undo_index_changed`)** —— issue 9、11、 + 15、16 明确依赖它(10/12/13/17 的 undo 刷新也要用)。 + ✅ 已在 `a030f2da2` 落地(issue 7 / #55)。**不再构成阻塞。** + +2. **issue 15 新增的 "project load finished" 钩子**(Core 在 TaskDialog + 加载成功后广播)。明确复用它的有: + - **issue 13(#38)** —— "re-read uniformly after undo/**load**" + - **issue 17(#42)** —— "undo and **load** rebuild uniformly" + - **issue 19(#44)** —— "on load completion do a uniform model + reset(**复用 issue 15 的钩子**)" + +其余 issue **没有任何先后约束**。 + +## 依赖图 + +``` +issue 7(已完成) ──┬─> 9, 10, 11, 12, 16 (undo 刷新信号,现已可用) + └─> 15 ──┬─> 13 (项目加载钩子) + ├─> 17 + └─> 19 +14、18、20 —— 完全独立 +``` + +## 文件重叠(合并冲突)风险 + +| 组合 | 重叠区域 | 程度 | +|------|---------|------| +| 15 / 16 | 都在 `app/widget/nodeview/`(16 还涉及 `mainwindow.cpp`) | 低——不同文件 | +| 17 / 18 | 都在 `app/widget/timelinewidget/`(17 会大改 `timelinewidget.cpp`) | 中——建议排在同一个人的队列里 | +| 12 / 13 / 14 | 都在 `app/widget/nodeparamview/` 下 | 低——不同文件 | +| 9 / 10 | marker/workarea 的处理模式完全相同,但不是同一批文件 | 无冲突,但连着做成本低 | + +## 建议分工 + +**A 同学 —— "信号已就绪" 批次(今天就能开始):** + +- issue 9(#34)—— seekablewidget marker/workarea +- issue 10(#35)—— resizabletimelinescrollbar marker/workarea(与 9 同模式) +- issue 11(#36)—— nodeviewitem label/color/message/array +- issue 12(#37)—— NodeParamViewItem / arraywidget / keyframecontrol +- issue 14(#39)—— NodeParamView group passthrough / context +- issue 20(#45)—— 零散收尾 + +**B 同学 —— "项目加载钩子" 链(先做 15,再做它的下游):** + +- issue 15(#40)—— nodeviewcontext 结构核心 + **新增加载钩子** +- issue 16(#41)—— nodeview NODE_REMOVED_FROM_GRAPH(与 15 同区域) +- issue 13(#38)—— nodeparamviewwidgetbridge 参数值(依赖钩子) +- issue 17(#42)—— timelinewidget track/block 结构(依赖钩子;同时把 18 的邻域保持在同一队列) +- issue 18(#43)—— trackviewitem index/muted +- issue 19(#44)—— projectviewmodel folder/label(依赖钩子) + +理由:所有消费 issue 15 钩子的 issue 都归 B,钩子的 API 由同一个人 +设计并使用,零跨人阻塞;A 的批次只依赖已经落地的 issue 7 信号, +两人今天都能开工。工作量两侧均约为 6 × 0.5 天。 + +每个 issue 的通用要求不变:提交前 +`cmake --build cmake-build-debug -j8 && cd cmake-build-debug && ctest -j4` +必须全绿(122/122),完成后移除对应的 +`bridge_->subscribe` / `oakengine_event_subscribe` 调用。 From 774eda75fea300a19ee0712f0168539ce391f858 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Wed, 5 Aug 2026 14:48:08 +0800 Subject: [PATCH 2/4] docs(riir): design inter-module C ABI interfaces and split out oakstorage - riir.md: split project file read/write into standalone oakstorage module (pluggable backend, DB-replaceable); add Mermaid data flow diagram of all modules; adjust batch table and milestones - riir/04-interfaces.md: new provides/consumes contract matrix for all modules - riir/M10-oakstorage.md: new manual with frozen C API (typed opaque handles, manual vtable for storage backends, URI addressing) - riir/01: codify pure-C OO interface rules (typed handles instead of void*, init/free pairs, manual vtables, no C++ objects or member calls across shared library boundaries); upper layers issue commands only, no module-to-module event subscriptions (async task callbacks excepted) - sync 00/02/03 and M2-M9 manuals: remove subscribe APIs, replace void* with typed handles, update tests accordingly --- docs/zh/plans/riir.md | 109 ++++++++++++- docs/zh/plans/riir/00-overview.md | 15 +- docs/zh/plans/riir/01-adapter-pattern.md | 87 ++++++---- docs/zh/plans/riir/02-modules-and-order.md | 8 +- docs/zh/plans/riir/03-testing.md | 6 +- docs/zh/plans/riir/04-interfaces.md | 147 +++++++++++++++++ docs/zh/plans/riir/M10-oakstorage.md | 176 +++++++++++++++++++++ docs/zh/plans/riir/M2-oakundo.md | 25 +-- docs/zh/plans/riir/M3-oaknode.md | 20 +-- docs/zh/plans/riir/M4-oaktimeline.md | 22 +-- docs/zh/plans/riir/M6-oakaudio.md | 8 +- docs/zh/plans/riir/M7-oakrender.md | 10 +- docs/zh/plans/riir/M8-oaktask.md | 27 ++-- docs/zh/plans/riir/M9-oakplugin.md | 4 +- 14 files changed, 565 insertions(+), 99 deletions(-) create mode 100644 docs/zh/plans/riir/04-interfaces.md create mode 100644 docs/zh/plans/riir/M10-oakstorage.md diff --git a/docs/zh/plans/riir.md b/docs/zh/plans/riir.md index 54f1d752b5..2a7764d310 100644 --- a/docs/zh/plans/riir.md +++ b/docs/zh/plans/riir.md @@ -147,11 +147,11 @@ │ liboakengine-facade(壳:capi + 事件 + init) │ - ┌────────┬────────┼─────────┬──────────┐ - oaktask oakrender oakplugin oakaudio oakserialize - │ │ │ │ │ - └────────┴────┬───┴──────────┴──────────┘ - │ + ┌────────┬────────┼─────────┬──────────┬──────────────┐ + oaktask oakrender oakplugin oakaudio oakserialize oakstorage + │ │ │ │ │ (工程文件读写, + └────────┴────┬───┴──────────┴──────────┘ 独立模块,未来 + │ 替换为数据库) oakmodel(节点图 + 项目模型 + 时间线模型) │ ┌────────┼─────────┐ @@ -160,6 +160,99 @@ ffmpeg_bridge(已是 C ABI .so) ``` +**oakstorage 单列说明(本计划对原模块图的唯一结构性修改)**: +工程文件的读写(`node/project/serializer` 的落盘路径 + `task/project/` +load/save/loadotio/saveotio 的文件 IO)从 oakserialize / oaktask 中**单独拆出**为 +oakstorage 模块。它对上只暴露**存储后端无关**的 C ABI(打开/保存/探测工程, +URI 寻址),当前唯一后端是 XML .ove 文件;**未来替换为数据库时只新增一个 +后端实现,上层(oaktask/facade)零改动**。剪贴板序列化(copy/paste)不属于 +存储,仍留在 oakserialize。详细接口设计见 `riir/04-interfaces.md` 与 +`riir/M10-oakstorage.md`。 + +### 3.1.1 模块数据流图(Mermaid) + +下图描述终态各模块之间的调用与数据流向。**实线 = 命令调用(上层→下层, +内部 C ABI `oak_*`,调用方知道影响);虚线 = 仅两种允许的反向通知: +facade→app 的 `oakengine_event` 通道,以及异步任务(oaktask 任务 / +渲染 ticket / GPU 帧完成)的完成回调。下层对上层没有事件订阅。** + +```mermaid +flowchart TB + subgraph 消费侧["消费侧(不感知实现语言)"] + APP["oak-editor (app)"] + CLI["oak-cli"] + WRK["oak-render-worker"] + end + + FACADE["liboakengine-facade
壳:oakengine_* 公共 C ABI(冻结)
+ 事件注册表 + init/shutdown"] + + subgraph 引擎模块["引擎模块(facade 之下,只经内部 C ABI 互调)"] + TASK["oaktask
任务编排(Task/TaskManager)"] + RENDER["oakrender
渲染管线/缓存/色彩"] + PLUGIN["oakplugin
OpenFX 宿主"] + AUDIO["oakaudio
音频 DSP/输出/电平"] + SERIAL["oakserialize
节点图 XML 序列化
(剪贴板 copy/paste,不落盘)"] + STORAGE["oakstorage
工程持久化(URI 打开/保存/探测)
后端可插拔:ove-xml 文件|未来 oakdb 数据库"] + UNDO["oakundo
UndoStack/UndoCommand"] + MODEL["oakmodel
Node 类型簇 + Project/Sequence/Track/Block
(最大不可拆分类型簇)"] + CODEC["oakcodec
decoder/encoder/conform/proxy"] + CORE["liboakcore
rational/timecode/bezier/samplebuffer"] + BACKEND["oakbackend
GPU 插件:oakgl / oakvulkan / 未来 Rust(wgpu)"] + FFMPEG["ffmpeg_bridge
FFmpeg 的 C ABI 桥(现成 .so)"] + end + + APP -->|"oakengine_* 调用"| FACADE + CLI --> FACADE + WRK --> FACADE + FACADE -.->|"oakengine_event 变更通知
(发射线程同步回调)"| APP + + FACADE -->|"任务工厂/进度"| TASK + FACADE -->|"渲染请求/帧句柄"| RENDER + FACADE --> AUDIO + FACADE --> PLUGIN + FACADE -->|"工程打开/保存(URI)"| STORAGE + FACADE --> UNDO + + TASK -->|"load/save 任务委托
(Project 句柄 + XML 字节流)"| STORAGE + TASK -->|"import/conform/proxy 任务"| CODEC + TASK -->|"导出任务"| RENDER + + RENDER -->|"遍历节点图求值"| MODEL + RENDER -->|"解码帧请求"| CODEC + RENDER -->|"上传纹理/绘制"| BACKEND + RENDER -->|"OCIO 色彩变换"| CORE + + STORAGE -->|"反序列化建图 / 序列化取图(同步命令)"| MODEL + SERIAL --> MODEL + + MODEL -->|"读取媒体参数"| CODEC + MODEL --> CORE + UNDO -->|"命令持有 Node/Project 句柄"| MODEL + AUDIO --> CORE + CODEC --> FFMPEG + CODEC --> CORE + BACKEND -.->|"帧完成(异步任务回调,ticket 返回通道)"| RENDER +``` + +**数据流要点**: + +1. **命令流(自上而下)**:app 的每个动作 → facade `oakengine_*` → 对应 + 模块内部 C ABI。facade 是唯一入口,模块不允许被 app 直接链接。 +2. **工程 IO 流**:`oaktask` 创建 load/save 任务 → 委托 `oakstorage`; + oakstorage 按 URI scheme 选后端(`file://*.ove` → ove-xml 后端, + 未来 `oakdb://` → 数据库后端),序列化/反序列化时对 `oakmodel` 建图 + 取图。**替换为数据库只发生在 oakstorage 内部。** +3. **帧数据流**:`oakcodec` 经 `ffmpeg_bridge` 解码 → `oakrender` 遍历 + `oakmodel` 节点图求值 → `oakbackend`(GPU 插件)上屏/导出;帧以不透明 + 句柄 + buf/size 约定跨边界,不传递 C++ 对象。 +4. **通知流(仅两种反向通道,虚线)**:模块间没有事件订阅——上层调 + 下层只有命令,调用方知道影响;变更通知由命令发起层(通常是 facade) + 经 `oakengine_event` 同步回调给 app,Rust 化后只是发射端换语言, + 通道不变(§6.1)。另一例外是异步任务的完成回调(oaktask 任务、 + 渲染 ticket、GPU 帧完成)——回调即该异步命令的返回通道。 +5. **undo 流**:所有可撤销编辑(无论来自 facade 还是模块内部)都包装成 + `OakUndoCommand` 进入 `oakundo` 栈,命令体内只持 oakmodel 句柄。 + **关键架构事实(拆分顺序的依据)**: - `Node` 及其子类簇(Project/Folder/Footage/Sequence/Block/Track/Clip/Gap/ Transition/Subtitle/各效果节点)是 C++ 继承绑死的**不可拆分类型簇**—— @@ -177,7 +270,8 @@ | M0 | **oakcore** | liboakcore 整体(rational/timecode/bezier/samplebuffer/audioparams,Qt-free) | 无 | 极低;工具链试金石 | | M1 | **oakaudio** | AudioProcessor、AudioSynchronizer、AudioLevelMeter、波形计算 | oakcore | 低;顺带消掉 AudioProcessor 豁免项 | | M2 | **oakcodec** | decoder/encoder/conform/proxy | ffmpeg_bridge | 中;FFmpeg 行为复刻 | -| M3 | **oakserialize** | node/project/serializer/*(XML 项目文件) | oakmodel(经 facade node/project 族) | 中;round-trip 必须字节一致 | +| M3a | **oakstorage** | node/project/serializer 落盘路径 + task/project/{load,save,loadotio,saveotio} 文件 IO(工程持久化,后端可插拔:当前 XML 文件,未来数据库) | oakmodel(经 facade node/project 族) | 中;round-trip 必须字节一致;后端接口一次冻结 | +| M3b | **oakserialize** | node/project/serializer 的剪贴板/节点图 XML 序列化(copy/paste,不落盘) | oakmodel(经 facade node/project 族) | 中;round-trip 必须字节一致 | | M4 | **oakundo** | UndoCommand/UndoStack/MultiUndoCommand | oakmodel(经 facade) | 中;全局调用点多 | | M5 | **oakrender** | RenderManager/ticket/watcher/cache/PreviewAutoCacher/ColorProcessor | oakmodel、oakcodec | 高;线程与 OCIO | | M6 | **oakmodel** | Node/NodeInput/keyframe/traverser/factory/Project/Folder/Footage/Sequence/Block/Track/效果节点 | oakcore、oakcodec | 最高;最大类型簇 | @@ -312,7 +406,8 @@ timeline.h,本就是为外部消费设计的)充当模块间缝,缝的质 1. **S1 完成**:Rust 工具链 + 门禁脚本进 CI;M0(liboakcore)G6 退役。 2. **M1–M2 完成**:音频 DSP 与编解码 Rust 化;AudioProcessor 豁免项消除。 -3. **M3–M4 完成**:序列化与 undo Rust 化;项目文件 round-trip 金标准常青。 +3. **M3a–M4 完成**:工程存储(oakstorage,含后端可插拔接口冻结)、剪贴板 + 序列化与 undo Rust 化;项目文件 round-trip 金标准常青。 4. **M5 完成**:渲染管线 Rust 化(OCIO 孤岛与否已裁决并记录)。 5. **M6 完成**:oakmodel Rust 化——**最大里程碑**,此后 liboakengine 主体为 Rust。 6. **M7–M8 完成**:任务系统与 facade 壳 Rust 化;liboakengine.so(C++ 版)正式退役。 diff --git a/docs/zh/plans/riir/00-overview.md b/docs/zh/plans/riir/00-overview.md index 077ca31c21..d9599daf57 100644 --- a/docs/zh/plans/riir/00-overview.md +++ b/docs/zh/plans/riir/00-overview.md @@ -6,9 +6,10 @@ > 并稳定后,才逐模块用 Rust 重写(届时模块的 C ABI 原样保留,Rust > 实现替换 C++ 实现对调用方透明)。 > -> 阅读顺序:`00`(本文)→ `01-adapter-pattern.md`(适配器规范, -> 所有模块共用)→ `02-modules-and-order.md`(模块清单、依赖矩阵、 -> 拆分顺序)→ `03-testing.md`(测试规范)→ `M1`…`M9`(逐模块执行 +> 阅读顺序:`00`(本文)→ `01-adapter-pattern.md`(适配器规范 + §0 +> 接口铁律,所有模块共用)→ `02-modules-and-order.md`(模块清单、依赖矩阵、 +> 拆分顺序)→ `03-testing.md`(测试规范)→ `04-interfaces.md`(模块间 +> 接口 provides/consumes 全表)→ `M1`…`M10`(逐模块执行 > 手册,**C API 已在各手册中冻结**)。 ## 目标与判据 @@ -18,9 +19,11 @@ ``` oakcore(已有,不动) oakcommon ─ oakundo ─ oaknode ─ oaktimeline ─ oakcodec ─ oakrender ─ oaktask ─ oakplugin - │ - oakaudio ───────────────────┤ - ▼ + │ │ + └────────────── oakstorage(工程持久化, ┘ + 后端可插拔:文件→数据库) + oakaudio ─────────────┐ + ▼ liboakengine(= facade + coreengine,纯装配层) ``` diff --git a/docs/zh/plans/riir/01-adapter-pattern.md b/docs/zh/plans/riir/01-adapter-pattern.md index f6e8eed690..ade6e467aa 100644 --- a/docs/zh/plans/riir/01-adapter-pattern.md +++ b/docs/zh/plans/riir/01-adapter-pattern.md @@ -4,6 +4,32 @@ > C API 设计与适配类实现都必须照此执行。命名、内存所有权、错误码、 > 线程与信号的处理在此**冻结**。 +## 0. 接口铁律(2026-08 修订,优先级高于本文件其余各节) + +模块边界上的接口必须是**纯 C 的、面向对象的**: + +1. **对象 = 不透明句柄**。每个跨界类型是一个不透明结构体指针: + `typedef struct OakModClazz OakModClazz;`(**手写 struct 标签的不透明 + 指针**,禁止用 `void *` 充当对象——现有代码里 `void *parent_qobject`、 + `void *oaktask_import_take_command()` 这类用法是反面教材,新接口一律 + 禁止,旧接口在所属模块拆分时顺手改为有类型句柄)。 +2. **构造/析构 = init/free 函数对**:`oakmod_clazz_init*()` / + `oakmod_clazz_free()`。free 对 NULL 是 no-op。 +3. **成员函数 = 首参为 self 句柄的普通函数**: + `oakmod_clazz_(OakModClazz *self, ...)`;静态成员函数无 self + (`_s` 后缀)。 +4. **多态 = 手工虚函数表**。确需"基类句柄 + 多种实现"(如存储后端、 + undo 命令、渲染后端插件)时,在公共头里定义纯 C 函数指针表 + (`typedef struct { ...; int (*save)(...); ... } OakModClazzVTable;`), + 提供侧填充、消费侧经表调用,**不得让 C++ vtable 跨界**。模板见 + `M10-oakstorage.md` §2.3 的 `OakStorageBackend`。 +5. **禁止 C++ 对象跨越动态库边界**:边界上只出现 C 类型(整数、double、 + 指针、`const char *`、纯 C POD、不透明句柄)。C++ 类实例、引用、 + `std::` 类型、Qt 类型一律不得出现在任何模块的 `include/` 公共头里。 +6. **禁止跨界调用 C++ 成员函数**:消费侧对提供侧对象的一切操作必须经 + 该对象的 C ABI 函数;拿到句柄后 `reinterpret_cast` 回 C++ 指针再调 + 成员函数视为违规(nm 审计 + 代码评审双保险)。 + ## 1. 提供侧:C API 层 对被消费的每个 C++ 类 `Clazz`,模块在 `include/oak/clazz.h` @@ -32,7 +58,7 @@ OAKMOD_API oakmod_clazz__s(/* 参数 */); 3. 多个构造重载用后缀区分:`oakmod_clazz_init`(默认)、 `oakmod_clazz_init_from_file`、`oakmod_clazz_init_copy` 等。 4. 命名全小写,模块前缀 `oak_`(oakundo/oaknode/oaktimeline/ - oakcodec/oakrender/oaktask/oakaudio/oakplugin/oakcommon)。 + oakcodec/oakrender/oaktask/oakaudio/oakplugin/oakcommon/oakstorage)。 5. 导出宏 `OAKMOD_API` 照 `oakengine/export.h` 样式 (`__attribute__((visibility("default")))`),模块编译加 `-fvisibility=hidden`——每个模块**出生即 visibility 干净**, @@ -99,22 +125,26 @@ C ABI 上只允许:整数、`double`、`int64_t`、指针、`const char *`、 | `Qt::enum`/内部枚举 | `int`(取值表写进手册,两侧枚举**序数一致性**用 static_assert 或测试钉死) | | `std::shared_ptr` | 不透明句柄 + retain/free(协议见 display.h R7-A 的先例) | -## 4. 信号、回调与线程 - -Qt 信号不许跨模块。处理优先级: - -1. **回调注册**:`oakmod_clazz_set__cb(self, fn, userdata)`, - 提供侧在发信号处调 `fn(event_pod, userdata)`。userdata 所有权归 - 注册方,适配类析构时先 `set_*_cb(self, NULL, NULL)` 反注册。 -2. **事件总线**:模块级通知(非单对象)用 - `oakmod_subscribe(event_id, fn, userdata)` → 返回订阅 id, - `oakmod_unsubscribe(id)`——照 `oakengine/events.h` 的现成模式。 -3. 线程语义照现状:提供侧在发射线程同步调回调(DirectConnection +## 4. 信号、回调与线程(2026-08 修订:上层对下层只有命令) + +Qt 信号不许跨模块;**下层对上层也不许持有回调**——上层调用下层时 +必须知道其影响(改了什么全在返回值/出参里),变更通知由**调用方所在 +层**发出,不经下层反向通知。各模块 C ABI 因此一律不含 +subscribe/unsubscribe 类函数。处理规则: + +1. **同步命令**:提供侧把结果放在返回值/出参;消费侧适配类在调用后 + 自行发 Qt 信号(适配类知道刚执行了什么命令,见 §7 例)。原 + `connect()` 到适配类信号的 widget 代码零改动。 +2. **唯一例外——异步任务**:后台执行单元(oaktask 任务、oakrender + 渲染 ticket)提交时拿不到结果,允许进度/完成回调作为该命令的 + 返回通道:`oak__start(handle, done_fn, userdata)` 形式, + 一次性语义,完成后自动失效。 +3. 线程语义照现状:异步回调在发射线程同步调用(DirectConnection 等价),需要跨线程排队是消费侧适配类自己的事(它可以用 `QMetaObject::invokeMethod(..., Qt::QueuedConnection)`)。 -4. **适配类可以把 C 回调再转回 Qt 信号**:适配类继承 QObject、 - 静态 trampoline 里 `emit` 同名信号——消费侧原有 `connect()` 全部 - 零改动。这是大多数 widget 侧适配的默认做法。 +4. **facade→app 的 `oakengine_event` 通道不在此列**:那是引擎对最外层 + 的唯一通知机制(riir.md §6.1),事件由 facade 在命令完成后发射, + 不由下层模块直接发射。 ## 5. 继承与虚函数 @@ -139,40 +169,35 @@ Qt 信号不许跨模块。处理优先级: ```c /* oakundo/include/oakundo/undostack.h */ typedef struct OakUndoStack OakUndoStack; -OAKUNDO_API OakUndoStack *oakundo_undostack_init(void *parent_qobject); +typedef struct OakUndoObjectParent OakUndoObjectParent; /* borrowed QObject 挂载点 */ +OAKUNDO_API OakUndoStack *oakundo_undostack_init( + const OakUndoObjectParent *parent); OAKUNDO_API void oakundo_undostack_free(OakUndoStack *self); OAKUNDO_API void oakundo_undostack_push(OakUndoStack *self, OakUndoCommand *cmd, const char *name); OAKUNDO_API int oakundo_undostack_can_undo(const OakUndoStack *self); -/* ... 完整表见 M2 手册 ... */ -OAKUNDO_API int64_t oakundo_undostack_subscribe(OakUndoStack *self, - int event_id, oakundo_event_fn fn, void *userdata); -OAKUNDO_API void oakundo_unsubscribe(int64_t id); +OAKUNDO_API int64_t oakundo_undostack_index(const OakUndoStack *self); +/* ... 完整表见 M2 手册(纯命令接口,无任何 subscribe) ... */ ``` ```cpp -// 消费侧 adapter/undostack.h —— app/other 模块里 connect() 零改动 +// 消费侧 adapter/undostack.h —— app/other 模块里 connect() 零改动。 +// 通知规则(§4):适配类发了变更命令,它知道影响,信号由适配类自己 emit。 class UndoStack : public QObject { Q_OBJECT public: explicit UndoStack(QObject *p = nullptr) - : QObject(p), h_(oakundo_undostack_init(p)) { - sub_ = oakundo_undostack_subscribe(h_, OAKUNDO_EVENT_INDEX_CHANGED, - &UndoStack::tramp, this); - } - ~UndoStack() override { oakundo_unsubscribe(sub_); oakundo_undostack_free(h_); } + : QObject(p), h_(oakundo_undostack_init( + reinterpret_cast(p))) {} + ~UndoStack() override { oakundo_undostack_free(h_); } void push(UndoCommand *c, const QString &n) { oakundo_undostack_push(h_, c->handle(), n.toUtf8().constData()); + emit index_changed(int(oakundo_undostack_index(h_))); // 命令后自发通知 } bool canUndo() const { return oakundo_undostack_can_undo(h_) != 0; } signals: void index_changed(int); private: - static void tramp(int event_id, int64_t a, int64_t b, void *ud) { - if (event_id == OAKUNDO_EVENT_INDEX_CHANGED) - emit static_cast(ud)->index_changed(int(a)); - } OakUndoStack *h_; - int64_t sub_; }; ``` diff --git a/docs/zh/plans/riir/02-modules-and-order.md b/docs/zh/plans/riir/02-modules-and-order.md index e7e632531b..5bf58d9e28 100644 --- a/docs/zh/plans/riir/02-modules-and-order.md +++ b/docs/zh/plans/riir/02-modules-and-order.md @@ -23,6 +23,11 @@ 已知分层违规 1 处:`render/` 引用了 `src/capi/displayinternal.h` (R7-A 重做 display.h 时一并消除)。 +> **2026-08 增补(oakstorage 拆出)**:矩阵扫描早于 oakstorage 单列。 +> 其依赖关系为:oakstorage → node(serializer 落盘路径,含版本化 +> serializerXXXXXX 族)+ common;oaktask → oakstorage(load/save/otio +> 委托);facade → oakstorage。详细契约见 04 §2.10 与 M10。 + ## 2. 模块定义与拆分顺序 顺序原则:叶子先、根后;每步只引入"已拆模块的 C ABI",不引入 @@ -34,11 +39,12 @@ | M1 | oakcommon | `common/`(41 文件工具集)+ `config/` | common→render/node/undo/codec/plugin 的 12 次反向 include(清单见 M1 §3) | | M2 | oakundo | `undo/`(undocommand/undostack) | undo→node/project.h 1 处(M2 §3) | | M3 | oaknode | `node/`(图、工厂、keyframe、nodeundo、traverser) | node→render 47、node→codec 8、node→timeline 5、node→audio 4、node→undo 4(M3 §3,最大的活) | +| M3a | **oakstorage** | `node/project/serializer` 落盘路径 + `task/project/{load,save,loadotio,saveotio}` 文件 IO(**工程持久化单列**,后端可插拔:当前 XML 文件,未来数据库) | storage→node(序列化建图取图,经 oaknode C ABI);手册 M10 | | M4 | oaktimeline | `timeline/`(marker/workarea/timeline undo 命令族/timelinecommon) | timeline→node 32(经 oaknode C ABI + 适配类) | | M5 | oakcodec | `codec/`(decoder/encoder/frame/proxy/conform) | codec→render 11(videoparams 等随 M3.5 下沉)、codec→task 5、codec→node 3 | | M6 | oakaudio | `audio/`(AudioManager/AudioProcessor/输出) | audio→render 2、audio→codec 1 | | M7 | oakrender | `render/`(Renderer/PlaybackCache/ColorManager/帧缓存/job) | render→node 38、render→codec 9、render→task 2、render→undo 1、render→src 1(违规) | -| M8 | oaktask | `task/`(Task/TaskManager/项目 load/save/import/OTIO) | task→node 38、task→codec 4、task→render 4 | +| M8 | oaktask | `task/`(Task/TaskManager/项目任务编排/cache 任务;**工程文件 IO 已划给 oakstorage,M3a**) | task→node 38、task→codec 4、task→render 4、task→storage(load/save 委托) | | M9 | oakplugin | `pluginSupport/`(OpenFX host) | plugin→node 6、plugin→render 6、plugin→undo 2、plugin→coreengine 2 | | — | liboakengine | `src/capi` + `coreengine` + `tool/` + `ui/` 残余 | 纯装配层:facade 内部调用改经各模块 C ABI(或保持现状直接链,见 M9 §4 裁决) | diff --git a/docs/zh/plans/riir/03-testing.md b/docs/zh/plans/riir/03-testing.md index 339b3732e1..1f820efc58 100644 --- a/docs/zh/plans/riir/03-testing.md +++ b/docs/zh/plans/riir/03-testing.md @@ -29,8 +29,10 @@ 4. **枚举序数一致性**:C 侧 POD/枚举与 C++ 侧枚举的映射(01 §3 表) 每个映射 1 个 TEST(如 `oakundo` 的 movement mode 0-3 ⇄ `Timeline::MovementMode`)。 -5. **事件/回调**:每个 `set_*_cb`/subscribe 至少 1 个 TEST:触发后 - 断言回调被调、payload 正确;反注册后断言不再被调。 +5. **回调(仅异步任务)**:模块间 C ABI 无 subscribe 类接口(04 §3); + 仅异步命令(任务/渲染 ticket)的进度/完成回调需要测试:触发后 + 断言回调被调、payload 正确、FINISHED 后自动失效。同步命令的测试 + 改为"调用后直接读状态断言生效"。 6. **所有权**:borrowed 句柄(文档注释标了 `/* borrowed */` 的) free 后原对象仍存活,1 个 TEST。 diff --git a/docs/zh/plans/riir/04-interfaces.md b/docs/zh/plans/riir/04-interfaces.md new file mode 100644 index 0000000000..c90e0f2dae --- /dev/null +++ b/docs/zh/plans/riir/04-interfaces.md @@ -0,0 +1,147 @@ +# 04 · 模块间接口设计(provides / consumes 全表) + +> 本文是各模块手册(M1–M10)C API 的**汇总视图**:每个模块对外提供哪些 +> 接口族、消费哪些模块的哪些接口族、边界上流什么数据。逐函数签名以各 +> M 手册的"冻结 C API"节为准;本文管"模块与模块之间的契约关系",管不到 +> 逐函数细节。 +> +> 全部接口遵守 `01-adapter-pattern.md` §0 的铁律:**纯 C、面向对象**—— +> 有类型的不透明句柄(禁止 `void *` 当对象)、`init_*`/`free_*` 构造析构、 +> 首参 self 的普通函数当成员函数、多态用手工函数指针表、C++ 对象与成员 +> 函数一律不跨动态库边界。 + +## 1. 接口关系总表 + +行 = 消费方,列 = 提供方;单元格 = 消费的接口族(详见各提供方手册)。 + +| 消费 ↓ \ 提供 → | oakcommon | oakundo | oaknode | oaktimeline | oakcodec | oakaudio | oakrender | oakstorage | oaktask | oakplugin | oakcore | +|---|---|---|---|---|---|---|---|---|---|---|---| +| **facade**(src/capi) | 工具/类型 | undo 全族 | node/project/footage/serializer(clipboard) 族 | timeline 全族 | decoder/frame/proxy 族 | audio 全族 | renderer/playback/preview 族 | open/save/probe 族 | task/manager 全族 | plugin 全族 | rational/timecode | +| **oaktask** | 工具 | command 句柄 | footage/project/sequence/folder 族 | — | conform/proxy 族 | — | 导出用 render 族 | **load/save/otio 委托** | — | — | — | +| **oakrender** | videoparams/colortransform(M3.5 下沉) | command 句柄 | node 遍历/取值、project、colormanager | — | decoder/frame 族 | 音频参数 | — | — | — | plugin 实例句柄 | — | +| **oakplugin** | 工具 | command 句柄 | node 参数读写 | — | — | — | 帧缓冲/纹理句柄 | — | — | — | — | +| **oakaudio** | 工具 | — | — | — | frame/解码(1 处) | — | videoparams(下沉后消失) | — | — | — | samplebuffer | +| **oakcodec** | videoparams/subtitleparams(下沉) | — | footage 流信息(3 处) | — | — | — | — | — | — | — | rational | +| **oaktimeline** | timelinecommon 枚举(下沉) | command 句柄 | track/block/sequence 族(32 处) | — | — | — | — | — | — | — | — | +| **oaknode** | 枚举/常量/工具 | undocommand.h(4 处) | — | marker/workarea(2 处,M4 反向) | decoder/frame/proxy(8 处,M5 反向) | audio 参数(4 处,M6 反向) | colorprocessor/rendermanager/job(M7 反向,02 §4 裁决 A) | — | — | — | rational/bezier | +| **oakundo** | 工具 | — | — | — | — | — | — | — | — | — | — | +| **oakstorage** | 工具 | — | **project/root/序列化建图取图** | — | — | — | — | — | — | — | — | +| **oakcommon** | — | — | — | — | — | — | — | — | — | — | — | + +(空格 = 无依赖。"N 处"数据来自 02 的 include 扫描。oakcore 与 +ffmpeg_bridge 为现成独立库,不参与拆分顺序。) + +## 2. 逐模块接口契约 + +### 2.1 oakcommon(M1)— 纯下沉,无业务对象 + +- **提供**:`include/oakcommon/types.h` 的全模块共用 POD(时间戳/区间/ + 枚举常量,含 M3.5 下沉的 `OakVideoParams`/`OakSubtitleParams`/ + `OakColorTransform`);工具函数(全 `_s` 静态式,无句柄)。 +- **消费**:无(叶子)。 +- **边界数据**:纯 POD 值,无所有权问题。 + +### 2.2 oakundo(M2)— 手工虚表的第一个用户 + +- **提供**:`OakUndoCommand` / `OakUndoStack` 两类句柄。命令的多态 + (redo/undo 行为)经 **回调函数指针** 实现 + (`oakundo_command_create(name, redo, undo, free_fn, userdata)`), + 即铁律 §0.4 的手工虚表;消费侧**不构造 C++ 子类**。 +- **消费**:oakcommon。 +- **边界数据**:命令句柄(owned)。无事件——push/undo/redo 的调用方 + 知道栈索引变化,通知由调用方(facade 适配层)发出(见 §3)。 + +### 2.3 oaknode(M3)— 最大提供方 + +- **提供**:`OakNodeNode/NodeGroup/NodeKeyframe/NodeFactory/NodeTraverser/ + Project/Folder/Sequence/Track/TrackList/Block/Footage/ColorManager` + 句柄族。逐族清单见 M3 §2。无订阅接口——所有修改经命令函数完成, + 调用方知道影响(§3)。 +- **消费**:oakcommon、oakundo;对 render/codec/audio/timeline 的引用按 + 02 §3/§4 的反向切割表在各模块就位后改经其 C ABI。 +- **边界数据**:节点句柄(borrowed 为主,工程拥有节点)、 + `oak_node_value` POD、id 字符串(buf/size)。 + +### 2.4 oaktimeline(M4) + +- **提供**:marker/workarea/timeline 编辑原语句柄族(`OakTimelineMarker` + 等),timeline 专用 undo 命令**经 oakundo 的回调式命令**注册,不自带 + 命令子类。 +- **消费**:oaknode(32 处,全部经句柄族)、oakundo、oakcommon。 + +### 2.5 oakcodec(M5) + +- **提供**:`OakCodecDecoder/Encoder/Frame/ProxyManager` 句柄族; + 帧以 `OakCodecFrame *` 不透明句柄跨边界(owned,配对 free), + 像素数据经 `oakcodec_frame_data(frame, plane, &linesize)` 取出指针 + (borrowed,生命周期随 frame)。 +- **消费**:oakcommon、oaknode(footage 流信息)、oakcore、ffmpeg_bridge。 + +### 2.6 oakaudio(M6) + +- **提供**:`OakAudioManager/Processor/Synchronizer` 句柄族;波形/电平 + 数据以 POD 数组 + count 出参。 +- **消费**:oakcore(samplebuffer)、oakcodec(1 处)。 + +### 2.7 oakrender(M7) + +- **提供**:`OakRenderRenderer/Ticket/Cache/ColorProcessor` 句柄族; + 渲染结果帧为 owned 句柄;渲染 ticket 是**异步命令**(后台线程), + 进度/完成回调是它的返回通道——这是 §3 允许回调的唯一情形 + (线程语义按 riir.md §6.2 钉死)。 +- **消费**:oaknode、oakcodec、oakcommon、oakundo(1 处)、oakbackend + (GPU 插件,经 `renderbackend_c.h` 手工虚表——现有先例)。 + +### 2.8 oaktask(M8)— 编排者 + +- **提供**:`OakTaskTask` 句柄 + 任务工厂族 + TaskManager 查询函数。 + 任务是**异步命令**:进度/完成回调即其返回通道(§3 唯一例外), + 无其他事件。load/save/import/otio 任务工厂保留,但**实现改为委托 + oakstorage**(见 M10 §4);任务结果(Project、Footage 列表)以 + **有类型句柄**返回(`oaktask_import_take_command` 的 `void *` 返回 + 按铁律 §0.1 改为 `OakUndoCommand *`)。 +- **消费**:oakstorage、oaknode、oakcodec、oakrender、oaktimeline。 + +### 2.9 oakplugin(M9) + +- **提供**:OFX 插件加载/实例句柄族(`OakPluginHost/Instance`)。 +- **消费**:oaknode、oakrender、oakundo。 + +### 2.10 oakstorage(M10,新拆)— 工程持久化 + +- **提供**:`OakStorageProject`(打开的工程会话)、`OakStorageBackend` + (手工虚函数表,后端注册用)两类句柄 + `oakstorage_open/save/probe` + 静态函数。**URI 寻址**:`file://…/*.ove` 走内建 ove-xml 后端;未来 + `oakdb://` 走数据库后端——替换数据库 = 新增一个后端实现并注册, + 消费侧零改动。 +- **消费**:oaknode(反序列化建图 / 序列化取图)、oakcommon。 +- **边界数据**:工程句柄(owned)、XML 字节流(buf/size)、后端表。 + 无事件——open/save 是同步命令,成败与结果全在返回值里,调用方 + (oaktask/facade)知道影响,由它发通知(§3)。 +- 详见 `M10-oakstorage.md`。 + +## 3. 通知的统一约定(2026-08 修订:上层对下层只有命令) + +**铁律:上层调下层只发命令,不调订阅。** 上层调用下层时必须知道该调用的 +影响——改了什么、结果是什么,全部由返回值/出参告知,调用方自己决定后续 +动作。因此: + +- **模块间 C ABI 一律不提供 subscribe/unsubscribe 类接口。** 下层不持有 + 上层的函数指针,不反向通知。各模块手册里原有的 `oak_subscribe` + 设计全部作废(M2/M3/M10 已改)。 +- **变更通知由命令发起方负责。** 例:app 经 facade 调 `undo` 命令后, + facade 知道栈索引变了,由 facade 经既有 `oakengine_event` 通道通知 + app 侧——通知的起点永远是最靠近调用者的那一层。 +- **唯一例外:异步任务。** 后台执行的单元(oaktask 的任务、oakrender 的 + 渲染 ticket)本质上是"提交时拿不到结果"的命令,允许进度/完成回调—— + 回调即该命令的返回通道,线程语义按 01 §4 / riir.md §6.2 钉死。 + 同步接口不得配回调。 + +## 4. 错误与所有权(横向统一) + +- 返回码:`0 = OK`,负值 `OAK_E_*`(值与 oakengine 现有对齐); + 细节经 `oak_last_error(buf, size)`(线程局部)。 +- 所有权注释三档:`/* owned */`(init/take 返回,必须配对 free)、 + `/* borrowed */`(访问器返回,禁 free)、`/* retained */`(register + 类,配对 unregister)。句柄默认 owned。 +- 每个模块提供 `oak_debug_alive_count()`(测试专用,钉死泄漏)。 diff --git a/docs/zh/plans/riir/M10-oakstorage.md b/docs/zh/plans/riir/M10-oakstorage.md new file mode 100644 index 0000000000..e38d6f4b3c --- /dev/null +++ b/docs/zh/plans/riir/M10-oakstorage.md @@ -0,0 +1,176 @@ +# M10 · oakstorage 拆分手册(工程持久化,数据库替换预备) + +> 内容:工程文件的读写——`engine/node/project/serializer/` 的**落盘路径** +> (Load/Save/SaveData/LoadData 的文件分支 + 版本化 serializerXXXXXX 族) +> 与 `engine/task/project/{load,save,loadotio,saveotio}` 的**文件 IO 部分**。 +> **不含**剪贴板序列化(copy/paste 的节点图 XML 留在 oaknode 的 +> serializer 族,见 M3)。 +> 依赖:oaknode(project/root/序列化建图取图)、oakcommon。 +> 被依赖:oaktask(load/save 任务委托)、facade。 +> 拆分顺序:M3a(oaknode 之后、oakserialize 同批)。 +> +> **本模块存在的理由**:把"工程从哪来、存到哪去"收敛成唯一模块、唯一 +> 接口,当前后端是 XML .ove 文件,**未来整体替换为数据库**。替换时新增 +> 一个后端实现并注册即可,oaktask / facade / app 零改动。因此本模块的 +> 接口按"存储后端无关"设计:URI 寻址、手工虚函数表、字节流 + 句柄, +> 接口里**不出现任何文件路径特有的概念以外的存储语义**(事务、连接串 +> 等都藏在后端内部)。 + +## 1. 目标形态 + +``` +oakstorage/ + include/oakstorage/{storage.h, backend.h, types.h, export.h} + src/ + capi_storage.cpp # storage.h 实现:URI 分发 + 会话 + backends/ove_xml/ # 内建后端:XML .ove(现有 serializer 落盘路径迁入) + backends/... # 未来:oakdb(数据库后端) + tests/ # oakstorage_gtest +``` + +## 2. 冻结 C API + +遵守 01 §0 铁律:纯 C、有类型不透明句柄、init/free、self 首参、 +多态用手工虚函数表。 + +### 2.1 `oakstorage/types.h` + +```c +/* 错误码(0=OK,负值错误;版本/格式探测用正值信息码) */ +#define OAKSTORAGE_OK 0 +#define OAKSTORAGE_TOO_OLD 1 /* 工程版本过旧(信息码) */ +#define OAKSTORAGE_TOO_NEW 2 /* 工程版本过新 */ +#define OAKSTORAGE_UNKNOWN_VERSION 3 +#define OAKSTORAGE_E_INVALID -1 +#define OAKSTORAGE_E_STATE -2 +#define OAKSTORAGE_E_NOT_FOUND -3 +#define OAKSTORAGE_E_FAILED -4 +#define OAKSTORAGE_E_NO_BACKEND -5 /* 无后端认领该 URI */ +#define OAKSTORAGE_E_FORMAT -6 /* 解析失败(XML/DB 约束) */ +#define OAKSTORAGE_E_IO -7 /* 读写失败 */ +``` + +### 2.2 `oakstorage/storage.h`(消费侧主接口) + +```c +/* 打开的工程会话:包裹一个已加载(或待保存)的 oakmodel Project。 + * owned 句柄,配对 oakstorage_project_free。 */ +typedef struct OakStorageProject OakStorageProject; + +/* --- 静态函数(无 self,对应"类方法") --- + * 全部是同步命令:成败与结果全在返回值/出参里,调用方知道影响, + * 由调用方(oaktask/facade)负责对外通知;本模块无任何回调/事件。 */ + +/* 探测 URI:返回认领该 URI 的后端名(buf/size,先 NULL 查长度), + * 或负值(OAKSTORAGE_E_NO_BACKEND)。不写盘、不建会话。 */ +OAKSTORAGE_API int oakstorage_probe(const char *uri, char *buf, int buf_size); + +/* 打开工程(load)。URI scheme 选后端: + * file:///path/to/proj.ove → ove-xml 后端 + * file:///…/proj.otio → otio 后端(import 语义) + * oakdb://… → 未来数据库后端 + * 失败返回 NULL,细节经 oakstorage_last_error。 */ +OAKSTORAGE_API OakStorageProject *oakstorage_open( + const char *uri, int *result_code); + +/* 把 project 保存到 URI(save / save-as)。 + * `options`:位掩码,OAKSTORAGE_SAVE_COMPRESS 等;后端忽略不识别的位。 */ +#define OAKSTORAGE_SAVE_COMPRESS 0x1 +OAKSTORAGE_API int oakstorage_save(OakNodeProject *project, + const char *uri, unsigned options); + +/* --- 成员函数(self 首参) --- */ + +OAKSTORAGE_API void oakstorage_project_free(OakStorageProject *self); +/* 取出工程句柄:所有权转移给调用方(此后 self 为空壳,仍须 free)。 + * 对应 oakengine/task.h 的 take_project 语义。 */ +OAKSTORAGE_API OakNodeProject *oakstorage_project_take_project( + OakStorageProject *self); +/* borrowed:不转移所有权 */ +OAKSTORAGE_API OakNodeProject *oakstorage_project_project( + const OakStorageProject *self); +/* 会话来源 URI(buf/size) */ +OAKSTORAGE_API int oakstorage_project_uri(const OakStorageProject *self, + char *buf, int buf_size); + +OAKSTORAGE_API int oakstorage_last_error(char *buf, int buf_size); +OAKSTORAGE_API int oakstorage_debug_alive_count(void); /* 测试专用 */ +``` + +### 2.3 `oakstorage/backend.h`(手工虚函数表——后端注册接口) + +多态按 01 §0.4:纯 C 函数指针表,提供侧(后端)填充,oakstorage 核心 +经表调用。**后端实现不进公共头**;数据库后端未来只是多注册一行。 + +```c +/* 存储后端虚表。所有函数必需;返回码用 OAKSTORAGE_*。 + * 句柄协议:load 成功时 *out_project 收到 owned OakNodeProject*; + * 后端可在 vtable 之外持有任意私有状态(连接池、事务句柄等)。 */ +typedef struct OakStorageBackend { + const char *name; /* "ove-xml" / "otio" / "oakdb"(静态字符串) */ + const char *uri_scheme; /* "file" / "oakdb";同一 scheme 可注册多个 + * 后端,按 can_handle 顺序裁决 */ + + /* 是否认领该 URI(后缀、magic bytes、连接可达性等,后端自决) */ + int (*can_handle)(const char *uri); + + /* 加载:URI → owned Project 句柄;*result_code 收 OAKSTORAGE_* */ + OakNodeProject *(*load)(const char *uri, int *result_code, + char *err_buf, int err_buf_size); + + /* 保存:Project → URI;options 透传 storage.h 的位掩码 */ + int (*save)(OakNodeProject *project, const char *uri, unsigned options, + char *err_buf, int err_buf_size); +} OakStorageBackend; + +/* 注册/注销。oakstorage 核心不拷贝表体——后端必须保证表与 name 字符串 + * 在 unregister 前存活(内建后端为静态存储期,天然满足)。 */ +OAKSTORAGE_API int oakstorage_backend_register(const OakStorageBackend *backend); +OAKSTORAGE_API int oakstorage_backend_unregister(const char *name); +``` + +**数据库替换路径(未来的活,接口已预留)**: +1. 写 `backends/oakdb/`:`can_handle` 认 `oakdb://`,`load/save` 走 SQL, + 建表 schema 是后端私事; +2. `oakstorage_backend_register(&oakdb_backend);` 一行接入; +3. oaktask/facade/app 不动;`file://` 的 .ove 后端继续共存(迁移期 + 双后端并存,经 URI 显式选择)。 +**反向约束**:任何"必须改本手册 §2.2 才能接数据库"的需求,说明接口 +冻结有洞——先改本手册再动手。 + +### 2.4 与 oaktask 的边界(任务只是壳) + +M8 的任务工厂保留原签名,实现改为薄委托: + +```c +/* oaktask/project.cpp(概念) */ +OakTaskTask *oaktask_create_project_load(const char *filename) { + /* 任务体内:oakstorage_open(uri) → 完成回调里 + * oakstorage_project_take_project() */ +} +``` + +import/conform/precache 等非工程 IO 任务不经 oakstorage。 + +## 3. 切割点 + +| 现状 | 处理 | +|---|---| +| `node/project/serializer/*` 落盘路径在 oaknode 内 | 文件分支 + serializerXXXXXX 版本族迁入 `oakstorage/backends/ove_xml/`;**节点图 XML 生成/解析(SaveData/LoadData 的内存形态)仍属 oaknode**,ove-xml 后端经 oaknode C ABI(`oaknode_serializer_*` 族)取图/建图。剪贴板分支留 oaknode(M3/M3b) | +| `task/project/load|save` 含文件 IO | IO 部分下沉 oakstorage;task 保留任务编排(进度、取消、事件) | +| `task/project/loadotio|saveotio` | 同上,注册为 "otio" 后端(scheme=file,can_handle 认 .otio) | +| serializer 对 Qt 文件对话框/布局的引用 | 布局信息(SerializedLayoutInfo)随保存走 options 的不透明 blob(buf/size),不进入本手册冻结面 | + +## 4. 测试(映射 03 §2/§3) + +- **round-trip 字节一致**(金标准):project_with_footage.ove → + `oakstorage_open` → `oakstorage_save` 到临时 URI → 两文件字节一致; + 再 load 后 `oakstorage_project_project()` 非空、root 非空。 +- probe:.ove(压缩/未压缩各一)、.otio、未知 scheme → + E_NO_BACKEND。 +- 错误路径:不存在文件 open → NULL + last_error 非空;TOO_NEW 版本 + 头 → result_code = OAKSTORAGE_TOO_NEW。 +- 后端虚表:注册一个内存 mock 后端(`mem://`,load/save 记日志), + 断言 open/save 全走虚表、unregister 后 probe 报 E_NO_BACKEND—— + **这条用例就是"数据库可插拔"的接口验证**。 +- `oakstorage_debug_alive_count()`:open/take/free 配对无泄漏。 diff --git a/docs/zh/plans/riir/M2-oakundo.md b/docs/zh/plans/riir/M2-oakundo.md index cfc3f9e057..8dc01a9398 100644 --- a/docs/zh/plans/riir/M2-oakundo.md +++ b/docs/zh/plans/riir/M2-oakundo.md @@ -40,7 +40,12 @@ OAKUNDO_API void oakundo_command_free(OakUndoCommand *cmd); /* NULL no-op */ ```c typedef struct OakUndoStack OakUndoStack; -OAKUNDO_API OakUndoStack *oakundo_undostack_init(void *parent_qobject); +typedef struct OakUndoObjectParent OakUndoObjectParent; /* borrowed QObject 挂载点 */ +OAKUNDO_API OakUndoStack *oakundo_undostack_init( + const OakUndoObjectParent *parent); +/* OakUndoObjectParent:QObject 父子树挂载点的 borrowed 不透明句柄 + * (2026-08 修订:按 01 §0.1 由 void* 改为有类型句柄; + * 提供侧内部即 QObject*,消费侧不可解引用;可传 NULL 表无父) */ OAKUNDO_API void oakundo_undostack_free(OakUndoStack *self); OAKUNDO_API void oakundo_undostack_push(OakUndoStack *self, @@ -65,16 +70,13 @@ OAKUNDO_API void oakundo_undostack_update_actions(OakUndoStack *self); /* QAction* 句柄(GUI 菜单绑定用,borrowed) */ OAKUNDO_API void *oakundo_undostack_undo_action(OakUndoStack *self); OAKUNDO_API void *oakundo_undostack_redo_action(OakUndoStack *self); - -/* 事件(index_changed) */ -#define OAKUNDO_EVENT_INDEX_CHANGED 1 -typedef void (*oakundo_event_fn)(int event_id, int64_t a, int64_t b, - void *userdata); -OAKUNDO_API int64_t oakundo_undostack_subscribe(OakUndoStack *self, - int event_id, oakundo_event_fn fn, void *userdata); -OAKUNDO_API void oakundo_unsubscribe(int64_t subscription_id); ``` +**无事件接口(2026-08 修订)**:上层对下层只有命令。push/undo/redo/jump +的调用方知道栈索引的变化(`oakundo_undostack_index` 调用后即可读), +`index_changed` 通知改由**调用方所在层**(facade 的 undo 适配层)在 +每次变更命令后自行发出——oakundo 不持有任何上层回调。 + ## 3. 切割点(1 处) `undocommand.cpp` include `node/project.h`(`get_relevant_project()` @@ -96,7 +98,8 @@ Project* 本来就只是作为不透明身份被使用(修改标记归属) - 每条 API 正常+错误路径(NULL self、空 push 不入栈—— 空 MultiUndoCommand 被删除的既有行为必须有 TEST 钉死)。 -- push/undo/redo/jump/clear 全序列;command_text/is_done 边界行。 -- 事件:push 后 index_changed 触发且 a=新 index;unsubscribe 后不再触发。 +- push/undo/redo/jump/clear 全序列;command_text/is_done 边界行; + 每步后 `oakundo_undostack_index` 读数与预期一致(替代原事件断言—— + 调用方知道影响,直接读状态)。 - 往返测试:C API 与适配类各做一遍 push-undo-redo,状态一致。 - `oakundo_debug_alive_count()`:init/free 配对无泄漏。 diff --git a/docs/zh/plans/riir/M3-oaknode.md b/docs/zh/plans/riir/M3-oaknode.md index 13bd5a3bf6..7eb6c550d4 100644 --- a/docs/zh/plans/riir/M3-oaknode.md +++ b/docs/zh/plans/riir/M3-oaknode.md @@ -40,16 +40,18 @@ project.h、timeline.h 的对应函数就是模板,参数命名前缀换 | Track / TrackList | timeline | height/mute/lock/index/type、block 增删、split/ripple 原语 | | Block / ClipBlock / GapBlock / TransitionBlock | timeline | in/out/length/media_in、speed/reverse/loop、links | | Footage | task | filename、streams、proxy、duration | -| ProjectSerializer | task | save/load/copy/paste(clipboard 族,照 oakengine/serializer.h 模板) | +| ProjectSerializer | task / oakstorage | **剪贴板** copy/paste + 节点图 XML 的内存形态(SaveData/LoadData,照 oakengine/serializer.h 模板);**落盘 save/load 迁 oakstorage(M10),不在本模块** | | ColorManager | render | config、default config、display transform | **特殊约定**: -1. undoable 变体与 live 变体成对(`_live` 后缀或 `, void *command` - 尾参),与 oakengine 现状一致。 -2. 事件:Node 族事件(label/input/keyframe/context 等)经 - `oaknode_subscribe(handle, event_id, fn, userdata)`——事件 ID 表 - 直接沿用 `oakengine/events.h` 的 70-95 段(值不变,便于 - EngineEventBridge 逐步换绑)。 +1. undoable 变体与 live 变体成对(`_live` 后缀或 + `, OakUndoCommand *command` 尾参——有类型句柄,禁 void*), + 与 oakengine 现状一致。 +2. 无事件订阅接口(2026-08 修订,04 §3):oaknode 的所有修改都经 + 命令函数完成,调用方知道影响;Node 族的变更通知(label/input/ + keyframe/context 等)由 facade 在执行命令后经既有 `oakengine_event` + 通道发出(事件 id 沿用 `oakengine/events.h` 70-95 段,值不变), + oaknode 自身不持有任何上层回调。 3. `Node *`、`Project *` 等句柄即 `OakNodeNode *`/`OakNodeProject *`, 不透明。 4. 虚函数不出模块(01 §5);具体节点类型经 @@ -73,8 +75,8 @@ M3 阶段判据(放宽版):oaknode 目录就位、C API 实现、oaknode_g - 重点:Node 增删连边、Project/Folder 层级、Track 属性、 keyframe live/undoable 对称、serializer 剪贴板往返、 - factory 枚举与创建。 -- 事件:每族至少 1 个 subscribe/trigger/unsubscribe 用例。 + factory 枚举与创建;每个变更命令执行后直接读状态断言生效 + (无事件可断言——通知在 facade 层测)。 - 枚举序数:NodeValue::Type ⇄ oak_node_value_type 映射表(已在 nodevaluehandle.h 钉过一次,oaknode 测试再钉一次,防两侧漂移)。 - `oaknode_debug_alive_count()` 泄漏断言。 diff --git a/docs/zh/plans/riir/M4-oaktimeline.md b/docs/zh/plans/riir/M4-oaktimeline.md index 0105e66850..51e505c596 100644 --- a/docs/zh/plans/riir/M4-oaktimeline.md +++ b/docs/zh/plans/riir/M4-oaktimeline.md @@ -35,17 +35,16 @@ OAKTL_API int oaktimeline_marker_at(const OakTimelineMarkerList *l, int i, int64_t *in_ts, int64_t *out_ts, int *color, char *name_buf, int n); OAKTL_API int oaktimeline_marker_add(OakTimelineMarkerList *l, int64_t in_ts, int64_t out_ts, const char *name, int color, - void *command); /* command=NULL 时自行入栈 */ + OakUndoCommand *command); /* command=NULL 时自行入栈(2026-08:void* → 有类型句柄) */ OAKTL_API int oaktimeline_marker_remove_at(OakTimelineMarkerList *l, - int i, void *command); + int i, OakUndoCommand *command); OAKTL_API int oaktimeline_marker_set_time(OakTimelineMarkerList *l, - int i, int64_t in_ts, int64_t out_ts, void *command); + int i, int64_t in_ts, int64_t out_ts, OakUndoCommand *command); OAKTL_API int oaktimeline_marker_set_props(OakTimelineMarkerList *l, - int i, int color, const char *name, void *command); -/* 事件:MARKER_ADDED/REMOVED/MODIFIED(id 沿用 oakengine events 段) */ -OAKTL_API int64_t oaktimeline_subscribe(void *handle, int32_t event_id, - oaktl_event_fn fn, void *userdata); -OAKTL_API void oaktimeline_unsubscribe(int64_t id); + int i, int color, const char *name, OakUndoCommand *command); +/* 无事件接口(2026-08 修订,04 §3):marker 的增删改都是调用方发的 + * 命令,MARKER_ADDED/REMOVED/MODIFIED 通知由调用方所在层(facade, + * id 沿用 oakengine events 段)在命令后发出。 */ ``` ### 2.2 `oaktimeline/workarea.h` @@ -58,9 +57,9 @@ OAKTL_API int oaktimeline_workarea_set_range(OakTimelineWorkarea *w, int64_t in_ts, int64_t out_ts); /* live */ OAKTL_API int oaktimeline_workarea_set_range_undoable( OakTimelineWorkarea *w, int64_t in_ts, int64_t out_ts, - int64_t old_in_ts, int64_t old_out_ts, void *command); + int64_t old_in_ts, int64_t old_out_ts, OakUndoCommand *command); OAKTL_API int oaktimeline_workarea_set_enabled_undoable( - OakTimelineWorkarea *w, int enabled, void *command); + OakTimelineWorkarea *w, int enabled, OakUndoCommand *command); OAKTL_API void oaktimeline_workarea_reset(int64_t *in_ts, int64_t *out_ts); /* load/save 经 oakcommon_xml 句柄在 oaknode 序列化路径调用 */ OAKTL_API int oaktimeline_workarea_load(OakTimelineWorkarea *w, @@ -107,7 +106,8 @@ OAKTL_API int64_t oaktimeline_nearest_block_ts(OakNodeTrack *track, ## 4. 测试(映射 03 §2/§3) -- marker:增删改查、undo 往返(push 后 undo 恢复)、事件三件套。 +- marker:增删改查、undo 往返(push 后 undo 恢复);每命令后读 + count/at 断言生效(无事件——通知在 facade 层测)。 - workarea:set/get、undoable 旧值恢复、reset 哨兵、xml 往返。 - edit:每个 `_command` 工厂 1 个"构造→入栈→undo 还原"用例 (track 增删、place/replace/trim/split/ripple/slide)。 diff --git a/docs/zh/plans/riir/M6-oakaudio.md b/docs/zh/plans/riir/M6-oakaudio.md index cd2437627d..5aa254f824 100644 --- a/docs/zh/plans/riir/M6-oakaudio.md +++ b/docs/zh/plans/riir/M6-oakaudio.md @@ -45,10 +45,8 @@ OAKAU_API int oakaudio_manager_push(const float *samples, int frame_count, double speed); OAKAU_API int oakaudio_manager_is_playing(void); OAKAU_API void oakaudio_manager_stop(void); -/* 输出参数变化事件 */ -OAKAU_API int64_t oakaudio_manager_subscribe_params_changed( - oakaudio_event_fn fn, void *userdata); -OAKAU_API void oakaudio_unsubscribe(int64_t id); +/* 无事件接口(2026-08 修订,04 §3):输出参数的修改是调用方发的 + * 命令(set_params),params_changed 通知由调用方所在层发出。 */ ``` ## 3. 切割点 @@ -63,5 +61,5 @@ OAKAU_API void oakaudio_unsubscribe(int64_t id); - processor:open/convert/close 全链(44.1k stereo → 48k stereo, 帧数换算正确、无爆音断言用能量差阈值)、速度 1.5x。 - manager:无音频设备环境用 null backend 初始化(现有后端探测 - 模式),params set/get 往返、事件触发。 + 模式),params set/get 往返(set 后 get 读数即生效——无事件)。 - `oakaudio_debug_alive_count()` 泄漏断言。 diff --git a/docs/zh/plans/riir/M7-oakrender.md b/docs/zh/plans/riir/M7-oakrender.md index 8d9a4a1296..b42998aea2 100644 --- a/docs/zh/plans/riir/M7-oakrender.md +++ b/docs/zh/plans/riir/M7-oakrender.md @@ -51,9 +51,10 @@ OAKRD_API int oakrender_frame_cache_load(OakRenderCache *c, OakCodecFrame **out_frame); OAKRD_API void oakrender_frame_cache_save(OakRenderCache *c, const char *path, const char *uuid, const OakCodecFrame *f); -/* 缓存事件(playback invalidated/validated、frame invalidated) */ -OAKRD_API int64_t oakrender_cache_subscribe(OakRenderCache *c, - int32_t event_id, oakrender_event_fn fn, void *userdata); +/* 无缓存事件(2026-08 修订,04 §3):缓存的 invalidate/validate 由 + * 编辑命令的调用方触发并知情,通知由 facade 在命令后发出; + * oakrender 不持有上层回调。渲染 ticket 的完成回调属异步命令 + * 返回通道,不在此限(见 renderer.h 族)。 */ ``` ### 2.3 `oakrender/color.h` @@ -107,7 +108,8 @@ OAKRD_API int oakrender_disk_cache_clear(void); ## 4. 测试(映射 03 §2/§3) -- cache:invalidate/validate 状态机、帧缓存存取往返、事件触发。 +- cache:invalidate/validate 状态机、帧缓存存取往返(调用方触发后 + 读状态断言——无事件)。 - color:默认 config 建置、processor convert 已知值(sRGB→Linear 抽样点数值断言,容差 1e-3)。 - manager:request_frame 对 demo.mp4 + 最小 sequence 出帧非空 diff --git a/docs/zh/plans/riir/M8-oaktask.md b/docs/zh/plans/riir/M8-oaktask.md index bc1e04d4c5..60a14e95bd 100644 --- a/docs/zh/plans/riir/M8-oaktask.md +++ b/docs/zh/plans/riir/M8-oaktask.md @@ -1,7 +1,8 @@ # M8 · oaktask 拆分手册 -> 内容:`engine/task/`(Task 基类、TaskManager、project/ -> load/save/import/loadotio/saveotio、cache 任务)。 +> 内容:`engine/task/`(Task 基类、TaskManager、任务编排、cache 任务; +> **工程文件 IO——project/load/save/loadotio/saveotio 的落盘部分—— +> 已划给 oakstorage(M10),本模块只做任务壳**)。 > 依赖:node 38、codec 4、render 4、common 4、config 2、timeline 1、 > coreengine 2。 > 拆分顺序第 8 位。 @@ -30,7 +31,8 @@ OAKTK_API int oaktask_task_succeeded(const OakTaskTask *t); OAKTK_API int oaktask_task_progress(const OakTaskTask *t, double *out); OAKTK_API int oaktask_task_title(OakTaskTask *t, char *buf, int n); OAKTK_API int oaktask_task_error(OakTaskTask *t, char *buf, int n); -/* 事件:STARTED/PROGRESS/FINISHED(id 沿用 oakengine events 段) */ +/* 进度/完成回调:任务是异步命令,回调即其返回通道——04 §3 唯一 + * 例外情形(一次性语义,FINISHED 后自动失效) */ OAKTK_API int64_t oaktask_task_subscribe(OakTaskTask *t, int32_t event_id, oaktask_event_fn fn, void *userdata); ``` @@ -42,6 +44,9 @@ OAKTK_API OakTaskTask *oaktask_create_project_load(const char *filename); OAKTK_API OakTaskTask *oaktask_create_project_save(OakNodeProject *p, const char *filename_or_NULL, int use_compression, const void *layout_or_NULL); +/* 注:以上两个工厂是薄壳——文件 IO 全部委托 oakstorage(M10 §2.4), + * 任务只保留进度/取消/事件编排;`use_compression` 等打包进 + * oakstorage_save 的 options 位掩码。 */ OAKTK_API OakTaskTask *oaktask_create_project_import(OakNodeNode *folder, const char *const *urls, int url_count); OAKTK_API OakTaskTask *oaktask_create_project_load_otio( @@ -50,7 +55,7 @@ OAKTK_API OakTaskTask *oaktask_create_project_save_otio( OakNodeProject *p, const char *filename, const int *sequence_indexes, int count); /* import 结果(task 成功后读,borrowed) */ -OAKTK_API void *oaktask_import_take_command(OakTaskTask *t); /* 所有权转移 */ +OAKTK_API OakUndoCommand *oaktask_import_take_command(OakTaskTask *t); /* 所有权转移(2026-08 修订:按 01 §0.1 由 void* 改为有类型句柄) */ OAKTK_API int oaktask_import_footage_count(OakTaskTask *t); OAKTK_API OakNodeNode *oaktask_import_footage_at(OakTaskTask *t, int i); OAKTK_API int oaktask_import_invalid_count(OakTaskTask *t); @@ -66,10 +71,9 @@ OAKTK_API OakNodeProject *oaktask_load_take_project(OakTaskTask *t); OAKTK_API int oaktask_manager_count(void); OAKTK_API OakTaskTask *oaktask_manager_at(int i); /* borrowed */ OAKTK_API void oaktask_manager_delete_finished(void); -/* 事件:TASK_ADDED/REMOVED/FAILED/LIST_CHANGED */ -OAKTK_API int64_t oaktask_manager_subscribe(int32_t event_id, - oaktask_event_fn fn, void *userdata); -OAKTK_API void oaktask_unsubscribe(int64_t id); +/* 无 manager 事件(2026-08 修订,04 §3):任务的创建/删除都由调用方 + * 发起(facade 建任务即知 ADDED;delete_finished 的调用方知 REMOVED), + * LIST_CHANGED 通知由调用方所在层发出。 */ ``` ## 3. 切割点 @@ -77,6 +81,7 @@ OAKTK_API void oaktask_unsubscribe(int64_t id); | 现状 | 处理 | |---|---| | task → node/ 38(footage 6、project 5、sequence 3、serializer/layout 3、colormanager 2 等) | 全部经 oaknode C ABI + 适配类(M3 已就位)——本手册工作量主体 | +| task → 工程文件 IO(load/save/loadotio/saveotio 落盘) | 划给 **oakstorage**(M3a/M10):任务工厂保留签名,实现改为委托 `oakstorage_open/save` | | task → codec/ 4 | 经 oakcodec C ABI(M5) | | task → render/ 4 | 经 oakrender C ABI(M7) | | task → timeline/ 1 | 经 oaktimeline C ABI(M4) | @@ -88,7 +93,7 @@ OAKTK_API void oaktask_unsubscribe(int64_t id); 入栈)、save/load 往返(临时目录 .ove,load 后 project 非空、 root 非空)。 - start_sync 成功/失败路径(不存在文件 → failed + error 非空)。 -- 事件:STARTED→PROGRESS→FINISHED 序列(导入任务断言至少一次 - PROGRESS 且 FINISHED.succeeded==1)。 -- manager:添加/删除/list_changed 事件。 +- 事件(异步任务例外):STARTED→PROGRESS→FINISHED 序列(导入任务 + 断言至少一次 PROGRESS 且 FINISHED.succeeded==1)。 +- manager:count/at/delete_finished 行为(无事件,直接读状态断言)。 - `oaktask_debug_alive_count()` 泄漏断言。 diff --git a/docs/zh/plans/riir/M9-oakplugin.md b/docs/zh/plans/riir/M9-oakplugin.md index 66c1930674..74fe7bba0e 100644 --- a/docs/zh/plans/riir/M9-oakplugin.md +++ b/docs/zh/plans/riir/M9-oakplugin.md @@ -43,7 +43,9 @@ OAKPL_API int oakplugin_instance_get_param(OakPluginInstance *i, const char *param_id, oak_node_value *out); OAKPL_API int oakplugin_instance_render(OakPluginInstance *i, OakCodecFrame *dst, const OakCodecFrame *src, int64_t ts); -/* 进度事件(R6 已把 cancelled 信号改成 C 回调,沿用该机制) */ +/* 进度回调:instance_render 是异步命令(渲染中进行),进度/取消回调 + * 即其返回通道——04 §3 唯一例外情形(R6 已把 cancelled 信号改成 C + * 回调,沿用该机制)。非异步接口一律不配回调。 */ OAKPL_API void oakplugin_instance_set_progress_cb(OakPluginInstance *i, oakplugin_progress_fn fn, void *userdata); OAKPL_API void oakplugin_instance_cancel(OakPluginInstance *i); From 7b6042168a345c867961aa9eac13dd21325d9159 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Wed, 5 Aug 2026 14:53:04 +0800 Subject: [PATCH 3/4] fix(widget): harden TimelineAndTrackView teardown and TimeScaledObject limits - TimelineAndTrackView: disconnect the scrollbar sync connections in the destructor. Child views reset their scene during destruction, which can re-emit QScrollBar::valueChanged into a half-destroyed object and trip Qt's assertObjectType abort. - TimeScaledObject: keep minimum <= maximum when setting scale limits so set_scale()'s std::clamp is never called with inverted limits (UB). Setting the maximum below the minimum now pulls the minimum down, and vice versa, matching QAbstractSlider conventions. --- app/widget/timebased/timescaledobject.cpp | 12 ++++++++++++ app/widget/timelinewidget/timelineandtrackview.cpp | 10 ++++++++++ app/widget/timelinewidget/timelineandtrackview.h | 2 ++ 3 files changed, 24 insertions(+) diff --git a/app/widget/timebased/timescaledobject.cpp b/app/widget/timebased/timescaledobject.cpp index 7e436d3684..b72f843492 100644 --- a/app/widget/timebased/timescaledobject.cpp +++ b/app/widget/timebased/timescaledobject.cpp @@ -116,6 +116,12 @@ void TimeScaledObject::set_maximum_scale(const double &max) { max_scale_ = max; + // Keep min <= max so the clamp in set_scale() is never called with + // inverted limits (undefined behavior) + if (min_scale_ > max_scale_) { + min_scale_ = max_scale_; + } + if (get_scale() > max_scale_) { set_scale(max_scale_); } @@ -125,6 +131,12 @@ void TimeScaledObject::set_minimum_scale(const double &min) { min_scale_ = min; + // Keep min <= max so the clamp in set_scale() is never called with + // inverted limits (undefined behavior) + if (max_scale_ < min_scale_) { + max_scale_ = min_scale_; + } + if (get_scale() < min_scale_) { set_scale(min_scale_); } diff --git a/app/widget/timelinewidget/timelineandtrackview.cpp b/app/widget/timelinewidget/timelineandtrackview.cpp index fede8e1f07..76bd1c2340 100644 --- a/app/widget/timelinewidget/timelineandtrackview.cpp +++ b/app/widget/timelinewidget/timelineandtrackview.cpp @@ -54,6 +54,16 @@ TimelineAndTrackView::TimelineAndTrackView(Qt::Alignment vertical_alignment, splitter_->setSizes({ 180, width() }); } +TimelineAndTrackView::~TimelineAndTrackView() +{ + // When the child views are destroyed they reset their scene, which can + // push a valueChanged through these connections while this object is + // already half-destroyed (Qt aborts on a slot invoked past its class's + // destructor). Detach first so teardown emissions go nowhere. + disconnect(view_->verticalScrollBar(), nullptr, this, nullptr); + disconnect(track_view_->verticalScrollBar(), nullptr, this, nullptr); +} + QSplitter *TimelineAndTrackView::splitter() const { return splitter_; diff --git a/app/widget/timelinewidget/timelineandtrackview.h b/app/widget/timelinewidget/timelineandtrackview.h index 7181aebcdb..b0cafb633f 100644 --- a/app/widget/timelinewidget/timelineandtrackview.h +++ b/app/widget/timelinewidget/timelineandtrackview.h @@ -42,6 +42,8 @@ class TimelineAndTrackView : public QWidget { TrackView *track_view() const; + virtual ~TimelineAndTrackView() override; + private: QSplitter *splitter_; From 82162580efb0b070da0297c0c09eaeec9265e05d Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Wed, 5 Aug 2026 14:53:26 +0800 Subject: [PATCH 4/4] test(app): add gtest coverage for previously untested app modules Add 312 test cases across 14 new files under tests/gtest, covering: - app/common utility headers (tooltypes, projecttypes, nodedatatypes, trackreferencehandle, undowrapper, configwrapper, subtitleapp, ...) - all 8 standardcombos widgets - slider base classes (NumericSliderBase, SliderLadder) - timeline/track handles, marker handle/painting, drag button, elapsed counter - timebased views, selection manager, time target, resizable timeline scrollbar - viewer sizer, display buffer, playback timer, text editor - timeline common helpers, TimelineAndTrackView, TrackView(-item/-splitter) - project explorer views, navigation and undo commands - node view items/edges/connectors/minimap/toolbar/context - node param view sub-widgets - about/export/sequence/footage-properties dialogs - scope widgets (histogram/vectorscope/waveform), managed display - main menu, main window undo commands, viewer/footage panels, engine event bridge - keyframe view handle/input connection, multicam display, node combo box, audio monitor Includes regression tests for the TimelineAndTrackView teardown and TimeScaledObject inverted-limits fixes. Full suite: 2341 tests, 0 failures (64 skips are pre-existing GL-dependent cases under the offscreen QPA). --- tests/gtest/CMakeLists.txt | 14 + tests/gtest/common_misc2_test.cpp | 802 ++++++++++++ tests/gtest/dialog_misc2_test.cpp | 672 ++++++++++ tests/gtest/mainwindow2_test.cpp | 1003 +++++++++++++++ tests/gtest/timeline_common_test.cpp | 569 +++++++++ tests/gtest/widget_handles_test.cpp | 396 ++++++ tests/gtest/widget_keyframe_multicam_test.cpp | 660 ++++++++++ tests/gtest/widget_nodeparamview_test.cpp | 846 +++++++++++++ tests/gtest/widget_nodeview_test.cpp | 1088 +++++++++++++++++ tests/gtest/widget_projectexplorer2_test.cpp | 539 ++++++++ tests/gtest/widget_scope_test.cpp | 589 +++++++++ tests/gtest/widget_sliderbase_test.cpp | 436 +++++++ tests/gtest/widget_standardcombos_test.cpp | 496 ++++++++ tests/gtest/widget_timebased2_test.cpp | 870 +++++++++++++ tests/gtest/widget_viewer2_test.cpp | 781 ++++++++++++ 15 files changed, 9761 insertions(+) create mode 100644 tests/gtest/common_misc2_test.cpp create mode 100644 tests/gtest/dialog_misc2_test.cpp create mode 100644 tests/gtest/mainwindow2_test.cpp create mode 100644 tests/gtest/timeline_common_test.cpp create mode 100644 tests/gtest/widget_handles_test.cpp create mode 100644 tests/gtest/widget_keyframe_multicam_test.cpp create mode 100644 tests/gtest/widget_nodeparamview_test.cpp create mode 100644 tests/gtest/widget_nodeview_test.cpp create mode 100644 tests/gtest/widget_projectexplorer2_test.cpp create mode 100644 tests/gtest/widget_scope_test.cpp create mode 100644 tests/gtest/widget_sliderbase_test.cpp create mode 100644 tests/gtest/widget_standardcombos_test.cpp create mode 100644 tests/gtest/widget_timebased2_test.cpp create mode 100644 tests/gtest/widget_viewer2_test.cpp diff --git a/tests/gtest/CMakeLists.txt b/tests/gtest/CMakeLists.txt index 7726e3f999..789e277a4a 100644 --- a/tests/gtest/CMakeLists.txt +++ b/tests/gtest/CMakeLists.txt @@ -142,6 +142,20 @@ add_executable(olive-gtest mainwindow_test.cpp serialized_layout_xml_test.cpp common_ratiodialog_test.cpp + common_misc2_test.cpp + widget_standardcombos_test.cpp + widget_sliderbase_test.cpp + widget_handles_test.cpp + widget_timebased2_test.cpp + widget_viewer2_test.cpp + timeline_common_test.cpp + widget_projectexplorer2_test.cpp + widget_nodeview_test.cpp + widget_nodeparamview_test.cpp + dialog_misc2_test.cpp + widget_scope_test.cpp + mainwindow2_test.cpp + widget_keyframe_multicam_test.cpp ) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Test) diff --git a/tests/gtest/common_misc2_test.cpp b/tests/gtest/common_misc2_test.cpp new file mode 100644 index 0000000000..8739bde13b --- /dev/null +++ b/tests/gtest/common_misc2_test.cpp @@ -0,0 +1,802 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "common/configwrapper.h" +#include "common/debugapp.h" +#include "common/nodedatatypes.h" +#include "common/nodevaluehandle.h" +#include "common/oakvaluehelper.h" +#include "common/projecttypes.h" +#include "common/subtitleapp.h" +#include "common/tooltypes.h" +#include "common/trackreferencehandle.h" +#include "common/undowrapper.h" +#include "oakengine/undo.h" +#include "oakutil/qtutils.h" + +namespace +{ + +// OakConfigValue reads/writes go through the process-wide engine config, so +// snapshot and restore each key the tests touch +class ScopedConfigKey { +public: + explicit ScopedConfigKey(const QString &key) + : key_(key) + , old_value_(oakengine_config_string(key)) + { + } + + ~ScopedConfigKey() + { + oakengine_config_set_string(key_.toUtf8().constData(), + old_value_.toUtf8().constData()); + } + +private: + static QString oakengine_config_string(const QString &key) + { + char buf[1024]; + const int len = oakengine_config_get_string(key.toUtf8().constData(), + buf, sizeof(buf)); + return QString::fromUtf8(buf, len); + } + + QString key_; + QString old_value_; +}; + +// App-side undo command for wrap_app_undo_command(): counts invocations and +// reports its own destruction so ownership transfer can be observed +struct CountingCmd { + int *redo_count; + int *undo_count; + bool *destroyed; + + void redo() + { + ++*redo_count; + } + void undo() + { + ++*undo_count; + } + ~CountingCmd() + { + *destroyed = true; + } +}; + +} // namespace + +TEST(ToolTypes, AddableObjectNamesCoverAllValues) +{ + EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_empty), + QStringLiteral("Empty")); + EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_bars), + QStringLiteral("Bars")); + EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_shape), + QStringLiteral("Shape")); + EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_solid), + QStringLiteral("Solid")); + EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_title), + QStringLiteral("Title")); + EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_tone), + QStringLiteral("Tone")); + EXPECT_EQ( + olive::Tool::get_addable_object_name(olive::Tool::k_addable_subtitle), + QStringLiteral("Subtitle")); + + // Out-of-range values fall through to "Unknown" + EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_count), + QStringLiteral("Unknown")); + EXPECT_EQ(olive::Tool::get_addable_object_name( + static_cast(-1)), + QStringLiteral("Unknown")); +} + +TEST(ToolTypes, AddableObjectIdsCoverAllValues) +{ + EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_empty), + QStringLiteral("empty")); + EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_bars), + QStringLiteral("bars")); + EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_shape), + QStringLiteral("shape")); + EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_solid), + QStringLiteral("solid")); + EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_title), + QStringLiteral("title")); + EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_tone), + QStringLiteral("tone")); + EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_subtitle), + QStringLiteral("subtitle")); + + // Every real addable has a unique non-empty id + QSet ids; + for (int i = 0; i < olive::Tool::k_addable_count; i++) { + const QString id = olive::Tool::get_addable_object_id( + static_cast(i)); + EXPECT_FALSE(id.isEmpty()); + EXPECT_FALSE(ids.contains(id)) << id.toStdString(); + ids.insert(id); + } + + // Out-of-range values return an empty id + EXPECT_TRUE( + olive::Tool::get_addable_object_id(olive::Tool::k_addable_count).isEmpty()); + EXPECT_TRUE(olive::Tool::get_addable_object_id( + static_cast(-1)).isEmpty()); +} + +TEST(ToolTypes, ItemOrdinalsMatchEngineMirror) +{ + // The C ABI transports these as ints, so the ordinals are the contract + EXPECT_EQ(olive::Tool::k_none, 0); + EXPECT_EQ(olive::Tool::k_pointer, 1); + EXPECT_EQ(olive::Tool::k_track_select, 13); + EXPECT_EQ(olive::Tool::k_count, 14); + EXPECT_EQ(olive::Tool::k_addable_count, 7); +} + +TEST(ProjectTypes, CacheSettingOrdinals) +{ + EXPECT_EQ(olive::Project::k_cache_use_default_location, 0); + EXPECT_EQ(olive::Project::k_cache_store_alongside_project, 1); + EXPECT_EQ(olive::Project::k_cache_custom_path, 2); +} + +TEST(NodeDataTypes, OrdinalsMatchEngineMirror) +{ + EXPECT_EQ(olive::k_node_data_icon, 0); + EXPECT_EQ(olive::k_node_data_duration, 1); + EXPECT_EQ(olive::k_node_data_created_time, 2); + EXPECT_EQ(olive::k_node_data_modified_time, 3); + EXPECT_EQ(olive::k_node_data_frequency_rate, 4); + EXPECT_EQ(olive::k_node_data_tooltip, 5); +} + +TEST(NodeValueHandle, TypeOrdinalsMatchEngineMirror) +{ + EXPECT_EQ(olive::NodeValueType::k_none, 0); + EXPECT_EQ(olive::NodeValueType::k_boolean, 4); + EXPECT_EQ(olive::NodeValueType::k_vec2, 12); + EXPECT_EQ(olive::NodeValueType::k_data_type_count, 23); +} + +TEST(NodeValueHandle, KeyframeTypeOrdinalsMatchEngineMirror) +{ + EXPECT_EQ(olive::NodeKeyframeType::k_invalid, -1); + EXPECT_EQ(olive::NodeKeyframeType::k_linear, 0); + EXPECT_EQ(olive::NodeKeyframeType::k_hold, 1); + EXPECT_EQ(olive::NodeKeyframeType::k_bezier, 2); +} + +TEST(NodeValueHandle, ToCMappingsDifferFromPlainCast) +{ + // The two enums do NOT share ordinals; the helper must remap each one + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_int), + OAK_NODE_VALUE_INT); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_float), + OAK_NODE_VALUE_FLOAT); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_boolean), + OAK_NODE_VALUE_BOOL); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_rational), + OAK_NODE_VALUE_RATIONAL); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_color), + OAK_NODE_VALUE_COLOR); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_vec2), + OAK_NODE_VALUE_VEC2); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_vec3), + OAK_NODE_VALUE_VEC3); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_vec4), + OAK_NODE_VALUE_VEC4); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_combo), + OAK_NODE_VALUE_COMBO); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_file), + OAK_NODE_VALUE_STRING); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_text), + OAK_NODE_VALUE_TEXT); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_font), + OAK_NODE_VALUE_FONT); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_str_combo), + OAK_NODE_VALUE_STR_COMBO); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_binary), + OAK_NODE_VALUE_BINARY); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_bezier), + OAK_NODE_VALUE_BEZIER); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_texture), + OAK_NODE_VALUE_TEXTURE); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_samples), + OAK_NODE_VALUE_SAMPLES); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_video_params), + OAK_NODE_VALUE_VIDEO_PARAMS); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_audio_params), + OAK_NODE_VALUE_AUDIO_PARAMS); + + // k_boolean=4 vs OAK_NODE_VALUE_BOOL=3: a plain cast would be wrong + EXPECT_NE(olive::NodeValueType::k_boolean, OAK_NODE_VALUE_BOOL); +} + +TEST(NodeValueHandle, ToCReturnsMinusOneForUnrepresentable) +{ + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_none), -1); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_matrix), -1); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_subtitle_params), + -1); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_push_button), -1); + EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_data_type_count), + -1); + EXPECT_EQ(olive::node_value_type_to_c(-7), -1); +} + +TEST(TrackReferenceHandle, DefaultIsInvalidNone) +{ + const olive::TrackReference ref; + EXPECT_EQ(ref.type(), olive::TrackReference::k_none); + EXPECT_EQ(ref.index(), -1); + EXPECT_FALSE(ref.is_valid()); + EXPECT_TRUE(ref.to_string().isEmpty()); +} + +TEST(TrackReferenceHandle, ConstructionAndValidity) +{ + const olive::TrackReference video(olive::TrackReference::k_video, 3); + EXPECT_EQ(video.type(), olive::TrackReference::k_video); + EXPECT_EQ(video.index(), 3); + EXPECT_TRUE(video.is_valid()); + + // A known type with a negative index is not valid + EXPECT_FALSE( + olive::TrackReference(olive::TrackReference::k_video, -1).is_valid()); + // k_none and k_count are never valid, even with a non-negative index + EXPECT_FALSE(olive::TrackReference(olive::TrackReference::k_none, 5).is_valid()); + EXPECT_FALSE( + olive::TrackReference(olive::TrackReference::k_count, 0).is_valid()); + // Index zero is a valid track + EXPECT_TRUE( + olive::TrackReference(olive::TrackReference::k_audio, 0).is_valid()); +} + +TEST(TrackReferenceHandle, ComparisonOperators) +{ + const olive::TrackReference a(olive::TrackReference::k_audio, 1); + const olive::TrackReference a2(olive::TrackReference::k_audio, 1); + const olive::TrackReference a3(olive::TrackReference::k_audio, 2); + const olive::TrackReference v0(olive::TrackReference::k_video, 0); + + EXPECT_TRUE(a == a2); + EXPECT_FALSE(a != a2); + EXPECT_TRUE(a != a3); + EXPECT_TRUE(a != v0); + + // Ordering is by type first (k_video=0 < k_audio=1), then index + EXPECT_TRUE(v0 < a); + EXPECT_FALSE(a < v0); + EXPECT_TRUE(a < a3); + EXPECT_FALSE(a3 < a); + EXPECT_FALSE(a < a2); +} + +TEST(TrackReferenceHandle, TypeStringMappings) +{ + EXPECT_EQ(olive::TrackReference::type_to_string(olive::TrackReference::k_video), + QStringLiteral("v")); + EXPECT_EQ(olive::TrackReference::type_to_string(olive::TrackReference::k_audio), + QStringLiteral("a")); + EXPECT_EQ( + olive::TrackReference::type_to_string(olive::TrackReference::k_subtitle), + QStringLiteral("s")); + EXPECT_TRUE( + olive::TrackReference::type_to_string(olive::TrackReference::k_none) + .isEmpty()); + EXPECT_TRUE( + olive::TrackReference::type_to_string(olive::TrackReference::k_count) + .isEmpty()); + + EXPECT_EQ(olive::TrackReference::type_to_translated_string( + olive::TrackReference::k_video), + QStringLiteral("V")); + EXPECT_EQ(olive::TrackReference::type_to_translated_string( + olive::TrackReference::k_audio), + QStringLiteral("A")); + EXPECT_EQ(olive::TrackReference::type_to_translated_string( + olive::TrackReference::k_subtitle), + QStringLiteral("S")); + EXPECT_TRUE(olive::TrackReference::type_to_translated_string( + olive::TrackReference::k_none).isEmpty()); + EXPECT_TRUE(olive::TrackReference::type_to_translated_string( + olive::TrackReference::k_count).isEmpty()); +} + +TEST(TrackReferenceHandle, StringRoundTrip) +{ + const olive::TrackReference ref(olive::TrackReference::k_video, 12); + EXPECT_EQ(ref.to_string(), QStringLiteral("v:12")); + + const olive::TrackReference parsed = + olive::TrackReference::from_string(QStringLiteral("v:12")); + EXPECT_EQ(parsed, ref); + EXPECT_TRUE(parsed.is_valid()); + + EXPECT_EQ(olive::TrackReference(olive::TrackReference::k_audio, 0).to_string(), + QStringLiteral("a:0")); + EXPECT_EQ( + olive::TrackReference(olive::TrackReference::k_subtitle, 7).to_string(), + QStringLiteral("s:7")); + + // An invalid reference serializes to an empty string + EXPECT_TRUE(olive::TrackReference().to_string().isEmpty()); +} + +TEST(TrackReferenceHandle, FromStringRejectsGarbage) +{ + // Bad prefixes and malformed input all parse back to the default ref + const olive::TrackReference fallback; + for (const QString &s : { QStringLiteral("x:1"), QStringLiteral("v"), + QStringLiteral("v:"), QStringLiteral("v:abc"), + QStringLiteral(""), QStringLiteral("a"), + QStringLiteral("vv:1") }) { + EXPECT_EQ(olive::TrackReference::from_string(s), fallback) + << s.toStdString(); + } + + EXPECT_EQ(olive::TrackReference::type_from_string(QStringLiteral("v:1")), + olive::TrackReference::k_video); + EXPECT_EQ(olive::TrackReference::type_from_string(QStringLiteral("a:2")), + olive::TrackReference::k_audio); + EXPECT_EQ(olive::TrackReference::type_from_string(QStringLiteral("s:3")), + olive::TrackReference::k_subtitle); + EXPECT_EQ(olive::TrackReference::type_from_string(QStringLiteral("q:3")), + olive::TrackReference::k_none); +} + +TEST(TrackReferenceHandle, HashAndSetSemantics) +{ + QSet set; + set.insert(olive::TrackReference(olive::TrackReference::k_video, 0)); + set.insert(olive::TrackReference(olive::TrackReference::k_video, 0)); + set.insert(olive::TrackReference(olive::TrackReference::k_audio, 0)); + + EXPECT_EQ(set.size(), 2); + EXPECT_TRUE( + set.contains(olive::TrackReference(olive::TrackReference::k_video, 0))); + EXPECT_TRUE( + set.contains(olive::TrackReference(olive::TrackReference::k_audio, 0))); + EXPECT_FALSE( + set.contains(olive::TrackReference(olive::TrackReference::k_video, 1))); + + // Equal references hash equally + EXPECT_EQ(qHash(olive::TrackReference(olive::TrackReference::k_subtitle, 2)), + qHash(olive::TrackReference(olive::TrackReference::k_subtitle, 2))); +} + +TEST(TrackReferenceHandle, DataStreamRoundTrip) +{ + QByteArray bytes; + { + QBuffer buffer(&bytes); + ASSERT_TRUE(buffer.open(QIODevice::WriteOnly)); + QDataStream out(&buffer); + out << olive::TrackReference(olive::TrackReference::k_audio, 4) + << olive::TrackReference(); + } + + QBuffer buffer(&bytes); + ASSERT_TRUE(buffer.open(QIODevice::ReadOnly)); + QDataStream in(&buffer); + olive::TrackReference first, second; + in >> first >> second; + + EXPECT_EQ(first, olive::TrackReference(olive::TrackReference::k_audio, 4)); + EXPECT_EQ(second, olive::TrackReference()); +} + +TEST(OakValueHelper, QVariantToOakNodeValueScalars) +{ + oak_node_value out; + + ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_int, + QVariant(42), &out)); + EXPECT_EQ(out.type, OAK_NODE_VALUE_INT); + EXPECT_EQ(out.num, 42); + + // Combos map to the COMBO facade type, not INT + ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_combo, + QVariant(3), &out)); + EXPECT_EQ(out.type, OAK_NODE_VALUE_COMBO); + EXPECT_EQ(out.num, 3); + + ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_float, + QVariant(2.5), &out)); + EXPECT_EQ(out.type, OAK_NODE_VALUE_FLOAT); + EXPECT_DOUBLE_EQ(out.f[0], 2.5); + + ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_boolean, + QVariant(true), &out)); + EXPECT_EQ(out.type, OAK_NODE_VALUE_BOOL); + EXPECT_EQ(out.num, 1); + ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_boolean, + QVariant(false), &out)); + EXPECT_EQ(out.num, 0); + + ASSERT_TRUE(olive::QVariantToOakNodeValue( + olive::NodeValueType::k_rational, + QVariant::fromValue(olive::core::Rational(1001, 30000)), &out)); + EXPECT_EQ(out.type, OAK_NODE_VALUE_RATIONAL); + EXPECT_EQ(out.num, 1001); + EXPECT_EQ(out.den, 30000); + + // Types with no POD representation fail + EXPECT_FALSE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_text, + QVariant(QStringLiteral("x")), + &out)); + EXPECT_FALSE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_matrix, + QVariant(), &out)); +} + +TEST(OakValueHelper, QVariantToOakNodeValueColorAndVectors) +{ + oak_node_value out; + + ASSERT_TRUE(olive::QVariantToOakNodeValue( + olive::NodeValueType::k_color, + QVariant::fromValue(olive::core::Color(0.25f, 0.5f, 0.75f, 1.0f)), + &out)); + EXPECT_EQ(out.type, OAK_NODE_VALUE_COLOR); + EXPECT_DOUBLE_EQ(out.f[0], 0.25); + EXPECT_DOUBLE_EQ(out.f[1], 0.5); + EXPECT_DOUBLE_EQ(out.f[2], 0.75); + EXPECT_DOUBLE_EQ(out.f[3], 1.0); + + ASSERT_TRUE(olive::QVariantToOakNodeValue( + olive::NodeValueType::k_vec2, + QVariant::fromValue(QVector2D(1.0f, 2.0f)), &out)); + EXPECT_EQ(out.type, OAK_NODE_VALUE_VEC2); + EXPECT_DOUBLE_EQ(out.f[0], 1.0); + EXPECT_DOUBLE_EQ(out.f[1], 2.0); + + ASSERT_TRUE(olive::QVariantToOakNodeValue( + olive::NodeValueType::k_vec3, + QVariant::fromValue(QVector3D(1.0f, 2.0f, 3.0f)), &out)); + EXPECT_EQ(out.type, OAK_NODE_VALUE_VEC3); + EXPECT_DOUBLE_EQ(out.f[2], 3.0); + + ASSERT_TRUE(olive::QVariantToOakNodeValue( + olive::NodeValueType::k_vec4, + QVariant::fromValue(QVector4D(1.0f, 2.0f, 3.0f, 4.0f)), &out)); + EXPECT_EQ(out.type, OAK_NODE_VALUE_VEC4); + EXPECT_DOUBLE_EQ(out.f[3], 4.0); +} + +TEST(OakValueHelper, TrackComponentUsesDeclaredTypeWithSingleSlot) +{ + oak_node_value out; + + // Beziers take a FLOAT per track, not the full-bezier POD type + ASSERT_TRUE(olive::NodeTrackComponentToOakNodeValue( + olive::NodeValueType::k_bezier, QVariant(0.75), &out)); + EXPECT_EQ(out.type, OAK_NODE_VALUE_FLOAT); + EXPECT_DOUBLE_EQ(out.f[0], 0.75); + + // A color channel is one float in f[0] but keeps the COLOR type + ASSERT_TRUE(olive::NodeTrackComponentToOakNodeValue( + olive::NodeValueType::k_color, QVariant(0.5), &out)); + EXPECT_EQ(out.type, OAK_NODE_VALUE_COLOR); + EXPECT_DOUBLE_EQ(out.f[0], 0.5); + EXPECT_DOUBLE_EQ(out.f[1], 0.0); + + ASSERT_TRUE(olive::NodeTrackComponentToOakNodeValue( + olive::NodeValueType::k_vec4, QVariant(1.5), &out)); + EXPECT_EQ(out.type, OAK_NODE_VALUE_VEC4); + EXPECT_DOUBLE_EQ(out.f[0], 1.5); + + ASSERT_TRUE(olive::NodeTrackComponentToOakNodeValue( + olive::NodeValueType::k_combo, QVariant(2), &out)); + EXPECT_EQ(out.type, OAK_NODE_VALUE_COMBO); + EXPECT_EQ(out.num, 2); + + ASSERT_TRUE(olive::NodeTrackComponentToOakNodeValue( + olive::NodeValueType::k_rational, + QVariant::fromValue(olive::core::Rational(1, 24)), &out)); + EXPECT_EQ(out.type, OAK_NODE_VALUE_RATIONAL); + EXPECT_EQ(out.num, 1); + EXPECT_EQ(out.den, 24); + + EXPECT_FALSE(olive::NodeTrackComponentToOakNodeValue( + olive::NodeValueType::k_text, QVariant(QStringLiteral("x")), &out)); +} + +TEST(OakValueHelper, PodBackToQVariantRoundTrips) +{ + oak_node_value pod; + + ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_int, + QVariant(17), &pod)); + EXPECT_EQ(olive::OakNodeValueToQVariant(pod).toLongLong(), 17); + + ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_float, + QVariant(3.25), &pod)); + EXPECT_DOUBLE_EQ(olive::OakNodeValueToQVariant(pod).toDouble(), 3.25); + + ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_boolean, + QVariant(true), &pod)); + EXPECT_TRUE(olive::OakNodeValueToQVariant(pod).toBool()); + + ASSERT_TRUE(olive::QVariantToOakNodeValue( + olive::NodeValueType::k_rational, + QVariant::fromValue(olive::core::Rational(24, 1)), &pod)); + EXPECT_EQ(olive::OakNodeValueToQVariant(pod).value(), + olive::core::Rational(24, 1)); + + ASSERT_TRUE(olive::QVariantToOakNodeValue( + olive::NodeValueType::k_color, + QVariant::fromValue(olive::core::Color(0.1f, 0.2f, 0.3f, 0.4f)), &pod)); + const olive::core::Color c = + olive::OakNodeValueToQVariant(pod).value(); + EXPECT_FLOAT_EQ(c.red(), 0.1f); + EXPECT_FLOAT_EQ(c.green(), 0.2f); + EXPECT_FLOAT_EQ(c.blue(), 0.3f); + EXPECT_FLOAT_EQ(c.alpha(), 0.4f); + + ASSERT_TRUE(olive::QVariantToOakNodeValue( + olive::NodeValueType::k_vec3, + QVariant::fromValue(QVector3D(4.0f, 5.0f, 6.0f)), &pod)); + EXPECT_EQ(olive::OakNodeValueToQVariant(pod).value(), + QVector3D(4.0f, 5.0f, 6.0f)); + + // Combos come back as plain ints + ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_combo, + QVariant(5), &pod)); + EXPECT_EQ(olive::OakNodeValueToQVariant(pod).toInt(), 5); + + // Types with no POD representation produce an invalid QVariant + pod.type = OAK_NODE_VALUE_STRING; + EXPECT_FALSE(olive::OakNodeValueToQVariant(pod).isValid()); + pod.type = OAK_NODE_VALUE_BINARY; + EXPECT_FALSE(olive::OakNodeValueToQVariant(pod).isValid()); + pod.type = OAK_NODE_VALUE_NONE; + EXPECT_FALSE(olive::OakNodeValueToQVariant(pod).isValid()); +} + +TEST(OakValueHelper, KeyframeTypeToFacadeRenumbering) +{ + // Facade easing: 0=linear, 1=bezier, 2=hold — NOT the engine ordinals + EXPECT_EQ(olive::NodeKeyframeTypeToFacade(olive::NodeKeyframeType::k_linear), + 0); + EXPECT_EQ(olive::NodeKeyframeTypeToFacade(olive::NodeKeyframeType::k_bezier), + 1); + EXPECT_EQ(olive::NodeKeyframeTypeToFacade(olive::NodeKeyframeType::k_hold), 2); + + // Anything else (including k_invalid) falls back to linear + EXPECT_EQ(olive::NodeKeyframeTypeToFacade(olive::NodeKeyframeType::k_invalid), + 0); + EXPECT_EQ(olive::NodeKeyframeTypeToFacade(99), 0); +} + +TEST(UndoWrapper, WrapsRedoUndoAndOwnership) +{ + int redo_count = 0; + int undo_count = 0; + bool destroyed = false; + + void *cmd = olive::wrap_app_undo_command( + "GTest counting command", + new CountingCmd{ &redo_count, &undo_count, &destroyed }); + ASSERT_NE(cmd, nullptr); + EXPECT_FALSE(destroyed); + + // redo_now/undo_now forward to the wrapped object + EXPECT_EQ(oakengine_undo_command_redo_now(cmd), 0); + EXPECT_EQ(redo_count, 1); + EXPECT_EQ(undo_count, 0); + + EXPECT_EQ(oakengine_undo_command_undo_now(cmd), 0); + EXPECT_EQ(undo_count, 1); + + EXPECT_EQ(oakengine_undo_command_redo_now(cmd), 0); + EXPECT_EQ(redo_count, 2); + + // Freeing the engine command deletes the wrapped object exactly once + oakengine_undo_command_free(cmd); + EXPECT_TRUE(destroyed); +} + +TEST(ConfigWrapper, IntRoundTripAndComparisons) +{ + const ScopedConfigKey guard(QStringLiteral("GTestConfigWrapperInt")); + + olive::OakConfigValue value(QStringLiteral("GTestConfigWrapperInt")); + + value = 42; + EXPECT_EQ(value.toInt(), 42); + EXPECT_EQ(static_cast(value), 42); + EXPECT_TRUE(value == 42); + EXPECT_FALSE(value != 42); + EXPECT_TRUE(value == qint64(42)); + EXPECT_TRUE(value.toBool()); // non-zero reads as true + + value = 0; + EXPECT_EQ(value.toInt(), 0); + EXPECT_FALSE(value.toBool()); + + // Large values survive the int64 round trip + value = qint64(5000000000LL); + EXPECT_EQ(value.toLongLong(), 5000000000LL); +} + +TEST(ConfigWrapper, StringRoundTripAndComparisons) +{ + const ScopedConfigKey guard(QStringLiteral("GTestConfigWrapperString")); + + olive::OakConfigValue value(QStringLiteral("GTestConfigWrapperString")); + + value = QStringLiteral("hello world"); + EXPECT_EQ(value.toString(), QStringLiteral("hello world")); + EXPECT_TRUE(value == QStringLiteral("hello world")); + EXPECT_TRUE(value == "hello world"); + EXPECT_FALSE(value != "hello world"); + EXPECT_TRUE(value != "goodbye"); + + // const char* assignment, including UTF-8 payloads + value = "caf\u00E9"; + EXPECT_EQ(value.toString(), QStringLiteral("caf\u00E9")); + + // The QVariant conversion yields a string variant + const QVariant as_variant = static_cast(value); + EXPECT_EQ(as_variant.typeId(), QMetaType::QString); + EXPECT_EQ(as_variant.toString(), QStringLiteral("caf\u00E9")); +} + +TEST(ConfigWrapper, QVariantAssignmentDispatchesByType) +{ + const ScopedConfigKey guard(QStringLiteral("GTestConfigWrapperVariant")); + + olive::OakConfigValue value(QStringLiteral("GTestConfigWrapperVariant")); + + value = QVariant(true); + EXPECT_TRUE(value.toBool()); + + value = QVariant(7); + EXPECT_EQ(value.toInt(), 7); + + // Floating point variants are truncated to int64 by design + value = QVariant(3.9); + EXPECT_EQ(value.toLongLong(), 3); + + // Everything else falls back to its string form + value = QVariant(QStringLiteral("plain")); + EXPECT_EQ(value.toString(), QStringLiteral("plain")); +} + +TEST(ConfigWrapper, RationalValueParsesStoredString) +{ + const ScopedConfigKey guard(QStringLiteral("GTestConfigWrapperRational")); + + olive::OakConfigValue value(QStringLiteral("GTestConfigWrapperRational")); + + value = QStringLiteral("1001/30000"); + const olive::core::Rational r = value.value(); + EXPECT_EQ(r, olive::core::Rational(1001, 30000)); + EXPECT_EQ(r.numerator(), 1001); + EXPECT_EQ(r.denominator(), 30000); +} + +TEST(ConfigWrapper, MissingKeyReadsDefaults) +{ + // No guard: this key must never exist, so there is nothing to restore + olive::OakConfigValue value( + QStringLiteral("GTestConfigWrapperDefinitelyMissing")); + + EXPECT_FALSE(value.toBool()); + EXPECT_EQ(value.toInt(), 0); + EXPECT_EQ(value.toLongLong(), 0); + EXPECT_TRUE(value.toString().isEmpty()); +} + +TEST(ConfigWrapper, OakConfigMacrosBuildValues) +{ + const ScopedConfigKey guard(QStringLiteral("GTestConfigWrapperMacro")); + + OAK_CONFIG("GTestConfigWrapperMacro") = 9; + EXPECT_EQ(OAK_CONFIG("GTestConfigWrapperMacro").toInt(), 9); + + OAK_CONFIG_STR(QStringLiteral("GTestConfigWrapperMacro")) = + QStringLiteral("macro"); + EXPECT_EQ(OAK_CONFIG_STR(QStringLiteral("GTestConfigWrapperMacro")).toString(), + QStringLiteral("macro")); +} + +TEST(SubtitleApp, DefaultAndParameterizedConstruction) +{ + const olive::SubtitleApp empty; + EXPECT_TRUE(empty.text().isEmpty()); + + const olive::SubtitleApp sub( + olive::core::TimeRange(olive::core::Rational(1, 24), + olive::core::Rational(48, 24)), + QStringLiteral("Hello")); + EXPECT_EQ(sub.text(), QStringLiteral("Hello")); + EXPECT_EQ(sub.time().in(), olive::core::Rational(1, 24)); + EXPECT_EQ(sub.time().out(), olive::core::Rational(48, 24)); + EXPECT_EQ(sub.time().length(), olive::core::Rational(47, 24)); +} + +TEST(SubtitleApp, SettersRoundTrip) +{ + olive::SubtitleApp sub; + + sub.set_text(QStringLiteral("First")); + EXPECT_EQ(sub.text(), QStringLiteral("First")); + + sub.set_time(olive::core::TimeRange(olive::core::Rational(0, 1), + olive::core::Rational(10, 1))); + EXPECT_EQ(sub.time().in(), olive::core::Rational(0, 1)); + EXPECT_EQ(sub.time().out(), olive::core::Rational(10, 1)); + + sub.set_text(QStringLiteral("Second")); + EXPECT_EQ(sub.text(), QStringLiteral("Second")); +} + +TEST(SubtitleApp, MetatypeRoundTrip) +{ + const olive::SubtitleApp sub( + olive::core::TimeRange(olive::core::Rational(2, 1), + olive::core::Rational(5, 1)), + QStringLiteral("Packed")); + + const QVariant v = QVariant::fromValue(sub); + EXPECT_EQ(v.typeId(), qMetaTypeId()); + + const olive::SubtitleApp out = v.value(); + EXPECT_EQ(out.text(), QStringLiteral("Packed")); + EXPECT_EQ(out.time(), sub.time()); +} + +TEST(DebugApp, HandlerFormatsMessageTypeAndContext) +{ + testing::internal::CaptureStderr(); + olive::debug_handler(QtDebugMsg, QMessageLogContext("file.cpp", 12, "func", + "category"), + QStringLiteral("hello")); + const std::string out = testing::internal::GetCapturedStderr(); + + EXPECT_NE(out.find("Debug: hello"), std::string::npos); + EXPECT_NE(out.find("file.cpp"), std::string::npos); + EXPECT_NE(out.find("func"), std::string::npos); + + // A null context file/function is reported as + testing::internal::CaptureStderr(); + olive::debug_handler(QtInfoMsg, QMessageLogContext(), QStringLiteral("plain")); + const std::string out2 = testing::internal::GetCapturedStderr(); + EXPECT_NE(out2.find("Info: plain"), std::string::npos); + EXPECT_NE(out2.find(""), std::string::npos); +} + +TEST(DebugApp, HandlerSuppressesFilteredWarnings) +{ + // QXcbIntegration noise is always dropped + testing::internal::CaptureStderr(); + olive::debug_handler(QtWarningMsg, QMessageLogContext(), + QStringLiteral("QXcbIntegration: something")); + EXPECT_TRUE(testing::internal::GetCapturedStderr().empty()); + + // Other warnings depend on the OAK_TESTING environment variable, which the + // handler samples once; mirror that expectation here + testing::internal::CaptureStderr(); + olive::debug_handler(QtWarningMsg, QMessageLogContext(), + QStringLiteral("ordinary warning")); + const std::string out = testing::internal::GetCapturedStderr(); + if (qEnvironmentVariableIsSet("OAK_TESTING")) { + EXPECT_TRUE(out.empty()); + } else { + EXPECT_NE(out.find("Warning: ordinary warning"), std::string::npos); + } +} diff --git a/tests/gtest/dialog_misc2_test.cpp b/tests/gtest/dialog_misc2_test.cpp new file mode 100644 index 0000000000..ef2349fe3a --- /dev/null +++ b/tests/gtest/dialog_misc2_test.cpp @@ -0,0 +1,672 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "dialog/about/scrollinglabel.h" +#include "dialog/export/codec/av1section.h" +#include "dialog/export/codec/cineformsection.h" +#include "dialog/export/codec/codecsection.h" +#include "dialog/export/codec/codecstack.h" +#include "dialog/footageproperties/streamproperties/audiostreamproperties.h" +#include "dialog/footageproperties/streamproperties/streamproperties.h" +#include "dialog/footageproperties/streamproperties/videostreamproperties.h" +#include "dialog/sequence/presetmanager.h" +#include "node/color/colormanager/colormanager.h" +#include "node/project.h" +#include "node/project/footage/footage.h" +#include "oakengine/encoding.h" +#include "oakengine/footage.h" +#include "oakutil/qtutils.h" +#include "render/videoparams.h" +#include "widget/slider/integerslider.h" +#include "widget/standardcombos/interlacedcombobox.h" +#include "widget/standardcombos/pixelaspectratiocombobox.h" + +// dialog/about/patreon.h defines a global `QStringList patrons` (without +// extern); about.cpp already pulls that definition into the test binary, so +// including the header here would create a duplicate symbol. Reference the +// existing definition with a matching extern declaration instead. +extern QStringList patrons; + +namespace +{ + +// Redirects QStandardPaths (used for the preset file location) to a +// disposable test location for the lifetime of the guard +class StandardPathsTestModeGuard { +public: + StandardPathsTestModeGuard() + { + QStandardPaths::setTestModeEnabled(true); + } + + ~StandardPathsTestModeGuard() + { + QStandardPaths::setTestModeEnabled(false); + } +}; + +// Concrete Preset with a name and an integer so the PresetManager load/save +// cycle has something observable to round-trip +class NameValuePreset : public olive::Preset { +public: + int value_ = 0; + + virtual void load(QXmlStreamReader *reader) override + { + while (olive::xml_read_next_start_element(reader)) { + if (reader->name() == QStringLiteral("name")) { + set_name(reader->readElementText()); + } else if (reader->name() == QStringLiteral("value")) { + value_ = reader->readElementText().toInt(); + } else { + reader->skipCurrentElement(); + } + } + } + + virtual void save(QXmlStreamWriter *writer) const override + { + writer->writeTextElement(QStringLiteral("name"), get_name()); + writer->writeTextElement(QStringLiteral("value"), + QString::number(value_)); + } +}; + +// Footage subclass that exposes the protected stream mutator of ViewerOutput +// so tests can populate streams without probing real media +class TestableFootage : public olive::Footage { +public: + using olive::ViewerOutput::add_stream; +}; + +// A footage node with two video streams; the second carries the given +// (possibly empty) explicit colorspace override +TestableFootage *make_two_stream_footage(olive::Project *project, + const QString &explicit_colorspace) +{ + auto *footage = new TestableFootage(); + footage->setParent(project); + + olive::VideoParams v0(1920, 1080, olive::Rational(1, 24), + olive::core::PixelFormat::u8, 4); + v0.set_stream_index(0); + v0.set_duration(48); + footage->add_stream(olive::Track::k_video, QVariant::fromValue(v0)); + + olive::VideoParams v1(1280, 720, olive::Rational(1, 24), + olive::core::PixelFormat::u8, 4); + v1.set_stream_index(1); + v1.set_duration(48); + v1.set_colorspace(explicit_colorspace); + footage->add_stream(olive::Track::k_video, QVariant::fromValue(v1)); + + return footage; +} + +// The plain (non-combo-class) QComboBoxes of a VideoStreamProperties, in +// layout order: color space first, then color range. The widget also holds a +// PixelAspectRatioComboBox and an InterlacedComboBox, so filter those out. +QList plain_combos(QWidget *w) +{ + QList out; + foreach (QComboBox *c, w->findChildren()) { + if (!qobject_cast(c) && + !qobject_cast(c)) { + out.append(c); + } + } + return out; +} + +// Renders the widget and counts pixels drawn in its palette text color. +// QWidget::render() fills an opaque window-color background, so alpha-based +// checks are useless; the scrolling text is painted in palette().text(). +int count_text_pixels(QWidget *w) +{ + QImage img(w->size(), QImage::Format_ARGB32); + img.fill(Qt::transparent); + w->render(&img); + + const QColor text = w->palette().text().color(); + + int count = 0; + for (int y = 0; y < img.height(); y++) { + for (int x = 0; x < img.width(); x++) { + const QColor px = img.pixelColor(x, y); + if (qAbs(px.red() - text.red()) < 16 && + qAbs(px.green() - text.green()) < 16 && + qAbs(px.blue() - text.blue()) < 16) { + count++; + } + } + } + return count; +} + +} // namespace + +// +// about: patreon +// +TEST(DialogAboutPatreon, PatronsListIsEmptyInSourceTree) +{ + // The committed patreon.h is generated with no entries; patreon.py fills + // the list in when packaging a release + EXPECT_TRUE(patrons.isEmpty()); +} + +// +// about: scrollinglabel +// +TEST(DialogScrollingLabel, SetTextSizesMinimumToContent) +{ + olive::ScrollingLabel label; + EXPECT_EQ(label.minimumSize(), QSize(0, 0)); + + const QString long_line = + QStringLiteral("A considerably longer line of text"); + label.set_text({ QStringLiteral("Hi"), long_line }); + + QFontMetrics fm = label.fontMetrics(); + + // Minimum height is always ten lines, minimum width the widest line + EXPECT_EQ(label.minimumHeight(), fm.height() * 10); + EXPECT_EQ(label.minimumWidth(), + olive::QtUtils::q_font_metrics_width(fm, long_line)); + + // A narrower text shrinks the minimum width again + label.set_text({ QStringLiteral("Hi") }); + EXPECT_EQ(label.minimumWidth(), + olive::QtUtils::q_font_metrics_width(fm, QStringLiteral("Hi"))); + EXPECT_EQ(label.minimumHeight(), fm.height() * 10); + + // The text-list constructor applies the same sizing + olive::ScrollingLabel label2({ QStringLiteral("Hi") }); + EXPECT_EQ(label2.minimumWidth(), + olive::QtUtils::q_font_metrics_width(fm, QStringLiteral("Hi"))); +} + +TEST(DialogScrollingLabel, EmptyTextKeepsOnlyLineHeightMinimum) +{ + olive::ScrollingLabel label; + label.set_text({}); + + EXPECT_EQ(label.minimumWidth(), 0); + EXPECT_EQ(label.minimumHeight(), label.fontMetrics().height() * 10); +} + +TEST(DialogScrollingLabel, AnimationScrollsTextIntoView) +{ + olive::ScrollingLabel label( + { QStringLiteral("Hello"), QStringLiteral("World") }); + label.resize(120, 200); + + // At offset zero every line sits below the widget: nothing is painted + EXPECT_EQ(count_text_pixels(&label), 0); + + // animation_update() is a private slot; drive it through the meta-object. + // Half the widget height puts the first line near the vertical middle. + for (int i = 0; i < 100; i++) { + QMetaObject::invokeMethod(&label, "animation_update"); + } + EXPECT_GT(count_text_pixels(&label), 0); +} + +TEST(DialogScrollingLabel, AnimationWrapsAfterFullCycle) +{ + olive::ScrollingLabel label( + { QStringLiteral("Hello"), QStringLiteral("World") }); + label.resize(120, 200); + + // animate_ resets to 0 once it reaches height + lines * line_height + const int cycle = 200 + 2 * label.fontMetrics().height(); + for (int i = 0; i < cycle; i++) { + QMetaObject::invokeMethod(&label, "animation_update"); + } + + // Back at offset zero the text has scrolled out of view again + EXPECT_EQ(count_text_pixels(&label), 0); + + // start/stop only toggle the internal timer + label.start_animating(); + label.stop_animating(); +} + +// +// sequence: presetmanager +// +TEST(DialogPresetManager, StartsEmptyWhenPresetFileMissing) +{ + StandardPathsTestModeGuard test_mode; + + const QString preset_name = + QStringLiteral("oak-gtest-presetmanager-missing"); + const QString path = + QDir(olive::FileFunctions::get_configuration_location()) + .filePath(preset_name); + QFile::remove(path); + + { + olive::PresetManager mgr(nullptr, preset_name); + EXPECT_EQ(mgr.get_number_of_presets(), 0); + EXPECT_TRUE(mgr.get_preset_data().isEmpty()); + EXPECT_TRUE(mgr.get_custom_preset_filename().endsWith(preset_name)); + } + + // The destructor wrote an empty preset file; clean it up + QFile::remove(path); +} + +TEST(DialogPresetManager, LoadsPresetsFromXmlFile) +{ + StandardPathsTestModeGuard test_mode; + + const QString preset_name = + QStringLiteral("oak-gtest-presetmanager-load"); + const QString path = + QDir(olive::FileFunctions::get_configuration_location()) + .filePath(preset_name); + QFile::remove(path); + + { + QFile f(path); + ASSERT_TRUE(f.open(QFile::WriteOnly)); + f.write("\n" + "\n" + " Alpha7\n" + " Beta8\n" + "\n"); + f.close(); + } + + { + olive::PresetManager mgr(nullptr, preset_name); + + ASSERT_EQ(mgr.get_number_of_presets(), 2); + EXPECT_EQ(mgr.get_preset(0)->get_name(), QStringLiteral("Alpha")); + EXPECT_EQ(mgr.get_preset(1)->get_name(), QStringLiteral("Beta")); + EXPECT_EQ( + std::static_pointer_cast(mgr.get_preset(0))->value_, + 7); + EXPECT_EQ( + std::static_pointer_cast(mgr.get_preset(1))->value_, + 8); + EXPECT_EQ(mgr.get_preset_data().size(), 2); + + mgr.delete_preset(0); + ASSERT_EQ(mgr.get_number_of_presets(), 1); + EXPECT_EQ(mgr.get_preset(0)->get_name(), QStringLiteral("Beta")); + + // Change the surviving preset so the destructor's save has something + // new to write + std::static_pointer_cast(mgr.get_preset(0))->value_ = + 42; + } + + // The destructor persisted the current preset list to disk + { + olive::PresetManager mgr(nullptr, preset_name); + ASSERT_EQ(mgr.get_number_of_presets(), 1); + EXPECT_EQ(mgr.get_preset(0)->get_name(), QStringLiteral("Beta")); + EXPECT_EQ( + std::static_pointer_cast(mgr.get_preset(0))->value_, + 42); + } + + QFile::remove(path); +} + +// +// export: codecsection (base class) +// +TEST(DialogCodecSection, BaseClassOptsAreNoOps) +{ + olive::CodecSection section; + + OakEngineEncodingParams *params = oakengine_encoding_params_create(); + ASSERT_NE(params, nullptr); + + section.add_opts(params); + + // A base CodecSection writes no encoder options + char buf[64]; + EXPECT_EQ(oakengine_encoding_params_video_option(params, "qp", buf, + static_cast(sizeof(buf))), + OAKENGINE_E_NOT_FOUND); + + // set_opts must accept anything without touching the widget + section.set_opts(params); + + oakengine_encoding_params_destroy(params); +} + +// +// export: codecstack +// +TEST(DialogCodecStack, CurrentWidgetExpandsOthersAreIgnored) +{ + olive::CodecStack stack; + QWidget a; + QWidget b; + + stack.addWidget(&a); + stack.addWidget(&b); + + ASSERT_EQ(stack.count(), 2); + ASSERT_EQ(stack.currentIndex(), 0); + + // addWidget() reapplies the policies for the current index + EXPECT_EQ(a.sizePolicy().horizontalPolicy(), QSizePolicy::Expanding); + EXPECT_EQ(a.sizePolicy().verticalPolicy(), QSizePolicy::Expanding); + EXPECT_EQ(b.sizePolicy().horizontalPolicy(), QSizePolicy::Ignored); + EXPECT_EQ(b.sizePolicy().verticalPolicy(), QSizePolicy::Ignored); + + // Switching pages swaps the policies + stack.setCurrentIndex(1); + EXPECT_EQ(a.sizePolicy().horizontalPolicy(), QSizePolicy::Ignored); + EXPECT_EQ(b.sizePolicy().horizontalPolicy(), QSizePolicy::Expanding); + EXPECT_EQ(b.sizePolicy().verticalPolicy(), QSizePolicy::Expanding); +} + +// +// export: av1section +// +TEST(DialogAV1CRFSection, DefaultsAndSliderRange) +{ + // Copy to a local first: EXPECT_EQ binds by reference, which would + // odr-use the in-class-initialized static constant and fail to link + const int default_crf = olive::AV1CRFSection::k_default_a_v1_crf; + EXPECT_EQ(default_crf, 30); + + olive::AV1CRFSection section(olive::AV1CRFSection::k_default_a_v1_crf); + EXPECT_EQ(section.get_value(), 30); + + QSlider *slider = section.findChild(); + ASSERT_NE(slider, nullptr); + EXPECT_EQ(slider->minimum(), 0); + EXPECT_EQ(slider->maximum(), 63); + + // The slider is the value source, clamped to 0-63 + slider->setValue(45); + EXPECT_EQ(section.get_value(), 45); + + slider->setValue(99); + EXPECT_EQ(section.get_value(), 63); +} + +TEST(DialogAV1CRFSection, SliderSyncsIntoIntegerInput) +{ + olive::AV1CRFSection section(30); + + QSlider *slider = section.findChild(); + olive::IntegerSlider *input = section.findChild(); + ASSERT_NE(slider, nullptr); + ASSERT_NE(input, nullptr); + EXPECT_EQ(input->get_value(), 30); + + // QSlider::valueChanged is wired to IntegerSlider::set_value + slider->setValue(20); + EXPECT_EQ(input->get_value(), 20); +} + +TEST(DialogAV1Section, DefaultsAndAddOpts) +{ + olive::AV1Section section; + + // First combo is the preset list (0-13, default 8), second the + // compression method list (Constant Rate Factor only) + const auto combos = section.findChildren(); + ASSERT_EQ(combos.size(), 2); + EXPECT_EQ(combos.at(0)->count(), 14); + EXPECT_EQ(combos.at(0)->currentIndex(), 8); + EXPECT_EQ(combos.at(1)->count(), 1); + + OakEngineEncodingParams *params = oakengine_encoding_params_create(); + ASSERT_NE(params, nullptr); + + char buf[64]; + + // Default construction uses the default CRF + section.add_opts(params); + ASSERT_GT(oakengine_encoding_params_video_option( + params, "qp", buf, static_cast(sizeof(buf))), + 0); + EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("30")); + ASSERT_GT(oakengine_encoding_params_video_option( + params, "preset", buf, static_cast(sizeof(buf))), + 0); + EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("8")); + + // Changed widget state is reflected in the written options + combos.at(0)->setCurrentIndex(3); + QSlider *slider = section.findChild(); + ASSERT_NE(slider, nullptr); + slider->setValue(20); + + section.add_opts(params); + ASSERT_GT(oakengine_encoding_params_video_option( + params, "qp", buf, static_cast(sizeof(buf))), + 0); + EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("20")); + ASSERT_GT(oakengine_encoding_params_video_option( + params, "preset", buf, static_cast(sizeof(buf))), + 0); + EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("3")); + + oakengine_encoding_params_destroy(params); +} + +// +// export: cineformsection +// +TEST(DialogCineformSection, DefaultsToMediumQuality) +{ + olive::CineformSection section; + + QComboBox *quality = section.findChild(); + ASSERT_NE(quality, nullptr); + + // 13 FFmpeg quality levels (film3+ .. low), defaulting to "medium" + EXPECT_EQ(quality->count(), 13); + EXPECT_EQ(quality->currentIndex(), 10); +} + +TEST(DialogCineformSection, OptsRoundTripThroughParams) +{ + olive::CineformSection section; + QComboBox *quality = section.findChild(); + ASSERT_NE(quality, nullptr); + + OakEngineEncodingParams *params = oakengine_encoding_params_create(); + ASSERT_NE(params, nullptr); + + char buf[64]; + + section.add_opts(params); + ASSERT_GT(oakengine_encoding_params_video_option( + params, "quality", buf, static_cast(sizeof(buf))), + 0); + EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("10")); + + quality->setCurrentIndex(5); + section.add_opts(params); + ASSERT_GT(oakengine_encoding_params_video_option( + params, "quality", buf, static_cast(sizeof(buf))), + 0); + EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("5")); + + // set_opts applies a stored value back to the combo + oakengine_encoding_params_set_video_option(params, "quality", "2"); + section.set_opts(params); + EXPECT_EQ(quality->currentIndex(), 2); + + // A params handle without the key leaves the combo untouched + OakEngineEncodingParams *empty = oakengine_encoding_params_create(); + ASSERT_NE(empty, nullptr); + section.set_opts(empty); + EXPECT_EQ(quality->currentIndex(), 2); + + oakengine_encoding_params_destroy(empty); + oakengine_encoding_params_destroy(params); +} + +// +// footageproperties: streamproperties (base class) +// +TEST(DialogStreamProperties, BaseClassIsANoOpStub) +{ + olive::StreamProperties props; + + EXPECT_TRUE(props.sanity_check()); + + // accept() takes an optional parent pointer and does nothing + props.accept(nullptr); +} + +// +// footageproperties: audiostreamproperties +// +TEST(DialogAudioStreamProperties, ConstructsAroundFootageStream) +{ + olive::Project project; + TestableFootage *footage = make_two_stream_footage(&project, QString()); + + olive::AudioStreamProperties props( + reinterpret_cast(footage), 0); + + // The class is currently a stub: no UI, default sanity check, no-op accept + EXPECT_EQ(props.findChildren().size(), 0); + EXPECT_TRUE(props.sanity_check()); + props.accept(nullptr); +} + +// +// footageproperties: videostreamproperties +// +TEST(DialogVideoStreamProperties, ReflectsStreamDefaults) +{ + olive::ColorManager::set_up_default_config(); + olive::Project project; + + TestableFootage *footage = make_two_stream_footage(&project, QString()); + + olive::VideoStreamProperties props( + reinterpret_cast(footage), 0); + + auto *interlace = props.findChild(); + ASSERT_NE(interlace, nullptr); + EXPECT_EQ(interlace->get_interlace_mode(), + olive::VideoParams::k_interlace_none); + + auto *par = props.findChild(); + ASSERT_NE(par, nullptr); + EXPECT_EQ(par->get_pixel_aspect_ratio(), olive::Rational(1, 1)); + + const auto combos = plain_combos(&props); + ASSERT_EQ(combos.size(), 2); + + // Color space: "Default (...)" entry followed by every config colorspace + QComboBox *colorspace = combos.at(0); + EXPECT_TRUE(colorspace->itemText(0).startsWith(QStringLiteral("Default ("))); + EXPECT_EQ(colorspace->count(), + 1 + project.color_manager()->list_available_colorspaces().size()); + // No override on the stream: the default entry stays selected + EXPECT_EQ(colorspace->currentIndex(), 0); + + // Color range: limited/full, matching the stream's limited default + QComboBox *range = combos.at(1); + ASSERT_EQ(range->count(), 2); + EXPECT_EQ(range->itemData(0).toInt(), + olive::VideoParams::k_color_range_limited); + EXPECT_EQ(range->itemData(1).toInt(), olive::VideoParams::k_color_range_full); + EXPECT_EQ(range->currentIndex(), olive::VideoParams::k_color_range_limited); + + // Regular video is not an image sequence: no image sequence group box + EXPECT_EQ(props.findChild(), nullptr); + + // The premultiplied-alpha checkbox only exists for 4-channel processing + QCheckBox *premult = props.findChild(); + if (olive::VideoParams::k_internal_channel_count == 4) { + ASSERT_NE(premult, nullptr); + EXPECT_FALSE(premult->isChecked()); + } else { + EXPECT_EQ(premult, nullptr); + } + + // Not an image sequence, so sanity_check() passes without prompting + EXPECT_TRUE(props.sanity_check()); +} + +TEST(DialogVideoStreamProperties, ReflectsExplicitColorspaceOverride) +{ + olive::ColorManager::set_up_default_config(); + olive::Project project; + + const QStringList spaces = + project.color_manager()->list_available_colorspaces(); + ASSERT_FALSE(spaces.isEmpty()); + + TestableFootage *footage = + make_two_stream_footage(&project, spaces.first()); + + // Stream 1 carries the explicit colorspace override + olive::VideoStreamProperties props( + reinterpret_cast(footage), 1); + + const auto combos = plain_combos(&props); + ASSERT_EQ(combos.size(), 2); + + QComboBox *colorspace = combos.at(0); + EXPECT_EQ(colorspace->currentText(), spaces.first()); + EXPECT_GT(colorspace->currentIndex(), 0); +} + +TEST(DialogVideoStreamProperties, AcceptAppliesColorRangeChange) +{ + olive::ColorManager::set_up_default_config(); + olive::Project project; + + TestableFootage *footage = make_two_stream_footage(&project, QString()); + + olive::VideoStreamProperties props( + reinterpret_cast(footage), 0); + + const auto combos = plain_combos(&props); + ASSERT_EQ(combos.size(), 2); + QComboBox *range = combos.at(1); + + auto get_color_range = [&]() { + OakEngineFootage *handle = oakengine_footage_borrow( + reinterpret_cast(footage)); + EXPECT_NE(handle, nullptr); + int color_range = -1; + oakengine_footage_get_video_stream_overrides(handle, 0, nullptr, 0, + &color_range, nullptr, + nullptr); + oakengine_footage_free(handle); + return color_range; + }; + + // Accepting without edits must not touch the stream + props.accept(nullptr); + EXPECT_EQ(get_color_range(), olive::VideoParams::k_color_range_limited); + + // Switching to full range is written back through the facade + range->setCurrentIndex(1); + props.accept(nullptr); + EXPECT_EQ(get_color_range(), olive::VideoParams::k_color_range_full); +} diff --git a/tests/gtest/mainwindow2_test.cpp b/tests/gtest/mainwindow2_test.cpp new file mode 100644 index 0000000000..a63de6bcce --- /dev/null +++ b/tests/gtest/mainwindow2_test.cpp @@ -0,0 +1,1003 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "audio/audiomanager.h" +#include "common/tooltypes.h" +#include "config/config.h" +#include "core.h" +#include "engineeventbridge.h" +#include "node/color/colormanager/colormanager.h" +#include "node/math/math/math.h" +#include "node/output/viewer/viewer.h" +#include "node/project.h" +#include "node/project/folder/folder.h" +#include "node/project/sequence/sequence.h" +#include "oakengine/undo.h" +#include "oakengine/viewer.h" +#include "oakutil/oaknode.h" +#include "panel/footageviewer/footageviewer.h" +#include "panel/panelmanager.h" +#include "panel/project/footagemanagementpanel.h" +#include "panel/timebased/timebased.h" +#include "panel/viewer/viewerbase.h" +#include "render/diskmanager.h" +#include "render/rendermanager.h" +#include "task/taskmanager.h" +#include "undo/undocommand.h" +#include "widget/menu/menu.h" +#include "widget/menu/menushared.h" +#include "widget/timebased/timebasedwidget.h" +#include "window/mainwindow/mainmenu.h" +#include "window/mainwindow/mainwindow.h" +#include "window/mainwindow/mainwindowundo.h" + +using namespace olive; + +namespace +{ + +// Widgets/panels that talk to Core::instance() at construction require the +// application singleton, but not a MainWindow +void ensure_core() +{ + if (!Core::instance()) { + new Core(); // intentionally leaked + } +} + +// Panels register with the PanelManager singleton and ViewerWidget-based +// panels talk to TaskManager/RenderManager in their constructors. Same +// pattern as PanelEnvironment in panel_test.cpp. +class PanelTestEnvironment { +public: + PanelTestEnvironment() + { + ColorManager::set_up_default_config(); + + ensure_core(); + + KDDockWidgets::initFrontend(KDDockWidgets::FrontendType::QtWidgets); + + if (!PanelManager::instance()) { + PanelManager::create_instance(); + created_panel_manager_ = true; + } + + if (!TaskManager::instance()) { + TaskManager::create_instance(); + created_task_manager_ = true; + } + + if (!RenderManager::instance()) { + saved_backend_ = + Config::current()[QStringLiteral("GraphicsBackend")]; + Config::current()[QStringLiteral("GraphicsBackend")] = + QStringLiteral("opengl"); + RenderManager::create_instance(); + created_render_manager_ = true; + } + + if (!DiskManager::instance()) { + DiskManager::create_instance(); + created_disk_manager_ = true; + } + } + + ~PanelTestEnvironment() + { + if (created_panel_manager_) { + PanelManager::instance()->delete_all_panels(); + PanelManager::destroy_instance(); + } + if (created_task_manager_) { + TaskManager::destroy_instance(); + } + if (created_render_manager_) { + RenderManager::destroy_instance(); + Config::current()[QStringLiteral("GraphicsBackend")] = + saved_backend_; + } + if (created_disk_manager_) { + DiskManager::destroy_instance(); + } + } + +private: + bool created_panel_manager_ = false; + bool created_task_manager_ = false; + bool created_render_manager_ = false; + bool created_disk_manager_ = false; + QVariant saved_backend_; +}; + +// Full environment for MainWindow construction, mirroring the setup in +// mainwindow_test.cpp (MainWindow needs MenuShared and AudioManager on top +// of the panel singletons) +class MainWindowEnvironment : public PanelTestEnvironment { +public: + MainWindowEnvironment() + { + if (!MenuShared::instance()) { + MenuShared::create_instance(); + created_menu_shared_ = true; + } + if (!AudioManager::instance()) { + AudioManager::create_instance(); + created_audio_manager_ = true; + } + + // Suppress the modal welcome dialog shown on first show + saved_welcome_ = Config::current()[QStringLiteral("ShowWelcomeDialog")]; + Config::current()[QStringLiteral("ShowWelcomeDialog")] = false; + } + + ~MainWindowEnvironment() + { + Config::current()[QStringLiteral("ShowWelcomeDialog")] = + saved_welcome_; + if (created_audio_manager_) { + AudioManager::destroy_instance(); + } + if (created_menu_shared_) { + MenuShared::destroy_instance(); + } + } + +private: + bool created_menu_shared_ = false; + bool created_audio_manager_ = false; + QVariant saved_welcome_; +}; + +// MainWindow instantiates viewer panels containing QOpenGLWidget, which the +// offscreen QPA cannot paint and which crashes on platforms without a usable +// GL implementation (see the MainWindow test in mainwindow_test.cpp) +bool main_window_unsupported_here() +{ + if (QGuiApplication::platformName() == QStringLiteral("offscreen")) { + return true; + } + QOffscreenSurface probe_surface; + probe_surface.create(); + QOpenGLContext probe_context; + const bool gl_available = + probe_context.create() && probe_context.makeCurrent(&probe_surface); + probe_context.doneCurrent(); + return !gl_available; +} + +// GTEST_SKIP only returns from the enclosing function, so the guard has to +// sit in the test body itself +#define SKIP_WITHOUT_MAIN_WINDOW() \ + if (main_window_unsupported_here()) { \ + GTEST_SKIP() << "MainWindow requires a GL-capable platform"; \ + } + +// Recursively finds the menu item conformed with the given "id" property. +// Iterates actions() rather than findChildren() because some items (the +// engine undo actions, the alternate delete item) are not menu children. +QAction *find_item_by_id(QMenu *menu, const QString &id) +{ + foreach (QAction *a, menu->actions()) { + if (a->property("id").toString() == id) { + return a; + } + if (QMenu *sub = a->menu()) { + if (QAction *found = find_item_by_id(sub, id)) { + return found; + } + } + } + return nullptr; +} + +QMenu *top_level_menu(QMenuBar *bar, const QString &title) +{ + foreach (QAction *a, bar->actions()) { + if (a->menu() && a->menu()->title() == title) { + return a->menu(); + } + } + return nullptr; +} + +// Exposes the protected set_viewer_widget and counts the playback overrides +// so signal routing into the panel can be observed +class ProbeViewerPanel : public ViewerPanelBase { +public: + explicit ProbeViewerPanel(const QString &object_name) + : ViewerPanelBase(object_name) + { + } + + void install_widget(ViewerWidget *vw) + { + set_viewer_widget(vw); + } + + virtual void play_pause() override + { + play_pause_count_++; + ViewerPanelBase::play_pause(); + } + virtual void play_in_to_out() override + { + play_in_to_out_count_++; + ViewerPanelBase::play_in_to_out(); + } + virtual void shuttle_left() override + { + shuttle_left_count_++; + ViewerPanelBase::shuttle_left(); + } + virtual void shuttle_stop() override + { + shuttle_stop_count_++; + ViewerPanelBase::shuttle_stop(); + } + virtual void shuttle_right() override + { + shuttle_right_count_++; + ViewerPanelBase::shuttle_right(); + } + + int play_pause_count_ = 0; + int play_in_to_out_count_ = 0; + int shuttle_left_count_ = 0; + int shuttle_stop_count_ = 0; + int shuttle_right_count_ = 0; +}; + +class ProbeTimeBasedPanel : public TimeBasedPanel { +public: + using TimeBasedPanel::TimeBasedPanel; + using TimeBasedPanel::set_time_based_widget; +}; + +// Minimal FootageManagementPanel implementation for interface dispatch tests +class FakeFootageManagementPanel : public FootageManagementPanel { +public: + virtual QVector get_selected_footage() const override + { + return selection_; + } + + QVector selection_; +}; + +int g_counting_redo_calls = 0; +int g_counting_undo_calls = 0; + +void counting_redo(void *userdata) +{ + Q_UNUSED(userdata) + g_counting_redo_calls++; +} + +void counting_undo(void *userdata) +{ + Q_UNUSED(userdata) + g_counting_undo_calls++; +} + +} // namespace + +class EngineEventBridgeTest : public ::testing::Test { +protected: + void SetUp() override + { + ColorManager::set_up_default_config(); + ensure_core(); + project_.initialize(); + } + + // Adds a node of type T parented to the fixture project + template T *add_node() + { + auto *node = new T(); + node->setParent(&project_); + return node; + } + + Project project_; +}; + +class ViewerPanelBaseTest : public ::testing::Test { +protected: + PanelTestEnvironment env_; +}; + +class FootageManagementPanelTest : public ::testing::Test { +protected: + PanelTestEnvironment env_; +}; + +// --- EngineEventBridge (app/engineeventbridge) --- + +TEST_F(EngineEventBridgeTest, SubscribeRejectsNullAndMismatchedHandles) +{ + auto *math = add_node(); + auto *math_handle = reinterpret_cast(math); + + EngineEventBridge bridge; + + // NULL handle fails, per the oakengine_event_subscribe contract + EXPECT_EQ(bridge.subscribe(nullptr, OAKENGINE_EVENT_NODE_LABEL_CHANGED), 0); + + // A plain node is neither a Project nor a viewer: both families reject it + EXPECT_EQ(bridge.subscribe(math_handle, + OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED), + 0); + EXPECT_EQ(bridge.subscribe(math_handle, + OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED), + 0); +} + +TEST_F(EngineEventBridgeTest, NodeLabelChangedForwardsSourceAndLabel) +{ + auto *math = add_node(); + auto *math_handle = reinterpret_cast(math); + + EngineEventBridge bridge; + ASSERT_GT(bridge.subscribe(math_handle, OAKENGINE_EVENT_NODE_LABEL_CHANGED), + 0); + + QSignalSpy spy(&bridge, &EngineEventBridge::node_label_changed); + + math->set_label(QStringLiteral("Bridge Label")); + ASSERT_EQ(spy.count(), 1); + EXPECT_EQ(spy.first().at(0).value(), math_handle); + EXPECT_EQ(spy.first().at(1).toString(), QStringLiteral("Bridge Label")); + + // Node::set_label() guards on actual changes; re-setting emits nothing + math->set_label(QStringLiteral("Bridge Label")); + EXPECT_EQ(spy.count(), 1); +} + +TEST_F(EngineEventBridgeTest, FolderInsertAndRemoveForwardChildAndIndex) +{ + auto *math = add_node(); + + Folder *root = project_.root(); + auto *root_handle = reinterpret_cast(root); + auto *child_handle = reinterpret_cast(math); + + EngineEventBridge bridge; + EXPECT_GT(bridge.subscribe(root_handle, + OAKENGINE_EVENT_FOLDER_BEGIN_INSERT_ITEM), + 0); + EXPECT_GT(bridge.subscribe(root_handle, + OAKENGINE_EVENT_FOLDER_END_INSERT_ITEM), + 0); + EXPECT_GT(bridge.subscribe(root_handle, + OAKENGINE_EVENT_FOLDER_BEGIN_REMOVE_ITEM), + 0); + EXPECT_GT(bridge.subscribe(root_handle, + OAKENGINE_EVENT_FOLDER_END_REMOVE_ITEM), + 0); + + QSignalSpy begin_insert(&bridge, + &EngineEventBridge::folder_begin_insert_item); + QSignalSpy end_insert(&bridge, &EngineEventBridge::folder_end_insert_item); + QSignalSpy begin_remove(&bridge, + &EngineEventBridge::folder_begin_remove_item); + QSignalSpy end_remove(&bridge, &EngineEventBridge::folder_end_remove_item); + + // The insert index is always the current child count (append-only) + const int insert_index = root->item_child_count(); + + FolderAddChild add(root, math); + add.redo_now(); + + ASSERT_EQ(begin_insert.count(), 1); + EXPECT_EQ(begin_insert.first().at(0).value(), root_handle); + EXPECT_EQ(begin_insert.first().at(1).value(), + child_handle); + EXPECT_EQ(begin_insert.first().at(2).toInt(), insert_index); + ASSERT_EQ(end_insert.count(), 1); + EXPECT_EQ(end_insert.first().first().value(), root_handle); + + // Undoing the add runs through Folder::InputDisconnectedEvent + add.undo_now(); + + ASSERT_EQ(begin_remove.count(), 1); + EXPECT_EQ(begin_remove.first().at(0).value(), root_handle); + EXPECT_EQ(begin_remove.first().at(1).value(), + child_handle); + EXPECT_EQ(begin_remove.first().at(2).toInt(), insert_index); + ASSERT_EQ(end_remove.count(), 1); + EXPECT_EQ(end_remove.first().first().value(), root_handle); +} + +TEST_F(EngineEventBridgeTest, ProjectNameAndModifiedForward) +{ + auto *project_handle = reinterpret_cast(&project_); + + EngineEventBridge bridge; + EXPECT_GT(bridge.subscribe(project_handle, + OAKENGINE_EVENT_PROJECT_NAME_CHANGED), + 0); + EXPECT_GT(bridge.subscribe(project_handle, + OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED), + 0); + + QSignalSpy name_spy(&bridge, &EngineEventBridge::project_name_changed); + QSignalSpy modified_spy(&bridge, + &EngineEventBridge::project_modified_changed); + + project_.set_filename(QStringLiteral("/tmp/bridge_test_project.ove")); + EXPECT_EQ(name_spy.count(), 1); + + project_.set_modified(true); + ASSERT_EQ(modified_spy.count(), 1); + EXPECT_TRUE(modified_spy.first().first().toBool()); + + project_.set_modified(false); + ASSERT_EQ(modified_spy.count(), 2); + EXPECT_FALSE(modified_spy.at(1).first().toBool()); +} + +TEST_F(EngineEventBridgeTest, ViewerPlayheadForwardsRationalPayload) +{ + auto *sequence = add_node(); + auto *sequence_handle = reinterpret_cast(sequence); + + EngineEventBridge bridge; + ASSERT_GT(bridge.subscribe(sequence_handle, + OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED), + 0); + + QSignalSpy spy(&bridge, &EngineEventBridge::viewer_playhead_changed); + + // Rational payloads are delivered as num/den pairs + ASSERT_EQ(oakengine_viewer_set_playhead(sequence_handle, 2, 1), + OAKENGINE_OK); + ASSERT_EQ(spy.count(), 1); + EXPECT_EQ(spy.first().at(0).value(), sequence_handle); + EXPECT_EQ(spy.first().at(1).toLongLong(), 2); + EXPECT_EQ(spy.first().at(2).toLongLong(), 1); +} + +TEST_F(EngineEventBridgeTest, UnsubscribeStopsDelivery) +{ + auto *math = add_node(); + auto *math_handle = reinterpret_cast(math); + + EngineEventBridge bridge; + const int64_t sub = + bridge.subscribe(math_handle, OAKENGINE_EVENT_NODE_LABEL_CHANGED); + ASSERT_GT(sub, 0); + + QSignalSpy spy(&bridge, &EngineEventBridge::node_label_changed); + + math->set_label(QStringLiteral("Before")); + ASSERT_EQ(spy.count(), 1); + + bridge.unsubscribe(sub); + math->set_label(QStringLiteral("After")); + EXPECT_EQ(spy.count(), 1); + + // Unknown or already-dead ids are ignored + bridge.unsubscribe(sub); + bridge.unsubscribe(0); + bridge.unsubscribe(424242); + math->set_label(QStringLiteral("Later")); + EXPECT_EQ(spy.count(), 1); +} + +TEST_F(EngineEventBridgeTest, UnsubscribeAllStopsEverySubscription) +{ + auto *math = add_node(); + auto *math_handle = reinterpret_cast(math); + auto *project_handle = reinterpret_cast(&project_); + + EngineEventBridge bridge; + ASSERT_GT(bridge.subscribe(math_handle, OAKENGINE_EVENT_NODE_LABEL_CHANGED), + 0); + ASSERT_GT(bridge.subscribe(project_handle, + OAKENGINE_EVENT_PROJECT_NAME_CHANGED), + 0); + + QSignalSpy label_spy(&bridge, &EngineEventBridge::node_label_changed); + QSignalSpy name_spy(&bridge, &EngineEventBridge::project_name_changed); + + bridge.unsubscribe_all(); + + math->set_label(QStringLiteral("Muted")); + project_.set_filename(QStringLiteral("/tmp/muted_project.ove")); + EXPECT_EQ(label_spy.count(), 0); + EXPECT_EQ(name_spy.count(), 0); +} + +TEST_F(EngineEventBridgeTest, DestroyingBridgeDropsItsSubscriptions) +{ + auto *math = add_node(); + auto *math_handle = reinterpret_cast(math); + + // A second bridge proves live subscribers are still notified afterwards + EngineEventBridge control; + ASSERT_GT(control.subscribe(math_handle, OAKENGINE_EVENT_NODE_LABEL_CHANGED), + 0); + QSignalSpy control_spy(&control, &EngineEventBridge::node_label_changed); + + auto *doomed = new EngineEventBridge(); + ASSERT_GT(doomed->subscribe(math_handle, OAKENGINE_EVENT_NODE_LABEL_CHANGED), + 0); + delete doomed; + + // Must not dispatch into the freed bridge + math->set_label(QStringLiteral("After Delete")); + ASSERT_EQ(control_spy.count(), 1); + EXPECT_EQ(control_spy.first().at(1).toString(), + QStringLiteral("After Delete")); +} + +TEST_F(EngineEventBridgeTest, UndoIndexChangedFiresOnPushAndJump) +{ + ASSERT_NE(oakengine_undo_handle(), nullptr); + + EngineEventBridge bridge; + ASSERT_GT(bridge.subscribe(oakengine_undo_handle(), + OAKENGINE_EVENT_UNDO_INDEX_CHANGED), + 0); + + QSignalSpy spy(&bridge, &EngineEventBridge::undo_index_changed); + + const int64_t index_before = oakengine_undo_index(); + g_counting_redo_calls = 0; + g_counting_undo_calls = 0; + + void *cmd = oakengine_undo_command_create("gtest bridge command", + counting_redo, counting_undo, + nullptr, nullptr); + ASSERT_NE(cmd, nullptr); + + ASSERT_EQ(oakengine_undo_push(cmd, "gtest bridge command"), OAKENGINE_OK); + EXPECT_EQ(g_counting_redo_calls, 1); + EXPECT_EQ(oakengine_undo_index(), index_before + 1); + ASSERT_GE(spy.count(), 1); + EXPECT_EQ(spy.last().first().toInt(), int(index_before + 1)); + + ASSERT_EQ(oakengine_undo_jump(index_before), OAKENGINE_OK); + EXPECT_EQ(g_counting_undo_calls, 1); + EXPECT_EQ(oakengine_undo_index(), index_before); + EXPECT_EQ(spy.last().first().toInt(), int(index_before)); +} + +// --- MainWindow undo commands (window/mainwindow/mainwindowundo) --- + +TEST(MainWindowUndo, CommandsAreDistinctAndFreeable) +{ + ColorManager::set_up_default_config(); + ensure_core(); + + Project project; + project.initialize(); + auto *sequence = new Sequence(); + sequence->setParent(&project); + const oak::Node sequence_node(reinterpret_cast(sequence)); + + const int64_t rows_before = oakengine_undo_count(); + + void *open_cmd = make_open_sequence_command(sequence_node); + void *close_cmd = make_close_sequence_command(sequence_node); + ASSERT_NE(open_cmd, nullptr); + ASSERT_NE(close_cmd, nullptr); + EXPECT_NE(open_cmd, close_cmd); + + // Creation must not execute the commands or touch the undo stack + EXPECT_EQ(oakengine_undo_count(), rows_before); + + oakengine_undo_command_free(open_cmd); + oakengine_undo_command_free(close_cmd); + EXPECT_EQ(oakengine_undo_count(), rows_before); +} + +TEST(MainWindowUndo, RedoUndoDriveMainWindowSequenceState) +{ + SKIP_WITHOUT_MAIN_WINDOW(); + + MainWindowEnvironment env; + + // The command callbacks reach the window through Core::main_window(), + // which is only assigned by the private Core::start_gui(); a directly + // constructed MainWindow does not register itself there, so the redo/undo + // path cannot run in this harness. + if (!Core::instance()->main_window()) { + GTEST_SKIP() << "Core::main_window() is unset without Core::start_gui()"; + } + + Project project; + project.initialize(); + auto *sequence = new Sequence(); + sequence->setParent(&project); + const oak::Node sequence_node(reinterpret_cast(sequence)); + auto *sequence_handle = reinterpret_cast(sequence); + + MainWindow *window = Core::instance()->main_window(); + EXPECT_FALSE(window->is_sequence_open(sequence_handle)); + + // Open command: redo opens, undo closes; the close command mirrors it + void *open_cmd = make_open_sequence_command(sequence_node); + ASSERT_NE(open_cmd, nullptr); + ASSERT_EQ(oakengine_undo_command_redo_now(open_cmd), OAKENGINE_OK); + EXPECT_TRUE(window->is_sequence_open(sequence_handle)); + ASSERT_EQ(oakengine_undo_command_undo_now(open_cmd), OAKENGINE_OK); + EXPECT_FALSE(window->is_sequence_open(sequence_handle)); + oakengine_undo_command_free(open_cmd); + + ASSERT_NE(window->open_sequence(sequence_handle), nullptr); + void *close_cmd = make_close_sequence_command(sequence_node); + ASSERT_NE(close_cmd, nullptr); + ASSERT_EQ(oakengine_undo_command_redo_now(close_cmd), OAKENGINE_OK); + EXPECT_FALSE(window->is_sequence_open(sequence_handle)); + ASSERT_EQ(oakengine_undo_command_undo_now(close_cmd), OAKENGINE_OK); + EXPECT_TRUE(window->is_sequence_open(sequence_handle)); + oakengine_undo_command_free(close_cmd); + + window->close_sequence(sequence_handle); +} + +// --- ViewerPanelBase (panel/viewer/viewerbase) --- + +TEST_F(ViewerPanelBaseTest, ConstructionWiresViewerWidget) +{ + ProbeViewerPanel panel(QStringLiteral("ViewerBaseConstructPanel")); + EXPECT_EQ(panel.objectName(), QStringLiteral("ViewerBaseConstructPanel")); + + auto *vw = new ViewerWidget(&panel); + panel.install_widget(vw); + + EXPECT_EQ(panel.get_viewer_widget(), vw); + EXPECT_EQ(panel.get_time_based_widget(), vw); + + // play_internal() returns early without a connected viewer node, so none + // of the playback commands may start playback on a fresh panel + EXPECT_FALSE(vw->is_playing()); + + panel.play_pause(); + EXPECT_EQ(panel.play_pause_count_, 1); + EXPECT_FALSE(vw->is_playing()); + + panel.play_in_to_out(); + EXPECT_EQ(panel.play_in_to_out_count_, 1); + EXPECT_FALSE(vw->is_playing()); + + panel.shuttle_left(); + EXPECT_EQ(panel.shuttle_left_count_, 1); + panel.shuttle_right(); + EXPECT_EQ(panel.shuttle_right_count_, 1); + panel.shuttle_stop(); + EXPECT_EQ(panel.shuttle_stop_count_, 1); + EXPECT_FALSE(vw->is_playing()); +} + +TEST_F(ViewerPanelBaseTest, ForwardsViewerWidgetSignals) +{ + ProbeViewerPanel panel(QStringLiteral("ViewerBaseForwardPanel")); + auto *vw = new ViewerWidget(&panel); + panel.install_widget(vw); + + // Lambdas avoid relying on opaque-handle metatypes for QSignalSpy + void *received_texture = nullptr; + int texture_count = 0; + QObject::connect(&panel, &ViewerPanelBase::texture_changed, + [&received_texture, &texture_count](void *t) { + received_texture = t; + texture_count++; + }); + + OakEngineColorManager *received_manager = + reinterpret_cast(quintptr(1)); + int manager_count = 0; + QObject::connect(&panel, &ViewerPanelBase::color_manager_changed, + [&received_manager, &manager_count](OakEngineColorManager *m) { + received_manager = m; + manager_count++; + }); + + emit vw->texture_changed(&panel); + EXPECT_EQ(texture_count, 1); + EXPECT_EQ(received_texture, static_cast(&panel)); + + emit vw->color_manager_changed(nullptr); + EXPECT_EQ(manager_count, 1); + EXPECT_EQ(received_manager, nullptr); +} + +TEST_F(ViewerPanelBaseTest, RoutesTimeBasedPanelPlaybackRequests) +{ + ProbeViewerPanel panel(QStringLiteral("ViewerBaseRoutingPanel")); + panel.install_widget(new ViewerWidget(&panel)); + + ProbeTimeBasedPanel source(QStringLiteral("ViewerBaseRoutingSource")); + source.set_time_based_widget(new TimeBasedWidget(false, false, &source)); + + panel.connect_time_based_panel(&source); + + // TimeBasedPanel actions become request signals the viewer panel hears + source.play_pause(); + EXPECT_EQ(panel.play_pause_count_, 1); + source.play_in_to_out(); + EXPECT_EQ(panel.play_in_to_out_count_, 1); + source.shuttle_left(); + EXPECT_EQ(panel.shuttle_left_count_, 1); + source.shuttle_stop(); + EXPECT_EQ(panel.shuttle_stop_count_, 1); + source.shuttle_right(); + EXPECT_EQ(panel.shuttle_right_count_, 1); + + // After disconnecting, requests no longer reach the panel + panel.disconnect_time_based_panel(&source); + source.play_pause(); + source.shuttle_left(); + EXPECT_EQ(panel.play_pause_count_, 1); + EXPECT_EQ(panel.shuttle_left_count_, 1); +} + +TEST_F(ViewerPanelBaseTest, FocusChangeSignalsAreSafeWhenIdle) +{ + ProbeViewerPanel panel_a(QStringLiteral("ViewerBaseFocusPanelA")); + panel_a.install_widget(new ViewerWidget(&panel_a)); + ProbeViewerPanel panel_b(QStringLiteral("ViewerBaseFocusPanelB")); + panel_b.install_widget(new ViewerWidget(&panel_b)); + + ProbeTimeBasedPanel non_viewer(QStringLiteral("ViewerBaseFocusOther")); + non_viewer.set_time_based_widget( + new TimeBasedWidget(false, false, &non_viewer)); + + // Nothing is playing, so focus changes must leave both viewers untouched + emit PanelManager::instance()->focused_panel_changed(&non_viewer); + EXPECT_FALSE(panel_a.get_viewer_widget()->is_playing()); + + emit PanelManager::instance()->focused_panel_changed(&panel_b); + EXPECT_FALSE(panel_a.get_viewer_widget()->is_playing()); + EXPECT_FALSE(panel_b.get_viewer_widget()->is_playing()); + + emit PanelManager::instance()->focused_panel_changed(nullptr); + EXPECT_FALSE(panel_a.get_viewer_widget()->is_playing()); +} + +// --- FootageManagementPanel (panel/project/footagemanagementpanel) --- + +TEST_F(FootageManagementPanelTest, InterfaceDispatchesToImplementation) +{ + Project project; + project.initialize(); + auto *math = new MathNode(); + math->setParent(&project); + + FakeFootageManagementPanel fake; + FootageManagementPanel *iface = &fake; + + EXPECT_TRUE(iface->get_selected_footage().isEmpty()); + + fake.selection_.append(reinterpret_cast(math)); + fake.selection_.append(reinterpret_cast(project.root())); + + ASSERT_EQ(iface->get_selected_footage().size(), 2); + EXPECT_EQ(iface->get_selected_footage().at(0), + reinterpret_cast(math)); + EXPECT_EQ(iface->get_selected_footage().at(1), + reinterpret_cast(project.root())); +} + +TEST_F(FootageManagementPanelTest, FootageViewerDiscoverableThroughInterface) +{ + Project project; + project.initialize(); + auto *viewer = new ViewerOutput(); + viewer->setParent(&project); + auto *viewer_handle = reinterpret_cast(viewer); + + FootageViewerPanel panel; + + // The lookup MainMenu's insert/overwrite actions perform + emit panel.shown(Qt::OtherFocusReason); + FootageManagementPanel *found = + PanelManager::instance()->most_recently_focused(); + ASSERT_EQ(found, static_cast(&panel)); + + EXPECT_TRUE(found->get_selected_footage().isEmpty()); + + panel.connect_viewer_node(viewer_handle); + ASSERT_EQ(found->get_selected_footage().size(), 1); + EXPECT_EQ(found->get_selected_footage().first(), viewer_handle); + + panel.disconnect_viewer_node(); + EXPECT_TRUE(found->get_selected_footage().isEmpty()); +} + +// --- MainMenu (window/mainwindow/mainmenu) - requires a MainWindow --- + +TEST(MainMenu, TopLevelMenusHaveExpectedTitles) +{ + SKIP_WITHOUT_MAIN_WINDOW(); + + MainWindowEnvironment env; + MainWindow window; + + auto *menu = qobject_cast(window.menuBar()); + ASSERT_NE(menu, nullptr); + + const QStringList expected = { QStringLiteral("&File"), QStringLiteral("&Edit"), + QStringLiteral("&View"), QStringLiteral("&Playback"), + QStringLiteral("&Sequence"), QStringLiteral("&Window"), + QStringLiteral("&Tools"), QStringLiteral("&Help") }; + + ASSERT_EQ(window.menuBar()->actions().size(), expected.size()); + for (int i = 0; i < expected.size(); i++) { + QMenu *m = window.menuBar()->actions().at(i)->menu(); + ASSERT_NE(m, nullptr) << i; + EXPECT_EQ(m->title(), expected.at(i)); + } +} + +TEST(MainMenu, EditMenuContainsUndoActionsAndAlternateDelete) +{ + SKIP_WITHOUT_MAIN_WINDOW(); + + MainWindowEnvironment env; + MainWindow window; + + QMenu *edit_menu = + top_level_menu(window.menuBar(), QStringLiteral("&Edit")); + ASSERT_NE(edit_menu, nullptr); + + // The undo/redo items are the engine undo stack's own QActions + QAction *undo_item = find_item_by_id(edit_menu, QStringLiteral("undo")); + QAction *redo_item = find_item_by_id(edit_menu, QStringLiteral("redo")); + ASSERT_NE(undo_item, nullptr); + ASSERT_NE(redo_item, nullptr); + EXPECT_EQ(undo_item, + reinterpret_cast(oakengine_undo_undo_action())); + EXPECT_EQ(redo_item, + reinterpret_cast(oakengine_undo_redo_action())); + + // The alternate delete item hides while the menu is open + QAction *delete2 = find_item_by_id(edit_menu, QStringLiteral("delete2")); + ASSERT_NE(delete2, nullptr); + EXPECT_TRUE(delete2->isVisible()); + + emit edit_menu->aboutToShow(); + EXPECT_FALSE(delete2->isVisible()); + + emit edit_menu->aboutToHide(); + EXPECT_TRUE(delete2->isVisible()); +} + +TEST(MainMenu, ToolItemsDriveCoreToolState) +{ + SKIP_WITHOUT_MAIN_WINDOW(); + + MainWindowEnvironment env; + MainWindow window; + + QMenu *tools_menu = + top_level_menu(window.menuBar(), QStringLiteral("&Tools")); + ASSERT_NE(tools_menu, nullptr); + + QAction *razor = find_item_by_id(tools_menu, QStringLiteral("razortool")); + QAction *hand = find_item_by_id(tools_menu, QStringLiteral("handtool")); + ASSERT_NE(razor, nullptr); + ASSERT_NE(hand, nullptr); + + Core::instance()->set_tool(Tool::k_pointer); + razor->trigger(); + EXPECT_EQ(Core::instance()->tool(), Tool::k_razor); + + // aboutToShow re-checks whichever tool Core currently holds + Core::instance()->set_tool(Tool::k_hand); + emit tools_menu->aboutToShow(); + EXPECT_TRUE(hand->isChecked()); + EXPECT_FALSE(razor->isChecked()); + + Core::instance()->set_tool(Tool::k_pointer); +} + +TEST(MainMenu, SnappingAndLoopItemsPersistState) +{ + SKIP_WITHOUT_MAIN_WINDOW(); + + MainWindowEnvironment env; + MainWindow window; + + QMenu *tools_menu = + top_level_menu(window.menuBar(), QStringLiteral("&Tools")); + ASSERT_NE(tools_menu, nullptr); + QAction *snapping = find_item_by_id(tools_menu, QStringLiteral("snapping")); + ASSERT_NE(snapping, nullptr); + ASSERT_TRUE(snapping->isCheckable()); + + // Connected straight to Core::set_snapping + const bool snapping_before = snapping->isChecked(); + snapping->trigger(); + EXPECT_EQ(Core::instance()->snapping(), !snapping_before); + snapping->trigger(); + EXPECT_EQ(Core::instance()->snapping(), snapping_before); + + QMenu *playback_menu = + top_level_menu(window.menuBar(), QStringLiteral("&Playback")); + ASSERT_NE(playback_menu, nullptr); + QAction *loop = find_item_by_id(playback_menu, QStringLiteral("loop")); + ASSERT_NE(loop, nullptr); + ASSERT_TRUE(loop->isCheckable()); + + // aboutToShow syncs the check state from the config + const QVariant old_loop = Config::current()[QStringLiteral("Loop")]; + Config::current()[QStringLiteral("Loop")] = false; + emit playback_menu->aboutToShow(); + EXPECT_FALSE(loop->isChecked()); + + loop->trigger(); + EXPECT_TRUE(Config::current()[QStringLiteral("Loop")].toBool()); + loop->trigger(); + EXPECT_FALSE(Config::current()[QStringLiteral("Loop")].toBool()); + + Config::current()[QStringLiteral("Loop")] = old_loop; +} + +TEST(MainMenu, FileMenuReflectsActiveProjectAndRecentList) +{ + SKIP_WITHOUT_MAIN_WINDOW(); + + MainWindowEnvironment env; + MainWindow window; + + QMenu *file_menu = + top_level_menu(window.menuBar(), QStringLiteral("&File")); + ASSERT_NE(file_menu, nullptr); + + QAction *save = find_item_by_id(file_menu, QStringLiteral("saveproj")); + QAction *save_as = find_item_by_id(file_menu, QStringLiteral("saveprojas")); + ASSERT_NE(save, nullptr); + ASSERT_NE(save_as, nullptr); + + emit file_menu->aboutToShow(); + + // Save items are enabled only with an active project + const bool has_active_project = + Core::instance()->get_active_project() != nullptr; + EXPECT_EQ(save->isEnabled(), has_active_project); + EXPECT_EQ(save_as->isEnabled(), has_active_project); + if (!has_active_project) { + EXPECT_EQ(save->text(), QStringLiteral("&Save Project")); + EXPECT_EQ(save_as->text(), QStringLiteral("Save Project &As")); + } + + // With no recent projects the submenu shows a disabled placeholder + QMenu *open_recent = nullptr; + foreach (QAction *a, file_menu->actions()) { + if (a->menu() && + a->menu()->title() == QStringLiteral("Open &Recent")) { + open_recent = a->menu(); + break; + } + } + ASSERT_NE(open_recent, nullptr); + + if (Core::instance()->get_recent_project_count() == 0) { + ASSERT_FALSE(open_recent->actions().isEmpty()); + QAction *placeholder = open_recent->actions().first(); + EXPECT_EQ(placeholder->text(), QStringLiteral("(None)")); + EXPECT_FALSE(placeholder->isEnabled()); + } else { + const int count = Core::instance()->get_recent_project_count(); + int entries = 0; + foreach (QAction *a, open_recent->actions()) { + if (a->data().isValid()) { + entries++; + } + } + EXPECT_EQ(entries, count); + } +} diff --git a/tests/gtest/timeline_common_test.cpp b/tests/gtest/timeline_common_test.cpp new file mode 100644 index 0000000000..e358f84357 --- /dev/null +++ b/tests/gtest/timeline_common_test.cpp @@ -0,0 +1,569 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "core.h" +#include "node/color/colormanager/colormanager.h" +#include "node/output/track/track.h" +#include "node/output/track/tracklist.h" +#include "node/project.h" +#include "node/project/sequence/sequence.h" +#include "oakengine/node.h" +#include "oakengine/timeline.h" +#include "timeline/timelinecommonapp.h" +#include "widget/clickablelabel/clickablelabel.h" +#include "widget/focusablelineedit/focusablelineedit.h" +#include "widget/timelinewidget/timelineandtrackview.h" +#include "widget/timelinewidget/trackview/trackview.h" +#include "widget/timelinewidget/trackview/trackviewitem.h" +#include "widget/timelinewidget/trackview/trackviewsplitter.h" +#include "widget/timelinewidget/view/timelineview.h" + +namespace +{ + +// HandMovableView (base of TimelineView) connects to Core::instance() at +// construction, so anything embedding a TimelineView needs the singleton +void ensure_core() +{ + if (!olive::Core::instance()) { + new olive::Core(); // intentionally leaked + } +} + +olive::Sequence *create_sequence(olive::Project *project) +{ + auto *sequence = new olive::Sequence(); + sequence->setParent(project); + return sequence; +} + +// Mirrors sequence_test.cpp: grow the track input array, then connect the +// edge so the TrackList assigns type, index and owning sequence +olive::Track *append_track(olive::Project *project, olive::Sequence *sequence, + olive::Track::Type type) +{ + auto *track = new olive::Track(); + track->setParent(project); + olive::TrackList *list = sequence->track_list(type); + list->array_append(); + olive::Node::connect_edge(track, list->track_input(list->array_size() - 1)); + return track; +} + +OakEngineSequence *seq_handle(olive::Sequence *sequence) +{ + return reinterpret_cast(sequence); +} + +OakEngineNode *node_handle(olive::Node *node) +{ + return reinterpret_cast(node); +} + +OakEngineTrack *track_handle(olive::Track *track) +{ + return reinterpret_cast(track); +} + +// TrackViewItem's mute/lock buttons are distinguished only by the checked +// color baked into their style sheets (red for mute, gray for lock) +QPushButton *find_button_by_checked_color(QWidget *parent, const QString &rgb) +{ + for (QPushButton *b : parent->findChildren()) { + if (b->styleSheet().contains(rgb)) { + return b; + } + } + return nullptr; +} + +} // namespace + +TEST(TimelineApp, MovementModeOrdinalsMatchEngineAbi) +{ + // The C ABI transports these as ints (OAKENGINE_MOVEMENT_MODE_*), so the + // app-side enum ordinals must stay in sync with the engine + EXPECT_EQ(int(olive::TimelineApp::k_none), OAKENGINE_MOVEMENT_MODE_NONE); + EXPECT_EQ(int(olive::TimelineApp::k_move), OAKENGINE_MOVEMENT_MODE_MOVE); + EXPECT_EQ(int(olive::TimelineApp::k_trim_in), OAKENGINE_MOVEMENT_MODE_TRIM_IN); + EXPECT_EQ(int(olive::TimelineApp::k_trim_out), + OAKENGINE_MOVEMENT_MODE_TRIM_OUT); +} + +TEST(TimelineApp, IsATrimMode) +{ + EXPECT_TRUE(olive::TimelineApp::is_a_trim_mode(olive::TimelineApp::k_trim_in)); + EXPECT_TRUE( + olive::TimelineApp::is_a_trim_mode(olive::TimelineApp::k_trim_out)); + EXPECT_FALSE(olive::TimelineApp::is_a_trim_mode(olive::TimelineApp::k_none)); + EXPECT_FALSE(olive::TimelineApp::is_a_trim_mode(olive::TimelineApp::k_move)); +} + +TEST(TimelineApp, ThumbnailAndWaveformOrdinals) +{ + EXPECT_EQ(int(olive::TimelineApp::k_thumbnail_off), 0); + EXPECT_EQ(int(olive::TimelineApp::k_thumbnail_in_out), 1); + EXPECT_EQ(int(olive::TimelineApp::k_thumbnail_on), 2); + + EXPECT_EQ(int(olive::TimelineApp::k_waveforms_disabled), 0); + EXPECT_EQ(int(olive::TimelineApp::k_waveforms_enabled), 1); +} + +TEST(TimelineApp, EditToInfoIsPlainStruct) +{ + olive::TimelineApp::EditToInfo info; + info.track = nullptr; + info.nearest_block = nullptr; + info.nearest_time = olive::core::Rational(5, 1); + + EXPECT_EQ(info.track, nullptr); + EXPECT_EQ(info.nearest_block, nullptr); + EXPECT_EQ(info.nearest_time, olive::core::Rational(5, 1)); + + olive::TimelineApp::EditToInfo copy = info; + EXPECT_EQ(copy.nearest_time, olive::core::Rational(5, 1)); +} + +TEST(TimelineAndTrackView, ConstructionWiresSplitterAndViews) +{ + ensure_core(); + + olive::TimelineAndTrackView tav; + ASSERT_NE(tav.splitter(), nullptr); + ASSERT_NE(tav.view(), nullptr); + ASSERT_NE(tav.track_view(), nullptr); + + EXPECT_EQ(tav.splitter()->orientation(), Qt::Horizontal); + EXPECT_FALSE(tav.splitter()->childrenCollapsible()); + ASSERT_EQ(tav.splitter()->count(), 2); + + // Track headers on the left, timeline view on the right + EXPECT_EQ(tav.splitter()->widget(0), tav.track_view()); + EXPECT_EQ(tav.splitter()->widget(1), tav.view()); +} + +TEST(TimelineAndTrackView, ScrollbarsTrackEachOther) +{ + ensure_core(); + + olive::TimelineAndTrackView tav; + QScrollBar *view_sb = tav.view()->verticalScrollBar(); + QScrollBar *track_sb = tav.track_view()->verticalScrollBar(); + ASSERT_NE(view_sb, nullptr); + ASSERT_NE(track_sb, nullptr); + + // With an empty sequence both scrollbars have a degenerate range; give + // them explicit ranges so the sync math is observable. The ranges stick + // because nothing relayouts the hidden widget afterwards + view_sb->setRange(10, 100); + track_sb->setRange(0, 90); + + // Scrolling the view scrolls the track headers by (value - minimum) + view_sb->setValue(50); + EXPECT_EQ(track_sb->value(), 40); + + // Scrolling the track headers scrolls the view by (minimum + value) + track_sb->setValue(25); + EXPECT_EQ(view_sb->value(), 35); + + // Deliberately no cleanup of the sync connections: leaving the scrollbars + // at non-zero values exercises ~TimelineAndTrackView, which must detach + // them before the child views reset their scenes during teardown +} + +TEST(TrackView, ConstructionDefaults) +{ + olive::TrackView view; + EXPECT_EQ(view.horizontalScrollBarPolicy(), Qt::ScrollBarAlwaysOff); + EXPECT_EQ(view.verticalScrollBarPolicy(), Qt::ScrollBarAlwaysOff); + EXPECT_TRUE(view.widgetResizable()); + EXPECT_EQ(view.alignment(), Qt::Alignment(Qt::AlignLeft | Qt::AlignTop)); + + auto *splitter = view.findChild(); + ASSERT_NE(splitter, nullptr); + EXPECT_EQ(splitter->orientation(), Qt::Vertical); + // Only the trailing spacer widget before any track list is connected + EXPECT_EQ(splitter->count(), 1); + + olive::TrackView bottom(Qt::AlignBottom); + EXPECT_EQ(bottom.alignment(), Qt::Alignment(Qt::AlignLeft | Qt::AlignBottom)); +} + +TEST(TrackView, ConnectTrackListPopulatesAndDisconnectClears) +{ + olive::ColorManager::set_up_default_config(); + olive::Project project; + project.initialize(); + olive::Sequence *sequence = create_sequence(&project); + append_track(&project, sequence, olive::Track::k_video); + append_track(&project, sequence, olive::Track::k_video); + append_track(&project, sequence, olive::Track::k_audio); + + olive::TrackView view; + auto *splitter = view.findChild(); + ASSERT_NE(splitter, nullptr); + EXPECT_EQ(splitter->count(), 1); + + // Binding the video track list adds one item per track (plus the spacer) + view.connect_track_list(seq_handle(sequence), OAKENGINE_TRACK_TYPE_VIDEO); + EXPECT_EQ(splitter->count(), 3); + + // Rebinding to the audio list swaps the items, it doesn't accumulate + view.connect_track_list(seq_handle(sequence), OAKENGINE_TRACK_TYPE_AUDIO); + EXPECT_EQ(splitter->count(), 2); + + view.disconnect_track_list(); + EXPECT_EQ(splitter->count(), 1); +} + +TEST(TrackView, InsertAndRemoveTrackUpdateSplitter) +{ + olive::ColorManager::set_up_default_config(); + olive::Project project; + project.initialize(); + olive::Sequence *sequence = create_sequence(&project); + append_track(&project, sequence, olive::Track::k_video); + + olive::TrackView view; + auto *splitter = view.findChild(); + ASSERT_NE(splitter, nullptr); + + view.connect_track_list(seq_handle(sequence), OAKENGINE_TRACK_TYPE_VIDEO); + ASSERT_EQ(splitter->count(), 2); + + // A track added to the sequence afterwards can be inserted explicitly + olive::Track *t2 = append_track(&project, sequence, olive::Track::k_video); + view.insert_track(track_handle(t2)); + ASSERT_EQ(splitter->count(), 3); + + // AlignTop ordering: items first, spacer last + QPointer item = splitter->widget(1); + ASSERT_FALSE(item.isNull()); + + view.remove_track(track_handle(t2)); + EXPECT_EQ(splitter->count(), 2); + // remove() deletes the item widget + EXPECT_TRUE(item.isNull()); +} + +TEST(TrackViewItem, LabelShowsTypePrefixAndCustomLabel) +{ + olive::ColorManager::set_up_default_config(); + olive::Project project; + project.initialize(); + olive::Sequence *sequence = create_sequence(&project); + olive::Track *v1 = append_track(&project, sequence, olive::Track::k_video); + olive::Track *v2 = append_track(&project, sequence, olive::Track::k_video); + olive::Track *a1 = append_track(&project, sequence, olive::Track::k_audio); + olive::Track *s1 = append_track(&project, sequence, olive::Track::k_subtitle); + + ASSERT_EQ( + oakengine_node_set_label(node_handle(v1), "Hero"), 0); + + olive::TrackViewItem item_v1(track_handle(v1)); + olive::TrackViewItem item_v2(track_handle(v2)); + olive::TrackViewItem item_a1(track_handle(a1)); + olive::TrackViewItem item_s1(track_handle(s1)); + + auto *label_v1 = item_v1.findChild(); + auto *label_v2 = item_v2.findChild(); + auto *label_a1 = item_a1.findChild(); + auto *label_s1 = item_s1.findChild(); + ASSERT_NE(label_v1, nullptr); + ASSERT_NE(label_v2, nullptr); + ASSERT_NE(label_a1, nullptr); + ASSERT_NE(label_s1, nullptr); + + // NLE-style prefix is 1-based (V1/V2, A1, S1) followed by the custom + // label or, when unset, the engine default track name + EXPECT_TRUE(label_v1->text().startsWith(QStringLiteral("V1 "))); + EXPECT_TRUE(label_v1->text().contains(QStringLiteral("Hero"))); + + EXPECT_TRUE(label_v2->text().startsWith(QStringLiteral("V2 "))); + EXPECT_FALSE(label_v2->text().contains(QStringLiteral("Hero"))); + EXPECT_GT(label_v2->text().size(), 4); + + EXPECT_TRUE(label_a1->text().startsWith(QStringLiteral("A1 "))); + EXPECT_TRUE(label_s1->text().startsWith(QStringLiteral("S1 "))); +} + +TEST(TrackViewItem, RenameThroughLineEditRoundTrips) +{ + olive::ColorManager::set_up_default_config(); + olive::Project project; + project.initialize(); + olive::Sequence *sequence = create_sequence(&project); + olive::Track *track = append_track(&project, sequence, olive::Track::k_video); + + olive::TrackViewItem item(track_handle(track)); + auto *stack = item.findChild(); + auto *label = item.findChild(); + auto *edit = item.findChild(); + ASSERT_NE(stack, nullptr); + ASSERT_NE(label, nullptr); + ASSERT_NE(edit, nullptr); + + // The label is the resting page of the stack + EXPECT_EQ(stack->currentWidget(), label); + + // Double-clicking the label swaps in the line edit + emit label->mouse_double_clicked(); + EXPECT_EQ(stack->currentWidget(), edit); + + // Confirming writes the label to the engine and swaps back + edit->setText(QStringLiteral("Renamed")); + emit edit->confirmed(); + EXPECT_EQ(stack->currentWidget(), label); + EXPECT_TRUE(label->text().contains(QStringLiteral("Renamed"))); + + char buf[256]; + buf[0] = '\0'; + oakengine_node_get_label(node_handle(track), buf, sizeof(buf)); + EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("Renamed")); + + // Cancelling discards the edit and restores the label page + emit label->mouse_double_clicked(); + EXPECT_EQ(stack->currentWidget(), edit); + edit->setText(QStringLiteral("Discarded")); + emit edit->cancelled(); + EXPECT_EQ(stack->currentWidget(), label); + + buf[0] = '\0'; + oakengine_node_get_label(node_handle(track), buf, sizeof(buf)); + EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("Renamed")); + EXPECT_TRUE(label->text().contains(QStringLiteral("Renamed"))); +} + +TEST(TrackViewItem, MuteAndLockButtonsDriveEngineState) +{ + olive::ColorManager::set_up_default_config(); + olive::Project project; + project.initialize(); + olive::Sequence *sequence = create_sequence(&project); + olive::Track *track = append_track(&project, sequence, olive::Track::k_video); + + olive::TrackViewItem item(track_handle(track)); + + // The buttons are identified by the checked color baked into their style + // sheets (Qt::red for mute, Qt::gray for lock); resolve the colors the + // same way the implementation does -- QColor(Qt::gray).name() is not the + // SVG "#808080" one might expect + QPushButton *mute = find_button_by_checked_color( + &item, QColor(Qt::red).name()); + QPushButton *lock = find_button_by_checked_color( + &item, QColor(Qt::gray).name()); + ASSERT_NE(mute, nullptr); + ASSERT_NE(lock, nullptr); + EXPECT_TRUE(mute->isCheckable()); + EXPECT_TRUE(lock->isCheckable()); + EXPECT_FALSE(mute->isChecked()); + EXPECT_FALSE(lock->isChecked()); + + OakEngineSequence *seq = seq_handle(sequence); + EXPECT_EQ(oakengine_track_is_muted(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0), 0); + EXPECT_EQ(oakengine_track_is_locked(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0), 0); + + // Clicking the button writes through to the engine track + mute->click(); + EXPECT_EQ(oakengine_track_is_muted(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0), 1); + EXPECT_TRUE(mute->isChecked()); + + // An external mute change reflects back on the button via the + // EngineEventBridge subscription + oakengine_track_set_muted(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0); + EXPECT_FALSE(mute->isChecked()); + + lock->click(); + EXPECT_EQ(oakengine_track_is_locked(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0), 1); + EXPECT_TRUE(lock->isChecked()); +} + +TEST(TrackViewItem, DeleteTrackEmitsSignalAndRemovesTrack) +{ + olive::ColorManager::set_up_default_config(); + olive::Project project; + project.initialize(); + olive::Sequence *sequence = create_sequence(&project); + olive::Track *track = append_track(&project, sequence, olive::Track::k_video); + + // Owned by the test so the dangling engine handle after removal can't + // outlive the widget + auto *item = new olive::TrackViewItem(track_handle(track)); + + OakEngineTrack *received = nullptr; + QObject::connect(item, &olive::TrackViewItem::about_to_delete_track, + [&received](OakEngineTrack *t) { received = t; }); + + // The context menu wires this slot with a queued connection; on the same + // thread a direct meta-call is equivalent and avoids the modal menu + QMetaObject::invokeMethod(item, "delete_track"); + + EXPECT_EQ(received, track_handle(track)); + + int video = -1, audio = -1, subtitle = -1; + oakengine_sequence_track_count(seq_handle(sequence), &video, &audio, + &subtitle); + EXPECT_EQ(video, 0); + + delete item; +} + +TEST(TrackViewSplitter, ConstructionDefaults) +{ + olive::TrackViewSplitter splitter(Qt::AlignTop); + EXPECT_EQ(splitter.orientation(), Qt::Vertical); + EXPECT_EQ(splitter.handleWidth(), 1); + // The trailing spacer is added by the constructor + EXPECT_EQ(splitter.count(), 1); + EXPECT_EQ(splitter.height(), 0); + + olive::TrackViewSplitter bottom(Qt::AlignBottom); + EXPECT_EQ(bottom.count(), 1); +} + +TEST(TrackViewSplitter, InsertGrowsFixedHeightAndOrdersWidgets) +{ + olive::TrackViewSplitter splitter(Qt::AlignTop); + + auto *w1 = new QWidget(); + auto *w2 = new QWidget(); + splitter.insert(0, 100, w1); + + // Height = item height + one handle width + EXPECT_EQ(splitter.count(), 2); + EXPECT_EQ(splitter.widget(0), w1); + EXPECT_EQ(splitter.height(), 101); + + splitter.insert(1, 50, w2); + + // Height = both items + two handle widths; spacer stays last + EXPECT_EQ(splitter.count(), 3); + EXPECT_EQ(splitter.widget(0), w1); + EXPECT_EQ(splitter.widget(1), w2); + EXPECT_EQ(splitter.height(), 152); + + const QList sz = splitter.sizes(); + ASSERT_EQ(sz.size(), 3); + EXPECT_EQ(sz.at(0), 100); + EXPECT_EQ(sz.at(1), 50); +} + +TEST(TrackViewSplitter, InsertAlignBottomAppendsAfterSpacer) +{ + olive::TrackViewSplitter splitter(Qt::AlignBottom); + + auto *w1 = new QWidget(); + auto *w2 = new QWidget(); + splitter.insert(0, 60, w1); + splitter.insert(1, 40, w2); + + // With bottom alignment the spacer stays at the top and later inserts + // land between the spacer and the existing items + ASSERT_EQ(splitter.count(), 3); + EXPECT_EQ(splitter.widget(1), w2); + EXPECT_EQ(splitter.widget(2), w1); + + // Height = both items + two handle widths + EXPECT_EQ(splitter.height(), 102); +} + +TEST(TrackViewSplitter, RemoveDeletesWidgetAndShrinksHeight) +{ + olive::TrackViewSplitter splitter(Qt::AlignTop); + + auto *w1 = new QWidget(); + auto *w2 = new QWidget(); + splitter.insert(0, 100, w1); + splitter.insert(1, 50, w2); + ASSERT_EQ(splitter.height(), 152); + + QPointer gone = w1; + splitter.remove(0); + + EXPECT_TRUE(gone.isNull()); + EXPECT_EQ(splitter.count(), 2); + EXPECT_EQ(splitter.widget(0), w2); + // Removing an item subtracts its height and one handle width + EXPECT_EQ(splitter.height(), 51); + + const QList sz = splitter.sizes(); + ASSERT_EQ(sz.size(), 2); + EXPECT_EQ(sz.at(0), 50); +} + +TEST(TrackViewSplitter, SetSpacerHeightGrowsTotalHeight) +{ + olive::TrackViewSplitter splitter(Qt::AlignTop); + + auto *w1 = new QWidget(); + splitter.insert(0, 100, w1); + ASSERT_EQ(splitter.height(), 101); + + // The spacer keeps a handle on the trailing edge; its height is added to + // the fixed total + splitter.set_spacer_height(40); + EXPECT_EQ(splitter.height(), 141); +} + +TEST(TrackViewSplitter, SetTrackHeightAdjustsFixedHeight) +{ + olive::TrackViewSplitter splitter(Qt::AlignTop); + + splitter.insert(0, 100, new QWidget()); + splitter.insert(1, 50, new QWidget()); + ASSERT_EQ(splitter.height(), 152); + + // Growing a track by 30px grows the whole splitter by 30px + splitter.set_track_height(0, 130); + EXPECT_EQ(splitter.height(), 182); + + // The fixed height always moves by (new - old) for the addressed track; + // read the current size back instead of assuming an exact layout, since + // QSplitter redistributes sizes when the fixed height changes + const int old_size = splitter.sizes().at(1); + const int old_height = splitter.height(); + splitter.set_track_height(1, 20); + EXPECT_EQ(splitter.height(), old_height + (20 - old_size)); +} + +TEST(TrackViewSplitter, HandlesAreTrackViewSplitterHandles) +{ + olive::TrackViewSplitter splitter(Qt::AlignTop); + splitter.insert(0, 100, new QWidget()); + + // createHandle() must produce the custom handle type + EXPECT_NE(qobject_cast(splitter.handle(1)), + nullptr); +} + +TEST(TrackViewSplitter, HandleReceiverResizesAndEmits) +{ + olive::TrackViewSplitter splitter(Qt::AlignTop); + splitter.insert(0, 100, new QWidget()); + splitter.insert(1, 50, new QWidget()); + ASSERT_EQ(splitter.height(), 152); + + const QList sz = splitter.sizes(); + ASSERT_EQ(sz.size(), 3); + ASSERT_EQ(sz.at(0), 100); + + QSignalSpy spy(&splitter, &olive::TrackViewSplitter::track_height_changed); + + // Dragging the handle below the first track grows it by the drag delta + auto *handle = + qobject_cast(splitter.handle(1)); + ASSERT_NE(handle, nullptr); + splitter.handle_receiver(handle, 30); + + ASSERT_EQ(spy.count(), 1); + EXPECT_EQ(spy.first().at(0).toInt(), 0); + EXPECT_EQ(spy.first().at(1).toInt(), 130); + EXPECT_EQ(splitter.height(), 182); +} diff --git a/tests/gtest/widget_handles_test.cpp b/tests/gtest/widget_handles_test.cpp new file mode 100644 index 0000000000..f4e6127f78 --- /dev/null +++ b/tests/gtest/widget_handles_test.cpp @@ -0,0 +1,396 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "node/block/clip/clip.h" +#include "node/color/colormanager/colormanager.h" +#include "node/output/track/track.h" +#include "node/output/track/tracklist.h" +#include "node/project.h" +#include "node/project/sequence/sequence.h" +#include "timeline/timelinemarker.h" +#include "widget/playbackcontrols/dragbutton.h" +#include "widget/taskview/elapsedcounterwidget.h" +#include "widget/timelinewidget/cliphandle.h" +#include "widget/timelinewidget/trackhandle.h" +#include "widget/timeruler/markerhandle.h" +#include "widget/timeruler/markerpainting.h" +#include "oakutil/qtutils.h" + +TEST(WidgetDragButton, DefaultsAndPlainClick) +{ + olive::DragButton btn; + btn.resize(80, 30); + EXPECT_EQ(btn.cursor().shape(), Qt::OpenHandCursor); + + QSignalSpy drag_spy(&btn, &olive::DragButton::drag_started); + QSignalSpy click_spy(&btn, &QPushButton::clicked); + + // A plain click is still a normal button click, not a drag + QTest::mouseClick(&btn, Qt::LeftButton, Qt::NoModifier, QPoint(10, 10)); + EXPECT_EQ(drag_spy.count(), 0); + EXPECT_EQ(click_spy.count(), 1); +} + +TEST(WidgetDragButton, DragEmitsDragStartedOncePerPress) +{ + olive::DragButton btn; + btn.resize(80, 30); + + QSignalSpy drag_spy(&btn, &olive::DragButton::drag_started); + + // Press then move: the first move with a button held starts the drag + QTest::mousePress(&btn, Qt::LeftButton, Qt::NoModifier, QPoint(10, 10)); + QTest::mouseMove(&btn, QPoint(20, 15)); + ASSERT_EQ(drag_spy.count(), 1); + + // The drag state latches until release + QTest::mouseMove(&btn, QPoint(30, 20)); + EXPECT_EQ(drag_spy.count(), 1); + QTest::mouseRelease(&btn, Qt::LeftButton, Qt::NoModifier, QPoint(30, 20)); + + // A new press re-arms the drag detection + QTest::mousePress(&btn, Qt::LeftButton, Qt::NoModifier, QPoint(10, 10)); + QTest::mouseMove(&btn, QPoint(25, 15)); + EXPECT_EQ(drag_spy.count(), 2); + QTest::mouseRelease(&btn, Qt::LeftButton, Qt::NoModifier, QPoint(25, 15)); +} + +TEST(WidgetDragButton, MoveWithoutButtonsDoesNotEmit) +{ + olive::DragButton btn; + btn.resize(80, 30); + + QSignalSpy drag_spy(&btn, &olive::DragButton::drag_started); + + QTest::mouseMove(&btn, QPoint(40, 15)); + EXPECT_EQ(drag_spy.count(), 0); +} + +TEST(WidgetElapsedCounter, InitialLabelsAreZero) +{ + olive::ElapsedCounterWidget w; + const auto labels = w.findChildren(); + ASSERT_EQ(labels.size(), 2); + + EXPECT_EQ(labels.at(0)->text(), QStringLiteral("Elapsed: 00:00:00")); + EXPECT_EQ(labels.at(1)->text(), QStringLiteral("Remaining: 00:00:00")); +} + +TEST(WidgetElapsedCounter, HalfProgressMakesElapsedEqualRemaining) +{ + olive::ElapsedCounterWidget w; + const auto labels = w.findChildren(); + ASSERT_EQ(labels.size(), 2); + + // At 50% progress, remaining time is derived from the same elapsed + // value, so both labels must agree regardless of wall-clock drift + w.start(QDateTime::currentMSecsSinceEpoch() - 60000); + w.set_progress(0.5); + + const QString elapsed = labels.at(0)->text(); + const QString remaining = labels.at(1)->text(); + EXPECT_NE(elapsed, QStringLiteral("Elapsed: 00:00:00")); + EXPECT_EQ(elapsed, QStringLiteral("Elapsed: ") + + remaining.mid(QStringLiteral("Remaining: ").size())); + + // A full progress report leaves nothing remaining + w.set_progress(1.0); + EXPECT_EQ(labels.at(1)->text(), QStringLiteral("Remaining: 00:00:00")); + + w.stop(); +} + +TEST(WidgetElapsedCounter, ZeroProgressResetsLabels) +{ + olive::ElapsedCounterWidget w; + const auto labels = w.findChildren(); + ASSERT_EQ(labels.size(), 2); + + w.start(QDateTime::currentMSecsSinceEpoch() - 60000); + w.set_progress(0.5); + EXPECT_NE(labels.at(0)->text(), QStringLiteral("Elapsed: 00:00:00")); + + // No progress means no estimate at all + w.set_progress(0.0); + EXPECT_EQ(labels.at(0)->text(), QStringLiteral("Elapsed: 00:00:00")); + EXPECT_EQ(labels.at(1)->text(), QStringLiteral("Remaining: 00:00:00")); + + w.stop(); +} + +TEST(WidgetClipHandle, EmptyClipDefaults) +{ + OakEngineBlock *clip = olive::clip_create_empty("Test Clip"); + ASSERT_NE(clip, nullptr); + + EXPECT_EQ(olive::cliphandle(clip), + reinterpret_cast(clip)); + EXPECT_DOUBLE_EQ(olive::clip_speed(clip), 1.0); + EXPECT_EQ(olive::clip_loop_mode(clip), 0); + EXPECT_FALSE(olive::clip_is_reversed(clip)); + EXPECT_FALSE(olive::clip_maintain_audio_pitch(clip)); + EXPECT_FALSE(olive::clip_is_autocaching(clip)); + + // Nothing is connected to the buffer input and the clip is on no track + EXPECT_EQ(olive::clip_connected_node(clip), nullptr); + EXPECT_EQ(olive::clip_thumbnails(clip), nullptr); + EXPECT_EQ(olive::clip_waveform(clip), nullptr); + EXPECT_EQ(olive::clip_connected_video_cache(clip), nullptr); + EXPECT_EQ(olive::block_track_handle(clip), nullptr); + + EXPECT_EQ(olive::clip_media_in(clip), olive::Rational(0)); + const olive::TimeRange range = olive::clip_media_range(clip); + EXPECT_EQ(range.in(), olive::Rational(0)); + EXPECT_EQ(range.out(), olive::Rational(0)); + + // Invalidation requests with nothing connected are a safe no-op + olive::clip_request_invalidate_connected(clip); + + delete reinterpret_cast(clip); +} + +TEST(WidgetClipHandle, SetMediaInRoundTripsThroughFacade) +{ + OakEngineBlock *clip = olive::clip_create_empty(nullptr); + ASSERT_NE(clip, nullptr); + + olive::clip_set_media_in(clip, olive::Rational(2, 1)); + EXPECT_EQ(olive::clip_media_in(clip), olive::Rational(2, 1)); + + // Media out is media in plus the clip's (zero) length + const olive::TimeRange range = olive::clip_media_range(clip); + EXPECT_EQ(range.in(), olive::Rational(2, 1)); + EXPECT_EQ(range.out(), olive::Rational(2, 1)); + + delete reinterpret_cast(clip); +} + +TEST(WidgetClipHandle, NullClipIsTolerated) +{ + olive::clip_set_media_in(nullptr, olive::Rational(1, 1)); + olive::clip_request_invalidate_connected(nullptr); + olive::clip_request_invalidate_connected( + nullptr, true, + olive::TimeRange(olive::Rational(0, 1), olive::Rational(1, 1))); + SUCCEED(); +} + +TEST(WidgetTrackHandle, NullTrackIsTolerated) +{ + EXPECT_EQ(olive::trackhandle(nullptr), nullptr); + EXPECT_EQ(olive::track_sequence_handle(nullptr), nullptr); + EXPECT_EQ(olive::track_type_of(nullptr), -1); + EXPECT_EQ(olive::track_index_of(nullptr), -1); + EXPECT_FALSE(olive::track_is_locked(nullptr)); + EXPECT_FALSE(olive::track_is_muted(nullptr)); +} + +TEST(WidgetTrackHandle, ConnectedTrackReportsSequenceIndexAndFlags) +{ + olive::ColorManager::set_up_default_config(); + olive::Project project; + project.initialize(); + + auto *sequence = new olive::Sequence(); + sequence->setParent(&project); + sequence->add_default_nodes(); + + olive::TrackList *video = sequence->track_list(olive::Track::k_video); + olive::TrackList *audio = sequence->track_list(olive::Track::k_audio); + ASSERT_EQ(video->get_track_count(), 1); + ASSERT_EQ(audio->get_track_count(), 1); + + olive::Track *video_track = video->get_track_at(0); + auto *h = reinterpret_cast(video_track); + + EXPECT_EQ(olive::trackhandle(h), h); + EXPECT_EQ(olive::track_type_of(h), OAKENGINE_TRACK_TYPE_VIDEO); + EXPECT_EQ(olive::track_index_of(h), 0); + EXPECT_EQ(olive::track_sequence_handle(h), + reinterpret_cast(sequence)); + + EXPECT_FALSE(olive::track_is_locked(h)); + EXPECT_FALSE(olive::track_is_muted(h)); + + video_track->set_locked(true); + EXPECT_TRUE(olive::track_is_locked(h)); + + video_track->set_muted(true); + EXPECT_TRUE(olive::track_is_muted(h)); + + // Lock/mute are per-track; the audio track is unaffected + auto *ah = reinterpret_cast(audio->get_track_at(0)); + EXPECT_EQ(olive::track_type_of(ah), OAKENGINE_TRACK_TYPE_AUDIO); + EXPECT_FALSE(olive::track_is_locked(ah)); + EXPECT_FALSE(olive::track_is_muted(ah)); +} + +TEST(WidgetMarkerHandle, DetachedMarkerAccessors) +{ + OakEngineMarker *m = oakengine_marker_create(3, 5, 1, 8, 1, "Intro"); + ASSERT_NE(m, nullptr); + + const olive::TimeRange t = olive::marker_time(m); + EXPECT_EQ(t.in(), olive::Rational(5, 1)); + EXPECT_EQ(t.out(), olive::Rational(8, 1)); + EXPECT_EQ(olive::marker_name(m), QStringLiteral("Intro")); + EXPECT_EQ(olive::marker_color(m), 3); + + // ADL customization points used by the selection manager + EXPECT_EQ(olive::selection_time(m), olive::Rational(5, 1)); + EXPECT_EQ(olive::selection_time_end(m), olive::Rational(8, 1)); + EXPECT_EQ(olive::selection_time_target_parent(m), nullptr); + + oakengine_marker_free(m); +} + +TEST(WidgetMarkerHandle, SetTimeLiveUpdatesRange) +{ + OakEngineMarker *m = oakengine_marker_create(0, 5, 1, 8, 1, "M"); + ASSERT_NE(m, nullptr); + + olive::marker_set_time_live( + m, olive::TimeRange(olive::Rational(1, 1), olive::Rational(2, 1))); + + const olive::TimeRange t = olive::marker_time(m); + EXPECT_EQ(t.in(), olive::Rational(1, 1)); + EXPECT_EQ(t.out(), olive::Rational(2, 1)); + + oakengine_marker_free(m); +} + +TEST(WidgetMarkerHandle, SelectionSetTimePreservesLength) +{ + OakEngineMarker *m = oakengine_marker_create(0, 5, 1, 8, 1, nullptr); + ASSERT_NE(m, nullptr); + + // Moving the in-point drags the out-point along, keeping a length of 3 + olive::selection_set_time(m, olive::Rational(10, 1)); + + const olive::TimeRange t = olive::marker_time(m); + EXPECT_EQ(t.in(), olive::Rational(10, 1)); + EXPECT_EQ(t.out(), olive::Rational(13, 1)); + + oakengine_marker_free(m); +} + +TEST(WidgetMarkerHandle, SiblingDetectionWithinList) +{ + // The list asserts that in-points are unique, so use distinct times + olive::TimelineMarkerList list; + olive::TimelineMarker a( + 1, + olive::core::TimeRange(olive::core::Rational(5, 1), + olive::core::Rational(5, 1)), + QStringLiteral("A"), &list); + olive::TimelineMarker b( + 2, + olive::core::TimeRange(olive::core::Rational(7, 1), + olive::core::Rational(7, 1)), + QStringLiteral("B"), &list); + olive::TimelineMarker c( + 3, + olive::core::TimeRange(olive::core::Rational(9, 1), + olive::core::Rational(9, 1)), + QStringLiteral("C"), &list); + ASSERT_EQ(list.size(), 3); + + auto *hc = reinterpret_cast(&c); + + // Another marker sits at t=5, so c has a sibling there + EXPECT_TRUE(olive::marker_has_sibling_at_time(hc, olive::Rational(5, 1))); + + // c is the only marker at t=9; a marker is not its own sibling + EXPECT_FALSE(olive::marker_has_sibling_at_time(hc, olive::Rational(9, 1))); + + // No marker at all at t=11 + EXPECT_FALSE(olive::marker_has_sibling_at_time(hc, olive::Rational(11, 1))); +} + +TEST(WidgetMarkerPainting, HeightMatchesFontMetrics) +{ + QImage img(200, 100, QImage::Format_ARGB32); + img.fill(Qt::transparent); + QPainter p(&img); + + EXPECT_EQ(olive::MarkerPainting::height(p.fontMetrics()), + p.fontMetrics().height()); +} + +TEST(WidgetMarkerPainting, PointMarkerGeometryAndFill) +{ + QImage img(200, 100, QImage::Format_ARGB32); + img.fill(Qt::transparent); + QPainter p(&img); + + const QFontMetrics fm = p.fontMetrics(); + const int h = olive::MarkerPainting::height(fm); + const int w = olive::QtUtils::q_font_metrics_width(fm, QStringLiteral("H")); + + const QPoint pt(100, 90); + const QRect r = olive::MarkerPainting::draw(&p, pt, -1, 10.0, false, + QString(), 0, + olive::Rational(2, 1), + olive::Rational(2, 1)); + p.end(); + + // A point marker (in == out) is centered horizontally on the anchor and + // sits one marker-height above it + EXPECT_EQ(r, QRect(pt.x() - w / 2, pt.y() - h, w, h)); + + // The polygon body is filled around its center + const QColor px = img.pixelColor(QPoint(pt.x(), pt.y() - h / 2)); + EXPECT_GT(px.alpha(), 0); +} + +TEST(WidgetMarkerPainting, RangedMarkerGeometryScalesWithRange) +{ + QImage img(400, 100, QImage::Format_ARGB32); + img.fill(Qt::transparent); + QPainter p(&img); + + const QFontMetrics fm = p.fontMetrics(); + const int h = olive::MarkerPainting::height(fm); + + const QPoint pt(50, 90); + const QRect r = olive::MarkerPainting::draw(&p, pt, -1, 10.0, false, + QStringLiteral("Span"), 0, + olive::Rational(2, 1), + olive::Rational(5, 1)); + p.end(); + + // A ranged marker starts at the anchor and extends (out - in) * scale px + EXPECT_EQ(r, QRect(pt.x(), pt.y() - h, 30, h)); + + // The rect body is filled + const QColor px = img.pixelColor(QPoint(pt.x() + 2, pt.y() - h / 2)); + EXPECT_GT(px.alpha(), 0); +} + +TEST(WidgetMarkerPainting, SelectionAndLabelDoNotAffectGeometry) +{ + QImage img(400, 100, QImage::Format_ARGB32); + img.fill(Qt::transparent); + QPainter p(&img); + + const QPoint pt(100, 90); + + const QRect plain = olive::MarkerPainting::draw( + &p, pt, -1, 10.0, false, QString(), 0, olive::Rational(2, 1), + olive::Rational(2, 1)); + + // Selected + a label to the right of the marker + const QRect selected = olive::MarkerPainting::draw( + &p, pt, 300, 10.0, true, QStringLiteral("Named"), 0, + olive::Rational(2, 1), olive::Rational(2, 1)); + p.end(); + + EXPECT_EQ(plain, selected); +} diff --git a/tests/gtest/widget_keyframe_multicam_test.cpp b/tests/gtest/widget_keyframe_multicam_test.cpp new file mode 100644 index 0000000000..de0056e7d2 --- /dev/null +++ b/tests/gtest/widget_keyframe_multicam_test.cpp @@ -0,0 +1,660 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core.h" +#include "dialog/configbase/configdialogbasetab.h" +#include "dialog/otioproperties/otiopropertiesdialog.h" +#include "node/color/colormanager/colormanager.h" +#include "node/factory.h" +#include "node/input/multicam/multicamnode.h" +#include "node/keyframe.h" +#include "node/math/math/math.h" +#include "node/nodeundo.h" +#include "node/project.h" +#include "node/project/sequence/sequence.h" +#include "oakengine/node.h" +#include "render/diskmanager.h" +#include "widget/audiomonitor/audiomonitor.h" +#include "widget/keyframeview/keyframehandle.h" +#include "widget/keyframeview/keyframeview.h" +#include "widget/keyframeview/keyframeviewinputconnection.h" +#include "widget/multicam/multicamdisplay.h" +#include "widget/nodecombobox/nodecombobox.h" + +using namespace olive; + +namespace +{ + +// Keyframe views and the facade event subscriptions need the application +// singletons; create them once and leak them (same pattern as the other +// widget suites) +void ensure_app_singletons() +{ + if (!olive::Core::instance()) { + new olive::Core(); // intentionally leaked + } + if (!olive::DiskManager::instance()) { + olive::DiskManager::create_instance(); + } +} + +NodeKeyframe *insert_keyframe(Node *node, const QString &input, + const Rational &time, const QVariant &value, + int track = 0) +{ + auto *key = + new NodeKeyframe(time, value, NodeKeyframe::k_linear, track, -1, input); + NodeParamInsertKeyframeCommand(node, key).redo_now(); + return key; +} + +oak::KeyframeTrackRef track_ref(Node *n, const QString &input, int track) +{ + return oak::KeyframeTrackRef( + oak::Input(reinterpret_cast(n), input), track); +} + +OakEngineKeyframe *handle_of(NodeKeyframe *key) +{ + return reinterpret_cast(key); +} + +// Minimal concrete tab: the base class only provides a default validate() +class RecordingTab : public olive::ConfigDialogBaseTab { +public: + virtual void accept(void *parent) override + { + accepted_with = parent; + } + + void *accepted_with = nullptr; +}; + +} // namespace + +// +// keyframehandle.h: facade accessors over OakEngineKeyframe identity handles +// +class KeyframeHandleTest : public ::testing::Test { +protected: + void SetUp() override + { + ColorManager::set_up_default_config(); + ensure_app_singletons(); + + project_ = std::make_unique(); + project_->initialize(); + + node_ = new MathNode(); + node_->setParent(project_.get()); + } + + std::unique_ptr project_; + MathNode *node_ = nullptr; +}; + +TEST_F(KeyframeHandleTest, AccessorsReadEngineState) +{ + NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in, + Rational(1, 2), 2.5); + const OakEngineKeyframe *handle = handle_of(key); + + EXPECT_EQ(key_node(handle), reinterpret_cast(node_)); + EXPECT_EQ(key_time(handle), Rational(1, 2)); + EXPECT_EQ(key_easing(handle), 0); // facade order: 0 = linear + EXPECT_EQ(key_input_id(handle), MathNode::k_param_a_in); + EXPECT_EQ(key_track(handle), 0); + EXPECT_EQ(key_element(handle), -1); +} + +TEST_F(KeyframeHandleTest, ValueAsDoubleConvertsNumericTypes) +{ + NodeKeyframe *float_key = insert_keyframe(node_, MathNode::k_param_a_in, + Rational(0), 2.5); + const oak_node_value float_value = key_value(handle_of(float_key)); + EXPECT_EQ(float_value.type, OAK_NODE_VALUE_FLOAT); + EXPECT_DOUBLE_EQ(key_value_as_double(handle_of(float_key)), 2.5); + + // The base-class enabled input carries boolean keyframes + NodeKeyframe *bool_key = insert_keyframe(node_, Node::k_enabled_input, + Rational(0), true); + const oak_node_value bool_value = key_value(handle_of(bool_key)); + EXPECT_EQ(bool_value.type, OAK_NODE_VALUE_BOOL); + EXPECT_DOUBLE_EQ(key_value_as_double(handle_of(bool_key)), 1.0); + + NodeKeyframe *false_key = insert_keyframe(node_, Node::k_enabled_input, + Rational(1), false); + EXPECT_DOUBLE_EQ(key_value_as_double(handle_of(false_key)), 0.0); +} + +TEST_F(KeyframeHandleTest, LiveValueSetUpdatesFacadeReads) +{ + NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in, + Rational(0), 1.0); + + oak_node_value v; + std::memset(&v, 0, sizeof(v)); + v.type = OAK_NODE_VALUE_FLOAT; + v.f[0] = 7.25; + key_set_value_live(handle_of(key), v); + + EXPECT_DOUBLE_EQ(key_value_as_double(handle_of(key)), 7.25); + EXPECT_DOUBLE_EQ(key->value().toDouble(), 7.25); +} + +TEST_F(KeyframeHandleTest, LiveTimeSetMovesKeyframe) +{ + NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in, + Rational(0), 0.0); + + key_set_time_live(handle_of(key), Rational(2)); + EXPECT_EQ(key_time(handle_of(key)), Rational(2)); + EXPECT_EQ(key->time(), Rational(2)); + + // The selection ADL wrappers route through the same accessors + EXPECT_EQ(selection_time(handle_of(key)), Rational(2)); + selection_set_time(handle_of(key), Rational(4)); + EXPECT_EQ(key->time(), Rational(4)); +} + +TEST_F(KeyframeHandleTest, BezierPointRoundTrip) +{ + NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in, + Rational(0), 0.0); + key->set_type(NodeKeyframe::k_bezier); + ASSERT_EQ(key_easing(handle_of(key)), 1); // facade order: 1 = bezier + + key_set_bezier_point_live(handle_of(key), 0, QPointF(0.25, 0.75)); + key_set_bezier_point_live(handle_of(key), 1, QPointF(-0.5, 1.5)); + + EXPECT_EQ(key_bezier_point(handle_of(key), 0), QPointF(0.25, 0.75)); + EXPECT_EQ(key_bezier_point(handle_of(key), 1), QPointF(-0.5, 1.5)); + + // For a bezier keyframe the "valid" points are the actual points + EXPECT_EQ(key_valid_bezier_point(handle_of(key), 0), QPointF(0.25, 0.75)); + EXPECT_EQ(key_valid_bezier_point(handle_of(key), 1), QPointF(-0.5, 1.5)); +} + +TEST_F(KeyframeHandleTest, HasSiblingAtTimeExcludesSelf) +{ + // The sibling lookup goes through Node::get_keyframe_at_time_on_track(), + // which only searches tracks that are not on the standard value, so + // keyframing must be enabled on the input first + node_->set_input_is_keyframing(MathNode::k_param_a_in, true); + + NodeKeyframe *key_a = insert_keyframe(node_, MathNode::k_param_a_in, + Rational(0), 0.0); + NodeKeyframe *key_b = insert_keyframe(node_, MathNode::k_param_a_in, + Rational(1), 1.0); + + // Another keyframe sits at t=1 + EXPECT_TRUE(key_has_sibling_at_time(handle_of(key_a), Rational(1))); + // t=0 resolves to key_a itself, which does not count as a sibling + EXPECT_FALSE(key_has_sibling_at_time(handle_of(key_a), Rational(0))); + // Nothing at t=5 + EXPECT_FALSE(key_has_sibling_at_time(handle_of(key_a), Rational(5))); + + EXPECT_TRUE(selection_has_sibling_at_time(handle_of(key_b), Rational(0))); + EXPECT_EQ(selection_time_target_parent(handle_of(key_a)), + reinterpret_cast(node_)); +} + +// +// keyframeviewinputconnection.h: per-track connection between engine keyframe +// events and a KeyframeView +// +class KeyframeConnectionTest : public ::testing::Test { +protected: + void SetUp() override + { + ColorManager::set_up_default_config(); + ensure_app_singletons(); + + project_ = std::make_unique(); + project_->initialize(); + + node_ = new MathNode(); + node_->setParent(project_.get()); + } + + oak::KeyframeTrackRef param_a_ref() const + { + return track_ref(node_, MathNode::k_param_a_in, 0); + } + + std::unique_ptr project_; + MathNode *node_ = nullptr; +}; + +TEST_F(KeyframeConnectionTest, DefaultsAndAccessors) +{ + KeyframeView view; + const oak::KeyframeTrackRef ref = param_a_ref(); + KeyframeViewInputConnection connection(ref, &view); + + EXPECT_EQ(connection.get_keyframe_y(), 0); + EXPECT_EQ(connection.get_brush().color(), QColor(Qt::white)); + EXPECT_EQ(connection.get_reference(), ref); + EXPECT_TRUE(connection.get_keyframes().isEmpty()); +} + +TEST_F(KeyframeConnectionTest, SetKeyframeYEmitsOnlyOnChange) +{ + KeyframeView view; + KeyframeViewInputConnection connection(param_a_ref(), &view); + QSignalSpy spy(&connection, &KeyframeViewInputConnection::require_update); + + connection.set_keyframe_y(10); + EXPECT_EQ(connection.get_keyframe_y(), 10); + EXPECT_EQ(spy.count(), 1); + + // Same value is a no-op + connection.set_keyframe_y(10); + EXPECT_EQ(spy.count(), 1); + + connection.set_keyframe_y(-3); + EXPECT_EQ(connection.get_keyframe_y(), -3); + EXPECT_EQ(spy.count(), 2); +} + +TEST_F(KeyframeConnectionTest, SetBrushEmitsOnlyOnChange) +{ + KeyframeView view; + KeyframeViewInputConnection connection(param_a_ref(), &view); + QSignalSpy spy(&connection, &KeyframeViewInputConnection::require_update); + + // Same as the default brush: no emission + connection.set_brush(QBrush(Qt::white)); + EXPECT_EQ(spy.count(), 0); + + connection.set_brush(QBrush(Qt::red)); + EXPECT_EQ(connection.get_brush().color(), QColor(Qt::red)); + EXPECT_EQ(spy.count(), 1); +} + +TEST_F(KeyframeConnectionTest, SetYBehaviorEmitsOnlyOnChange) +{ + KeyframeView view; + KeyframeViewInputConnection connection(param_a_ref(), &view); + QSignalSpy spy(&connection, &KeyframeViewInputConnection::require_update); + + // Default behavior is k_single_row + connection.set_y_behavior(KeyframeViewInputConnection::k_single_row); + EXPECT_EQ(spy.count(), 0); + + connection.set_y_behavior(KeyframeViewInputConnection::k_value_is_height); + EXPECT_EQ(spy.count(), 1); + + connection.set_y_behavior(KeyframeViewInputConnection::k_value_is_height); + EXPECT_EQ(spy.count(), 1); +} + +TEST_F(KeyframeConnectionTest, MatchingKeyframeInsertionEmitsRequireUpdate) +{ + KeyframeView view; + KeyframeViewInputConnection connection(param_a_ref(), &view); + QSignalSpy spy(&connection, &KeyframeViewInputConnection::require_update); + + insert_keyframe(node_, MathNode::k_param_a_in, Rational(0), 0.0); + EXPECT_EQ(spy.count(), 1); + ASSERT_EQ(connection.get_keyframes().size(), 1); + + // A keyframe on a different input of the same node does not match + insert_keyframe(node_, MathNode::k_param_b_in, Rational(0), 0.0); + EXPECT_EQ(spy.count(), 1); + EXPECT_EQ(connection.get_keyframes().size(), 1); +} + +TEST_F(KeyframeConnectionTest, LiveValueAndTimeChangesEmitRequireUpdate) +{ + NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in, + Rational(0), 0.0); + + KeyframeView view; + KeyframeViewInputConnection connection(param_a_ref(), &view); + QSignalSpy spy(&connection, &KeyframeViewInputConnection::require_update); + + oak_node_value v; + std::memset(&v, 0, sizeof(v)); + v.type = OAK_NODE_VALUE_FLOAT; + v.f[0] = 3.0; + key_set_value_live(handle_of(key), v); + EXPECT_EQ(spy.count(), 1); + + key_set_time_live(handle_of(key), Rational(3)); + EXPECT_EQ(spy.count(), 2); +} + +TEST_F(KeyframeConnectionTest, TypeChangeEmitsTypeChangedAndRequireUpdate) +{ + // Node::invalidate_from_keyframe_type_changed() returns early when the + // track holds a single keyframe (interpolation is a no-op), so the type + // change signal only reaches the connection with two or more keyframes + NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in, + Rational(0), 0.0); + insert_keyframe(node_, MathNode::k_param_a_in, Rational(1), 1.0); + + KeyframeView view; + KeyframeViewInputConnection connection(param_a_ref(), &view); + QSignalSpy update_spy(&connection, + &KeyframeViewInputConnection::require_update); + QSignalSpy type_spy(&connection, + &KeyframeViewInputConnection::type_changed); + + key->set_type(NodeKeyframe::k_hold); + EXPECT_EQ(update_spy.count(), 1); + EXPECT_EQ(type_spy.count(), 1); + + // Setting the same type again emits nothing + key->set_type(NodeKeyframe::k_hold); + EXPECT_EQ(update_spy.count(), 1); + EXPECT_EQ(type_spy.count(), 1); +} + +TEST_F(KeyframeConnectionTest, RemovalEmitsRequireUpdate) +{ + NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in, + Rational(0), 0.0); + + KeyframeView view; + KeyframeViewInputConnection connection(param_a_ref(), &view); + QSignalSpy spy(&connection, &KeyframeViewInputConnection::require_update); + + NodeParamRemoveKeyframeCommand(key).redo_now(); + EXPECT_EQ(spy.count(), 1); + EXPECT_TRUE(connection.get_keyframes().isEmpty()); +} + +// +// multicamdisplay.h: the shader/paint paths need a GL renderer, but the +// widget itself constructs offscreen and its node contract is facade-readable +// +TEST(WidgetMulticamDisplay, ConstructsWithoutNode) +{ + ensure_app_singletons(); + + MulticamDisplay display; + EXPECT_FALSE(display.isVisible()); + + // Accepts (and clears) a null node without touching the paint path + display.set_multicam_node(nullptr); +} + +TEST(WidgetMulticamDisplay, MulticamNodeStateMatchesPaintContract) +{ + ensure_app_singletons(); + + MulticamDisplay display; + + MultiCamNode node; + node.input_array_resize(MultiCamNode::k_sources_input, 3); + node.set_standard_value(MultiCamNode::k_current_input, 1); + + auto *handle = reinterpret_cast(&node); + display.set_multicam_node(handle); + + // on_paint() lays the highlight rect out from exactly these facade reads + EXPECT_EQ(oakengine_multicam_get_source_count(handle), 3); + EXPECT_EQ(oakengine_multicam_get_current_source(handle), 1); + + int rows = 0, cols = 0; + oakengine_multicam_get_rows_and_columns(3, &rows, &cols); + EXPECT_EQ(rows, 2); + EXPECT_EQ(cols, 2); + + int row = -1, col = -1; + oakengine_multicam_index_to_row_cols(1, rows, cols, &row, &col); + EXPECT_EQ(row, 0); + EXPECT_EQ(col, 1); + + display.set_multicam_node(nullptr); +} + +// +// nodecombobox.h: the popup path is modal; the retranslation and factory-name +// paths are not (selection round-trips are covered in widget_combos_test.cpp) +// +TEST(WidgetNodeComboBox, LanguageChangeRetranslatesSelection) +{ + NodeFactory::initialize(); + + { + NodeComboBox combo; + const QString id = QStringLiteral("org.olivevideoeditor.Olive.math"); + combo.set_node(id); + ASSERT_EQ(combo.count(), 1); + const QString before = combo.itemText(0); + EXPECT_EQ(before, NodeFactory::get_name_from_id(id)); + ASSERT_FALSE(before.isEmpty()); + + QEvent lang(QEvent::LanguageChange); + QApplication::sendEvent(&combo, &lang); + + // The selection survives and the label is re-fetched from the factory + EXPECT_EQ(combo.get_selected_node(), id); + ASSERT_EQ(combo.count(), 1); + EXPECT_EQ(combo.itemText(0), before); + } + + NodeFactory::destroy(); +} + +TEST(WidgetNodeComboBox, UnknownIdProducesEmptyText) +{ + NodeFactory::initialize(); + + { + NodeComboBox combo; + combo.set_node(QStringLiteral("org.example.bogus")); + + // The id is stored even though the factory has no name for it + EXPECT_EQ(combo.get_selected_node(), QStringLiteral("org.example.bogus")); + ASSERT_EQ(combo.count(), 1); + EXPECT_TRUE(combo.itemText(0).isEmpty()); + } + + NodeFactory::destroy(); +} + +// +// audiomonitor.h: painting needs a GL context; the playback state machine, +// parameter handling and the static instance broadcasters do not +// +TEST(WidgetAudioMonitor, ConstructionDefaults) +{ + AudioMonitor monitor; + + EXPECT_FALSE(monitor.is_playing()); + // The ctor sizes the minimum width to the font height + EXPECT_EQ(monitor.minimumWidth(), monitor.fontMetrics().height()); +} + +TEST(WidgetAudioMonitor, StartWaveformWithNullCacheStaysStopped) +{ + AudioMonitor monitor; + + // A null cache reports sample rate 0, so the start request is rejected + monitor.start_waveform(nullptr, Rational(0), 1); + EXPECT_FALSE(monitor.is_playing()); + + monitor.stop(); + EXPECT_FALSE(monitor.is_playing()); +} + +TEST(WidgetAudioMonitor, PushSampleBufferWithoutParamsIsIgnored) +{ + AudioMonitor monitor; + + // No params set -> channel_count() is 0 -> early return + monitor.push_sample_buffer(SampleBuffer()); + EXPECT_FALSE(monitor.is_playing()); +} + +TEST(WidgetAudioMonitor, PushSampleBufferWithParamsDoesNotStartPlayback) +{ + const olive::core::AudioParams params(48000, + olive::core::k_channel_layout_stereo, + olive::core::SampleFormat::f32_p); + + AudioMonitor monitor; + monitor.set_params(params); + + SampleBuffer buffer(params, size_t(64)); + ASSERT_TRUE(buffer.is_allocated()); + float *left = buffer.data(0); + ASSERT_NE(left, nullptr); + for (int i = 0; i < 64; i++) { + left[i] = 0.5f; + } + + // Level analysis runs, but sample playback is not waveform playback + monitor.push_sample_buffer(buffer); + EXPECT_FALSE(monitor.is_playing()); + + monitor.stop(); + EXPECT_FALSE(monitor.is_playing()); +} + +TEST(WidgetAudioMonitor, StaticBroadcastsReachAllInstances) +{ + const olive::core::AudioParams params(48000, + olive::core::k_channel_layout_stereo, + olive::core::SampleFormat::f32_p); + + AudioMonitor first; + AudioMonitor second; + first.set_params(params); + second.set_params(params); + + // A null waveform is rejected on every registered instance + AudioMonitor::start_waveform_on_all(nullptr, Rational(0), 1); + EXPECT_FALSE(first.is_playing()); + EXPECT_FALSE(second.is_playing()); + + SampleBuffer buffer(params, size_t(32)); + AudioMonitor::push_sample_buffer_on_all(buffer); + EXPECT_FALSE(first.is_playing()); + EXPECT_FALSE(second.is_playing()); + + AudioMonitor::stop_on_all(); + EXPECT_FALSE(first.is_playing()); + EXPECT_FALSE(second.is_playing()); +} + +// +// configdialogbasetab.h +// +TEST(DialogConfigBaseTab, DefaultValidateAccepts) +{ + RecordingTab tab; + EXPECT_TRUE(tab.validate()); +} + +TEST(DialogConfigBaseTab, AcceptReceivesParentPointer) +{ + RecordingTab tab; + int parent_token = 0; + + tab.accept(&parent_token); + EXPECT_EQ(tab.accepted_with, static_cast(&parent_token)); +} + +// +// otiopropertiesdialog.h: the per-sequence Settings button opens a modal +// SequenceDialog (not exercised); construction, listing and OK/Cancel are not +// modal +// +TEST(DialogOTIOProperties, ListsSequencesWithIndexedSettingsButtons) +{ + ColorManager::set_up_default_config(); + + Project project; + project.initialize(); + + auto *seq_a = new Sequence(); + seq_a->setParent(&project); + seq_a->set_label(QStringLiteral("Alpha")); + auto *seq_b = new Sequence(); + seq_b->setParent(&project); + seq_b->set_label(QStringLiteral("Beta")); + + const QList sequences = { + reinterpret_cast(seq_a), + reinterpret_cast(seq_b), + }; + + OTIOPropertiesDialog dialog( + sequences, reinterpret_cast(&project)); + + EXPECT_EQ(dialog.windowTitle(), QStringLiteral("Load OpenTimelineIO Project")); + + auto *table = dialog.findChild(); + ASSERT_NE(table, nullptr); + EXPECT_EQ(table->columnCount(), 2); + EXPECT_EQ(table->headerItem()->text(0), QStringLiteral("Sequence")); + EXPECT_EQ(table->headerItem()->text(1), QStringLiteral("Actions")); + EXPECT_FALSE(table->rootIsDecorated()); + + ASSERT_EQ(table->topLevelItemCount(), 2); + EXPECT_EQ(table->topLevelItem(0)->text(0), QStringLiteral("Alpha")); + EXPECT_EQ(table->topLevelItem(1)->text(0), QStringLiteral("Beta")); + + // Each row carries a Settings button whose "index" property is its row + for (int i = 0; i < 2; i++) { + QWidget *actions = table->itemWidget(table->topLevelItem(i), 1); + ASSERT_NE(actions, nullptr) << "row " << i; + auto *button = actions->findChild(); + ASSERT_NE(button, nullptr) << "row " << i; + EXPECT_EQ(button->text(), QStringLiteral("Settings")); + EXPECT_EQ(button->property("index").toInt(), i); + } +} + +TEST(DialogOTIOProperties, EmptyListAndButtonBoxResults) +{ + ColorManager::set_up_default_config(); + + Project project; + project.initialize(); + + { + OTIOPropertiesDialog dialog( + {}, reinterpret_cast(&project)); + + auto *table = dialog.findChild(); + ASSERT_NE(table, nullptr); + EXPECT_EQ(table->topLevelItemCount(), 0); + + auto *buttons = dialog.findChild(); + ASSERT_NE(buttons, nullptr); + QPushButton *ok = buttons->button(QDialogButtonBox::Ok); + ASSERT_NE(ok, nullptr); + ok->click(); + EXPECT_EQ(dialog.result(), QDialog::Accepted); + } + + { + OTIOPropertiesDialog dialog( + {}, reinterpret_cast(&project)); + + auto *buttons = dialog.findChild(); + ASSERT_NE(buttons, nullptr); + QPushButton *cancel = buttons->button(QDialogButtonBox::Cancel); + ASSERT_NE(cancel, nullptr); + cancel->click(); + EXPECT_EQ(dialog.result(), QDialog::Rejected); + } +} diff --git a/tests/gtest/widget_nodeparamview_test.cpp b/tests/gtest/widget_nodeparamview_test.cpp new file mode 100644 index 0000000000..9c0cb3b68e --- /dev/null +++ b/tests/gtest/widget_nodeparamview_test.cpp @@ -0,0 +1,846 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core.h" +#include "node/color/colormanager/colormanager.h" +#include "node/generator/text/textv3.h" +#include "node/globals.h" +#include "node/math/math/math.h" +#include "node/project.h" +#include "node/project/folder/folder.h" +#include "oakengine/app.h" +#include "undo/undostack.h" +#include "widget/clickablelabel/clickablelabel.h" +#include "widget/collapsebutton/collapsebutton.h" +#include "widget/nodeparamview/nodeparambutton.h" +#include "widget/nodeparamview/nodeparamviewarraywidget.h" +#include "widget/nodeparamview/nodeparamviewconnectedlabel.h" +#include "widget/nodeparamview/nodeparamviewcontext.h" +#include "widget/nodeparamview/nodeparamviewitem.h" +#include "widget/nodeparamview/nodeparamviewitemtitlebar.h" +#include "widget/nodeparamview/nodeparamviewkeyframecontrol.h" +#include "widget/nodeparamview/nodeparamviewtextedit.h" + +namespace +{ + +// Widgets that connect to Core::instance() at construction require the +// application singleton, but not a MainWindow +void ensure_core() +{ + if (!olive::Core::instance()) { + new olive::Core(); // intentionally leaked + } +} + +// Helper: wrap an engine Node* as oak::Node for the C ABI widget interface +inline oak::Node to_oak(olive::Node *n) +{ + return oak::Node(reinterpret_cast(n)); +} + +// Helper: build an oak::Input value from an engine Node* + input id +inline oak::Input to_oak_input(olive::Node *n, const QString &input, + int element = -1) +{ + return oak::Input(reinterpret_cast(n), input, element); +} + +// The process-wide undo stack previously reached via Core::undo_stack(); +// facade calls below (array insert/remove, keyframing, edge disconnect) push +// commands onto it, so it must be cleared before the owning Project dies +inline olive::UndoStack *app_undo_stack() +{ + return static_cast(oakengine_app_undo_stack()); +} + +// Minimal node with a plain float output so it can feed MathNode's +// parameter inputs in connection tests +class FloatSourceNode : public olive::Node { +public: + FloatSourceNode() = default; + + NODE_DEFAULT_FUNCTIONS(FloatSourceNode) + + virtual QString name() const override + { + return QStringLiteral("Test Float Source"); + } + + virtual QString id() const override + { + return QStringLiteral("org.oak.test.floatsource"); + } + + virtual QVector category() const override + { + return { k_category_math }; + } + + virtual void value(const olive::NodeValueRow &value, + const olive::NodeGlobals &globals, + olive::NodeValueTable *table) const override + { + Q_UNUSED(value) + Q_UNUSED(globals) + + table->push(olive::NodeValue::k_float, QVariant(1.5), this); + } +}; + +// Sends a mouse event straight to the widget, bypassing child hit-testing +// and cursor tracking (deterministic under the offscreen QPA) +void send_mouse_event(QWidget *w, QEvent::Type type, const QPointF &pos) +{ + QMouseEvent ev(type, pos, pos, pos, Qt::LeftButton, Qt::LeftButton, + Qt::NoModifier); + QApplication::sendEvent(w, &ev); +} + +} // namespace + +// --------------------------------------------------------------------------- +// NodeParamButton (global namespace, plain QPushButton with a stored name) +// --------------------------------------------------------------------------- + +TEST(WidgetNodeParamButton, ClickEmitsStoredName) +{ + NodeParamButton btn(QStringLiteral("volume")); + + // The name is stored separately; it is not used as the button text + EXPECT_TRUE(btn.text().isEmpty()); + + QSignalSpy spy(&btn, &NodeParamButton::on_pressed); + btn.click(); + + ASSERT_EQ(spy.count(), 1); + EXPECT_EQ(spy.first().first().toString(), QStringLiteral("volume")); +} + +TEST(WidgetNodeParamButton, InstancesEmitTheirOwnNames) +{ + NodeParamButton a(QStringLiteral("alpha")); + NodeParamButton b(QStringLiteral("beta")); + + QStringList received; + QObject::connect(&a, &NodeParamButton::on_pressed, + [&received](const QString &n) { received.append(n); }); + QObject::connect(&b, &NodeParamButton::on_pressed, + [&received](const QString &n) { received.append(n); }); + + b.click(); + a.click(); + b.click(); + + ASSERT_EQ(received.size(), 3); + EXPECT_EQ(received.at(0), QStringLiteral("beta")); + EXPECT_EQ(received.at(1), QStringLiteral("alpha")); + EXPECT_EQ(received.at(2), QStringLiteral("beta")); +} + +// --------------------------------------------------------------------------- +// NodeParamViewItemTitleBar +// --------------------------------------------------------------------------- + +TEST(WidgetNodeParamViewItemTitleBar, DefaultsAreExpandedWithHiddenButtons) +{ + olive::NodeParamViewItemTitleBar bar; + + // CollapseButton defaults to checked, so the bar starts expanded + EXPECT_TRUE(bar.is_expanded()); + + auto *collapse = bar.findChild(); + ASSERT_NE(collapse, nullptr); + EXPECT_TRUE(collapse->isChecked()); + EXPECT_TRUE(collapse->isVisibleTo(&bar)); + + // Pin / add-effect / enabled-checkbox all start hidden + QPushButton *pin = nullptr; + QPushButton *add_fx = nullptr; + for (QPushButton *b : bar.findChildren()) { + if (b == collapse) { + continue; + } + if (b->text() == QStringLiteral("P")) { + pin = b; + } else { + add_fx = b; + } + } + ASSERT_NE(pin, nullptr); + ASSERT_NE(add_fx, nullptr); + EXPECT_TRUE(pin->isCheckable()); + EXPECT_FALSE(pin->isVisibleTo(&bar)); + EXPECT_FALSE(add_fx->isVisibleTo(&bar)); + + auto *checkbox = bar.findChild(); + ASSERT_NE(checkbox, nullptr); + EXPECT_FALSE(checkbox->isVisibleTo(&bar)); +} + +TEST(WidgetNodeParamViewItemTitleBar, SetExpandedRoundTripsWithoutSignal) +{ + olive::NodeParamViewItemTitleBar bar; + QSignalSpy spy(&bar, + &olive::NodeParamViewItemTitleBar::expanded_state_changed); + + bar.set_expanded(false); + EXPECT_FALSE(bar.is_expanded()); + + bar.set_expanded(true); + EXPECT_TRUE(bar.is_expanded()); + + // Programmatic expansion changes do not count as user interaction + EXPECT_EQ(spy.count(), 0); +} + +TEST(WidgetNodeParamViewItemTitleBar, SetTextUpdatesLabelAndTooltip) +{ + olive::NodeParamViewItemTitleBar bar; + bar.set_text(QStringLiteral("My Node")); + + auto *lbl = bar.findChild(); + ASSERT_NE(lbl, nullptr); + EXPECT_EQ(lbl->text(), QStringLiteral("My Node")); + EXPECT_EQ(lbl->toolTip(), QStringLiteral("My Node")); +} + +TEST(WidgetNodeParamViewItemTitleBar, VisibilitySettersToggleChildren) +{ + olive::NodeParamViewItemTitleBar bar; + + QPushButton *pin = nullptr; + QPushButton *add_fx = nullptr; + for (QPushButton *b : bar.findChildren()) { + if (qobject_cast(b)) { + continue; + } + if (b->text() == QStringLiteral("P")) { + pin = b; + } else { + add_fx = b; + } + } + auto *checkbox = bar.findChild(); + ASSERT_NE(pin, nullptr); + ASSERT_NE(add_fx, nullptr); + ASSERT_NE(checkbox, nullptr); + + bar.set_pin_button_visible(true); + EXPECT_TRUE(pin->isVisibleTo(&bar)); + EXPECT_FALSE(add_fx->isVisibleTo(&bar)); + + bar.set_add_effect_button_visible(true); + EXPECT_TRUE(add_fx->isVisibleTo(&bar)); + + bar.set_enabled_check_box_visible(true); + EXPECT_TRUE(checkbox->isVisibleTo(&bar)); + + bar.set_pin_button_visible(false); + EXPECT_FALSE(pin->isVisibleTo(&bar)); +} + +TEST(WidgetNodeParamViewItemTitleBar, EnabledCheckBoxCheckedRoundTrips) +{ + olive::NodeParamViewItemTitleBar bar; + auto *checkbox = bar.findChild(); + ASSERT_NE(checkbox, nullptr); + EXPECT_FALSE(checkbox->isChecked()); + + bar.set_enabled_check_box_checked(true); + EXPECT_TRUE(checkbox->isChecked()); + + bar.set_enabled_check_box_checked(false); + EXPECT_FALSE(checkbox->isChecked()); +} + +TEST(WidgetNodeParamViewItemTitleBar, CollapseClickEmitsExpandedState) +{ + olive::NodeParamViewItemTitleBar bar; + auto *collapse = bar.findChild(); + ASSERT_NE(collapse, nullptr); + + QSignalSpy spy(&bar, + &olive::NodeParamViewItemTitleBar::expanded_state_changed); + + // Starts expanded; clicking collapses + collapse->click(); + ASSERT_EQ(spy.count(), 1); + EXPECT_FALSE(spy.first().first().toBool()); + + collapse->click(); + ASSERT_EQ(spy.count(), 2); + EXPECT_TRUE(spy.at(1).first().toBool()); +} + +TEST(WidgetNodeParamViewItemTitleBar, ButtonsForwardTheirSignals) +{ + olive::NodeParamViewItemTitleBar bar; + + QPushButton *pin = nullptr; + QPushButton *add_fx = nullptr; + for (QPushButton *b : bar.findChildren()) { + if (qobject_cast(b)) { + continue; + } + if (b->text() == QStringLiteral("P")) { + pin = b; + } else { + add_fx = b; + } + } + auto *checkbox = bar.findChild(); + ASSERT_NE(pin, nullptr); + ASSERT_NE(add_fx, nullptr); + ASSERT_NE(checkbox, nullptr); + + QSignalSpy pin_spy(&bar, &olive::NodeParamViewItemTitleBar::pin_toggled); + QSignalSpy add_spy( + &bar, &olive::NodeParamViewItemTitleBar::add_effect_button_clicked); + QSignalSpy enabled_spy( + &bar, &olive::NodeParamViewItemTitleBar::enabled_check_box_clicked); + + pin->click(); + ASSERT_EQ(pin_spy.count(), 1); + EXPECT_TRUE(pin_spy.first().first().toBool()); + + add_fx->click(); + EXPECT_EQ(add_spy.count(), 1); + + checkbox->click(); + ASSERT_EQ(enabled_spy.count(), 1); + EXPECT_TRUE(enabled_spy.first().first().toBool()); +} + +TEST(WidgetNodeParamViewItemTitleBar, MousePressEmitsClicked) +{ + olive::NodeParamViewItemTitleBar bar; + QSignalSpy spy(&bar, &olive::NodeParamViewItemTitleBar::clicked); + + send_mouse_event(&bar, QEvent::MouseButtonPress, QPointF(5, 5)); + EXPECT_EQ(spy.count(), 1); +} + +TEST(WidgetNodeParamViewItemTitleBar, DoubleClickTogglesExpansion) +{ + olive::NodeParamViewItemTitleBar bar; + ASSERT_TRUE(bar.is_expanded()); + + QSignalSpy spy(&bar, + &olive::NodeParamViewItemTitleBar::expanded_state_changed); + + send_mouse_event(&bar, QEvent::MouseButtonDblClick, QPointF(5, 5)); + EXPECT_FALSE(bar.is_expanded()); + ASSERT_EQ(spy.count(), 1); + EXPECT_FALSE(spy.first().first().toBool()); + + send_mouse_event(&bar, QEvent::MouseButtonDblClick, QPointF(5, 5)); + EXPECT_TRUE(bar.is_expanded()); + EXPECT_EQ(spy.count(), 2); +} + +// --------------------------------------------------------------------------- +// NodeParamViewTextEdit +// --------------------------------------------------------------------------- + +TEST(WidgetNodeParamViewTextEdit, SetTextRoundTripsWithoutSignal) +{ + olive::NodeParamViewTextEdit w; + QSignalSpy spy(&w, &olive::NodeParamViewTextEdit::text_edited); + + w.setText(QStringLiteral("hello")); + EXPECT_EQ(w.text(), QStringLiteral("hello")); + + // Programmatic changes don't count as user edits + EXPECT_EQ(spy.count(), 0); +} + +TEST(WidgetNodeParamViewTextEdit, TypingEmitsTextEdited) +{ + olive::NodeParamViewTextEdit w; + auto *edit = w.findChild(); + ASSERT_NE(edit, nullptr); + + QSignalSpy spy(&w, &olive::NodeParamViewTextEdit::text_edited); + QTest::keyClicks(edit, QStringLiteral("hi")); + + ASSERT_EQ(spy.count(), 2); + EXPECT_EQ(spy.first().first().toString(), QStringLiteral("h")); + EXPECT_EQ(spy.at(1).first().toString(), QStringLiteral("hi")); + EXPECT_EQ(w.text(), QStringLiteral("hi")); +} + +TEST(WidgetNodeParamViewTextEdit, SetTextPreservingCursorKeepsPosition) +{ + olive::NodeParamViewTextEdit w; + auto *edit = w.findChild(); + ASSERT_NE(edit, nullptr); + + w.setText(QStringLiteral("hello")); + QTextCursor c = edit->textCursor(); + c.setPosition(5); + edit->setTextCursor(c); + + w.setTextPreservingCursor(QStringLiteral("hello world")); + + EXPECT_EQ(w.text(), QStringLiteral("hello world")); + EXPECT_EQ(edit->textCursor().position(), 5); +} + +TEST(WidgetNodeParamViewTextEdit, ViewerOnlyModeSwapsVisibleChildren) +{ + olive::NodeParamViewTextEdit w; + auto *edit = w.findChild(); + ASSERT_NE(edit, nullptr); + + QPushButton *viewer_btn = nullptr; + QPushButton *edit_btn = nullptr; + for (QPushButton *b : w.findChildren()) { + if (b->text() == QStringLiteral("Edit In Viewer")) { + viewer_btn = b; + } else { + edit_btn = b; + } + } + ASSERT_NE(viewer_btn, nullptr); + ASSERT_NE(edit_btn, nullptr); + + // Default mode: plain-text edit + dialog button, no viewer button + EXPECT_TRUE(edit->isVisibleTo(&w)); + EXPECT_TRUE(edit_btn->isVisibleTo(&w)); + EXPECT_FALSE(viewer_btn->isVisibleTo(&w)); + + w.set_edit_in_viewer_only_mode(true); + EXPECT_FALSE(edit->isVisibleTo(&w)); + EXPECT_FALSE(edit_btn->isVisibleTo(&w)); + EXPECT_TRUE(viewer_btn->isVisibleTo(&w)); + + w.set_edit_in_viewer_only_mode(false); + EXPECT_TRUE(edit->isVisibleTo(&w)); + EXPECT_TRUE(edit_btn->isVisibleTo(&w)); + EXPECT_FALSE(viewer_btn->isVisibleTo(&w)); +} + +TEST(WidgetNodeParamViewTextEdit, EditInViewerButtonEmitsRequest) +{ + olive::NodeParamViewTextEdit w; + + QPushButton *viewer_btn = nullptr; + for (QPushButton *b : w.findChildren()) { + if (b->text() == QStringLiteral("Edit In Viewer")) { + viewer_btn = b; + break; + } + } + ASSERT_NE(viewer_btn, nullptr); + + QSignalSpy spy(&w, &olive::NodeParamViewTextEdit::request_edit_in_viewer); + viewer_btn->click(); + EXPECT_EQ(spy.count(), 1); +} + +// NOTE: the other push button opens a modal TextDialog (exec()); not +// testable under the offscreen QPA. + +// --------------------------------------------------------------------------- +// Shared fixture for the engine-backed widgets +// --------------------------------------------------------------------------- + +class NodeParamViewTest : public ::testing::Test { +protected: + void SetUp() override + { + olive::ColorManager::set_up_default_config(); + ensure_core(); + + project_ = std::make_unique(); + project_->initialize(); + } + + void TearDown() override + { + // Undo commands pushed by facade calls reference project nodes + app_undo_stack()->clear(); + project_.reset(); + } + + template T *add_node() + { + auto *node = new T(); + node->setParent(project_.get()); + return node; + } + + std::unique_ptr project_; +}; + +// --------------------------------------------------------------------------- +// NodeParamViewConnectedLabel +// --------------------------------------------------------------------------- + +TEST_F(NodeParamViewTest, ConnectedLabelDisconnectedInputShowsNothing) +{ + auto *math = add_node(); + + olive::NodeParamViewConnectedLabel w( + to_oak_input(math, olive::MathNode::k_param_a_in)); + + auto *lbl = w.findChild(); + ASSERT_NE(lbl, nullptr); + EXPECT_EQ(lbl->text(), QStringLiteral("Nothing")); +} + +TEST_F(NodeParamViewTest, ConnectedLabelShowsSourceNameWhenConnected) +{ + auto *source = add_node(); + auto *math = add_node(); + + olive::Node::connect_edge( + source, olive::NodeInput(math, olive::MathNode::k_param_a_in)); + + olive::NodeParamViewConnectedLabel w( + to_oak_input(math, olive::MathNode::k_param_a_in)); + + auto *lbl = w.findChild(); + ASSERT_NE(lbl, nullptr); + EXPECT_EQ(lbl->text(), QStringLiteral("Test Float Source")); +} + +TEST_F(NodeParamViewTest, ConnectedLabelTracksLiveConnectAndDisconnect) +{ + auto *source = add_node(); + auto *math = add_node(); + + olive::NodeParamViewConnectedLabel w( + to_oak_input(math, olive::MathNode::k_param_a_in)); + auto *lbl = w.findChild(); + ASSERT_NE(lbl, nullptr); + EXPECT_EQ(lbl->text(), QStringLiteral("Nothing")); + + // The widget subscribes to the engine's edge events via its bridge + OakEngineNode *src_handle = reinterpret_cast(source); + OakEngineNode *math_handle = reinterpret_cast(math); + const QByteArray input_id = olive::MathNode::k_param_a_in.toUtf8(); + + ASSERT_EQ(oakengine_node_connect(src_handle, math_handle, + input_id.constData()), + OAKENGINE_OK); + EXPECT_EQ(lbl->text(), QStringLiteral("Test Float Source")); + + ASSERT_EQ(oakengine_node_disconnect(math_handle, input_id.constData()), + OAKENGINE_OK); + EXPECT_EQ(lbl->text(), QStringLiteral("Nothing")); +} + +TEST_F(NodeParamViewTest, ConnectedLabelClickRequestsSourceSelection) +{ + auto *source = add_node(); + auto *math = add_node(); + + olive::Node::connect_edge( + source, olive::NodeInput(math, olive::MathNode::k_param_a_in)); + + olive::NodeParamViewConnectedLabel w( + to_oak_input(math, olive::MathNode::k_param_a_in)); + w.resize(400, 40); + w.show(); + EXPECT_TRUE(QTest::qWaitForWindowExposed(&w)); + + auto *lbl = w.findChild(); + ASSERT_NE(lbl, nullptr); + + // ClickableLabel only emits when the cursor is over it; if the + // offscreen platform can't track the cursor there's nothing to assert + QTest::mouseMove(lbl, QPoint(5, 5)); + if (!lbl->underMouse()) { + GTEST_SKIP() << "Platform does not track cursor position"; + } + + OakEngineNode *received = nullptr; + QObject::connect(&w, &olive::NodeParamViewConnectedLabel::request_select_node, + [&received](OakEngineNode *n) { received = n; }); + + QTest::mouseClick(lbl, Qt::LeftButton); + EXPECT_EQ(received, reinterpret_cast(source)); +} + +// --------------------------------------------------------------------------- +// NodeParamViewKeyframeControl +// --------------------------------------------------------------------------- + +TEST_F(NodeParamViewTest, KeyframeControlInvalidInputDisablesEverything) +{ + olive::NodeParamViewKeyframeControl control; + EXPECT_FALSE(control.get_connected_input().is_valid()); + + // Both constructor forms build the same four buttons + olive::NodeParamViewKeyframeControl left_aligned(false, nullptr); + for (QWidget *c : { static_cast(&control), + static_cast(&left_aligned) }) { + const auto buttons = c->findChildren(); + ASSERT_EQ(buttons.size(), 4); + + int checkable_count = 0; + for (QPushButton *b : buttons) { + EXPECT_FALSE(b->isEnabled()); + if (b->isCheckable()) { + checkable_count++; + } + } + // toggle + enable are checkable, prev/next are not + EXPECT_EQ(checkable_count, 2); + } + + // With keyframing off only the enable button is shown + int visible_count = 0; + for (QPushButton *b : control.findChildren()) { + if (b->isVisibleTo(&control)) { + visible_count++; + EXPECT_TRUE(b->isCheckable()); + EXPECT_FALSE(b->isChecked()); + } + } + EXPECT_EQ(visible_count, 1); +} + +TEST_F(NodeParamViewTest, KeyframeControlSetInputEnablesButtons) +{ + auto *math = add_node(); + + olive::NodeParamViewKeyframeControl control; + control.set_input(to_oak_input(math, olive::MathNode::k_param_a_in)); + + EXPECT_TRUE(control.get_connected_input().is_valid()); + EXPECT_EQ(control.get_connected_input().input_id(), + olive::MathNode::k_param_a_in); + + const auto buttons = control.findChildren(); + ASSERT_EQ(buttons.size(), 4); + for (QPushButton *b : buttons) { + EXPECT_TRUE(b->isEnabled()); + } + + // Input is not keyframing yet: nav buttons stay hidden, enable unchecked + for (QPushButton *b : buttons) { + if (b->isCheckable() && b->isVisibleTo(&control)) { + EXPECT_FALSE(b->isChecked()); // the enable button + } else { + EXPECT_FALSE(b->isVisibleTo(&control)); // prev/toggle/next + } + } +} + +TEST_F(NodeParamViewTest, KeyframeControlReflectsKeyframingChanges) +{ + auto *math = add_node(); + + olive::NodeParamViewKeyframeControl control; + control.set_input(to_oak_input(math, olive::MathNode::k_param_a_in)); + + // Identify the enable button while it is the only visible checkable one + QPushButton *enable_btn = nullptr; + for (QPushButton *b : control.findChildren()) { + if (b->isCheckable() && b->isVisibleTo(&control)) { + enable_btn = b; + break; + } + } + ASSERT_NE(enable_btn, nullptr); + EXPECT_FALSE(enable_btn->isChecked()); + + OakEngineNode *handle = reinterpret_cast(math); + const QByteArray input_id = olive::MathNode::k_param_a_in.toUtf8(); + + // Enabling keyframing through the facade fires the bridge event, which + // checks the enable button and reveals the navigation buttons + ASSERT_EQ(oakengine_node_set_input_keyframing(handle, input_id.constData(), + -1, 1, 0, 1, nullptr), + OAKENGINE_OK); + EXPECT_TRUE(enable_btn->isChecked()); + for (QPushButton *b : control.findChildren()) { + EXPECT_TRUE(b->isVisibleTo(&control)); + } + + // Disabling hides the navigation buttons again + ASSERT_EQ(oakengine_node_set_input_keyframing(handle, input_id.constData(), + -1, 0, 0, 1, nullptr), + OAKENGINE_OK); + EXPECT_FALSE(enable_btn->isChecked()); + for (QPushButton *b : control.findChildren()) { + EXPECT_EQ(b->isVisibleTo(&control), b == enable_btn); + } +} + +// --------------------------------------------------------------------------- +// NodeParamViewArrayWidget / NodeParamViewArrayButton +// --------------------------------------------------------------------------- + +TEST(WidgetNodeParamViewArrayButton, TypeDeterminesText) +{ + olive::NodeParamViewArrayButton add(olive::NodeParamViewArrayButton::k_add); + EXPECT_EQ(add.text(), QStringLiteral("+")); + + olive::NodeParamViewArrayButton remove( + olive::NodeParamViewArrayButton::k_remove); + EXPECT_EQ(remove.text(), QStringLiteral("-")); +} + +TEST_F(NodeParamViewTest, ArrayWidgetCounterTracksArraySize) +{ + auto *text = add_node(); + + olive::NodeParamViewArrayWidget w(to_oak(text), + olive::TextGeneratorV3::k_args_input); + + auto *lbl = w.findChild(); + ASSERT_NE(lbl, nullptr); + EXPECT_EQ(lbl->text(), QStringLiteral("0 element(s)")); + + OakEngineNode *handle = reinterpret_cast(text); + const QByteArray input_id = olive::TextGeneratorV3::k_args_input.toUtf8(); + + // The widget subscribes to the engine's array-size event via its bridge + ASSERT_EQ(oakengine_node_array_insert_at(handle, input_id.constData(), 0), + OAKENGINE_OK); + EXPECT_EQ(lbl->text(), QStringLiteral("1 element(s)")); + + ASSERT_EQ(oakengine_node_array_insert_at(handle, input_id.constData(), 1), + OAKENGINE_OK); + EXPECT_EQ(lbl->text(), QStringLiteral("2 element(s)")); + + ASSERT_EQ(oakengine_node_array_remove_at(handle, input_id.constData(), 0), + OAKENGINE_OK); + EXPECT_EQ(lbl->text(), QStringLiteral("1 element(s)")); +} + +TEST_F(NodeParamViewTest, ArrayWidgetDoubleClickEmitsSignal) +{ + auto *text = add_node(); + + olive::NodeParamViewArrayWidget w(to_oak(text), + olive::TextGeneratorV3::k_args_input); + + QSignalSpy spy(&w, &olive::NodeParamViewArrayWidget::double_clicked); + send_mouse_event(&w, QEvent::MouseButtonDblClick, QPointF(2, 2)); + EXPECT_EQ(spy.count(), 1); +} + +// --------------------------------------------------------------------------- +// NodeParamViewContext +// --------------------------------------------------------------------------- + +TEST_F(NodeParamViewTest, ContextDefaultConstruction) +{ + olive::NodeParamViewContext ctx; + + EXPECT_NE(ctx.get_dock_area(), nullptr); + EXPECT_TRUE(ctx.get_contexts().isEmpty()); + EXPECT_TRUE(ctx.get_items().isEmpty()); +} + +TEST_F(NodeParamViewTest, ContextAddAndRemoveContexts) +{ + auto *math = add_node(); + auto *folder = add_node(); + + olive::NodeParamViewContext ctx; + ctx.add_context(to_oak(math)); + ctx.add_context(to_oak(folder)); + + ASSERT_EQ(ctx.get_contexts().size(), 2); + EXPECT_EQ(ctx.get_contexts().at(0), to_oak(math)); + EXPECT_EQ(ctx.get_contexts().at(1), to_oak(folder)); + + ctx.remove_context(to_oak(math)); + ASSERT_EQ(ctx.get_contexts().size(), 1); + EXPECT_EQ(ctx.get_contexts().first(), to_oak(folder)); + + // Removing something that isn't there is a no-op + ctx.remove_context(to_oak(math)); + EXPECT_EQ(ctx.get_contexts().size(), 1); +} + +TEST_F(NodeParamViewTest, ContextAddGetAndRemoveNodeItems) +{ + auto *math = add_node(); + auto *folder = add_node(); + + olive::NodeParamViewContext ctx; + + auto *item = new olive::NodeParamViewItem(to_oak(math), + olive::k_no_check_boxes, &ctx); + item->set_context(to_oak(folder)); + + ctx.add_node(item); + ASSERT_EQ(ctx.get_items().size(), 1); + EXPECT_EQ(ctx.get_items().first(), item); + + // Lookup matches on (node, context) pair only + EXPECT_EQ(ctx.get_item(to_oak(math), to_oak(folder)), item); + EXPECT_EQ(ctx.get_item(to_oak(folder), to_oak(math)), nullptr); + EXPECT_EQ(ctx.get_item(to_oak(math), to_oak(math)), nullptr); + + olive::NodeParamViewItem *about_to_delete = nullptr; + QObject::connect( + &ctx, &olive::NodeParamViewContext::about_to_delete_item, + [&about_to_delete](olive::NodeParamViewItem *i) { + about_to_delete = i; + }); + + // Removing a non-matching pair leaves the item alone + ctx.remove_node(to_oak(math), to_oak(math)); + EXPECT_EQ(ctx.get_items().size(), 1); + EXPECT_EQ(about_to_delete, nullptr); + + ctx.remove_node(to_oak(math), to_oak(folder)); + EXPECT_EQ(about_to_delete, item); + EXPECT_TRUE(ctx.get_items().isEmpty()); + EXPECT_EQ(ctx.get_item(to_oak(math), to_oak(folder)), nullptr); +} + +TEST_F(NodeParamViewTest, ContextRemoveNodesWithContextRemovesOnlyMatches) +{ + auto *math_a = add_node(); + auto *math_b = add_node(); + auto *folder_a = add_node(); + auto *folder_b = add_node(); + + olive::NodeParamViewContext ctx; + + auto *item_a = new olive::NodeParamViewItem(to_oak(math_a), + olive::k_no_check_boxes, &ctx); + item_a->set_context(to_oak(folder_a)); + auto *item_b = new olive::NodeParamViewItem(to_oak(math_b), + olive::k_no_check_boxes, &ctx); + item_b->set_context(to_oak(folder_b)); + + ctx.add_node(item_a); + ctx.add_node(item_b); + ASSERT_EQ(ctx.get_items().size(), 2); + + QVector deleted; + QObject::connect(&ctx, &olive::NodeParamViewContext::about_to_delete_item, + [&deleted](olive::NodeParamViewItem *i) { + deleted.append(i); + }); + + ctx.remove_nodes_with_context(to_oak(folder_a)); + + ASSERT_EQ(deleted.size(), 1); + EXPECT_EQ(deleted.first(), item_a); + + ASSERT_EQ(ctx.get_items().size(), 1); + EXPECT_EQ(ctx.get_items().first(), item_b); + EXPECT_EQ(ctx.get_item(to_oak(math_a), to_oak(folder_a)), nullptr); + EXPECT_EQ(ctx.get_item(to_oak(math_b), to_oak(folder_b)), item_b); +} diff --git a/tests/gtest/widget_nodeview_test.cpp b/tests/gtest/widget_nodeview_test.cpp new file mode 100644 index 0000000000..741e219c78 --- /dev/null +++ b/tests/gtest/widget_nodeview_test.cpp @@ -0,0 +1,1088 @@ +#include + +#include +#include +#include +#include + +#include "core.h" +#include "node/generator/solid/solid.h" +#include "node/math/math/math.h" +#include "node/project.h" +#include "widget/nodeview/nodeviewcommon.h" +#include "widget/nodeview/nodeviewcontext.h" +#include "widget/nodeview/nodeviewedge.h" +#include "widget/nodeview/nodeviewitem.h" +#include "widget/nodeview/nodeviewitemconnector.h" +#include "widget/nodeview/nodeviewminimap.h" +#include "widget/nodeview/nodeviewscene.h" +#include "widget/nodeview/nodeviewtoolbar.h" +#include "widget/nodeview/nodewidget.h" + +namespace +{ + +using NVC = olive::NodeViewCommon; +using NVI = olive::NodeViewItem; + +// NodeView (inside NodeWidget) is a HandMovableView, whose constructor +// connects to Core::instance(); the singleton is required but a MainWindow +// is not +void ensure_core() +{ + if (!olive::Core::instance()) { + new olive::Core(); // intentionally leaked + } +} + +// Wrap an engine Node* as oak::Node for the C ABI widget interface +inline oak::Node to_oak(olive::Node *n) +{ + return oak::Node(reinterpret_cast(n)); +} + +// Number of inputs a NodeViewItem would create child items for when expanded +// (mirrors NodeViewItem::is_input_valid for non-plugin nodes) +int count_valid_inputs(olive::Node *node) +{ + oak::Node n = to_oak(node); + int count = 0; + const int input_count = n.input_count(); + for (int i = 0; i < input_count; i++) { + oak::Input in(n.handle(), n.input_id(i)); + if (in.is_connectable() && !in.is_hidden()) { + count++; + } + } + return count; +} + +// The direct child items of `item` that are themselves NodeViewItems (the +// per-input rows); connectors are a different class and are excluded +QList child_node_items(NVI *item) +{ + QList out; + for (QGraphicsItem *c : item->childItems()) { + if (auto *nvi = dynamic_cast(c)) { + out.append(nvi); + } + } + return out; +} + +// The input (is_output()==false) or output connector of a NodeViewItem +olive::NodeViewItemConnector *find_connector(NVI *item, bool output) +{ + for (QGraphicsItem *c : item->childItems()) { + if (auto *conn = dynamic_cast(c)) { + if (conn->is_output() == output) { + return conn; + } + } + } + return nullptr; +} + +QPushButton *find_button_by_tooltip(QWidget *parent, const QString &tooltip) +{ + for (QPushButton *b : parent->findChildren()) { + if (b->toolTip() == tooltip) { + return b; + } + } + return nullptr; +} + +// NodeViewMiniMap is a QGraphicsView; QTest mouse delivery to the scroll-area +// frame is unreliable under the offscreen QPA, so expose the protected +// handlers and feed them crafted events directly (same pattern as +// ProbeHandView in widget_misc_test.cpp) +class ProbeMiniMap : public olive::NodeViewMiniMap { +public: + explicit ProbeMiniMap(olive::NodeViewScene *scene) + : olive::NodeViewMiniMap(scene) + { + } + + void pub_press(const QPoint &pos) + { + QMouseEvent e(QEvent::MouseButtonPress, QPointF(pos), QPointF(pos), + QPointF(pos), Qt::LeftButton, Qt::LeftButton, + Qt::NoModifier); + mousePressEvent(&e); + } + + void pub_release(const QPoint &pos) + { + QMouseEvent e(QEvent::MouseButtonRelease, QPointF(pos), QPointF(pos), + QPointF(pos), Qt::LeftButton, Qt::NoButton, + Qt::NoModifier); + mouseReleaseEvent(&e); + } +}; + +} // namespace + +TEST(WidgetNodeViewCommon, FlowOrientationAndPredicates) +{ + EXPECT_EQ(NVC::get_flow_orientation(NVC::k_top_to_bottom), Qt::Vertical); + EXPECT_EQ(NVC::get_flow_orientation(NVC::k_bottom_to_top), Qt::Vertical); + EXPECT_EQ(NVC::get_flow_orientation(NVC::k_left_to_right), Qt::Horizontal); + EXPECT_EQ(NVC::get_flow_orientation(NVC::k_right_to_left), Qt::Horizontal); + // Anything else falls through to horizontal + EXPECT_EQ(NVC::get_flow_orientation(NVC::k_invalid_direction), Qt::Horizontal); + + EXPECT_TRUE(NVC::is_flow_vertical(NVC::k_top_to_bottom)); + EXPECT_TRUE(NVC::is_flow_vertical(NVC::k_bottom_to_top)); + EXPECT_FALSE(NVC::is_flow_vertical(NVC::k_left_to_right)); + EXPECT_FALSE(NVC::is_flow_vertical(NVC::k_invalid_direction)); + + EXPECT_TRUE(NVC::is_flow_horizontal(NVC::k_left_to_right)); + EXPECT_TRUE(NVC::is_flow_horizontal(NVC::k_right_to_left)); + EXPECT_FALSE(NVC::is_flow_horizontal(NVC::k_top_to_bottom)); + EXPECT_FALSE(NVC::is_flow_horizontal(NVC::k_invalid_direction)); +} + +TEST(WidgetNodeViewCommon, DirectionsAreOpposing) +{ + EXPECT_TRUE(NVC::directions_are_opposing(NVC::k_left_to_right, NVC::k_right_to_left)); + EXPECT_TRUE(NVC::directions_are_opposing(NVC::k_right_to_left, NVC::k_left_to_right)); + EXPECT_TRUE(NVC::directions_are_opposing(NVC::k_top_to_bottom, NVC::k_bottom_to_top)); + EXPECT_TRUE(NVC::directions_are_opposing(NVC::k_bottom_to_top, NVC::k_top_to_bottom)); + + // Same direction, perpendicular, and invalid pairs are not opposing + EXPECT_FALSE(NVC::directions_are_opposing(NVC::k_left_to_right, NVC::k_left_to_right)); + EXPECT_FALSE(NVC::directions_are_opposing(NVC::k_left_to_right, NVC::k_top_to_bottom)); + EXPECT_FALSE(NVC::directions_are_opposing(NVC::k_invalid_direction, NVC::k_right_to_left)); +} + +TEST(WidgetNodeViewItem, DefaultMetricsDeriveFromFontMetrics) +{ + const QFont f; + const QFontMetrics fm(f); + const int padding = fm.height() / 4; + + EXPECT_EQ(NVI::default_text_padding(), padding); + EXPECT_EQ(NVI::default_item_height(), fm.height() + padding * 2); + EXPECT_EQ(NVI::default_item_border(), fm.height() / 12); + EXPECT_GT(NVI::default_item_width(), 0); +} + +TEST(WidgetNodeViewItem, FlowPaddingDependsOnOrientation) +{ + const double w = NVI::default_item_width(); + const double h = NVI::default_item_height(); + + EXPECT_DOUBLE_EQ(NVI::default_item_horizontal_padding(NVC::k_left_to_right), w * 1.5); + EXPECT_DOUBLE_EQ(NVI::default_item_horizontal_padding(NVC::k_top_to_bottom), w * 1.25); + EXPECT_DOUBLE_EQ(NVI::default_item_vertical_padding(NVC::k_left_to_right), h * 1.5); + EXPECT_DOUBLE_EQ(NVI::default_item_vertical_padding(NVC::k_top_to_bottom), h * 2.0); +} + +TEST(WidgetNodeViewItem, NodeScreenPointMappingPerDirection) +{ + const double hpad_h = NVI::default_item_horizontal_padding(NVC::k_left_to_right); + const double vpad_h = NVI::default_item_vertical_padding(NVC::k_left_to_right); + const double hpad_v = NVI::default_item_horizontal_padding(NVC::k_top_to_bottom); + const double vpad_v = NVI::default_item_vertical_padding(NVC::k_top_to_bottom); + + const QPointF p(1, 2); + + // Left-to-right is the internal representation: no axis shuffling + QPointF s = NVI::node_to_screen_point(p, NVC::k_left_to_right); + EXPECT_DOUBLE_EQ(s.x(), hpad_h); + EXPECT_DOUBLE_EQ(s.y(), 2 * vpad_h); + + // Right-to-left inverts X + s = NVI::node_to_screen_point(p, NVC::k_right_to_left); + EXPECT_DOUBLE_EQ(s.x(), -hpad_h); + EXPECT_DOUBLE_EQ(s.y(), 2 * vpad_h); + + // Top-to-bottom swaps the axes + s = NVI::node_to_screen_point(p, NVC::k_top_to_bottom); + EXPECT_DOUBLE_EQ(s.x(), 2 * hpad_v); + EXPECT_DOUBLE_EQ(s.y(), vpad_v); + + // Bottom-to-top swaps the axes and inverts Y + s = NVI::node_to_screen_point(p, NVC::k_bottom_to_top); + EXPECT_DOUBLE_EQ(s.x(), 2 * hpad_v); + EXPECT_DOUBLE_EQ(s.y(), -vpad_v); +} + +TEST(WidgetNodeViewItemConnector, ConstructionSetsOutputFlagAndColors) +{ + olive::NodeViewItemConnector in(false); + EXPECT_FALSE(in.is_output()); + + olive::NodeViewItemConnector out(true); + EXPECT_TRUE(out.is_output()); + + // Pen and brush use the palette text color; pen width is the item border + const QColor text_color = qApp->palette().text().color(); + EXPECT_EQ(out.pen().color(), text_color); + EXPECT_EQ(out.brush().color(), text_color); + EXPECT_EQ(out.pen().width(), NVI::default_item_border()); +} + +TEST(WidgetNodeViewItemConnector, FlowDirectionShapesTriangle) +{ + const int triangle_sz = QFontMetricsF(QFont()).height() / 2; + const int half = triangle_sz / 2; + ASSERT_GT(half, 0); + + olive::NodeViewItemConnector conn(true); + + conn.set_flow_direction(NVC::k_left_to_right); + QPolygonF p = conn.polygon(); + ASSERT_EQ(p.size(), 3); + EXPECT_EQ(p.at(0), QPointF(0, -half)); + EXPECT_EQ(p.at(1), QPointF(half, 0)); // tip points right + EXPECT_EQ(p.at(2), QPointF(0, half)); + + conn.set_flow_direction(NVC::k_right_to_left); + p = conn.polygon(); + ASSERT_EQ(p.size(), 3); + EXPECT_EQ(p.at(1), QPointF(-half, 0)); // tip points left + + conn.set_flow_direction(NVC::k_top_to_bottom); + p = conn.polygon(); + ASSERT_EQ(p.size(), 3); + EXPECT_EQ(p.at(1), QPointF(0, half)); // tip points down + + conn.set_flow_direction(NVC::k_bottom_to_top); + p = conn.polygon(); + ASSERT_EQ(p.size(), 3); + EXPECT_EQ(p.at(1), QPointF(0, -half)); // tip points up +} + +TEST(WidgetNodeViewItemConnector, BoundingRectExpandsPolygonByRadius) +{ + olive::NodeViewItemConnector conn(true); + conn.set_flow_direction(NVC::k_left_to_right); + + const int radius = QFontMetrics(QFont()).height() / 2; + const QRectF poly_bounds = conn.polygon().boundingRect(); + const QRectF bounds = conn.boundingRect(); + + EXPECT_TRUE(bounds.contains(poly_bounds)); + EXPECT_DOUBLE_EQ(bounds.width(), poly_bounds.width() + 2 * radius); + EXPECT_DOUBLE_EQ(bounds.height(), poly_bounds.height() + 2 * radius); +} + +TEST(WidgetNodeViewEdge, DefaultConstructionState) +{ + olive::NodeViewEdge edge; + + EXPECT_EQ(edge.from_item(), nullptr); + EXPECT_EQ(edge.to_item(), nullptr); + EXPECT_FALSE(edge.is_connected()); + // Drawn behind the node items + EXPECT_DOUBLE_EQ(edge.zValue(), -1.0); + EXPECT_TRUE(edge.flags() & QGraphicsItem::ItemIsSelectable); +} + +TEST(WidgetNodeViewEdge, StraightLinePathHasTwoEndpoints) +{ + olive::NodeViewEdge edge; + edge.set_curved(false); + edge.set_points(QPointF(0, 0), QPointF(100, 50)); + + const QPainterPath path = edge.path(); + ASSERT_EQ(path.elementCount(), 2); // moveTo + lineTo + EXPECT_EQ(path.elementAt(0).type, QPainterPath::MoveToElement); + EXPECT_EQ(path.elementAt(1).type, QPainterPath::LineToElement); + EXPECT_DOUBLE_EQ(path.elementAt(1).x, 100.0); + EXPECT_DOUBLE_EQ(path.elementAt(1).y, 50.0); +} + +TEST(WidgetNodeViewEdge, CurvedPathWithoutItemsFallsBackToHorizontal) +{ + // With no attached items the flow direction is unknown and the code falls + // back to left-to-right: control points share the start/end Y and the + // midpoint X + olive::NodeViewEdge edge; // curved by default + edge.set_points(QPointF(0, 0), QPointF(100, 50)); + + const QPainterPath path = edge.path(); + ASSERT_EQ(path.elementCount(), 4); // moveTo + cubicTo (3 elements) + EXPECT_EQ(path.elementAt(1).type, QPainterPath::CurveToElement); + EXPECT_DOUBLE_EQ(path.elementAt(1).x, 50.0); + EXPECT_DOUBLE_EQ(path.elementAt(1).y, 0.0); + EXPECT_DOUBLE_EQ(path.elementAt(2).x, 50.0); + EXPECT_DOUBLE_EQ(path.elementAt(2).y, 50.0); + EXPECT_DOUBLE_EQ(path.elementAt(3).x, 100.0); + EXPECT_DOUBLE_EQ(path.elementAt(3).y, 50.0); +} + +TEST(WidgetNodeViewEdge, CurvedPathFollowsItemFlowDirection) +{ + olive::Project project; + project.initialize(); + + auto *a = new olive::SolidGenerator(); + a->setParent(&project); + auto *b = new olive::MathNode(); + b->setParent(&project); + + NVI from_item(to_oak(a), oak::Node()); + NVI to_item(to_oak(b), oak::Node()); + + // Vertical flow moves the control points onto the start/end X and the + // midpoint Y instead + from_item.set_flow_direction(NVC::k_top_to_bottom); + to_item.set_flow_direction(NVC::k_top_to_bottom); + + olive::NodeViewEdge edge(to_oak(a), + oak::Input(to_oak(b).handle(), olive::MathNode::k_param_a_in), + &from_item, &to_item); + edge.set_points(QPointF(0, 0), QPointF(40, 200)); + + const QPainterPath path = edge.path(); + ASSERT_EQ(path.elementCount(), 4); + EXPECT_DOUBLE_EQ(path.elementAt(1).x, 0.0); + EXPECT_DOUBLE_EQ(path.elementAt(1).y, 100.0); + EXPECT_DOUBLE_EQ(path.elementAt(2).x, 40.0); + EXPECT_DOUBLE_EQ(path.elementAt(2).y, 100.0); + EXPECT_DOUBLE_EQ(path.elementAt(3).x, 40.0); + EXPECT_DOUBLE_EQ(path.elementAt(3).y, 200.0); + + // The item destructor asserts its edge list is empty; the edge destructor + // unregisters itself, and the edge is destroyed first on the stack +} + +TEST(WidgetNodeViewEdge, FullConstructorRegistersWithItems) +{ + olive::Project project; + project.initialize(); + + auto *a = new olive::SolidGenerator(); + a->setParent(&project); + auto *b = new olive::MathNode(); + b->setParent(&project); + + NVI from_item(to_oak(a), oak::Node()); + NVI to_item(to_oak(b), oak::Node()); + + const oak::Input input(to_oak(b).handle(), olive::MathNode::k_param_a_in); + auto *edge = new olive::NodeViewEdge(to_oak(a), input, &from_item, &to_item); + + // A real edge is connected by definition + EXPECT_TRUE(edge->is_connected()); + EXPECT_EQ(edge->from_item(), &from_item); + EXPECT_EQ(edge->to_item(), &to_item); + EXPECT_EQ(edge->output(), to_oak(a)); + EXPECT_EQ(edge->input(), input); + + // Both items track the edge + EXPECT_EQ(from_item.edges().size(), 1); + EXPECT_EQ(to_item.edges().size(), 1); + EXPECT_EQ(from_item.edges().first(), edge); + + // Deleting the edge unregisters it from both items (the item destructor + // asserts on a non-empty edge list) + delete edge; + EXPECT_TRUE(from_item.edges().isEmpty()); + EXPECT_TRUE(to_item.edges().isEmpty()); +} + +TEST(WidgetNodeViewEdge, SetFromToItemMovesRegistration) +{ + olive::Project project; + project.initialize(); + + auto *a = new olive::SolidGenerator(); + a->setParent(&project); + auto *b = new olive::MathNode(); + b->setParent(&project); + auto *c = new olive::MathNode(); + c->setParent(&project); + + NVI from_item(to_oak(a), oak::Node()); + NVI other_from(to_oak(c), oak::Node()); + NVI to_item(to_oak(b), oak::Node()); + + auto *edge = new olive::NodeViewEdge( + to_oak(a), oak::Input(to_oak(b).handle(), olive::MathNode::k_param_a_in), + &from_item, &to_item); + + edge->set_from_item(&other_from); + EXPECT_EQ(edge->from_item(), &other_from); + EXPECT_TRUE(from_item.edges().isEmpty()); + EXPECT_EQ(other_from.edges().size(), 1); + + edge->set_to_item(&other_from); + EXPECT_EQ(edge->to_item(), &other_from); + EXPECT_TRUE(to_item.edges().isEmpty()); + EXPECT_EQ(other_from.edges().size(), 2); + + // set_connected round-trips and does not affect registration + edge->set_connected(false); + EXPECT_FALSE(edge->is_connected()); + edge->set_connected(true); + EXPECT_TRUE(edge->is_connected()); + + delete edge; + EXPECT_TRUE(other_from.edges().isEmpty()); +} + +TEST(WidgetNodeViewItem, OutputItemConstructionDefaults) +{ + olive::Project project; + project.initialize(); + + auto *math = new olive::MathNode(); + math->setParent(&project); + + NVI item(to_oak(math), oak::Node()); + + EXPECT_TRUE(item.is_output_item()); + EXPECT_EQ(item.get_node(), to_oak(math)); + EXPECT_TRUE(item.get_input().input_id().isEmpty()); + EXPECT_FALSE(item.is_expanded()); + EXPECT_FALSE(item.is_labelled_as_output_of_context()); + // The constructor installs a fallback flow direction + EXPECT_EQ(item.get_flow_direction(), NVC::k_left_to_right); + + // The rect is one logical unit tall and contains the origin + EXPECT_DOUBLE_EQ(item.rect().width(), NVI::default_item_width()); + EXPECT_DOUBLE_EQ(item.rect().height(), NVI::default_item_height()); + EXPECT_TRUE(item.rect().contains(QPointF(0, 0))); + + // Output items are interactive + EXPECT_TRUE(item.flags() & QGraphicsItem::ItemIsMovable); + EXPECT_TRUE(item.flags() & QGraphicsItem::ItemIsSelectable); +} + +TEST(WidgetNodeViewItem, InputItemHidesOutputConnector) +{ + olive::Project project; + project.initialize(); + + auto *math = new olive::MathNode(); + math->setParent(&project); + + NVI item(to_oak(math), olive::MathNode::k_param_a_in, -1, oak::Node()); + + EXPECT_FALSE(item.is_output_item()); + EXPECT_EQ(item.get_input().input_id(), olive::MathNode::k_param_a_in); + EXPECT_EQ(item.get_input().element(), -1); + + olive::NodeViewItemConnector *out_conn = find_connector(&item, true); + ASSERT_NE(out_conn, nullptr); + EXPECT_FALSE(out_conn->isVisible()); + + // A non-array input can never be expanded + EXPECT_FALSE(item.can_be_expanded()); + item.set_expanded(true); + EXPECT_FALSE(item.is_expanded()); +} + +TEST(WidgetNodeViewItem, SetFlowDirectionRepositionsConnectors) +{ + olive::Project project; + project.initialize(); + + auto *math = new olive::MathNode(); + math->setParent(&project); + + NVI item(to_oak(math), oak::Node()); + olive::NodeViewItemConnector *in_conn = find_connector(&item, false); + olive::NodeViewItemConnector *out_conn = find_connector(&item, true); + ASSERT_NE(in_conn, nullptr); + ASSERT_NE(out_conn, nullptr); + + const QRectF r = item.rect(); + + // The connector offset is measured from the connector's CURRENT polygon, + // which set_flow_direction replaces (horizontal and vertical triangles + // have different extents), so re-read it after every direction change + item.set_flow_direction(NVC::k_left_to_right); + EXPECT_EQ(item.get_flow_direction(), NVC::k_left_to_right); + EXPECT_EQ(out_conn->pos(), QPointF(r.right(), 0)); + EXPECT_EQ(in_conn->pos(), + QPointF(r.left() - in_conn->polygon().boundingRect().width(), 0)); + + // The output point sits one triangle-width to the right of the connector + const double tri_w = out_conn->polygon().boundingRect().width(); + const QPointF out_p = item.get_output_point(); + EXPECT_DOUBLE_EQ(out_p.x(), out_conn->scenePos().x() + tri_w); + EXPECT_DOUBLE_EQ(out_p.y(), out_conn->scenePos().y()); + + item.set_flow_direction(NVC::k_right_to_left); + EXPECT_EQ(out_conn->pos(), QPointF(r.left(), 0)); + EXPECT_EQ(in_conn->pos(), + QPointF(r.right() + in_conn->polygon().boundingRect().width(), 0)); + + item.set_flow_direction(NVC::k_top_to_bottom); + EXPECT_EQ(out_conn->pos(), QPointF(r.center().x(), r.bottom())); + EXPECT_EQ(in_conn->pos(), QPointF(r.center().x(), + r.top() - in_conn->polygon().boundingRect().height())); + + item.set_flow_direction(NVC::k_bottom_to_top); + EXPECT_EQ(out_conn->pos(), QPointF(r.center().x(), r.top())); + EXPECT_EQ(in_conn->pos(), QPointF(r.center().x(), + r.bottom() + in_conn->polygon().boundingRect().height())); +} + +TEST(WidgetNodeViewItem, NodePositionRoundTripsThroughScreen) +{ + olive::Project project; + project.initialize(); + + auto *math = new olive::MathNode(); + math->setParent(&project); + + NVI item(to_oak(math), oak::Node()); + + const NVC::FlowDirection dirs[] = { NVC::k_left_to_right, NVC::k_right_to_left, + NVC::k_top_to_bottom, NVC::k_bottom_to_top }; + for (NVC::FlowDirection dir : dirs) { + item.set_flow_direction(dir); + item.set_node_position(QPointF(2, 3)); + + // Logical position survives the trip through screen coordinates + const QPointF logical = item.get_node_position(); + EXPECT_NEAR(logical.x(), 2.0, 1e-6) << int(dir); + EXPECT_NEAR(logical.y(), 3.0, 1e-6) << int(dir); + + // And the scene position matches the static mapping + const QPointF screen = NVI::node_to_screen_point(QPointF(2, 3), dir); + EXPECT_NEAR(item.pos().x(), screen.x(), 1e-6) << int(dir); + EXPECT_NEAR(item.pos().y(), screen.y(), 1e-6) << int(dir); + } + + // The aggregate overload sets position and expanded state together + olive::NodeViewItemPosition data; + data.position = QPointF(4, 5); + data.expanded = true; + item.set_node_position(data); + + EXPECT_TRUE(item.is_expanded()); + const olive::NodeViewItemPosition out = item.get_node_position_data(); + EXPECT_TRUE(out.expanded); + EXPECT_NEAR(out.position.x(), 4.0, 1e-6); + EXPECT_NEAR(out.position.y(), 5.0, 1e-6); +} + +TEST(WidgetNodeViewItem, ExpansionCreatesRowPerValidInput) +{ + olive::Project project; + project.initialize(); + + auto *math = new olive::MathNode(); + math->setParent(&project); + // Measured via the same C ABI queries NodeViewItem::is_input_valid uses, + // so the expected row count tracks whatever the engine reports + const int valid_inputs = count_valid_inputs(math); + ASSERT_GE(valid_inputs, 1); + + NVI item(to_oak(math), oak::Node()); + ASSERT_TRUE(item.can_be_expanded()); + + olive::NodeViewItemConnector *in_conn = find_connector(&item, false); + ASSERT_NE(in_conn, nullptr); + EXPECT_TRUE(in_conn->isVisible()); + + // Collapsed: every input resolves to the item itself + const oak::Input input_a(to_oak(math).handle(), olive::MathNode::k_param_a_in); + EXPECT_EQ(item.get_item_for_input(input_a), &item); + + item.toggle_expanded(); + ASSERT_TRUE(item.is_expanded()); + + // Expanding swaps the single input connector for per-input rows + EXPECT_FALSE(in_conn->isVisible()); + + const QList rows = child_node_items(&item); + ASSERT_EQ(rows.size(), valid_inputs); + + // Rows are stacked one item-height apart below the title row + const double h = NVI::default_item_height(); + for (int i = 0; i < rows.size(); i++) { + EXPECT_DOUBLE_EQ(rows.at(i)->pos().x(), 0.0); + EXPECT_DOUBLE_EQ(rows.at(i)->pos().y(), (i + 1) * h); + EXPECT_FALSE(rows.at(i)->is_output_item()); + } + + // The item grows to cover the title row plus all children + EXPECT_DOUBLE_EQ(item.rect().height(), h * (1 + valid_inputs)); + + // Expanded: inputs resolve to their row + NVI *row = item.get_item_for_input(input_a); + ASSERT_NE(row, nullptr); + EXPECT_NE(row, &item); + EXPECT_EQ(row->get_input().input_id(), olive::MathNode::k_param_a_in); + + // Collapsing destroys the rows and restores the single-unit rect + item.toggle_expanded(); + EXPECT_FALSE(item.is_expanded()); + EXPECT_TRUE(child_node_items(&item).isEmpty()); + EXPECT_DOUBLE_EQ(item.rect().height(), h); + EXPECT_TRUE(in_conn->isVisible()); +} + +TEST(WidgetNodeViewItem, LabelAsOutputHidesOutputConnector) +{ + olive::Project project; + project.initialize(); + + auto *math = new olive::MathNode(); + math->setParent(&project); + + NVI item(to_oak(math), oak::Node()); + olive::NodeViewItemConnector *out_conn = find_connector(&item, true); + ASSERT_NE(out_conn, nullptr); + EXPECT_TRUE(out_conn->isVisible()); + + item.set_label_as_output(true); + EXPECT_TRUE(item.is_labelled_as_output_of_context()); + EXPECT_FALSE(out_conn->isVisible()); + + item.set_label_as_output(false); + EXPECT_FALSE(item.is_labelled_as_output_of_context()); + EXPECT_TRUE(out_conn->isVisible()); +} + +TEST(WidgetNodeViewContext, AddChildCreatesRegisteredItem) +{ + olive::Project project; + project.initialize(); + + auto *solid = new olive::SolidGenerator(); + solid->setParent(&project); + auto *math = new olive::MathNode(); + math->setParent(&project); + + QGraphicsScene scene; + auto *ctx = new olive::NodeViewContext(to_oak(solid)); + // flow_dir_/curved_edges_ are only set via setters; NodeViewScene does + // this in add_context, so do the same here + ctx->set_flow_direction(NVC::k_left_to_right); + ctx->set_curved_edges(true); + scene.addItem(ctx); + + EXPECT_EQ(ctx->get_context(), to_oak(solid)); + EXPECT_EQ(ctx->get_item_from_map(to_oak(math).handle()), nullptr); + + ctx->add_child(to_oak(math).handle()); + + NVI *item = ctx->get_item_from_map(to_oak(math).handle()); + ASSERT_NE(item, nullptr); + EXPECT_EQ(item->get_node(), to_oak(math)); + EXPECT_EQ(item->get_context(), to_oak(solid)); + EXPECT_EQ(item->parentItem(), ctx); + EXPECT_EQ(item->get_flow_direction(), NVC::k_left_to_right); + + // The context rect is padded around its children + EXPECT_TRUE(ctx->rect().contains(ctx->childrenBoundingRect())); + + // Position changes route through to the item's logical position + ctx->set_child_position(to_oak(math).handle(), QPointF(3, 4)); + EXPECT_NEAR(item->get_node_position().x(), 3.0, 1e-6); + EXPECT_NEAR(item->get_node_position().y(), 4.0, 1e-6); + + delete ctx; +} + +TEST(WidgetNodeViewContext, ConnectAndDisconnectManageEdges) +{ + olive::Project project; + project.initialize(); + + auto *solid = new olive::SolidGenerator(); + solid->setParent(&project); + auto *math = new olive::MathNode(); + math->setParent(&project); + + auto *ctx = new olive::NodeViewContext(to_oak(solid)); + ctx->set_flow_direction(NVC::k_left_to_right); + ctx->set_curved_edges(true); + + ctx->add_child(to_oak(solid).handle()); + ctx->add_child(to_oak(math).handle()); + NVI *from = ctx->get_item_from_map(to_oak(solid).handle()); + NVI *to = ctx->get_item_from_map(to_oak(math).handle()); + ASSERT_NE(from, nullptr); + ASSERT_NE(to, nullptr); + + const oak::Input input(to_oak(math).handle(), olive::MathNode::k_param_a_in); + + // Disconnecting a nonexistent edge reports failure + EXPECT_FALSE(ctx->child_input_disconnected(to_oak(solid).handle(), input)); + + ctx->child_input_connected(to_oak(solid).handle(), input); + + ASSERT_EQ(from->edges().size(), 1); + ASSERT_EQ(to->edges().size(), 1); + olive::NodeViewEdge *edge = from->edges().first(); + EXPECT_EQ(edge->from_item(), from); + EXPECT_EQ(edge->to_item(), to); + EXPECT_EQ(edge->output(), to_oak(solid)); + EXPECT_EQ(edge->input().node_handle(), to_oak(math).handle()); + EXPECT_EQ(edge->input().input_id(), olive::MathNode::k_param_a_in); + EXPECT_TRUE(edge->is_connected()); + + // Disconnecting deletes the edge and unregisters it from the items + EXPECT_TRUE(ctx->child_input_disconnected(to_oak(solid).handle(), input)); + EXPECT_TRUE(from->edges().isEmpty()); + EXPECT_TRUE(to->edges().isEmpty()); + EXPECT_FALSE(ctx->child_input_disconnected(to_oak(solid).handle(), input)); + + delete ctx; +} + +TEST(WidgetNodeViewContext, SelectionCollectsNodesAndEdges) +{ + olive::Project project; + project.initialize(); + + auto *solid = new olive::SolidGenerator(); + solid->setParent(&project); + auto *math = new olive::MathNode(); + math->setParent(&project); + + QGraphicsScene scene; + auto *ctx = new olive::NodeViewContext(to_oak(solid)); + ctx->set_flow_direction(NVC::k_left_to_right); + ctx->set_curved_edges(true); + scene.addItem(ctx); + + ctx->add_child(to_oak(solid).handle()); + ctx->add_child(to_oak(math).handle()); + NVI *solid_item = ctx->get_item_from_map(to_oak(solid).handle()); + ASSERT_NE(solid_item, nullptr); + + EXPECT_TRUE(ctx->get_selected_items().isEmpty()); + + ctx->select({ to_oak(solid).handle() }); + EXPECT_TRUE(solid_item->isSelected()); + + const QVector selected = ctx->get_selected_items(); + ASSERT_EQ(selected.size(), 1); + EXPECT_EQ(selected.first(), solid_item); + + // Select the edge too so both selection kinds land in the deletion lists + ctx->child_input_connected( + to_oak(solid).handle(), + oak::Input(to_oak(math).handle(), olive::MathNode::k_param_a_in)); + NVI *math_item = ctx->get_item_from_map(to_oak(math).handle()); + ASSERT_NE(math_item, nullptr); + ASSERT_EQ(math_item->edges().size(), 1); + olive::NodeViewEdge *edge = math_item->edges().first(); + edge->setSelected(true); + + QVector nodes, contexts; + QVector edges; + ctx->get_selected_for_deletion(nodes, contexts, edges); + + ASSERT_EQ(nodes.size(), 1); + EXPECT_EQ(nodes.first(), to_oak(solid).handle()); + ASSERT_EQ(contexts.size(), 1); + EXPECT_EQ(contexts.first(), to_oak(solid).handle()); + ASSERT_EQ(edges.size(), 1); + EXPECT_EQ(edges.first(), edge); + + delete ctx; +} + +TEST(WidgetNodeViewContext, RemoveChildNotifiesAndUnregisters) +{ + olive::Project project; + project.initialize(); + + auto *solid = new olive::SolidGenerator(); + solid->setParent(&project); + auto *math = new olive::MathNode(); + math->setParent(&project); + + QGraphicsScene scene; + auto *ctx = new olive::NodeViewContext(to_oak(solid)); + ctx->set_flow_direction(NVC::k_left_to_right); + ctx->set_curved_edges(true); + scene.addItem(ctx); + + ctx->add_child(to_oak(math).handle()); + NVI *item = ctx->get_item_from_map(to_oak(math).handle()); + ASSERT_NE(item, nullptr); + + // QSignalSpy cannot record the unregistered NodeViewItem* metatype, so + // capture the emission with a lambda instead + int notifications = 0; + NVI *notified_item = nullptr; + QObject::connect(ctx, &olive::NodeViewContext::item_about_to_be_deleted, + [¬ifications, ¬ified_item](NVI *i) { + notifications++; + notified_item = i; + }); + + ctx->remove_child(to_oak(math).handle()); + + EXPECT_EQ(notifications, 1); + EXPECT_EQ(notified_item, item); + EXPECT_EQ(ctx->get_item_from_map(to_oak(math).handle()), nullptr); + + delete ctx; +} + +TEST(WidgetNodeViewContext, MapScenePosToNodePos) +{ + olive::Project project; + project.initialize(); + + auto *solid = new olive::SolidGenerator(); + solid->setParent(&project); + auto *math = new olive::MathNode(); + math->setParent(&project); + + QGraphicsScene scene; + + // With no items the mapping falls back to the origin + auto *empty_ctx = new olive::NodeViewContext(to_oak(math)); + empty_ctx->set_flow_direction(NVC::k_left_to_right); + EXPECT_EQ(empty_ctx->map_scene_pos_to_node_pos_in_context(QPointF(50, 50)), + QPointF(0, 0)); + delete empty_ctx; + + auto *ctx = new olive::NodeViewContext(to_oak(solid)); + ctx->set_flow_direction(NVC::k_left_to_right); + ctx->set_curved_edges(true); + scene.addItem(ctx); + ctx->add_child(to_oak(math).handle()); + + // The child sits at node position (0,0); one horizontal padding unit to + // the right in scene coordinates is node position (1,0) + const double hpad = NVI::default_item_horizontal_padding(NVC::k_left_to_right); + const QPointF node_pos = ctx->map_scene_pos_to_node_pos_in_context(QPointF(hpad, 0)); + EXPECT_NEAR(node_pos.x(), 1.0, 1e-6); + EXPECT_NEAR(node_pos.y(), 0.0, 1e-6); + + delete ctx; +} + +TEST(WidgetNodeViewToolBar, ConstructionCreatesFiveButtons) +{ + olive::NodeViewToolBar bar; + + const auto buttons = bar.findChildren(); + ASSERT_EQ(buttons.size(), 5); + + // Only the mini-map toggle is checkable + int checkable = 0; + for (QPushButton *b : buttons) { + if (b->isCheckable()) { + checkable++; + } + EXPECT_FALSE(b->toolTip().isEmpty()); + } + EXPECT_EQ(checkable, 1); + + // The fit button carries a text label, the rest are icon-only + QPushButton *fit = find_button_by_tooltip(&bar, QStringLiteral("Fit to Content")); + ASSERT_NE(fit, nullptr); + EXPECT_EQ(fit->text(), QStringLiteral("Fit")); +} + +TEST(WidgetNodeViewToolBar, ButtonsEmitTheirSignals) +{ + olive::NodeViewToolBar bar; + + QSignalSpy add_spy(&bar, &olive::NodeViewToolBar::add_node_clicked); + QSignalSpy minimap_spy(&bar, &olive::NodeViewToolBar::mini_map_enabled_toggled); + QSignalSpy zoom_in_spy(&bar, &olive::NodeViewToolBar::zoom_in_clicked); + QSignalSpy zoom_out_spy(&bar, &olive::NodeViewToolBar::zoom_out_clicked); + QSignalSpy fit_spy(&bar, &olive::NodeViewToolBar::fit_clicked); + + QPushButton *add = find_button_by_tooltip(&bar, QStringLiteral("Add Node")); + QPushButton *minimap = find_button_by_tooltip(&bar, QStringLiteral("Toggle Mini-Map")); + QPushButton *zoom_in = find_button_by_tooltip(&bar, QStringLiteral("Zoom In")); + QPushButton *zoom_out = find_button_by_tooltip(&bar, QStringLiteral("Zoom Out")); + QPushButton *fit = find_button_by_tooltip(&bar, QStringLiteral("Fit to Content")); + ASSERT_NE(add, nullptr); + ASSERT_NE(minimap, nullptr); + ASSERT_NE(zoom_in, nullptr); + ASSERT_NE(zoom_out, nullptr); + ASSERT_NE(fit, nullptr); + + // set_mini_map_enabled drives the toggle's checked state + ASSERT_TRUE(minimap->isCheckable()); + bar.set_mini_map_enabled(true); + EXPECT_TRUE(minimap->isChecked()); + bar.set_mini_map_enabled(false); + EXPECT_FALSE(minimap->isChecked()); + + add->click(); + EXPECT_EQ(add_spy.count(), 1); + + zoom_in->click(); + EXPECT_EQ(zoom_in_spy.count(), 1); + + zoom_out->click(); + EXPECT_EQ(zoom_out_spy.count(), 1); + + fit->click(); + EXPECT_EQ(fit_spy.count(), 1); + + // The toggle signal carries the new checked state + minimap->click(); + ASSERT_EQ(minimap_spy.count(), 1); + EXPECT_TRUE(minimap_spy.first().first().toBool()); + + minimap->click(); + ASSERT_EQ(minimap_spy.count(), 2); + EXPECT_FALSE(minimap_spy.at(1).first().toBool()); +} + +TEST(WidgetNodeWidget, SetContextsTogglesToolbarAndView) +{ + ensure_core(); + + olive::Project project; + project.initialize(); + + auto *solid = new olive::SolidGenerator(); + solid->setParent(&project); + + olive::NodeWidget widget; + ASSERT_NE(widget.view(), nullptr); + + olive::NodeViewToolBar *bar = widget.findChild(); + ASSERT_NE(bar, nullptr); + EXPECT_TRUE(bar->isEnabled()); + + // An empty context list disables the toolbar + widget.set_contexts({}); + EXPECT_TRUE(widget.view()->get_contexts().isEmpty()); + EXPECT_FALSE(bar->isEnabled()); + + // A real context re-enables it and lands in the view + widget.set_contexts({ to_oak(solid) }); + EXPECT_TRUE(bar->isEnabled()); + ASSERT_EQ(widget.view()->get_contexts().size(), 1); + EXPECT_EQ(widget.view()->get_contexts().first(), to_oak(solid)); +} + +TEST(WidgetNodeWidget, ToolbarMinimapToggleHidesMiniMap) +{ + ensure_core(); + + olive::NodeWidget widget; + + olive::NodeViewMiniMap *minimap = + widget.view()->findChild(); + ASSERT_NE(minimap, nullptr); + + // NodeWidget enables the mini-map by default + EXPECT_FALSE(minimap->isHidden()); + + olive::NodeViewToolBar *bar = widget.findChild(); + ASSERT_NE(bar, nullptr); + QPushButton *toggle = find_button_by_tooltip(bar, QStringLiteral("Toggle Mini-Map")); + ASSERT_NE(toggle, nullptr); + EXPECT_TRUE(toggle->isChecked()); + + toggle->click(); + EXPECT_TRUE(minimap->isHidden()); + + toggle->click(); + EXPECT_FALSE(minimap->isHidden()); +} + +TEST(WidgetNodeWidget, ToolbarZoomButtonsChangeViewScale) +{ + ensure_core(); + + olive::NodeWidget widget; + widget.resize(400, 300); + + olive::NodeViewToolBar *bar = widget.findChild(); + ASSERT_NE(bar, nullptr); + QPushButton *zoom_in = find_button_by_tooltip(bar, QStringLiteral("Zoom In")); + QPushButton *zoom_out = find_button_by_tooltip(bar, QStringLiteral("Zoom Out")); + ASSERT_NE(zoom_in, nullptr); + ASSERT_NE(zoom_out, nullptr); + + const double initial = widget.view()->transform().m11(); + + zoom_in->click(); + EXPECT_NEAR(widget.view()->transform().m11(), initial * 1.25, 1e-9); + + zoom_out->click(); + EXPECT_NEAR(widget.view()->transform().m11(), initial, 1e-9); +} + +TEST(WidgetNodeViewMiniMap, ResizeEmitsSignalAndFitsSceneRect) +{ + olive::NodeViewScene scene; + scene.setSceneRect(0, 0, 400, 300); + + olive::NodeViewMiniMap minimap(&scene); + EXPECT_EQ(minimap.scene(), &scene); + EXPECT_EQ(minimap.horizontalScrollBarPolicy(), Qt::ScrollBarAlwaysOff); + EXPECT_EQ(minimap.verticalScrollBarPolicy(), Qt::ScrollBarAlwaysOff); + EXPECT_TRUE(minimap.hasMouseTracking()); + + // Hidden widgets do not receive QResizeEvent (Qt defers geometry events + // until show), so the widget must be exposed before resize() has any + // observable effect + minimap.show(); + ASSERT_TRUE(QTest::qWaitForWindowExposed(&minimap)); + + QSignalSpy spy(&minimap, &olive::NodeViewMiniMap::resized); + + minimap.resize(200, 150); + ASSERT_GE(spy.count(), 1); + + // The scene rect is scaled to fit the smaller of the two axes + EXPECT_NEAR(minimap.transform().m11(), 0.5, 1e-9); + EXPECT_NEAR(minimap.transform().m22(), 0.5, 1e-9); +} + +TEST(WidgetNodeViewMiniMap, ClickOutsideTriangleEmitsScenePoint) +{ + olive::NodeViewScene scene; + scene.setSceneRect(0, 0, 400, 300); + + ProbeMiniMap minimap(&scene); + + // The fit transform is only computed once the widget is exposed and + // receives its resize event + minimap.show(); + ASSERT_TRUE(QTest::qWaitForWindowExposed(&minimap)); + + minimap.resize(200, 150); // 0.5 scale in both axes + + QSignalSpy spy(&minimap, &olive::NodeViewMiniMap::move_to_scene_point); + + // Well outside the resize triangle (top-left corner) + minimap.pub_press(QPoint(100, 75)); + + ASSERT_EQ(spy.count(), 1); + const QPointF scene_pos = spy.first().first().toPointF(); + EXPECT_NEAR(scene_pos.x(), 200.0, 10.0); + EXPECT_NEAR(scene_pos.y(), 150.0, 10.0); + + minimap.pub_release(QPoint(100, 75)); + EXPECT_EQ(spy.count(), 1); +} + +TEST(WidgetNodeViewMiniMap, ClickInsideTriangleStartsResizeNotMove) +{ + olive::NodeViewScene scene; + scene.setSceneRect(0, 0, 400, 300); + + ProbeMiniMap minimap(&scene); + minimap.show(); + ASSERT_TRUE(QTest::qWaitForWindowExposed(&minimap)); + minimap.resize(200, 150); + + // The resize triangle spans resize_triangle_sz_ = fontMetrics height / 2 + // in the top-left corner; (1,1) is always inside it + const int triangle_sz = minimap.fontMetrics().height() / 2; + ASSERT_GE(triangle_sz, 1); + + QSignalSpy spy(&minimap, &olive::NodeViewMiniMap::move_to_scene_point); + minimap.pub_press(QPoint(1, 1)); + EXPECT_EQ(spy.count(), 0); + minimap.pub_release(QPoint(1, 1)); + EXPECT_EQ(spy.count(), 0); +} diff --git a/tests/gtest/widget_projectexplorer2_test.cpp b/tests/gtest/widget_projectexplorer2_test.cpp new file mode 100644 index 0000000000..8704c2ef4e --- /dev/null +++ b/tests/gtest/widget_projectexplorer2_test.cpp @@ -0,0 +1,539 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core.h" +#include "node/color/colormanager/colormanager.h" +#include "node/project.h" +#include "node/project/footage/footage.h" +#include "node/project/folder/folder.h" +#include "oakutil/define.h" +#include "render/diskmanager.h" +#include "widget/projectexplorer/projectexplorericonview.h" +#include "widget/projectexplorer/projectexplorericonviewitemdelegate.h" +#include "widget/projectexplorer/projectexplorerlistview.h" +#include "widget/projectexplorer/projectexplorerlistviewbase.h" +#include "widget/projectexplorer/projectexplorerlistviewitemdelegate.h" +#include "widget/projectexplorer/projectexplorernavigation.h" +#include "widget/projectexplorer/projectexplorertreeview.h" + +using namespace olive; + +namespace +{ + +// Undo commands that rename/move items go through app-wide singletons +void ensure_app_singletons() +{ + if (!olive::Core::instance()) { + new olive::Core(); // intentionally leaked + } + if (!olive::DiskManager::instance()) { + olive::DiskManager::create_instance(); + } +} + +} // namespace + +TEST(ProjectExplorerNavigation, DefaultsMatchDocumentedState) +{ + ProjectExplorerNavigation nav(nullptr); + + auto *dir_up = nav.findChild(); + auto *label = nav.findChild(); + auto *slider = nav.findChild(); + ASSERT_NE(dir_up, nullptr); + ASSERT_NE(label, nullptr); + ASSERT_NE(slider, nullptr); + + // Root folder assumption: no parent to go up to, no folder name + EXPECT_FALSE(dir_up->isEnabled()); + EXPECT_TRUE(label->text().isEmpty()); + + // Slider spans the project icon size constants, defaulting to the default + EXPECT_EQ(slider->minimum(), k_project_icon_size_minimum); + EXPECT_EQ(slider->maximum(), k_project_icon_size_maximum); + EXPECT_EQ(slider->value(), k_project_icon_size_default); + EXPECT_EQ(slider->orientation(), Qt::Horizontal); +} + +TEST(ProjectExplorerNavigation, SetTextUpdatesLabel) +{ + ProjectExplorerNavigation nav(nullptr); + auto *label = nav.findChild(); + ASSERT_NE(label, nullptr); + + nav.set_text(QStringLiteral("Media")); + EXPECT_EQ(label->text(), QStringLiteral("Media")); + + nav.set_text(QString()); + EXPECT_TRUE(label->text().isEmpty()); +} + +TEST(ProjectExplorerNavigation, DirUpButtonOnlyEmitsWhenEnabled) +{ + ProjectExplorerNavigation nav(nullptr); + auto *dir_up = nav.findChild(); + ASSERT_NE(dir_up, nullptr); + + QSignalSpy spy(&nav, &ProjectExplorerNavigation::directory_up_clicked); + + // Disabled button swallows clicks + dir_up->click(); + EXPECT_EQ(spy.count(), 0); + + nav.set_dir_up_enabled(true); + EXPECT_TRUE(dir_up->isEnabled()); + + dir_up->click(); + EXPECT_EQ(spy.count(), 1); + + // Disabling again silences it + nav.set_dir_up_enabled(false); + dir_up->click(); + EXPECT_EQ(spy.count(), 1); +} + +TEST(ProjectExplorerNavigation, SizeSliderEmitsSizeChanged) +{ + ProjectExplorerNavigation nav(nullptr); + auto *slider = nav.findChild(); + ASSERT_NE(slider, nullptr); + + QSignalSpy spy(&nav, &ProjectExplorerNavigation::size_changed); + + slider->setValue(k_project_icon_size_minimum); + ASSERT_EQ(spy.count(), 1); + EXPECT_EQ(spy.first().first().toInt(), k_project_icon_size_minimum); + + // The setter is implemented as a plain slider setValue, so despite the + // header comment claiming otherwise it does forward through the signal + nav.set_size_value(k_project_icon_size_maximum); + EXPECT_EQ(slider->value(), k_project_icon_size_maximum); + ASSERT_EQ(spy.count(), 2); + EXPECT_EQ(spy.at(1).first().toInt(), k_project_icon_size_maximum); + + // Setting the same value again is a no-op, as QSlider only emits on change + nav.set_size_value(k_project_icon_size_maximum); + EXPECT_EQ(spy.count(), 2); +} + +TEST(ProjectExplorerListViewBase, ConstructionDefaults) +{ + ProjectExplorerListViewBase view(nullptr); + + EXPECT_EQ(view.movement(), QListView::Free); + EXPECT_EQ(view.selectionMode(), QAbstractItemView::ExtendedSelection); + EXPECT_EQ(view.resizeMode(), QListView::Adjust); + EXPECT_EQ(view.contextMenuPolicy(), Qt::CustomContextMenu); +} + +TEST(ProjectExplorerListViewBase, DoubleClickEmptyAreaEmitsSignal) +{ + QStringListModel model({ QStringLiteral("One"), QStringLiteral("Two"), + QStringLiteral("Three") }); + + ProjectExplorerListViewBase view(nullptr); + view.setModel(&model); + view.resize(400, 300); + view.show(); + ASSERT_TRUE(QTest::qWaitForWindowExposed(&view)); + + const QModelIndex first = model.index(0, 0); + const QRect item_rect = view.visualRect(first); + if (!item_rect.isValid()) { + GTEST_SKIP() << "View did not lay out items under the offscreen platform"; + } + + int hits = 0; + QObject::connect(&view, + &ProjectExplorerListViewBase::double_clicked_empty_area, + [&hits] { ++hits; }); + + // Double clicking an item is not an empty-area click + QTest::mouseDClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, + item_rect.center()); + EXPECT_EQ(hits, 0); + + // Double clicking below the last item is + const QPoint empty(view.viewport()->width() - 4, + view.viewport()->height() - 4); + if (view.indexAt(empty).isValid()) { + GTEST_SKIP() << "Could not find an item-free spot in the viewport"; + } + QTest::mouseDClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, empty); + EXPECT_EQ(hits, 1); +} + +TEST(ProjectExplorerListViewBase, CtrlClickExtendsSelection) +{ + QStringListModel model({ QStringLiteral("One"), QStringLiteral("Two"), + QStringLiteral("Three") }); + + ProjectExplorerListViewBase view(nullptr); + view.setModel(&model); + view.resize(400, 300); + view.show(); + ASSERT_TRUE(QTest::qWaitForWindowExposed(&view)); + + const QModelIndex first = model.index(0, 0); + const QModelIndex third = model.index(2, 0); + const QRect first_rect = view.visualRect(first); + const QRect third_rect = view.visualRect(third); + if (!first_rect.isValid() || !third_rect.isValid()) { + GTEST_SKIP() << "View did not lay out items under the offscreen platform"; + } + + // Plain click selects exactly one item and makes it current + QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, + first_rect.center()); + EXPECT_EQ(view.selectionModel()->currentIndex(), first); + EXPECT_EQ(view.selectionModel()->selectedIndexes().size(), 1); + + // ExtendedSelection: Ctrl+click adds to the selection instead of replacing + QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::ControlModifier, + third_rect.center()); + const QModelIndexList selected = view.selectionModel()->selectedIndexes(); + EXPECT_EQ(selected.size(), 2); + EXPECT_TRUE(selected.contains(first)); + EXPECT_TRUE(selected.contains(third)); +} + +TEST(ProjectExplorerListView, UsesListModeAndItemDelegate) +{ + ProjectExplorerListView view(nullptr); + + EXPECT_EQ(view.viewMode(), QListView::ListMode); + + // The delegate classes carry no Q_OBJECT, so cast with RTTI + EXPECT_NE(dynamic_cast( + view.itemDelegate()), + nullptr); + + // Inherits the base view behavior + EXPECT_EQ(view.selectionMode(), QAbstractItemView::ExtendedSelection); + EXPECT_EQ(view.contextMenuPolicy(), Qt::CustomContextMenu); +} + +TEST(ProjectExplorerIconView, UsesIconModeAndItemDelegate) +{ + ProjectExplorerIconView view(nullptr); + + EXPECT_EQ(view.viewMode(), QListView::IconMode); + + EXPECT_NE(dynamic_cast( + view.itemDelegate()), + nullptr); + + EXPECT_EQ(view.selectionMode(), QAbstractItemView::ExtendedSelection); +} + +TEST(ProjectExplorerTreeView, ConstructionDefaultsEnableDragDrop) +{ + ProjectExplorerTreeView view(nullptr); + + EXPECT_EQ(view.selectionMode(), QAbstractItemView::ExtendedSelection); + EXPECT_EQ(view.dragDropMode(), QAbstractItemView::DragDrop); + EXPECT_TRUE(view.dragEnabled()); + EXPECT_TRUE(view.acceptDrops()); + EXPECT_EQ(view.contextMenuPolicy(), Qt::CustomContextMenu); +} + +TEST(ProjectExplorerTreeView, DoubleClickEmptyAreaEmitsSignal) +{ + QStringListModel model({ QStringLiteral("One"), QStringLiteral("Two"), + QStringLiteral("Three") }); + + ProjectExplorerTreeView view(nullptr); + view.setModel(&model); + view.resize(400, 300); + view.show(); + ASSERT_TRUE(QTest::qWaitForWindowExposed(&view)); + + const QModelIndex first = model.index(0, 0); + const QRect item_rect = view.visualRect(first); + if (!item_rect.isValid()) { + GTEST_SKIP() << "View did not lay out items under the offscreen platform"; + } + + int hits = 0; + QObject::connect(&view, &ProjectExplorerTreeView::double_clicked_empty_area, + [&hits] { ++hits; }); + + QTest::mouseDClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, + item_rect.center()); + EXPECT_EQ(hits, 0); + + const QPoint empty(view.viewport()->width() - 4, + view.viewport()->height() - 4); + if (view.indexAt(empty).isValid()) { + GTEST_SKIP() << "Could not find an item-free spot in the viewport"; + } + QTest::mouseDClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, empty); + EXPECT_EQ(hits, 1); +} + +TEST(ProjectExplorerListViewItemDelegate, SizeHintIsSquareFromDecorationHeight) +{ + ProjectExplorerListViewItemDelegate delegate; + + QStyleOptionViewItem opt; + opt.decorationSize = QSize(10, 32); + EXPECT_EQ(delegate.sizeHint(opt, QModelIndex()), QSize(32, 32)); + + opt.decorationSize = QSize(64, 20); + EXPECT_EQ(delegate.sizeHint(opt, QModelIndex()), QSize(20, 20)); +} + +TEST(ProjectExplorerListViewItemDelegate, PaintFillsHighlightWhenSelected) +{ + QStandardItemModel model; + model.appendRow(new QStandardItem(QStringLiteral("Clip"))); + + QStyleOptionViewItem opt; + opt.rect = QRect(0, 0, 200, 24); + opt.decorationSize = QSize(24, 24); + + ProjectExplorerListViewItemDelegate delegate; + + // Selected rows are filled with the palette highlight + QImage selected_img(200, 24, QImage::Format_ARGB32); + selected_img.fill(Qt::transparent); + opt.state = QStyle::State_Enabled | QStyle::State_Selected; + { + QPainter p(&selected_img); + delegate.paint(&p, opt, model.index(0, 0)); + } + EXPECT_EQ(selected_img.pixelColor(0, 0).rgb(), + opt.palette.highlight().color().rgb()); + + // Unselected rows paint nothing into the icon area (no icon set) + QImage plain_img(200, 24, QImage::Format_ARGB32); + plain_img.fill(Qt::transparent); + opt.state = QStyle::State_Enabled; + { + QPainter p(&plain_img); + delegate.paint(&p, opt, model.index(0, 0)); + } + EXPECT_EQ(plain_img.pixelColor(0, 0).alpha(), 0); +} + +TEST(ProjectExplorerIconViewItemDelegate, SizeHintIsFixed) +{ + ProjectExplorerIconViewItemDelegate delegate; + + QStyleOptionViewItem opt; + opt.decorationSize = QSize(16, 16); + + // Always 256x256 regardless of the option + EXPECT_EQ(delegate.sizeHint(opt, QModelIndex()), QSize(256, 256)); + EXPECT_EQ(delegate.sizeHint(QStyleOptionViewItem(), QModelIndex()), + QSize(256, 256)); +} + +TEST(ProjectExplorerIconViewItemDelegate, PaintDrawsTextBand) +{ + QStandardItemModel model; + model.appendRow(new QStandardItem(QStringLiteral("Clip"))); + const QModelIndex index = model.index(0, 0); + model.setData(index, QStringLiteral("00:00:01:00"), Qt::UserRole); + + ProjectExplorerIconViewItemDelegate delegate; + + QStyleOptionViewItem opt; + opt.rect = QRect(0, 0, 256, 256); + + // The text band occupies the bottom fm.height() rows of the cell; the + // painter over a bare QImage uses the default application font + const QFontMetrics fm((QFont())); + const int band_top = opt.rect.height() - fm.height(); + ASSERT_GT(opt.rect.height() / 2, fm.height()); + + // Unselected: band background is white + QImage plain_img(256, 256, QImage::Format_ARGB32); + plain_img.fill(Qt::transparent); + opt.state = QStyle::State_Enabled; + { + QPainter p(&plain_img); + delegate.paint(&p, opt, index); + } + EXPECT_EQ(plain_img.pixelColor(opt.rect.width() / 2, band_top).rgb(), + QColor(Qt::white).rgb()); + + // Selected: band background follows the palette highlight + QImage selected_img(256, 256, QImage::Format_ARGB32); + selected_img.fill(Qt::transparent); + opt.state = QStyle::State_Enabled | QStyle::State_Selected; + { + QPainter p(&selected_img); + delegate.paint(&p, opt, index); + } + EXPECT_EQ(selected_img.pixelColor(opt.rect.width() / 2, band_top).rgb(), + opt.palette.highlight().color().rgb()); +} + +class ProjectExplorerUndoTest : public ::testing::Test { +protected: + void SetUp() override + { + ColorManager::set_up_default_config(); + ensure_app_singletons(); + + project_ = std::make_unique(); + project_->initialize(); + } + + template T *make_node() + { + auto *node = new T(); + node->setParent(project_.get()); + return node; + } + + std::unique_ptr project_; +}; + +TEST_F(ProjectExplorerUndoTest, FolderAddChildRedoUndoRoundTrips) +{ + Folder *root = project_->root(); + Footage *footage = make_node(); + ASSERT_EQ(root->item_child_count(), 0); + ASSERT_EQ(footage->folder(), nullptr); + + FolderAddChild cmd(root, footage); + EXPECT_EQ(cmd.get_relevant_project(), project_.get()); + + cmd.redo_now(); + EXPECT_EQ(root->item_child_count(), 1); + EXPECT_EQ(root->item_child(0), footage); + EXPECT_EQ(footage->folder(), root); + + // UndoCommand guards on a done flag: a second redo is a no-op + cmd.redo_now(); + EXPECT_EQ(root->item_child_count(), 1); + + cmd.undo_now(); + EXPECT_EQ(root->item_child_count(), 0); + EXPECT_EQ(footage->folder(), nullptr); + + // Likewise a second undo does nothing + cmd.undo_now(); + EXPECT_EQ(root->item_child_count(), 0); +} + +TEST_F(ProjectExplorerUndoTest, FolderAddChildEmitsFolderInsertRemoveSignals) +{ + Folder *root = project_->root(); + Footage *footage = make_node(); + + // The signal payload carries Node*, which is not a registered metatype, + // so count with lambdas rather than QSignalSpy + int insert_began = 0, insert_ended = 0, remove_began = 0, remove_ended = 0; + Node *inserted_node = nullptr; + int inserted_index = -1; + QObject::connect(root, &Folder::begin_insert_item, + [&](Node *n, int index) { + ++insert_began; + inserted_node = n; + inserted_index = index; + }); + QObject::connect(root, &Folder::end_insert_item, + [&] { ++insert_ended; }); + QObject::connect(root, &Folder::begin_remove_item, + [&](Node *, int) { ++remove_began; }); + QObject::connect(root, &Folder::end_remove_item, + [&] { ++remove_ended; }); + + FolderAddChild cmd(root, footage); + cmd.redo_now(); + EXPECT_EQ(insert_began, 1); + EXPECT_EQ(insert_ended, 1); + EXPECT_EQ(inserted_node, footage); + EXPECT_EQ(inserted_index, 0); + EXPECT_EQ(remove_began, 0); + + cmd.undo_now(); + EXPECT_EQ(remove_began, 1); + EXPECT_EQ(remove_ended, 1); + EXPECT_EQ(insert_began, 1); +} + +TEST_F(ProjectExplorerUndoTest, RemoveElementCommandRedoUndoRoundTrips) +{ + Folder *root = project_->root(); + Footage *a = make_node(); + Footage *b = make_node(); + FolderAddChild(root, a).redo_now(); + FolderAddChild(root, b).redo_now(); + ASSERT_EQ(root->item_child_count(), 2); + + Folder::RemoveElementCommand cmd(root, a); + EXPECT_EQ(cmd.get_relevant_project(), project_.get()); + + cmd.redo_now(); + EXPECT_EQ(root->item_child_count(), 1); + EXPECT_EQ(root->index_of_child(a), -1); + EXPECT_EQ(a->folder(), nullptr); + EXPECT_EQ(b->folder(), root); + + cmd.undo_now(); + EXPECT_EQ(root->item_child_count(), 2); + EXPECT_NE(root->index_of_child(a), -1); + EXPECT_EQ(a->folder(), root); + EXPECT_EQ(b->folder(), root); +} + +TEST_F(ProjectExplorerUndoTest, RemoveElementCommandOnNonChildIsNoOp) +{ + Folder *root = project_->root(); + Footage *stray = make_node(); + ASSERT_EQ(root->item_child_count(), 0); + + // The child was never connected to the folder, so the command finds no + // array index and both directions do nothing + Folder::RemoveElementCommand cmd(root, stray); + cmd.redo_now(); + EXPECT_EQ(root->item_child_count(), 0); + EXPECT_EQ(stray->folder(), nullptr); + + cmd.undo_now(); + EXPECT_EQ(root->item_child_count(), 0); +} + +TEST_F(ProjectExplorerUndoTest, NestedFolderAddChildTracksHierarchy) +{ + Folder *root = project_->root(); + Folder *folder = make_node(); + Footage *footage = make_node(); + + FolderAddChild add_folder(root, folder); + add_folder.redo_now(); + FolderAddChild add_footage(folder, footage); + add_footage.redo_now(); + + EXPECT_EQ(root->item_child_count(), 1); + EXPECT_EQ(folder->item_child_count(), 1); + EXPECT_TRUE(root->has_child_recursive(footage)); + EXPECT_EQ(footage->folder(), folder); + + // Undoing the inner add leaves the folder in place but empty + add_footage.undo_now(); + EXPECT_EQ(folder->item_child_count(), 0); + EXPECT_EQ(footage->folder(), nullptr); + EXPECT_EQ(folder->folder(), root); + + add_folder.undo_now(); + EXPECT_EQ(root->item_child_count(), 0); + EXPECT_EQ(folder->folder(), nullptr); +} diff --git a/tests/gtest/widget_scope_test.cpp b/tests/gtest/widget_scope_test.cpp new file mode 100644 index 0000000000..a81f0f97a4 --- /dev/null +++ b/tests/gtest/widget_scope_test.cpp @@ -0,0 +1,589 @@ +#include + +#include + +#include +#include +#include + +#include "config/config.h" +#include "node/color/colormanager/colormanager.h" +#include "node/project.h" +#include "widget/colorwheel/colorswatchwidget.h" +#include "widget/manageddisplay/manageddisplay.h" +#include "widget/menu/menu.h" +#include "widget/scope/histogram/histogram.h" +#include "widget/scope/scopebase/scopebase.h" +#include "widget/scope/vectorscope/vectorscope.h" +#include "widget/scope/waveform/waveform.h" + +namespace +{ + +// ManagedDisplayWidget is abstract (on_paint), so a minimal concrete probe is +// needed even for tests that only exercise the base-class behavior +class ProbeDisplayWidget : public olive::ManagedDisplayWidget { +public: + using olive::ManagedDisplayWidget::ManagedDisplayWidget; + + void *pub_renderer() const + { + return renderer(); + } + bool pub_backend_neutral() const + { + return is_backend_neutral(); + } + + int processor_changed_events = 0; + +protected: + virtual void on_paint() override + { + } + + virtual void color_processor_changed_event() override + { + processor_changed_events++; + olive::ManagedDisplayWidget::color_processor_changed_event(); + } +}; + +// Exposes the protected renderer state of the concrete scopes so tests can +// verify construction wiring without a GL context +class ProbeHistogramScope : public olive::HistogramScope { +public: + void *pub_renderer() const + { + return renderer(); + } + bool pub_backend_neutral() const + { + return is_backend_neutral(); + } +}; + +class ProbeWaveformScope : public olive::WaveformScope { +public: + void *pub_renderer() const + { + return renderer(); + } + bool pub_backend_neutral() const + { + return is_backend_neutral(); + } + oak_video_params pub_viewport_params() const + { + return get_viewport_params(); + } +}; + +class ProbeVectorscopeScope : public olive::VectorscopeScope { +public: + void *pub_renderer() const + { + return renderer(); + } + bool pub_backend_neutral() const + { + return is_backend_neutral(); + } +}; + +// ColorSwatchWidget is abstract (get_color_from_screen_pos); this probe maps +// screen position deterministically to a color and records the change events +class ProbeSwatchWidget : public olive::ColorSwatchWidget { +public: + olive::Color pub_managed_color(const olive::Color &c) const + { + return get_managed_color(c); + } + Qt::GlobalColor pub_selector_color() const + { + return get_ui_selector_color(); + } + + int changed_events = 0; + bool last_external = false; + +protected: + virtual olive::Color get_color_from_screen_pos(const QPoint &p) const override + { + return olive::Color(p.x() / 100.0, p.y() / 100.0, 0.5); + } + + virtual void SelectedColorChangedEvent(const olive::Color &c, + bool external) override + { + changed_events++; + last_external = external; + olive::ColorSwatchWidget::SelectedColorChangedEvent(c, external); + } +}; + +// Returns the index of the first action whose data differs from the current +// transform component, or -1 when the menu offers no alternative +int alternative_action_index(olive::Menu *menu, const QString ¤t) +{ + const QList acts = menu->actions(); + for (int i = 0; i < acts.size(); i++) { + if (acts.at(i)->data().toString() != current) { + return i; + } + } + return -1; +} + +} // namespace + +TEST(WidgetManagedDisplay, DefaultConstructionState) +{ + ProbeDisplayWidget w; + + EXPECT_EQ(w.color_manager(), nullptr); + EXPECT_NE(w.pub_renderer(), nullptr); + + // No transform has been chosen yet + const oak::ColorTransform &t = w.get_color_transform(); + EXPECT_FALSE(t.is_display()); + EXPECT_TRUE(t.output().isEmpty()); + EXPECT_TRUE(t.view().isEmpty()); + EXPECT_TRUE(t.look().isEmpty()); + + EXPECT_EQ(w.processor_changed_events, 0); +} + +TEST(WidgetManagedDisplay, SetColorTransformWithoutManagerRoundTrips) +{ + ProbeDisplayWidget w; + + // ColorProcessorHandlePtr is not a registered metatype, so count the + // signal through a direct lambda connection instead of QSignalSpy + int signal_count = 0; + olive::ColorProcessorHandlePtr last_processor; + QObject::connect(&w, &olive::ManagedDisplayWidget::color_processor_changed, + [&signal_count, &last_processor]( + olive::ColorProcessorHandlePtr p) { + signal_count++; + last_processor = p; + }); + + w.set_color_transform( + oak::ColorTransform(QStringLiteral("MyDisplay"), + QStringLiteral("MyView"), QStringLiteral("MyLook"))); + + const oak::ColorTransform &t = w.get_color_transform(); + EXPECT_TRUE(t.is_display()); + EXPECT_EQ(t.display(), QStringLiteral("MyDisplay")); + EXPECT_EQ(t.view(), QStringLiteral("MyView")); + EXPECT_EQ(t.look(), QStringLiteral("MyLook")); + + // With no color manager connected, the processor is cleared and the + // change is signalled once + EXPECT_EQ(signal_count, 1); + EXPECT_EQ(last_processor, nullptr); + EXPECT_EQ(w.processor_changed_events, 1); + + // A plain colorspace transform stores only the output name + w.set_color_transform(oak::ColorTransform(QStringLiteral("ACEScg"))); + EXPECT_FALSE(w.get_color_transform().is_display()); + EXPECT_EQ(w.get_color_transform().output(), QStringLiteral("ACEScg")); + EXPECT_EQ(signal_count, 2); +} + +TEST(WidgetManagedDisplay, ConnectColorManagerDefaultsToDisplayViewTransform) +{ + olive::ColorManager::set_up_default_config(); + olive::Project project; + + ProbeDisplayWidget w; + + int manager_signals = 0; + OakEngineColorManager *last_manager = nullptr; + QObject::connect(&w, &olive::ManagedDisplayWidget::color_manager_changed, + [&manager_signals, + &last_manager](OakEngineColorManager *m) { + manager_signals++; + last_manager = m; + }); + + w.connect_color_manager(olive::oak_color_manager(project.color_manager())); + + EXPECT_EQ(w.color_manager(), + olive::oak_color_manager(project.color_manager())); + EXPECT_EQ(manager_signals, 1); + EXPECT_EQ(last_manager, olive::oak_color_manager(project.color_manager())); + + // An empty transform is conformed to the config's default display/view so + // the widget would show a sensible image + const oak::ColorTransform &t = w.get_color_transform(); + EXPECT_TRUE(t.is_display()); + EXPECT_FALSE(t.display().isEmpty()); + EXPECT_FALSE(t.view().isEmpty()); + + // Connecting the same manager again is a no-op + w.connect_color_manager(olive::oak_color_manager(project.color_manager())); + EXPECT_EQ(manager_signals, 1); +} + +TEST(WidgetManagedDisplay, DisconnectColorManagerClearsState) +{ + olive::ColorManager::set_up_default_config(); + olive::Project project; + + ProbeDisplayWidget w; + w.connect_color_manager(olive::oak_color_manager(project.color_manager())); + + int manager_signals = 0; + OakEngineColorManager *last_manager = + olive::oak_color_manager(project.color_manager()); + QObject::connect(&w, &olive::ManagedDisplayWidget::color_manager_changed, + [&manager_signals, + &last_manager](OakEngineColorManager *m) { + manager_signals++; + last_manager = m; + }); + + w.disconnect_color_manager(); + + EXPECT_EQ(w.color_manager(), nullptr); + EXPECT_EQ(manager_signals, 1); + EXPECT_EQ(last_manager, nullptr); +} + +TEST(WidgetManagedDisplay, DisplayMenuReflectsCurrentTransform) +{ + olive::ColorManager::set_up_default_config(); + olive::Project project; + + ProbeDisplayWidget w; + w.connect_color_manager(olive::oak_color_manager(project.color_manager())); + + std::unique_ptr menu(w.get_display_menu(nullptr)); + ASSERT_FALSE(menu->actions().isEmpty()); + + // Exactly the current display is checked + int checked = 0; + for (QAction *a : menu->actions()) { + EXPECT_TRUE(a->isCheckable()); + EXPECT_EQ(a->data().toString(), a->text()); + if (a->isChecked()) { + checked++; + EXPECT_EQ(a->data().toString(), w.get_color_transform().display()); + } + } + EXPECT_EQ(checked, 1); + + // Picking another display (if the config has one) retargets the transform + const int alt = + alternative_action_index(menu.get(), w.get_color_transform().display()); + if (alt >= 0) { + const QString target = menu->actions().at(alt)->data().toString(); + menu->actions().at(alt)->trigger(); + EXPECT_EQ(w.get_color_transform().display(), target); + } +} + +TEST(WidgetManagedDisplay, ViewMenuReflectsCurrentTransform) +{ + olive::ColorManager::set_up_default_config(); + olive::Project project; + + ProbeDisplayWidget w; + w.connect_color_manager(olive::oak_color_manager(project.color_manager())); + + std::unique_ptr menu(w.get_view_menu(nullptr)); + ASSERT_FALSE(menu->actions().isEmpty()); + + int checked = 0; + for (QAction *a : menu->actions()) { + EXPECT_TRUE(a->isCheckable()); + if (a->isChecked()) { + checked++; + EXPECT_EQ(a->data().toString(), w.get_color_transform().view()); + } + } + EXPECT_EQ(checked, 1); + + const int alt = + alternative_action_index(menu.get(), w.get_color_transform().view()); + if (alt >= 0) { + const QString target = menu->actions().at(alt)->data().toString(); + menu->actions().at(alt)->trigger(); + EXPECT_EQ(w.get_color_transform().view(), target); + // The display component survives a view change + EXPECT_FALSE(w.get_color_transform().display().isEmpty()); + } +} + +TEST(WidgetManagedDisplay, LookMenuStartsWithNoLookEntry) +{ + olive::ColorManager::set_up_default_config(); + olive::Project project; + + ProbeDisplayWidget w; + w.connect_color_manager(olive::oak_color_manager(project.color_manager())); + + std::unique_ptr menu(w.get_look_menu(nullptr)); + ASSERT_FALSE(menu->actions().isEmpty()); + + // The first entry is always the "(None)" entry carrying empty data + QAction *none = menu->actions().first(); + EXPECT_TRUE(none->isCheckable()); + EXPECT_EQ(none->data().toString(), QString()); + + // The default transform has no look, so "(None)" is checked + EXPECT_TRUE(w.get_color_transform().look().isEmpty()); + EXPECT_TRUE(none->isChecked()); + + for (int i = 1; i < menu->actions().size(); i++) { + EXPECT_TRUE(menu->actions().at(i)->isCheckable()); + EXPECT_EQ(menu->actions().at(i)->data().toString(), + menu->actions().at(i)->text()); + } +} + +TEST(WidgetManagedDisplay, ColorSpaceMenuListsConfigSpaces) +{ + olive::ColorManager::set_up_default_config(); + olive::Project project; + + ProbeDisplayWidget w; + w.connect_color_manager(olive::oak_color_manager(project.color_manager())); + + const QStringList spaces = + project.color_manager()->list_available_colorspaces(); + ASSERT_GE(spaces.size(), 2); + + std::unique_ptr menu(w.get_color_space_menu(nullptr)); + ASSERT_EQ(menu->actions().size(), spaces.size()); + + for (int i = 0; i < spaces.size(); i++) { + EXPECT_EQ(menu->actions().at(i)->data().toString(), spaces.at(i)); + } + + // Selecting a colorspace switches to a non-display transform + const int alt = + alternative_action_index(menu.get(), w.get_color_transform().output()); + if (alt >= 0) { + const QString target = menu->actions().at(alt)->data().toString(); + menu->actions().at(alt)->trigger(); + EXPECT_EQ(w.get_color_transform().output(), target); + } +} + +TEST(WidgetScopeBase, ViewportParamsTrackWidgetGeometry) +{ + ProbeWaveformScope w; + w.resize(640, 360); + + const oak_video_params vp = w.pub_viewport_params(); + EXPECT_EQ(vp.width, int(640 * w.devicePixelRatioF())); + EXPECT_EQ(vp.height, int(360 * w.devicePixelRatioF())); + EXPECT_EQ(vp.format, + olive::Config::current()[QStringLiteral("OfflinePixelFormat")] + .toInt()); +} + +TEST(WidgetHistogramScope, ConstructionCreatesRendererWithoutTexture) +{ + ProbeHistogramScope w; + + // The renderer abstraction exists right after construction, but nothing + // color-related is connected yet + EXPECT_NE(w.pub_renderer(), nullptr); + EXPECT_EQ(w.color_manager(), nullptr); + EXPECT_TRUE(w.get_color_transform().output().isEmpty()); + + // A null buffer is accepted and simply schedules a repaint; there is no + // texture to retain + w.set_buffer(nullptr); + w.set_buffer(nullptr); + + EXPECT_EQ(w.color_manager(), nullptr); +} + +TEST(WidgetHistogramScope, ConnectColorManagerDefaultsTransform) +{ + olive::ColorManager::set_up_default_config(); + olive::Project project; + + ProbeHistogramScope w; + w.connect_color_manager(olive::oak_color_manager(project.color_manager())); + + EXPECT_EQ(w.color_manager(), + olive::oak_color_manager(project.color_manager())); + EXPECT_TRUE(w.get_color_transform().is_display()); + EXPECT_FALSE(w.get_color_transform().display().isEmpty()); + EXPECT_FALSE(w.get_color_transform().view().isEmpty()); +} + +TEST(WidgetVectorscopeScope, ConstructionCreatesRenderer) +{ + ProbeVectorscopeScope w; + + EXPECT_NE(w.pub_renderer(), nullptr); + EXPECT_EQ(w.color_manager(), nullptr); + + // Null buffers are tolerated without a connected manager + w.set_buffer(nullptr); +} + +TEST(WidgetVectorscopeScope, ConnectColorManagerDefaultsTransform) +{ + olive::ColorManager::set_up_default_config(); + olive::Project project; + + ProbeVectorscopeScope w; + w.connect_color_manager(olive::oak_color_manager(project.color_manager())); + + EXPECT_TRUE(w.get_color_transform().is_display()); + EXPECT_FALSE(w.get_color_transform().display().isEmpty()); +} + +TEST(WidgetWaveformScope, ParadeModeFollowsConfig) +{ + const QVariant old = + olive::Config::current()[QStringLiteral("WaveformRgbParade")]; + + olive::Config::current()[QStringLiteral("WaveformRgbParade")] = true; + { + ProbeWaveformScope w; + EXPECT_TRUE(w.parade_mode()); + EXPECT_NE(w.pub_renderer(), nullptr); + } + + olive::Config::current()[QStringLiteral("WaveformRgbParade")] = false; + { + ProbeWaveformScope w; + EXPECT_FALSE(w.parade_mode()); + } + + olive::Config::current()[QStringLiteral("WaveformRgbParade")] = old; +} + +TEST(WidgetWaveformScope, SetParadeModePersistsToConfig) +{ + const QVariant old = + olive::Config::current()[QStringLiteral("WaveformRgbParade")]; + + { + ProbeWaveformScope w; + + w.set_parade_mode(true); + EXPECT_TRUE(w.parade_mode()); + EXPECT_TRUE( + olive::Config::current()[QStringLiteral("WaveformRgbParade")] + .toBool()); + + w.set_parade_mode(false); + EXPECT_FALSE(w.parade_mode()); + EXPECT_FALSE( + olive::Config::current()[QStringLiteral("WaveformRgbParade")] + .toBool()); + } + + // A newly constructed scope picks the persisted value up + { + ProbeWaveformScope w; + EXPECT_FALSE(w.parade_mode()); + } + + olive::Config::current()[QStringLiteral("WaveformRgbParade")] = old; +} + +TEST(WidgetColorSwatchWidget, SetSelectedColorRoundTrips) +{ + ProbeSwatchWidget w; + + w.set_selected_color(olive::Color(0.2, 0.4, 0.6)); + + EXPECT_FLOAT_EQ(w.get_selected_color().red(), 0.2f); + EXPECT_FLOAT_EQ(w.get_selected_color().green(), 0.4f); + EXPECT_FLOAT_EQ(w.get_selected_color().blue(), 0.6f); + + // Programmatic changes count as external + EXPECT_EQ(w.changed_events, 1); + EXPECT_TRUE(w.last_external); +} + +TEST(WidgetColorSwatchWidget, MousePressPicksColorAndEmits) +{ + ProbeSwatchWidget w; + w.resize(100, 100); + + // olive::Color is not a registered metatype in this harness, so capture + // the signal payload through a lambda + QVector received; + QObject::connect(&w, &olive::ColorSwatchWidget::selected_color_changed, + [&received](const olive::Color &c) { received.append(c); }); + + QTest::mouseClick(&w, Qt::LeftButton, Qt::NoModifier, QPoint(10, 20)); + + ASSERT_EQ(received.size(), 1); + EXPECT_NEAR(received.first().red(), 0.10, 1e-6); + EXPECT_NEAR(received.first().green(), 0.20, 1e-6); + EXPECT_NEAR(received.first().blue(), 0.5, 1e-6); + + // User interaction is reported as non-external + EXPECT_EQ(w.changed_events, 1); + EXPECT_FALSE(w.last_external); + + EXPECT_NEAR(w.get_selected_color().red(), 0.10, 1e-6); + EXPECT_NEAR(w.get_selected_color().green(), 0.20, 1e-6); +} + +TEST(WidgetColorSwatchWidget, DragEmitsForEachMove) +{ + ProbeSwatchWidget w; + w.resize(100, 100); + + QVector received; + QObject::connect(&w, &olive::ColorSwatchWidget::selected_color_changed, + [&received](const olive::Color &c) { received.append(c); }); + + QTest::mousePress(&w, Qt::LeftButton, Qt::NoModifier, QPoint(10, 10)); + ASSERT_EQ(received.size(), 1); + + // A move while the left button is held updates the color... + QTest::mouseMove(&w, QPoint(30, 40)); + ASSERT_EQ(received.size(), 2); + EXPECT_NEAR(received.at(1).red(), 0.30, 1e-6); + EXPECT_NEAR(received.at(1).green(), 0.40, 1e-6); + + QTest::mouseRelease(&w, Qt::LeftButton, Qt::NoModifier, QPoint(30, 40)); + EXPECT_EQ(received.size(), 2); + + // ...but a bare move without a pressed button is ignored + QTest::mouseMove(&w, QPoint(50, 50)); + EXPECT_EQ(received.size(), 2); +} + +TEST(WidgetColorSwatchWidget, ManagedColorPassesThroughWithoutProcessors) +{ + ProbeSwatchWidget w; + + // With no color processors installed, the managed color is the input + const olive::Color in(0.1, 0.2, 0.3, 1.0); + const olive::Color out = w.pub_managed_color(in); + EXPECT_FLOAT_EQ(out.red(), 0.1f); + EXPECT_FLOAT_EQ(out.green(), 0.2f); + EXPECT_FLOAT_EQ(out.blue(), 0.3f); + + // Setting null processors still forces a full external refresh + w.set_color_processor(nullptr, nullptr); + EXPECT_TRUE(w.last_external); +} + +TEST(WidgetColorSwatchWidget, SelectorColorFollowsLuminance) +{ + ProbeSwatchWidget w; + + // Bright swatches get a black selector, dark swatches a white one + w.set_selected_color(olive::Color(1.0, 1.0, 1.0)); + EXPECT_EQ(w.pub_selector_color(), Qt::black); + + w.set_selected_color(olive::Color(0.0, 0.0, 0.0)); + EXPECT_EQ(w.pub_selector_color(), Qt::white); +} diff --git a/tests/gtest/widget_sliderbase_test.cpp b/tests/gtest/widget_sliderbase_test.cpp new file mode 100644 index 0000000000..8c5011337f --- /dev/null +++ b/tests/gtest/widget_sliderbase_test.cpp @@ -0,0 +1,436 @@ +#include + +#include +#include +#include + +#include "common/configwrapper.h" +#include "widget/focusablelineedit/focusablelineedit.h" +#include "widget/slider/base/numericsliderbase.h" +#include "widget/slider/base/sliderlabel.h" +#include "widget/slider/base/sliderladder.h" +#include "widget/slider/sliderdisplaytypeapp.h" + +namespace +{ + +// NumericSliderBase is abstract (SliderBase's value_to_string / +// string_to_value / value_signal_event are pure), so test it through a +// minimal concrete probe that also exposes the protected entry points +class ProbeNumericSlider : public olive::NumericSliderBase { +public: + void set_value(const QVariant &v) + { + set_value_internal(v); + } + + QVariant get_value() const + { + return get_value_internal(); + } + + QVariant get_offset_public() const + { + return get_offset(); + } + + QVariant adjust_drag_public(const QVariant &start, const double &drag) const + { + return adjust_drag_distance_internal(start, drag); + } + + bool greater_public(const QVariant &lhs, const QVariant &rhs) const + { + return value_greater_than(lhs, rhs); + } + + bool less_public(const QVariant &lhs, const QVariant &rhs) const + { + return value_less_than(lhs, rhs); + } + + bool can_set_value_public() const + { + return can_set_value(); + } + + void set_minimum_public(const QVariant &v) + { + set_minimum_internal(v); + } + + void set_maximum_public(const QVariant &v) + { + set_maximum_internal(v); + } + + QVector signaled; + +protected: + virtual QString value_to_string(const QVariant &v) const override + { + return QString::number(v.toDouble()); + } + + virtual QVariant string_to_value(const QString &s, bool *ok) const override + { + return s.toDouble(ok); + } + + virtual void value_signal_event(const QVariant &value) override + { + signaled.append(value); + } +}; + +// Forces ladder mode on for the duration of a test. Ladder mode keeps the +// SliderLadder away from the macOS cursor-grab path +// (CGAssociateMouseAndMouseCursorPosition), which would otherwise decouple +// the host's real cursor while a ladder widget exists +struct LadderConfigGuard { + LadderConfigGuard() + : old_(OAK_CONFIG("UseSliderLadders").toBool()) + { + OAK_CONFIG("UseSliderLadders") = true; + } + + ~LadderConfigGuard() + { + OAK_CONFIG("UseSliderLadders") = old_; + } + + bool old_; +}; + +// The drag ladder is an orphan top-level popup; dig it out of the +// application's top-level widget list +olive::SliderLadder *find_ladder() +{ + const QWidgetList tops = QApplication::topLevelWidgets(); + for (QWidget *w : tops) { + if (auto *l = qobject_cast(w)) { + return l; + } + } + return nullptr; +} + +// Starts a ladder drag on the slider by emitting its label's press signal +void press_label(ProbeNumericSlider &s) +{ + olive::SliderLabel *label = s.findChild(); + ASSERT_NE(label, nullptr); + ASSERT_TRUE(QMetaObject::invokeMethod(label, "label_pressed")); +} + +} // namespace + +TEST(SliderDisplayType, OrdinalValuesAreAbiStable) +{ + // These cross the engine C ABI as ints inside node input properties; the + // ordinals must match engine node/sliderdisplaytype.h + EXPECT_EQ(int(olive::slider::k_normal), 0); + EXPECT_EQ(int(olive::slider::k_decibel), 1); + EXPECT_EQ(int(olive::slider::k_percentage), 2); + + EXPECT_EQ(int(olive::slider::k_time), 0); + EXPECT_EQ(int(olive::slider::k_float), 1); + EXPECT_EQ(int(olive::slider::k_rational), 2); +} + +TEST(NumericSliderBase, DefaultStateIsIdle) +{ + ProbeNumericSlider s; + EXPECT_FALSE(s.is_dragging()); + EXPECT_TRUE(s.can_set_value_public()); + EXPECT_EQ(s.cursor().shape(), Qt::SizeHorCursor); +} + +TEST(NumericSliderBase, OffsetRoundTrips) +{ + ProbeNumericSlider s; + EXPECT_FALSE(s.get_offset_public().isValid()); + + s.set_offset(7); + EXPECT_EQ(s.get_offset_public().toInt(), 7); + + s.set_offset(-2.5); + EXPECT_DOUBLE_EQ(s.get_offset_public().toDouble(), -2.5); +} + +TEST(NumericSliderBase, DragDistanceDefaultsToAddition) +{ + ProbeNumericSlider s; + EXPECT_DOUBLE_EQ(s.adjust_drag_public(1.5, 2.25).toDouble(), 3.75); + EXPECT_DOUBLE_EQ(s.adjust_drag_public(1.5, -0.5).toDouble(), 1.0); +} + +TEST(NumericSliderBase, ValueComparisonUsesNumericValues) +{ + ProbeNumericSlider s; + EXPECT_TRUE(s.greater_public(2.5, 2.4)); + EXPECT_FALSE(s.greater_public(1.0, 1.0)); + EXPECT_TRUE(s.less_public(1, 2)); + + // String variants compare by their numeric value, not lexicographically + EXPECT_TRUE(s.less_public(QStringLiteral("9"), QStringLiteral("10"))); +} + +TEST(NumericSliderBase, SetMinimumClampsExistingValue) +{ + ProbeNumericSlider s; + + s.set_value(5.0); + s.set_minimum_public(10.0); + EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 10.0); + + // New values below the minimum clamp on the way in; programmatic sets do + // not emit the value-changed signal + s.set_value(3.0); + EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 10.0); + EXPECT_TRUE(s.signaled.isEmpty()); + + s.set_value(42.0); + EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 42.0); +} + +TEST(NumericSliderBase, SetMaximumClampsExistingValue) +{ + ProbeNumericSlider s; + + s.set_value(5.0); + s.set_maximum_public(2.0); + EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 2.0); + + s.set_value(99.0); + EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 2.0); + + s.set_value(-7.0); + EXPECT_DOUBLE_EQ(s.get_value().toDouble(), -7.0); +} + +TEST(NumericSliderBase, LadderDragUpdatesValueAndSignals) +{ + LadderConfigGuard guard; + + { + ProbeNumericSlider s; + s.set_ladder_element_count(2); + s.set_value(10.0); + + press_label(s); + ASSERT_TRUE(s.is_dragging()); + EXPECT_FALSE(s.can_set_value_public()); + + olive::SliderLadder *ladder = find_ladder(); + ASSERT_NE(ladder, nullptr); + + // Two outer values either side plus the center entry + EXPECT_EQ(ladder->findChildren().size(), 5); + + // External sets are blocked while the drag owns the value + s.set_value(50.0); + EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 10.0); + EXPECT_TRUE(s.signaled.isEmpty()); + + // value * multiplier accumulates onto the drag start value + ASSERT_TRUE(QMetaObject::invokeMethod( + ladder, "dragged_by_value", Q_ARG(int, 3), Q_ARG(double, 2.0))); + EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 16.0); + ASSERT_EQ(s.signaled.size(), 1); + EXPECT_DOUBLE_EQ(s.signaled.last().toDouble(), 16.0); + + ASSERT_TRUE(QMetaObject::invokeMethod( + ladder, "dragged_by_value", Q_ARG(int, 1), Q_ARG(double, 1.0))); + EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 17.0); + ASSERT_EQ(s.signaled.size(), 2); + + // Releasing after a drag re-signals the final value and ends the drag + ladder->close(); + EXPECT_FALSE(s.is_dragging()); + EXPECT_TRUE(s.can_set_value_public()); + ASSERT_EQ(s.signaled.size(), 3); + EXPECT_DOUBLE_EQ(s.signaled.last().toDouble(), 17.0); + + // Programmatic sets are accepted again + s.set_value(3.0); + EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 3.0); + } + + // Flush the ladder's deleteLater and its queued drag-timer start only + // after the slider is gone, so a stray timer tick can't reach a ladder + // whose owner no longer exists + QCoreApplication::processEvents(); +} + +TEST(NumericSliderBase, LadderDragClampsToRange) +{ + LadderConfigGuard guard; + + { + ProbeNumericSlider s; + s.set_ladder_element_count(2); + s.set_minimum_public(0.0); + s.set_maximum_public(8.0); + s.set_value(5.0); + + press_label(s); + olive::SliderLadder *ladder = find_ladder(); + ASSERT_NE(ladder, nullptr); + + ASSERT_TRUE(QMetaObject::invokeMethod( + ladder, "dragged_by_value", Q_ARG(int, -10), Q_ARG(double, 1.0))); + EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 0.0); + + ASSERT_TRUE(QMetaObject::invokeMethod( + ladder, "dragged_by_value", Q_ARG(int, 100), Q_ARG(double, 1.0))); + EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 8.0); + + ladder->close(); + } + + QCoreApplication::processEvents(); +} + +TEST(NumericSliderBase, ClickWithoutDragShowsEditor) +{ + LadderConfigGuard guard; + + { + ProbeNumericSlider s; + s.set_ladder_element_count(2); + s.set_value(4.0); + + press_label(s); + olive::SliderLadder *ladder = find_ladder(); + ASSERT_NE(ladder, nullptr); + + // A press/release with no drag in between is treated as a click and + // opens the line editor instead of changing the value + ladder->close(); + EXPECT_FALSE(s.is_dragging()); + EXPECT_TRUE(s.signaled.isEmpty()); + + olive::FocusableLineEdit *editor = + s.findChild(); + ASSERT_NE(editor, nullptr); + EXPECT_EQ(s.currentWidget(), editor); + EXPECT_EQ(editor->text(), QStringLiteral("4")); + EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 4.0); + } + + QCoreApplication::processEvents(); +} + +TEST(SliderLadder, BuildsElementPerPowerOfTen) +{ + LadderConfigGuard guard; + + olive::SliderLadder ladder(1.0, 2, QStringLiteral("9999")); + EXPECT_TRUE(ladder.windowFlags() & Qt::Popup); + + const auto elements = ladder.findChildren(); + ASSERT_EQ(elements.size(), 5); + + // Coarse multipliers on top, the unit entry in the middle, fine below + EXPECT_DOUBLE_EQ(elements.at(0)->get_multiplier(), 100.0); + EXPECT_DOUBLE_EQ(elements.at(1)->get_multiplier(), 10.0); + EXPECT_DOUBLE_EQ(elements.at(2)->get_multiplier(), 1.0); + EXPECT_DOUBLE_EQ(elements.at(3)->get_multiplier(), 0.1); + EXPECT_DOUBLE_EQ(elements.at(4)->get_multiplier(), 0.01); + + // Only the center entry starts highlighted + for (int i = 0; i < elements.size(); i++) { + const QPalette::ColorRole expected = + (i == 2) ? QPalette::Highlight : QPalette::Window; + EXPECT_EQ(elements.at(i)->backgroundRole(), expected) << i; + } +} + +TEST(SliderLadder, DragMultiplierScalesElements) +{ + LadderConfigGuard guard; + + olive::SliderLadder ladder(2.5, 1, QStringLiteral("9999")); + + const auto elements = ladder.findChildren(); + ASSERT_EQ(elements.size(), 3); + EXPECT_DOUBLE_EQ(elements.at(0)->get_multiplier(), 25.0); + EXPECT_DOUBLE_EQ(elements.at(1)->get_multiplier(), 2.5); + EXPECT_DOUBLE_EQ(elements.at(2)->get_multiplier(), 0.25); +} + +TEST(SliderLadder, SetValueShowsOnHighlightedElementOnly) +{ + LadderConfigGuard guard; + + olive::SliderLadder ladder(1.0, 1, QStringLiteral("9999")); + ladder.set_value(QStringLiteral("42")); + + const auto elements = ladder.findChildren(); + ASSERT_EQ(elements.size(), 3); + + for (int i = 0; i < elements.size(); i++) { + QLabel *label = elements.at(i)->findChild(); + ASSERT_NE(label, nullptr); + if (i == 1) { + EXPECT_EQ(label->text(), QStringLiteral("1\n42")); + } else { + EXPECT_FALSE(label->text().contains(QStringLiteral("42"))); + } + } +} + +TEST(SliderLadder, CloseEmitsReleased) +{ + LadderConfigGuard guard; + + olive::SliderLadder ladder(1.0, 1, QStringLiteral("9999")); + ladder.show(); + + QSignalSpy spy(&ladder, &olive::SliderLadder::released); + ladder.close(); + EXPECT_EQ(spy.count(), 1); +} + +TEST(SliderLadder, SingleElementHidesMultiplier) +{ + LadderConfigGuard guard; + + // With no outer values the ladder is a single readout, and its multiplier + // is hidden since there's nothing to switch between + olive::SliderLadder ladder(1.0, 0, QStringLiteral("9999")); + + const auto elements = ladder.findChildren(); + ASSERT_EQ(elements.size(), 1); + + ladder.set_value(QStringLiteral("7")); + + QLabel *label = elements.first()->findChild(); + ASSERT_NE(label, nullptr); + EXPECT_EQ(label->text(), QStringLiteral("7")); +} + +TEST(SliderLadderElement, LabelReflectsHighlightAndMultiplierVisibility) +{ + olive::SliderLadderElement e(2.5, QStringLiteral("9999")); + QLabel *label = e.findChild(); + ASSERT_NE(label, nullptr); + + // Unhighlighted: only the multiplier shows, the value slot stays empty + EXPECT_EQ(label->text(), QStringLiteral("2.5\n")); + EXPECT_EQ(e.backgroundRole(), QPalette::Window); + + e.set_value(QStringLiteral("9")); + EXPECT_EQ(label->text(), QStringLiteral("2.5\n")); + + e.set_highlighted(true); + EXPECT_EQ(label->text(), QStringLiteral("2.5\n9")); + EXPECT_EQ(e.backgroundRole(), QPalette::Highlight); + + e.set_highlighted(false); + EXPECT_EQ(e.backgroundRole(), QPalette::Window); + + e.set_multiplier_visible(false); + EXPECT_EQ(label->text(), QStringLiteral("9")); +} diff --git a/tests/gtest/widget_standardcombos_test.cpp b/tests/gtest/widget_standardcombos_test.cpp new file mode 100644 index 0000000000..cd62d7fa45 --- /dev/null +++ b/tests/gtest/widget_standardcombos_test.cpp @@ -0,0 +1,496 @@ +#include + +#include + +#include +#include +#include +#include + +#include "render/videoparams.h" +#include "ui/humanstrings.h" +#include "widget/standardcombos/standardcombos.h" + +// These tests complement widget_combos_test.cpp, which already covers item +// counts and basic value round-trips. Here we cover item text/data contents, +// signal emissions, and the stateful edge cases (custom entries, restore +// behavior, language changes). + +namespace +{ + +// QComboBox::currentIndexChanged is overloaded; spy on the int form +QSignalSpy *index_spy(QComboBox *combo) +{ + return new QSignalSpy(combo, + static_cast( + &QComboBox::currentIndexChanged)); +} + +} // namespace + +TEST(WidgetStandardCombos, SampleRateTextsAndDataMatchHumanStrings) +{ + olive::SampleRateComboBox combo; + + ASSERT_EQ(combo.count(), + int(olive::AudioParams::k_supported_sample_rates.size())); + + for (int i = 0; i < combo.count(); i++) { + const int rate = olive::AudioParams::k_supported_sample_rates.at(i); + EXPECT_EQ(combo.itemData(i).toInt(), rate); + EXPECT_EQ(combo.itemText(i), + olive::HumanStrings::sample_rate_to_string(rate)); + EXPECT_FALSE(combo.itemText(i).isEmpty()); + } +} + +TEST(WidgetStandardCombos, SampleRateSetEmitsIndexChanged) +{ + olive::SampleRateComboBox combo; + combo.set_sample_rate(olive::AudioParams::k_supported_sample_rates.back()); + + QSignalSpy *spy = index_spy(&combo); + combo.set_sample_rate(olive::AudioParams::k_supported_sample_rates.front()); + + ASSERT_EQ(spy->count(), 1); + EXPECT_EQ(spy->first().first().toInt(), 0); + delete spy; +} + +TEST(WidgetStandardCombos, SampleRateUnknownRateLeavesSelection) +{ + olive::SampleRateComboBox combo; + combo.set_sample_rate(olive::AudioParams::k_supported_sample_rates.back()); + + int sentinel = 7; + while (combo.findData(sentinel) != -1) { + sentinel++; + } + + const int old_index = combo.currentIndex(); + combo.set_sample_rate(sentinel); + + EXPECT_EQ(combo.currentIndex(), old_index); + EXPECT_EQ(combo.get_sample_rate(), + olive::AudioParams::k_supported_sample_rates.back()); +} + +TEST(WidgetStandardCombos, ChannelLayoutTextsAndDataMatchHumanStrings) +{ + olive::ChannelLayoutComboBox combo; + + ASSERT_EQ(combo.count(), + int(olive::AudioParams::k_supported_channel_layouts.size())); + + for (int i = 0; i < combo.count(); i++) { + const uint64_t layout = + olive::AudioParams::k_supported_channel_layouts.at(i); + EXPECT_EQ(combo.itemData(i).toULongLong(), layout); + EXPECT_EQ(combo.itemText(i), + olive::HumanStrings::channel_layout_to_string(layout)); + EXPECT_FALSE(combo.itemText(i).isEmpty()); + } +} + +TEST(WidgetStandardCombos, ChannelLayoutSetEmitsIndexChanged) +{ + olive::ChannelLayoutComboBox combo; + combo.set_channel_layout( + olive::AudioParams::k_supported_channel_layouts.back()); + + QSignalSpy *spy = index_spy(&combo); + combo.set_channel_layout( + olive::AudioParams::k_supported_channel_layouts.front()); + + ASSERT_EQ(spy->count(), 1); + EXPECT_EQ(spy->first().first().toInt(), 0); + delete spy; +} + +TEST(WidgetStandardCombos, ChannelLayoutUnknownLayoutLeavesSelection) +{ + olive::ChannelLayoutComboBox combo; + combo.set_channel_layout( + olive::AudioParams::k_supported_channel_layouts.back()); + + const uint64_t sentinel = ~uint64_t(0); + for (int i = 0; i < combo.count(); i++) { + ASSERT_NE(combo.itemData(i).toULongLong(), sentinel); + } + + const int old_index = combo.currentIndex(); + combo.set_channel_layout(sentinel); + + EXPECT_EQ(combo.currentIndex(), old_index); + EXPECT_EQ(combo.get_channel_layout(), + olive::AudioParams::k_supported_channel_layouts.back()); +} + +TEST(WidgetStandardCombos, InterlacedTextsMatchEnumOrder) +{ + olive::InterlacedComboBox combo; + + ASSERT_EQ(combo.count(), 3); + EXPECT_EQ(combo.itemText(int(olive::VideoParams::k_interlace_none)), + QStringLiteral("None (Progressive)")); + EXPECT_EQ(combo.itemText(int(olive::VideoParams::k_interlaced_top_first)), + QStringLiteral("Top-Field First")); + EXPECT_EQ( + combo.itemText(int(olive::VideoParams::k_interlaced_bottom_first)), + QStringLiteral("Bottom-Field First")); +} + +TEST(WidgetStandardCombos, InterlacedSetEmitsIndexChanged) +{ + olive::InterlacedComboBox combo; + + QSignalSpy *spy = index_spy(&combo); + combo.set_interlace_mode(olive::VideoParams::k_interlaced_bottom_first); + + ASSERT_EQ(spy->count(), 1); + EXPECT_EQ(spy->first().first().toInt(), + int(olive::VideoParams::k_interlaced_bottom_first)); + delete spy; +} + +TEST(WidgetStandardCombos, PixelAspectRatioEntriesCarryNamesAndRatios) +{ + olive::PixelAspectRatioComboBox combo; + + const QVector &standards = + olive::VideoParams::k_standard_pixel_aspects; + const QStringList names = + olive::VideoParams::get_standard_pixel_aspect_ratio_names(); + ASSERT_EQ(combo.count(), standards.size() + 1); + ASSERT_EQ(names.size(), standards.size()); + + for (int i = 0; i < standards.size(); i++) { + EXPECT_EQ(combo.itemData(i).value(), standards.at(i)); + EXPECT_EQ(combo.itemText(i), names.at(i)); + } + + // Default selection is the first standard ratio + EXPECT_EQ(combo.currentIndex(), 0); + EXPECT_EQ(combo.get_pixel_aspect_ratio(), standards.first()); +} + +TEST(WidgetStandardCombos, PixelAspectRatioCustomEntryDefaultsToSquare) +{ + olive::PixelAspectRatioComboBox combo; + + const int last = combo.count() - 1; + EXPECT_EQ(combo.itemText(last), QStringLiteral("Custom...")); + + // A null custom ratio is backed by 1:1 so it can never produce a 0 PAR + const olive::Rational data = + combo.itemData(last).value(); + EXPECT_DOUBLE_EQ(data.to_double(), 1.0); +} + +TEST(WidgetStandardCombos, PixelAspectRatioCustomSetRenamesLastEntry) +{ + olive::PixelAspectRatioComboBox combo; + + const QVector &standards = + olive::VideoParams::k_standard_pixel_aspects; + const int last = combo.count() - 1; + + const olive::Rational custom(17, 13); + combo.set_pixel_aspect_ratio(custom); + + EXPECT_EQ(combo.currentIndex(), last); + EXPECT_TRUE( + combo.itemText(last).startsWith(QStringLiteral("Custom ("))); + EXPECT_EQ(combo.itemData(last).value(), custom); + + // Returning to a standard ratio selects it but keeps the custom label + combo.set_pixel_aspect_ratio(standards.first()); + EXPECT_EQ(combo.currentIndex(), 0); + EXPECT_EQ(combo.get_pixel_aspect_ratio(), standards.first()); + EXPECT_TRUE( + combo.itemText(last).startsWith(QStringLiteral("Custom ("))); +} + +TEST(WidgetStandardCombos, PixelAspectRatioSelectingStandardDoesNotPrompt) +{ + olive::PixelAspectRatioComboBox combo; + + const QVector &standards = + olive::VideoParams::k_standard_pixel_aspects; + ASSERT_GE(standards.size(), 2); + + // A user-style index change to a standard entry must not open the + // custom-ratio dialog (selecting the last entry would, so it is + // intentionally not exercised under the offscreen platform) + combo.setCurrentIndex(1); + + EXPECT_EQ(combo.get_pixel_aspect_ratio(), standards.at(1)); + EXPECT_EQ(combo.itemText(combo.count() - 1), + QStringLiteral("Custom...")); +} + +TEST(WidgetStandardCombos, PixelFormatEntriesOrderedWithNames) +{ + olive::PixelFormatComboBox combo(false); + + // Preview formats u8..f32 are added in enum order + ASSERT_EQ(combo.count(), int(olive::core::PixelFormat::count)); + for (int i = 0; i < combo.count(); i++) { + EXPECT_EQ(combo.itemData(i).toInt(), i); + const auto fmt = static_cast(i); + EXPECT_EQ(combo.itemText(i), + olive::VideoParams::get_format_name(fmt)); + EXPECT_FALSE(combo.itemText(i).isEmpty()); + } + + // Default selection is the first (u8) format + EXPECT_EQ(static_cast( + combo.get_pixel_format()), + olive::core::PixelFormat::u8); +} + +TEST(WidgetStandardCombos, PixelFormatSetEmitsIndexChanged) +{ + olive::PixelFormatComboBox combo(false); + + QSignalSpy *spy = index_spy(&combo); + combo.set_pixel_format(olive::core::PixelFormat::f32); + + ASSERT_EQ(spy->count(), 1); + EXPECT_EQ(spy->first().first().toInt(), + int(olive::core::PixelFormat::f32)); + delete spy; +} + +TEST(WidgetStandardCombos, PixelFormatFloatOnlyIgnoresIntegerFormat) +{ + olive::PixelFormatComboBox combo(true); + ASSERT_GT(combo.count(), 0); + + // u8 is not a float format, so it cannot be selected here + combo.set_pixel_format(olive::core::PixelFormat::u8); + + olive::core::PixelFormat selected = + static_cast( + combo.get_pixel_format()); + EXPECT_TRUE(selected.is_float()); + EXPECT_EQ(combo.currentIndex(), 0); +} + +TEST(WidgetStandardCombos, SampleFormatStartsEmpty) +{ + olive::SampleFormatComboBox combo; + EXPECT_EQ(combo.count(), 0); +} + +TEST(WidgetStandardCombos, SampleFormatAvailableFormatsPopulateInOrder) +{ + using SampleFormat = olive::core::SampleFormat; + + olive::SampleFormatComboBox combo; + + const std::vector formats = { + SampleFormat::u8, SampleFormat::s16, SampleFormat::f32 + }; + combo.set_available_formats(formats); + + ASSERT_EQ(combo.count(), int(formats.size())); + for (int i = 0; i < combo.count(); i++) { + EXPECT_EQ(combo.itemData(i).toInt(), + int(static_cast(formats.at(i)))); + EXPECT_EQ(combo.itemText(i), + olive::HumanStrings::format_to_string(formats.at(i))); + } + + // The first entry is selected by default + EXPECT_EQ(static_cast(combo.get_sample_format()), + SampleFormat::u8); +} + +TEST(WidgetStandardCombos, SampleFormatRestoreDisabledReselectsFirst) +{ + using Format = olive::core::SampleFormat::Format; + + olive::SampleFormatComboBox combo; + combo.set_attempt_to_restore_format(false); + + combo.set_packed_formats(); + combo.set_sample_format(olive::core::SampleFormat::f32); + ASSERT_EQ(static_cast(combo.get_sample_format()), + olive::core::SampleFormat::f32); + + // With restore disabled, repopulating falls back to the first entry + combo.set_packed_formats(); + EXPECT_EQ(combo.currentIndex(), 0); + EXPECT_EQ(static_cast(combo.get_sample_format()), + static_cast(olive::core::SampleFormat::packed_start)); +} + +TEST(WidgetStandardCombos, SampleFormatSetEmitsIndexChanged) +{ + olive::SampleFormatComboBox combo; + combo.set_packed_formats(); + + QSignalSpy *spy = index_spy(&combo); + combo.set_sample_format(olive::core::SampleFormat::f32); + + ASSERT_EQ(spy->count(), 1); + EXPECT_EQ(spy->first().first().toInt(), + int(olive::core::SampleFormat::f32) - + int(olive::core::SampleFormat::packed_start)); + delete spy; +} + +TEST(WidgetStandardCombos, VideoDividerTextsAndDataMatch) +{ + olive::VideoDividerComboBox combo; + + ASSERT_EQ(combo.count(), olive::VideoParams::k_supported_dividers.size()); + + for (int i = 0; i < combo.count(); i++) { + const int divider = olive::VideoParams::k_supported_dividers.at(i); + EXPECT_EQ(combo.itemData(i).toInt(), divider); + EXPECT_EQ(combo.itemText(i), + olive::VideoParams::get_name_for_divider(divider)); + EXPECT_FALSE(combo.itemText(i).isEmpty()); + } +} + +TEST(WidgetStandardCombos, VideoDividerSetEmitsIndexChanged) +{ + olive::VideoDividerComboBox combo; + combo.set_divider(olive::VideoParams::k_supported_dividers.last()); + + QSignalSpy *spy = index_spy(&combo); + combo.set_divider(olive::VideoParams::k_supported_dividers.first()); + + ASSERT_EQ(spy->count(), 1); + EXPECT_EQ(spy->first().first().toInt(), 0); + delete spy; +} + +TEST(WidgetStandardCombos, VideoDividerUnknownDividerLeavesSelection) +{ + olive::VideoDividerComboBox combo; + combo.set_divider(olive::VideoParams::k_supported_dividers.last()); + + int sentinel = 3; + while (combo.findData(sentinel) != -1) { + sentinel += 2; + } + + const int old_index = combo.currentIndex(); + combo.set_divider(sentinel); + + EXPECT_EQ(combo.currentIndex(), old_index); + EXPECT_EQ(combo.get_divider(), + olive::VideoParams::k_supported_dividers.last()); +} + +TEST(WidgetStandardCombos, FrameRateHasTrailingCustomEntry) +{ + olive::FrameRateComboBox combo; + + QComboBox *inner = combo.findChild(); + ASSERT_NE(inner, nullptr); + + const QVector &standards = + olive::VideoParams::k_supported_frame_rates; + ASSERT_EQ(inner->count(), standards.size() + 1); + + for (int i = 0; i < standards.size(); i++) { + EXPECT_EQ(inner->itemData(i).value(), + standards.at(i)); + EXPECT_EQ(inner->itemText(i), + olive::VideoParams::frame_rate_to_string(standards.at(i))); + } + + EXPECT_EQ(inner->itemText(standards.size()), + QStringLiteral("Custom...")); +} + +TEST(WidgetStandardCombos, FrameRateProgrammaticSetDoesNotEmit) +{ + olive::FrameRateComboBox combo; + + QSignalSpy spy(&combo, &olive::FrameRateComboBox::frame_rate_changed); + + combo.set_frame_rate(olive::VideoParams::k_supported_frame_rates.at(1)); + combo.set_frame_rate(olive::Rational(27, 2)); + combo.set_frame_rate(olive::VideoParams::k_supported_frame_rates.at(0)); + + EXPECT_EQ(spy.count(), 0); +} + +TEST(WidgetStandardCombos, FrameRateUserSelectionEmitsFrameRateChanged) +{ + olive::FrameRateComboBox combo; + + QComboBox *inner = combo.findChild(); + ASSERT_NE(inner, nullptr); + ASSERT_GE(olive::VideoParams::k_supported_frame_rates.size(), 3); + + QSignalSpy spy(&combo, &olive::FrameRateComboBox::frame_rate_changed); + + // Standard entries emit directly; only the last "Custom..." entry would + // open a modal input dialog, which is not exercised here + inner->setCurrentIndex(2); + + ASSERT_EQ(spy.count(), 1); + EXPECT_EQ(spy.first().first().value(), + olive::VideoParams::k_supported_frame_rates.at(2)); + EXPECT_EQ(combo.get_frame_rate(), + olive::VideoParams::k_supported_frame_rates.at(2)); +} + +TEST(WidgetStandardCombos, FrameRateCustomSetRenamesLastEntry) +{ + olive::FrameRateComboBox combo; + + QComboBox *inner = combo.findChild(); + ASSERT_NE(inner, nullptr); + const int last = inner->count() - 1; + + combo.set_frame_rate(olive::Rational(27, 2)); + EXPECT_EQ(inner->currentIndex(), last); + EXPECT_TRUE( + inner->itemText(last).startsWith(QStringLiteral("Custom ("))); + EXPECT_EQ(combo.get_frame_rate(), olive::Rational(27, 2)); + + // A second custom rate replaces the label's rate + combo.set_frame_rate(olive::Rational(29, 2)); + EXPECT_EQ(inner->currentIndex(), last); + EXPECT_EQ(combo.get_frame_rate(), olive::Rational(29, 2)); + + // User-selecting a standard rate afterwards emits and returns it + QSignalSpy spy(&combo, &olive::FrameRateComboBox::frame_rate_changed); + inner->setCurrentIndex(0); + + ASSERT_EQ(spy.count(), 1); + EXPECT_EQ(spy.first().first().value(), + olive::VideoParams::k_supported_frame_rates.first()); + EXPECT_EQ(combo.get_frame_rate(), + olive::VideoParams::k_supported_frame_rates.first()); +} + +TEST(WidgetStandardCombos, FrameRateLanguageChangePreservesCustom) +{ + olive::FrameRateComboBox combo; + + combo.set_frame_rate(olive::Rational(27, 2)); + + QEvent ev(QEvent::LanguageChange); + QApplication::sendEvent(&combo, &ev); + + QComboBox *inner = combo.findChild(); + ASSERT_NE(inner, nullptr); + + const QVector &standards = + olive::VideoParams::k_supported_frame_rates; + ASSERT_EQ(inner->count(), standards.size() + 1); + EXPECT_EQ(inner->currentIndex(), standards.size()); + EXPECT_EQ(combo.get_frame_rate(), olive::Rational(27, 2)); + EXPECT_TRUE(inner->itemText(standards.size()) + .startsWith(QStringLiteral("Custom ("))); +} diff --git a/tests/gtest/widget_timebased2_test.cpp b/tests/gtest/widget_timebased2_test.cpp new file mode 100644 index 0000000000..c31109c80d --- /dev/null +++ b/tests/gtest/widget_timebased2_test.cpp @@ -0,0 +1,870 @@ +#include + +#include +#include +#include +#include +#include + +#include "core.h" +#include "node/output/viewer/viewer.h" +#include "timeline/timelinemarker.h" +#include "timeline/timelineworkarea.h" +#include "widget/resizablescrollbar/resizabletimelinescrollbar.h" +#include "widget/timebased/timebasedview.h" +#include "widget/timebased/timebasedviewselectionmanager.h" +#include "widget/timebased/timescaledobject.h" +#include "widget/timetarget/timetarget.h" + +namespace +{ + +// HandMovableView (base of TimeBasedView) connects to Core::instance() at +// construction, so the application singleton must exist +void ensure_core() +{ + if (!olive::Core::instance()) { + new olive::Core(); // intentionally leaked + } +} + +// TimeScaledObject is not a QObject and its interesting hooks are protected, +// so expose them through a probe +class ProbeScaledObject : public olive::TimeScaledObject { +public: + int timebase_events = 0; + olive::Rational last_timebase; + int scale_events = 0; + double last_scale = 0.0; + + void pub_set_minimum_scale(const double &min) + { + set_minimum_scale(min); + } + void pub_set_maximum_scale(const double &max) + { + set_maximum_scale(max); + } + +protected: + void TimebaseChangedEvent(const olive::Rational &tb) override + { + timebase_events++; + last_timebase = tb; + } + + void ScaleChangedEvent(const double &scale) override + { + scale_events++; + last_scale = scale; + } +}; + +// Records the y-axis hook and exposes the protected playhead/zoom entry +// points of TimeBasedView +class ProbeTimeBasedView : public olive::TimeBasedView { +public: + int y_scale_events = 0; + double last_y_scale = 0.0; + std::vector select_events; + std::vector deselect_events; + + void pub_set_y_axis_enabled(bool e) + { + set_y_axis_enabled(e); + } + void pub_zoom(QWheelEvent *e, double multiplier, const QPointF &pos) + { + zoom_into_cursor_position(e, multiplier, pos); + } + bool pub_playhead_press(QMouseEvent *e) + { + return playhead_press(e); + } + bool pub_playhead_move(QMouseEvent *e) + { + return playhead_move(e); + } + bool pub_playhead_release(QMouseEvent *e) + { + return playhead_release(e); + } + + void SelectionManagerSelectEvent(void *obj) override + { + select_events.push_back(obj); + } + void SelectionManagerDeselectEvent(void *obj) override + { + deselect_events.push_back(obj); + } + +protected: + void VerticalScaleChangedEvent(double scale) override + { + y_scale_events++; + last_y_scale = scale; + } +}; + +// Stand-in selectable object for the templated selection manager; the drag +// paths (which need engine keyframe/marker free functions) are never +// instantiated by these tests +struct FakeSelectable { +}; + +// Records the protected TimeTargetObject hooks +class ProbeTimeTarget : public olive::TimeTargetObject { +public: + std::vector connected; + std::vector disconnected; + std::vector changed; + +protected: + void TimeTargetConnectEvent(OakEngineNode *n) override + { + connected.push_back(n); + } + void TimeTargetDisconnectEvent(OakEngineNode *n) override + { + disconnected.push_back(n); + } + void TimeTargetChangedEvent(OakEngineNode *n) override + { + changed.push_back(n); + } +}; + +QMouseEvent make_press(const QPointF &pos, Qt::MouseButton button, + Qt::KeyboardModifiers mods = Qt::NoModifier) +{ + return QMouseEvent(QEvent::MouseButtonPress, pos, pos, pos, button, button, + mods); +} + +// The selection manager hit-tests in unscaled scene coordinates: it maps the +// view-local click through mapToScene() and then unscale_point(). A hidden +// QGraphicsView centers its (zero-height) scene rect inside the viewport, so +// view-local coordinates are NOT scene coordinates; use the inverse mapping +// to aim a synthetic event at an unscaled scene point deterministically. +QPoint view_pos_for_unscaled_point(olive::TimeBasedView *view, const QPointF &p) +{ + return view->mapFromScene(view->scale_point(p)); +} + +// Renders a widget into a fresh image so two states can be compared +// pixel-for-pixel without needing the window to be exposed +QImage render_widget(QWidget *w) +{ + QImage img(w->size(), QImage::Format_ARGB32); + img.fill(Qt::transparent); + w->render(&img); + return img; +} + +} // namespace + +TEST(TimeScaledObject, DefaultsAreUnitScaleAndNullTimebase) +{ + olive::TimelineScaledWidget w; + EXPECT_DOUBLE_EQ(w.get_scale(), 1.0); + EXPECT_TRUE(w.timebase().isNull()); + // NB: timebase_dbl() is only meaningful after set_timebase(); the + // constructor leaves it uninitialized, so it is not asserted here + EXPECT_GT(w.get_maximum_scale(), 1.0); +} + +TEST(TimeScaledObject, SetTimebaseCachesDoubleAndFiresEvent) +{ + ProbeScaledObject o; + o.set_timebase(olive::Rational(1, 30)); + + EXPECT_EQ(o.timebase(), olive::Rational(1, 30)); + EXPECT_NEAR(o.timebase_dbl(), 1.0 / 30.0, 1e-12); + EXPECT_EQ(o.timebase_events, 1); + EXPECT_EQ(o.last_timebase, olive::Rational(1, 30)); + + o.set_timebase(olive::Rational(1, 24)); + EXPECT_EQ(o.timebase_events, 2); + EXPECT_NEAR(o.timebase_dbl(), 1.0 / 24.0, 1e-12); +} + +TEST(TimeScaledObject, ScaleClampsToMinimumAndMaximum) +{ + ProbeScaledObject o; + o.pub_set_minimum_scale(0.5); + o.pub_set_maximum_scale(10.0); + + // Default scale (1.0) is inside the new limits, so no clamp event yet + EXPECT_DOUBLE_EQ(o.get_scale(), 1.0); + + o.set_scale(100.0); + EXPECT_DOUBLE_EQ(o.get_scale(), 10.0); + EXPECT_DOUBLE_EQ(o.last_scale, 10.0); + + o.set_scale(0.01); + EXPECT_DOUBLE_EQ(o.get_scale(), 0.5); + EXPECT_DOUBLE_EQ(o.last_scale, 0.5); + + o.set_scale(2.0); + EXPECT_DOUBLE_EQ(o.get_scale(), 2.0); + EXPECT_EQ(o.scale_events, 3); +} + +TEST(TimeScaledObject, ShrinkingLimitsPullScaleIntoRange) +{ + ProbeScaledObject o; + o.set_scale(5.0); + + // Lowering the maximum below the current scale clamps the scale down + o.pub_set_maximum_scale(2.0); + EXPECT_DOUBLE_EQ(o.get_scale(), 2.0); + + // Raising the minimum above the current scale pushes the scale up + o.pub_set_maximum_scale(100.0); + o.pub_set_minimum_scale(4.0); + EXPECT_DOUBLE_EQ(o.get_scale(), 4.0); +} + +TEST(TimeScaledObject, InvertedLimitsNeverBreakScaleClamp) +{ + ProbeScaledObject o; + o.set_scale(5.0); + + // Narrow the valid range to [2, 3] + o.pub_set_maximum_scale(3.0); + o.pub_set_minimum_scale(2.0); + EXPECT_DOUBLE_EQ(o.get_scale(), 3.0); + + // Raising the minimum past the maximum pulls the maximum up with it, so + // the scale lands exactly on the new minimum instead of hitting an + // inverted std::clamp (undefined behavior) + o.pub_set_minimum_scale(10.0); + EXPECT_DOUBLE_EQ(o.get_maximum_scale(), 10.0); + EXPECT_DOUBLE_EQ(o.get_scale(), 10.0); + + // Lowering the maximum past the minimum pushes the minimum down instead + o.pub_set_maximum_scale(4.0); + EXPECT_DOUBLE_EQ(o.get_maximum_scale(), 4.0); + EXPECT_DOUBLE_EQ(o.get_scale(), 4.0); +} + +TEST(TimeScaledObject, TimeToSceneScalesTime) +{ + olive::TimelineScaledWidget w; + + // Null timebase maps everything to zero + EXPECT_DOUBLE_EQ(w.time_to_scene(olive::Rational(10)), 0.0); + + w.set_timebase(olive::Rational(1, 30)); + w.set_scale(30.0); + + // time_to_scene is time in seconds multiplied by the scale + EXPECT_DOUBLE_EQ(w.time_to_scene(olive::Rational(1, 30)), 1.0); + EXPECT_DOUBLE_EQ(w.time_to_scene(olive::Rational(2)), 60.0); +} + +TEST(TimeScaledObject, SceneToTimeFloorsCeilsAndRounds) +{ + olive::TimelineScaledWidget w; + w.set_timebase(olive::Rational(1, 30)); + w.set_scale(30.0); + + // 45.5 px / 30 px-per-frame = 45.5 frames + // floor for positive, ceil for negative, nearest when rounding + EXPECT_EQ(w.scene_to_time(45.5), olive::Rational(45, 30)); + EXPECT_EQ(w.scene_to_time(-10.5), olive::Rational(-10, 30)); + EXPECT_EQ(w.scene_to_time(45.5, true), olive::Rational(46, 30)); + + // Exact grid points map exactly + EXPECT_EQ(w.scene_to_time(60.0), olive::Rational(2)); + + // Null timebase always yields a null time + olive::TimelineScaledWidget null_tb; + EXPECT_TRUE(null_tb.scene_to_time(123.0).isNull()); + EXPECT_TRUE( + olive::TimeScaledObject::scene_to_time(10.0, 1.0, olive::Rational()) + .isNull()); +} + +TEST(TimeScaledObject, SceneToTimeNoGridIgnoresTimebase) +{ + // Static form is a pure scale division + const olive::Rational r = + olive::TimeScaledObject::scene_to_time_no_grid(3.0, 2.0); + EXPECT_NEAR(r.to_double(), 1.5, 1e-9); + + // The instance form with a null timebase divides by the current scale + olive::TimelineScaledWidget w; + w.set_scale(2.0); + EXPECT_NEAR(w.scene_to_time_no_grid(3.0).to_double(), 1.5, 1e-9); + + // A set timebase does not affect the no-grid conversion + w.set_timebase(olive::Rational(1, 30)); + EXPECT_NEAR(w.scene_to_time_no_grid(3.0).to_double(), 1.5, 1e-9); +} + +TEST(TimeScaledObject, ScaleFromDimensionsMath) +{ + // (viewport / 10 * 9) / content + EXPECT_DOUBLE_EQ( + olive::TimeScaledObject::calculate_scale_from_dimensions(1000, 500), + 1.8); + EXPECT_DOUBLE_EQ( + olive::TimeScaledObject::calculate_padding_from_dimension_scale(100), + 5.0); + + olive::TimelineScaledWidget w; + w.set_scale_from_dimensions(1000, 500); + EXPECT_DOUBLE_EQ(w.get_scale(), 1.8); +} + +TEST(TimeBasedView, ConstructionDefaults) +{ + ensure_core(); + + olive::TimeBasedView view; + EXPECT_DOUBLE_EQ(view.get_scale(), 1.0); + EXPECT_DOUBLE_EQ(view.get_y_scale(), 1.0); + EXPECT_FALSE(view.is_snapped()); + EXPECT_EQ(view.get_snap_service(), nullptr); + EXPECT_EQ(view.get_viewer_node(), nullptr); + EXPECT_FALSE(view.is_dragging_playhead()); + EXPECT_EQ(view.dragMode(), QGraphicsView::NoDrag); + EXPECT_NE(view.scene(), nullptr); +} + +TEST(TimeBasedView, SnapEnableDisableTogglesState) +{ + ensure_core(); + + olive::TimeBasedView view; + view.enable_snap({ olive::Rational(1), olive::Rational(2) }); + EXPECT_TRUE(view.is_snapped()); + + view.disable_snap(); + EXPECT_FALSE(view.is_snapped()); +} + +TEST(TimeBasedView, YScaleEventOnlyFiresWhenAxisEnabled) +{ + ensure_core(); + + ProbeTimeBasedView view; + + // Y axis disabled: the value is stored but no event fires + view.set_y_scale(2.0); + EXPECT_DOUBLE_EQ(view.get_y_scale(), 2.0); + EXPECT_EQ(view.y_scale_events, 0); + + view.pub_set_y_axis_enabled(true); + view.set_y_scale(3.0); + EXPECT_DOUBLE_EQ(view.get_y_scale(), 3.0); + EXPECT_EQ(view.y_scale_events, 1); + EXPECT_DOUBLE_EQ(view.last_y_scale, 3.0); +} + +TEST(TimeBasedView, ScalePointAndUnscalePointAreInverses) +{ + ensure_core(); + + olive::TimeBasedView view; + view.set_scale(2.0); + view.set_y_scale(4.0); + + const QPointF scaled = view.scale_point(QPointF(1.5, 0.5)); + EXPECT_DOUBLE_EQ(scaled.x(), 3.0); + EXPECT_DOUBLE_EQ(scaled.y(), 2.0); + + const QPointF unscaled = view.unscale_point(scaled); + EXPECT_DOUBLE_EQ(unscaled.x(), 1.5); + EXPECT_DOUBLE_EQ(unscaled.y(), 0.5); +} + +TEST(TimeBasedView, EndTimeDrivesSceneRectRightEdge) +{ + ensure_core(); + + olive::TimeBasedView view; + view.resize(400, 300); + view.set_timebase(olive::Rational(1, 30)); + view.set_scale(100.0); + view.set_end_time(olive::Rational(10)); + + // update_scene_rect pins the left edge to zero and puts the right edge + // one viewport width past the end of the content + QRectF rect = view.scene()->sceneRect(); + EXPECT_DOUBLE_EQ(rect.left(), 0.0); + EXPECT_DOUBLE_EQ(rect.right(), 10.0 * 100.0 + view.width()); + + // Changing the scale re-runs update_scene_rect + view.set_scale(200.0); + rect = view.scene()->sceneRect(); + EXPECT_DOUBLE_EQ(rect.right(), 10.0 * 200.0 + view.width()); +} + +TEST(TimeBasedView, ZoomWithoutYAxisEmitsHorizontalScaleChange) +{ + ensure_core(); + + ProbeTimeBasedView view; + view.resize(400, 300); + QSignalSpy spy(&view, &olive::TimeBasedView::scale_changed); + + QWheelEvent wheel(QPointF(10, 10), QPointF(10, 10), QPoint(), + QPoint(0, 120), Qt::NoButton, Qt::NoModifier, + Qt::NoScrollPhase, false); + view.pub_zoom(&wheel, 2.0, QPointF(10, 10)); + + // The view only emits the requested scale; applying it is up to the + // parent widget, so the stored scale is unchanged + ASSERT_EQ(spy.count(), 1); + EXPECT_DOUBLE_EQ(spy.first().first().toDouble(), 2.0); + EXPECT_DOUBLE_EQ(view.get_scale(), 1.0); + EXPECT_DOUBLE_EQ(view.get_y_scale(), 1.0); +} + +TEST(TimeBasedView, ZoomWithYAxisAndShiftOnlyScalesVertically) +{ + ensure_core(); + + ProbeTimeBasedView view; + view.resize(400, 300); + view.pub_set_y_axis_enabled(true); + QSignalSpy spy(&view, &olive::TimeBasedView::scale_changed); + + // Shift (without Alt) restricts the zoom to the vertical axis + QWheelEvent wheel(QPointF(10, 10), QPointF(10, 10), QPoint(), + QPoint(0, 120), Qt::NoButton, Qt::ShiftModifier, + Qt::NoScrollPhase, false); + view.pub_zoom(&wheel, 2.0, QPointF(10, 10)); + + EXPECT_EQ(spy.count(), 0); + EXPECT_DOUBLE_EQ(view.get_y_scale(), 2.0); + EXPECT_EQ(view.y_scale_events, 1); +} + +TEST(TimeBasedView, ZoomWithYAxisShiftAndAltOnlyScalesHorizontally) +{ + ensure_core(); + + ProbeTimeBasedView view; + view.resize(400, 300); + view.pub_set_y_axis_enabled(true); + QSignalSpy spy(&view, &olive::TimeBasedView::scale_changed); + + QWheelEvent wheel(QPointF(10, 10), QPointF(10, 10), QPoint(), + QPoint(0, 120), Qt::NoButton, + Qt::ShiftModifier | Qt::AltModifier, Qt::NoScrollPhase, + false); + view.pub_zoom(&wheel, 2.0, QPointF(10, 10)); + + ASSERT_EQ(spy.count(), 1); + EXPECT_DOUBLE_EQ(spy.first().first().toDouble(), 2.0); + EXPECT_DOUBLE_EQ(view.get_y_scale(), 1.0); + EXPECT_EQ(view.y_scale_events, 0); +} + +TEST(TimeBasedView, PlayheadDragRequiresPaintedPlayheadRect) +{ + ensure_core(); + + ProbeTimeBasedView view; + view.resize(400, 300); + + // The playhead hit rect is only populated by drawForeground; before any + // paint the rect is empty and nothing can grab the playhead + QMouseEvent press = make_press(QPointF(50, 50), Qt::LeftButton); + EXPECT_FALSE(view.pub_playhead_press(&press)); + EXPECT_FALSE(view.is_dragging_playhead()); + + // Move/release are no-ops without an active drag + QMouseEvent move(QEvent::MouseMove, QPointF(60, 50), QPointF(60, 50), + QPointF(60, 50), Qt::NoButton, Qt::LeftButton, + Qt::NoModifier); + EXPECT_FALSE(view.pub_playhead_move(&move)); + + QMouseEvent release(QEvent::MouseButtonRelease, QPointF(60, 50), + QPointF(60, 50), QPointF(60, 50), Qt::LeftButton, + Qt::NoButton, Qt::NoModifier); + EXPECT_FALSE(view.pub_playhead_release(&release)); +} + +TEST(TimeBasedView, SetViewerNodeStoresAndClears) +{ + ensure_core(); + + olive::TimeBasedView view; + auto *viewer = new olive::ViewerOutput(); + OakEngineNode *handle = reinterpret_cast(viewer); + + view.set_viewer_node(handle); + EXPECT_EQ(view.get_viewer_node(), handle); + + view.set_viewer_node(nullptr); + EXPECT_EQ(view.get_viewer_node(), nullptr); + + delete viewer; +} + +TEST(TimeBasedViewSelectionManager, SelectDeselectSemantics) +{ + ensure_core(); + + olive::TimeBasedView view; + olive::TimeBasedViewSelectionManager mgr(&view); + + FakeSelectable a, b; + EXPECT_TRUE(mgr.get_selected_objects().empty()); + + EXPECT_TRUE(mgr.select(&a)); + EXPECT_TRUE(mgr.is_selected(&a)); + EXPECT_FALSE(mgr.is_selected(&b)); + + // Re-selecting an already selected object is a no-op + EXPECT_FALSE(mgr.select(&a)); + EXPECT_EQ(mgr.get_selected_objects().size(), 1u); + + EXPECT_TRUE(mgr.select(&b)); + EXPECT_EQ(mgr.get_selected_objects().size(), 2u); + + EXPECT_TRUE(mgr.deselect(&a)); + EXPECT_FALSE(mgr.is_selected(&a)); + + // Deselecting something not selected reports failure + EXPECT_FALSE(mgr.deselect(&a)); + + mgr.clear_selection(); + EXPECT_TRUE(mgr.get_selected_objects().empty()); + EXPECT_FALSE(mgr.is_selected(&b)); +} + +TEST(TimeBasedViewSelectionManager, ObjectAtPointPrefersLastDrawn) +{ + ensure_core(); + + olive::TimeBasedView view; // scale 1.0, y scale 1.0: scene == unscaled + olive::TimeBasedViewSelectionManager mgr(&view); + + FakeSelectable bottom, top; + mgr.declare_drawn_object(&bottom, QRectF(0, 0, 100, 100)); + mgr.declare_drawn_object(&top, QRectF(50, 50, 100, 100)); + + // In the overlap the object drawn later (i.e. on top) wins + EXPECT_EQ(mgr.get_object_at_point(QPointF(75, 75)), &top); + // Outside the overlap the bottom object is hit + EXPECT_EQ(mgr.get_object_at_point(QPointF(25, 25)), &bottom); + // A complete miss returns nullptr + EXPECT_EQ(mgr.get_object_at_point(QPointF(500, 500)), nullptr); + + mgr.clear_drawn_objects(); + EXPECT_EQ(mgr.get_object_at_point(QPointF(25, 25)), nullptr); +} + +TEST(TimeBasedViewSelectionManager, ObjectAtPointMatchesDeclaredRectBounds) +{ + ensure_core(); + + olive::TimeBasedView view; // scale 1.0, y scale 1.0: scene == unscaled + olive::TimeBasedViewSelectionManager mgr(&view); + + FakeSelectable item; + mgr.declare_drawn_object(&item, QRectF(10, 10, 30, 30)); + + // The rect spans scene coordinates [10, 40] on both axes + EXPECT_EQ(mgr.get_object_at_point(QPointF(10, 10)), &item); + EXPECT_EQ(mgr.get_object_at_point(QPointF(40, 40)), &item); + EXPECT_EQ(mgr.get_object_at_point(QPointF(41, 41)), nullptr); + EXPECT_EQ(mgr.get_object_at_point(QPointF(9, 20)), nullptr); +} + +TEST(TimeBasedViewSelectionManager, MousePressSelectsAndClears) +{ + ensure_core(); + + ProbeTimeBasedView view; + olive::TimeBasedViewSelectionManager mgr(&view); + + FakeSelectable item; + mgr.declare_drawn_object(&item, QRectF(10, 10, 20, 20)); + + // Left click on the object selects it and notifies the view + QMouseEvent hit = make_press( + view_pos_for_unscaled_point(&view, QPointF(15, 15)), Qt::LeftButton); + EXPECT_EQ(mgr.mouse_press(&hit), &item); + EXPECT_TRUE(mgr.is_selected(&item)); + ASSERT_EQ(view.select_events.size(), 1u); + EXPECT_EQ(view.select_events.at(0), &item); + + // Left click on empty space clears the selection and returns nothing + QMouseEvent miss = make_press( + view_pos_for_unscaled_point(&view, QPointF(300, 300)), Qt::LeftButton); + EXPECT_EQ(mgr.mouse_press(&miss), nullptr); + EXPECT_TRUE(mgr.get_selected_objects().empty()); + + // Right click also selects + QMouseEvent right = make_press( + view_pos_for_unscaled_point(&view, QPointF(15, 15)), Qt::RightButton); + EXPECT_EQ(mgr.mouse_press(&right), &item); + EXPECT_TRUE(mgr.is_selected(&item)); +} + +TEST(TimeBasedViewSelectionManager, ShiftClickTogglesWithoutClearing) +{ + ensure_core(); + + ProbeTimeBasedView view; + olive::TimeBasedViewSelectionManager mgr(&view); + + FakeSelectable a, b; + mgr.declare_drawn_object(&a, QRectF(10, 10, 20, 20)); + mgr.declare_drawn_object(&b, QRectF(100, 100, 20, 20)); + + // Plain click selects a + QMouseEvent press_a = make_press( + view_pos_for_unscaled_point(&view, QPointF(15, 15)), Qt::LeftButton); + mgr.mouse_press(&press_a); + ASSERT_TRUE(mgr.is_selected(&a)); + + // Shift-click on b adds it without clearing a + QMouseEvent shift_b = + make_press(view_pos_for_unscaled_point(&view, QPointF(105, 105)), + Qt::LeftButton, Qt::ShiftModifier); + EXPECT_EQ(mgr.mouse_press(&shift_b), &b); + EXPECT_TRUE(mgr.is_selected(&a)); + EXPECT_TRUE(mgr.is_selected(&b)); + + // Shift-click on the already-selected a deselects it and reports no + // object under the cursor + QMouseEvent shift_a = + make_press(view_pos_for_unscaled_point(&view, QPointF(15, 15)), + Qt::LeftButton, Qt::ShiftModifier); + EXPECT_EQ(mgr.mouse_press(&shift_a), nullptr); + EXPECT_FALSE(mgr.is_selected(&a)); + EXPECT_TRUE(mgr.is_selected(&b)); + ASSERT_EQ(view.deselect_events.size(), 1u); + EXPECT_EQ(view.deselect_events.at(0), &a); +} + +TEST(TimeBasedViewSelectionManager, RubberBandSelectsIntersectingObjects) +{ + ensure_core(); + + olive::TimeBasedView view; + view.resize(400, 300); + olive::TimeBasedViewSelectionManager mgr(&view); + + FakeSelectable inside, outside; + mgr.declare_drawn_object(&inside, QRectF(10, 10, 20, 20)); + mgr.declare_drawn_object(&outside, QRectF(300, 300, 20, 20)); + + EXPECT_FALSE(mgr.is_rubber_banding()); + + QMouseEvent press = make_press( + view_pos_for_unscaled_point(&view, QPointF(0, 0)), Qt::LeftButton); + mgr.rubber_band_start(&press); + EXPECT_TRUE(mgr.is_rubber_banding()); + + mgr.rubber_band_move(view_pos_for_unscaled_point(&view, QPointF(100, 100))); + EXPECT_TRUE(mgr.is_selected(&inside)); + EXPECT_FALSE(mgr.is_selected(&outside)); + + mgr.rubber_band_stop(); + EXPECT_FALSE(mgr.is_rubber_banding()); + + // Stopping twice is harmless + mgr.rubber_band_stop(); +} + +TEST(TimeBasedViewSelectionManager, RubberBandKeepsPreselection) +{ + ensure_core(); + + olive::TimeBasedView view; + view.resize(400, 300); + olive::TimeBasedViewSelectionManager mgr(&view); + + FakeSelectable pre, banded; + mgr.declare_drawn_object(&pre, QRectF(300, 300, 20, 20)); + mgr.declare_drawn_object(&banded, QRectF(10, 10, 20, 20)); + mgr.select(&pre); + + QMouseEvent press = make_press( + view_pos_for_unscaled_point(&view, QPointF(0, 0)), Qt::LeftButton); + mgr.rubber_band_start(&press); + mgr.rubber_band_move(view_pos_for_unscaled_point(&view, QPointF(100, 100))); + + // Objects selected before the band started stay selected + EXPECT_TRUE(mgr.is_selected(&pre)); + EXPECT_TRUE(mgr.is_selected(&banded)); + + mgr.rubber_band_stop(); +} + +TEST(TimeTargetObject, DefaultsToNullTarget) +{ + olive::TimeTargetObject target; + EXPECT_EQ(target.get_time_target(), nullptr); +} + +TEST(TimeTargetObject, SetTimeTargetFiresEventSequence) +{ + auto *node_a = new olive::ViewerOutput(); + auto *node_b = new olive::ViewerOutput(); + OakEngineNode *handle_a = reinterpret_cast(node_a); + OakEngineNode *handle_b = reinterpret_cast(node_b); + + ProbeTimeTarget target; + + // null -> a: changed + connect, no disconnect + target.set_time_target(handle_a); + EXPECT_EQ(target.get_time_target(), handle_a); + EXPECT_TRUE(target.disconnected.empty()); + ASSERT_EQ(target.changed.size(), 1u); + EXPECT_EQ(target.changed.at(0), handle_a); + ASSERT_EQ(target.connected.size(), 1u); + EXPECT_EQ(target.connected.at(0), handle_a); + + // a -> b: disconnect(a), changed(b), connect(b) + target.set_time_target(handle_b); + ASSERT_EQ(target.disconnected.size(), 1u); + EXPECT_EQ(target.disconnected.at(0), handle_a); + ASSERT_EQ(target.changed.size(), 2u); + EXPECT_EQ(target.changed.at(1), handle_b); + ASSERT_EQ(target.connected.size(), 2u); + EXPECT_EQ(target.connected.at(1), handle_b); + + // b -> null: disconnect(b), changed(null), no connect + target.set_time_target(nullptr); + EXPECT_EQ(target.get_time_target(), nullptr); + ASSERT_EQ(target.disconnected.size(), 2u); + EXPECT_EQ(target.disconnected.at(1), handle_b); + ASSERT_EQ(target.changed.size(), 3u); + EXPECT_EQ(target.changed.at(2), nullptr); + EXPECT_EQ(target.connected.size(), 2u); + + delete node_a; + delete node_b; +} + +TEST(TimeTargetObject, AdjustedTimePassesThroughWhenEitherNodeIsNull) +{ + auto *node = new olive::ViewerOutput(); + OakEngineNode *handle = reinterpret_cast(node); + + olive::TimeTargetObject target; + target.set_path_index(1); + + // Null source returns the input unchanged + EXPECT_EQ(target.get_adjusted_time(nullptr, handle, olive::Rational(5), + olive::k_transform_towards_output), + olive::Rational(5)); + + // Null target returns the input unchanged + EXPECT_EQ(target.get_adjusted_time(handle, nullptr, olive::Rational(7), + olive::k_transform_towards_input), + olive::Rational(7)); + + // Same for the TimeRange overload + const olive::TimeRange range(olive::Rational(2), olive::Rational(9)); + const olive::TimeRange out = target.get_adjusted_time( + nullptr, nullptr, range, olive::k_transform_towards_output); + EXPECT_EQ(out.in(), olive::Rational(2)); + EXPECT_EQ(out.out(), olive::Rational(9)); + + delete node; +} + +TEST(ResizableTimelineScrollBar, ConstructionDefaults) +{ + olive::ResizableTimelineScrollBar bar; + EXPECT_EQ(bar.orientation(), Qt::Vertical); + EXPECT_EQ(bar.singleStep(), 20); // inherited from ResizableScrollBar + EXPECT_DOUBLE_EQ(bar.get_scale(), 1.0); // TimeScaledObject base + EXPECT_TRUE(bar.timebase().isNull()); + + olive::ResizableTimelineScrollBar hbar(Qt::Horizontal); + EXPECT_EQ(hbar.orientation(), Qt::Horizontal); +} + +TEST(ResizableTimelineScrollBar, NullConnectionsAreSafe) +{ + olive::ResizableTimelineScrollBar bar(Qt::Horizontal); + bar.connect_markers(nullptr); + bar.connect_work_area(nullptr); + bar.SetScale(2.0); + + // Disconnecting when nothing was ever connected is equally safe + bar.connect_markers(nullptr); + bar.connect_work_area(nullptr); +} + +TEST(ResizableTimelineScrollBar, EnabledWorkAreaChangesPaintedPixels) +{ + olive::ResizableTimelineScrollBar bar(Qt::Horizontal); + bar.resize(400, 20); + bar.set_timebase(olive::Rational(1, 30)); + bar.set_scale(10.0); + + olive::TimelineWorkArea workarea; // disabled by default + auto *handle = reinterpret_cast(&workarea); + bar.connect_work_area(handle); + oakengine_workarea_set_range(handle, 1, 1, 10, 1); + + const QImage disabled = render_widget(&bar); + + oakengine_workarea_set_enabled(handle, 1); + const QImage enabled = render_widget(&bar); + EXPECT_NE(disabled, enabled); + + // Disabling again restores the base scrollbar paint exactly + oakengine_workarea_set_enabled(handle, 0); + EXPECT_EQ(render_widget(&bar), disabled); + + // Disconnecting the workarea also restores it while the workarea is on + oakengine_workarea_set_enabled(handle, 1); + bar.connect_work_area(nullptr); + EXPECT_EQ(render_widget(&bar), disabled); +} + +TEST(ResizableTimelineScrollBar, MarkersChangePaintedPixels) +{ + olive::ResizableTimelineScrollBar bar(Qt::Horizontal); + bar.resize(400, 20); + bar.set_timebase(olive::Rational(1, 30)); + bar.set_scale(10.0); + + const QImage clean = render_widget(&bar); + + olive::TimelineMarkerList list; + auto *list_handle = reinterpret_cast(&list); + bar.connect_markers(list_handle); + + // Parenting a marker to the list registers it through the list's + // childEvent + auto *marker = new olive::TimelineMarker( + 0, olive::TimeRange(olive::Rational(2), olive::Rational(5)), + QStringLiteral("m"), &list); + ASSERT_EQ(oakengine_marker_list_count(list_handle), 1); + + const QImage with_marker = render_widget(&bar); + EXPECT_NE(clean, with_marker); + + // Removing the marker restores the base paint. NB: the list only + // unregisters a marker on ChildRemoved, which must arrive while the + // marker is still fully constructed (its childEvent dynamic_casts the + // child to TimelineMarker), so reparent before deleting — the same + // order MarkerRemoveCommand uses. + marker->setParent(nullptr); + delete marker; + EXPECT_EQ(render_widget(&bar), clean); + + // Disconnecting the list with a marker present also restores it + auto *marker2 = new olive::TimelineMarker( + 0, olive::TimeRange(olive::Rational(2), olive::Rational(5)), + QStringLiteral("m2"), &list); + bar.connect_markers(nullptr); + EXPECT_EQ(render_widget(&bar), clean); + delete marker2; +} + diff --git a/tests/gtest/widget_viewer2_test.cpp b/tests/gtest/widget_viewer2_test.cpp new file mode 100644 index 0000000000..065aaeaa6b --- /dev/null +++ b/tests/gtest/widget_viewer2_test.cpp @@ -0,0 +1,781 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "node/output/viewer/viewer.h" +#include "node/project/footage/footage.h" +#include "node/project/sequence/sequence.h" +#include "render/videoparams.h" +#include "widget/viewer/displaybuffer.h" +#include "widget/viewer/vieweroutpututils.h" +#include "widget/viewer/viewerplaybacktimer.h" +#include "widget/viewer/viewerpreventsleep.h" +#include "widget/viewer/viewersizer.h" +#include "widget/viewer/viewertexteditor.h" + +namespace +{ + +// ViewerSizer's scrollbars are created in a fixed order (horizontal first), +// and findChildren preserves child creation order +QList sizer_scrollbars(olive::ViewerSizer *sizer) +{ + return sizer->findChildren(); +} + +// ViewerTextEditorToolBar push buttons in creation order (see +// viewertexteditor.cpp): underline, strikethrough, color, align left/center/ +// right/justify, align top/middle/bottom, small caps +QList toolbar_buttons(olive::ViewerTextEditorToolBar *toolbar) +{ + return toolbar->findChildren(); +} + +enum ToolBarButton { + k_underline = 0, + k_strikethrough = 1, + k_color = 2, + k_align_left = 3, + k_align_center = 4, + k_align_right = 5, + k_align_justify = 6, + k_align_top = 7, + k_align_middle = 8, + k_align_bottom = 9, + k_small_caps = 10, +}; + +QMatrix4x4 last_matrix(QSignalSpy &spy) +{ + return spy.last().first().value(); +} + +} // namespace + +TEST(ViewerSizer, ScrollBarsStartHiddenAndZoomIsFit) +{ + olive::ViewerSizer sizer; + const auto bars = sizer_scrollbars(&sizer); + ASSERT_EQ(bars.size(), 2); + EXPECT_EQ(bars.at(0)->orientation(), Qt::Horizontal); + EXPECT_EQ(bars.at(1)->orientation(), Qt::Vertical); + EXPECT_FALSE(bars.at(0)->isVisibleTo(&sizer)); + EXPECT_FALSE(bars.at(1)->isVisibleTo(&sizer)); +} + +TEST(ViewerSizer, ZeroChildSizeFillsContainerWithoutScaleRequest) +{ + olive::ViewerSizer sizer; + sizer.resize(400, 300); + + auto *child = new QWidget(); + sizer.set_widget(child); + + QSignalSpy scale_spy(&sizer, &olive::ViewerSizer::request_scale); + + // width_/height_ default to 0, so the child simply fills the container + EXPECT_EQ(child->geometry(), QRect(0, 0, 400, 300)); + EXPECT_EQ(scale_spy.count(), 0); +} + +TEST(ViewerSizer, SetWidgetDestroysPreviousWidget) +{ + olive::ViewerSizer sizer; + sizer.resize(200, 200); + + QPointer first = new QWidget(); + sizer.set_widget(first); + ASSERT_FALSE(first.isNull()); + EXPECT_EQ(first->parentWidget(), &sizer); + + auto *second = new QWidget(); + sizer.set_widget(second); + + // set_widget() deletes the previously installed widget + EXPECT_TRUE(first.isNull()); + EXPECT_EQ(second->parentWidget(), &sizer); +} + +TEST(ViewerSizer, WideContainerScalesByHeight) +{ + olive::ViewerSizer sizer; + sizer.resize(400, 300); + sizer.set_widget(new QWidget()); + + QSignalSpy scale_spy(&sizer, &olive::ViewerSizer::request_scale); + + // Square image in a 4:3 container: container is wider, so the matrix + // compresses X by sequence_aspect / container_aspect = 1 / (4/3) + sizer.set_child_size(100, 100); + + ASSERT_GE(scale_spy.count(), 1); + const QMatrix4x4 m = last_matrix(scale_spy); + EXPECT_NEAR(m(0, 0), 0.75, 1e-9); + EXPECT_NEAR(m(1, 1), 1.0, 1e-9); +} + +TEST(ViewerSizer, TallContainerScalesByWidth) +{ + olive::ViewerSizer sizer; + sizer.resize(300, 300); + sizer.set_widget(new QWidget()); + + QSignalSpy scale_spy(&sizer, &olive::ViewerSizer::request_scale); + + // 2:1 image in a square container: container is taller, so the matrix + // compresses Y by container_aspect / sequence_aspect = 1 / 2 + sizer.set_child_size(200, 100); + + ASSERT_GE(scale_spy.count(), 1); + const QMatrix4x4 m = last_matrix(scale_spy); + EXPECT_NEAR(m(0, 0), 1.0, 1e-9); + EXPECT_NEAR(m(1, 1), 0.5, 1e-9); +} + +TEST(ViewerSizer, PixelAspectRatioFactorsIntoScale) +{ + olive::ViewerSizer sizer; + sizer.resize(300, 300); + sizer.set_widget(new QWidget()); + sizer.set_child_size(100, 100); + + QSignalSpy scale_spy(&sizer, &olive::ViewerSizer::request_scale); + + // 2:1 pixel aspect doubles the sequence aspect -> 2:1 in a square container + sizer.set_pixel_aspect_ratio(olive::Rational(2, 1)); + ASSERT_GE(scale_spy.count(), 1); + QMatrix4x4 m = last_matrix(scale_spy); + EXPECT_NEAR(m(0, 0), 1.0, 1e-9); + EXPECT_NEAR(m(1, 1), 0.5, 1e-9); + + // 1:2 pixel aspect halves it -> 1:2 image, container wider than image + sizer.set_pixel_aspect_ratio(olive::Rational(1, 2)); + m = last_matrix(scale_spy); + EXPECT_NEAR(m(0, 0), 0.5, 1e-9); + EXPECT_NEAR(m(1, 1), 1.0, 1e-9); +} + +TEST(ViewerSizer, ExplicitZoomShowsScrollBars) +{ + olive::ViewerSizer sizer; + sizer.resize(200, 200); + sizer.set_widget(new QWidget()); + sizer.set_child_size(100, 100); + sizer.show(); + ASSERT_TRUE(QTest::qWaitForWindowExposed(&sizer)); + + const auto bars = sizer_scrollbars(&sizer); + ASSERT_EQ(bars.size(), 2); + + QSignalSpy scale_spy(&sizer, &olive::ViewerSizer::request_scale); + + // 100px child at 400% is 400px > 200px container, so both bars appear + sizer.set_zoom(4.0); + EXPECT_TRUE(bars.at(0)->isVisible()); + EXPECT_TRUE(bars.at(1)->isVisible()); + + // The zoom is folded into the requested scale on top of the fit scale + ASSERT_GE(scale_spy.count(), 1); + const QMatrix4x4 m = last_matrix(scale_spy); + EXPECT_GT(m(0, 0), 1.0); + EXPECT_GT(m(1, 1), 1.0); + + // Back to fit: scrollbars disappear again + sizer.set_zoom(-1); + EXPECT_FALSE(bars.at(0)->isVisible()); + EXPECT_FALSE(bars.at(1)->isVisible()); +} + +TEST(ViewerSizer, ScrollBarMovementEmitsTranslate) +{ + olive::ViewerSizer sizer; + sizer.resize(200, 200); + sizer.set_widget(new QWidget()); + sizer.set_child_size(100, 100); + sizer.show(); + ASSERT_TRUE(QTest::qWaitForWindowExposed(&sizer)); + sizer.set_zoom(4.0); + + const auto bars = sizer_scrollbars(&sizer); + ASSERT_EQ(bars.size(), 2); + QScrollBar *horiz = bars.at(0); + ASSERT_TRUE(horiz->isVisible()); + ASSERT_GT(horiz->maximum(), 0); + + QSignalSpy translate_spy(&sizer, &olive::ViewerSizer::request_translate); + + // Scrolled to the far right the offset is negative. Go to the maximum + // first so the subsequent setValue(0) is a real change (QScrollBar only + // emits valueChanged, which drives scroll_bar_moved, on actual changes) + horiz->setValue(horiz->maximum()); + ASSERT_GE(translate_spy.count(), 1); + const float at_max = last_matrix(translate_spy)(0, 3); + EXPECT_LT(at_max, 0.0f); + + // Back at 0 the view centers on the left edge: positive X offset + translate_spy.clear(); + horiz->setValue(0); + ASSERT_GE(translate_spy.count(), 1); + const float at_min = last_matrix(translate_spy)(0, 3); + EXPECT_GT(at_min, 0.0f); +} + +TEST(ViewerSizer, HandDragMoveAdjustsVisibleScrollBars) +{ + olive::ViewerSizer sizer; + sizer.resize(200, 200); + sizer.set_widget(new QWidget()); + sizer.set_child_size(100, 100); + sizer.show(); + ASSERT_TRUE(QTest::qWaitForWindowExposed(&sizer)); + sizer.set_zoom(4.0); + + const auto bars = sizer_scrollbars(&sizer); + ASSERT_EQ(bars.size(), 2); + bars.at(0)->setValue(50); + bars.at(1)->setValue(30); + + // A drag of (+10, +5) pulls the content, decreasing both scroll values + sizer.hand_drag_move(10, 5); + EXPECT_EQ(bars.at(0)->value(), 40); + EXPECT_EQ(bars.at(1)->value(), 25); +} + +TEST(ViewerSizer, AnchoredZoomOutToFitResetsScrollPositions) +{ + olive::ViewerSizer sizer; + sizer.resize(200, 200); + sizer.set_widget(new QWidget()); + sizer.set_child_size(100, 100); + sizer.show(); + ASSERT_TRUE(QTest::qWaitForWindowExposed(&sizer)); + + const auto bars = sizer_scrollbars(&sizer); + ASSERT_EQ(bars.size(), 2); + + sizer.set_zoom_anchored(4.0, 10.0, 10.0); + ASSERT_TRUE(bars.at(0)->isVisible()); + + // A non-positive anchored zoom is the "fit" command: zoom clears and the + // scroll offsets reset + sizer.set_zoom_anchored(0.0, 0.0, 0.0); + EXPECT_FALSE(bars.at(0)->isVisible()); + EXPECT_FALSE(bars.at(1)->isVisible()); + EXPECT_EQ(bars.at(0)->value(), 0); + EXPECT_EQ(bars.at(1)->value(), 0); +} + +TEST(ViewerPlaybackTimer, ZeroSpeedKeepsStartTimestamp) +{ + olive::ViewerPlaybackTimer timer; + timer.start(42, 0, 1.0 / 30.0); + + // No elapsed real time can move the timestamp when the speed is zero + EXPECT_EQ(timer.get_timestamp_now(), 42); +} + +TEST(ViewerPlaybackTimer, HugeTimebaseFreezesFrameAdvance) +{ + olive::ViewerPlaybackTimer timer; + // 1000-second frames: any sub-second test run still rounds to zero frames + timer.start(7, 3, 1000.0); + EXPECT_EQ(timer.get_timestamp_now(), 7); +} + +TEST(ViewerPlaybackTimer, NegativeSpeedAlsoStartsAtTimestamp) +{ + olive::ViewerPlaybackTimer timer; + timer.start(100, -2, 1000.0); + EXPECT_EQ(timer.get_timestamp_now(), 100); +} + +TEST(ViewerPlaybackTimer, RestartReplacesAnchor) +{ + olive::ViewerPlaybackTimer timer; + timer.start(1, 0, 1.0 / 30.0); + EXPECT_EQ(timer.get_timestamp_now(), 1); + + timer.start(500, 0, 1.0 / 30.0); + EXPECT_EQ(timer.get_timestamp_now(), 500); +} + +TEST(ViewerPreventSleep, ToggleOnOffDoesNotCrash) +{ + // No observable state is exposed; on macOS this creates/releases an IOPM + // assertion, elsewhere it talks to the OS sleep-inhibit service. Toggling + // twice exercises the released-assertion no-op path. + olive::prevent_sleep(true); + olive::prevent_sleep(false); + olive::prevent_sleep(false); + SUCCEED(); +} + +TEST(DisplayBuffer, NullHandlesAreSafeToWrapAndDestroy) +{ + auto tex = olive::oak_make_shared_texture(nullptr); + EXPECT_EQ(tex->handle, nullptr); + EXPECT_EQ(tex->type, olive::OakSharedBuffer::k_texture); + + auto frame = olive::oak_make_shared_frame(nullptr); + EXPECT_EQ(frame->handle, nullptr); + EXPECT_EQ(frame->type, olive::OakSharedBuffer::k_frame); + + // Both facade free functions ignore nullptr, so destroying these is safe + tex.reset(); + frame.reset(); + SUCCEED(); +} + +TEST(DisplayBuffer, SharedCopiesExtendHandleLifetime) +{ + void *raw = oakengine_codec_frame_create(); + ASSERT_NE(raw, nullptr); + + auto frame = olive::oak_make_shared_frame(raw); + EXPECT_EQ(frame->handle, raw); + EXPECT_EQ(frame.use_count(), 1); + + { + auto copy = frame; + EXPECT_EQ(frame.use_count(), 2); + EXPECT_EQ(copy->handle, raw); + } + + // The copy is gone but the original still holds the handle + EXPECT_EQ(frame.use_count(), 1); +} + +TEST(DisplayBuffer, RoundTripsThroughVariant) +{ + auto frame = olive::oak_make_shared_frame(nullptr); + + QVariant v = QVariant::fromValue(frame); + EXPECT_TRUE(v.isValid()); + + const olive::OakSharedBufferPtr out = v.value(); + EXPECT_EQ(out, frame); + EXPECT_EQ(out.use_count(), frame.use_count()); +} + +TEST(ViewerOutputUtils, NullViewerReturnsDefaults) +{ + const oak::VideoParams vp = olive::viewer_output_video_params(nullptr); + EXPECT_EQ(vp.width(), 0); + EXPECT_EQ(vp.height(), 0); + EXPECT_EQ(vp, olive::empty_video_params()); + + const olive::AudioParams ap = olive::viewer_output_audio_params(nullptr); + EXPECT_EQ(ap.sample_rate(), 0); + EXPECT_FALSE(ap.is_valid()); + + EXPECT_DOUBLE_EQ(olive::viewer_output_playhead(nullptr).to_double(), 0.0); + EXPECT_DOUBLE_EQ(olive::viewer_output_length(nullptr).to_double(), 0.0); + EXPECT_DOUBLE_EQ(olive::viewer_output_video_length(nullptr).to_double(), 0.0); + EXPECT_DOUBLE_EQ(olive::viewer_output_audio_length(nullptr).to_double(), 0.0); + EXPECT_DOUBLE_EQ(olive::sequence_timebase(nullptr).to_double(), 0.0); + + EXPECT_FALSE(olive::viewer_output_is_sequence(nullptr)); + EXPECT_FALSE(olive::viewer_output_is_footage(nullptr)); + EXPECT_FALSE(olive::viewer_output_node_type_is(nullptr, "anything")); +} + +TEST(ViewerOutputUtils, VideoParamsRoundTripThroughFacade) +{ + olive::ViewerOutput viewer; + viewer.set_video_params(olive::VideoParams(320, 240, olive::Rational(1, 25), + olive::PixelFormat::u8, + olive::VideoParams::k_rgba_channel_count)); + + const oak::VideoParams vp = olive::viewer_output_video_params(&viewer); + EXPECT_EQ(vp.width(), 320); + EXPECT_EQ(vp.height(), 240); + EXPECT_TRUE(vp.is_valid()); + + // sequence_timebase is the frame duration as a rational + const olive::Rational tb = olive::sequence_timebase(&viewer); + EXPECT_EQ(tb.numerator(), 1); + EXPECT_EQ(tb.denominator(), 25); + + EXPECT_EQ(vp.time_base().numerator(), 1); + EXPECT_EQ(vp.time_base().denominator(), 25); +} + +TEST(ViewerOutputUtils, PlayheadRoundTripsAndLengthDefaultsToZero) +{ + olive::ViewerOutput viewer; + + viewer.set_playhead(olive::Rational(3, 2)); + const olive::Rational ph = olive::viewer_output_playhead(&viewer); + EXPECT_EQ(ph.numerator(), 3); + EXPECT_EQ(ph.denominator(), 2); + + // A fresh viewer has no content, so all lengths are zero + EXPECT_DOUBLE_EQ(olive::viewer_output_length(&viewer).to_double(), 0.0); + EXPECT_DOUBLE_EQ(olive::viewer_output_video_length(&viewer).to_double(), 0.0); + EXPECT_DOUBLE_EQ(olive::viewer_output_audio_length(&viewer).to_double(), 0.0); +} + +TEST(ViewerOutputUtils, AudioParamsRoundTripThroughFacade) +{ + olive::ViewerOutput viewer; + const uint64_t stereo_mask = 0x3; // front-left | front-right + viewer.set_audio_params( + olive::AudioParams(48000, stereo_mask, olive::SampleFormat::s16)); + + const olive::AudioParams ap = olive::viewer_output_audio_params(&viewer); + EXPECT_TRUE(ap.is_valid()); + EXPECT_EQ(ap.sample_rate(), 48000); + EXPECT_EQ(ap.channel_layout(), stereo_mask); + EXPECT_EQ(olive::SampleFormat::Format(ap.format()), olive::SampleFormat::s16); +} + +TEST(ViewerOutputUtils, TypeProbesMatchNodeIds) +{ + olive::Sequence sequence; + EXPECT_TRUE(olive::viewer_output_is_sequence(&sequence)); + EXPECT_FALSE(olive::viewer_output_is_footage(&sequence)); + EXPECT_TRUE(olive::viewer_output_node_type_is( + &sequence, "org.olivevideoeditor.Olive.sequence")); + EXPECT_FALSE( + olive::viewer_output_node_type_is(&sequence, "org.oak.test.nonexistent")); + + olive::Footage footage; + EXPECT_TRUE(olive::viewer_output_is_footage(&footage)); + EXPECT_FALSE(olive::viewer_output_is_sequence(&footage)); + + // A plain ViewerOutput is neither a Sequence nor Footage + olive::ViewerOutput viewer; + EXPECT_FALSE(olive::viewer_output_is_sequence(&viewer)); + EXPECT_FALSE(olive::viewer_output_is_footage(&viewer)); +} + +TEST(ViewerTextEditorToolBar, SettersUpdateButtonsWithoutEmitting) +{ + olive::ViewerTextEditorToolBar toolbar; + const auto buttons = toolbar_buttons(&toolbar); + ASSERT_EQ(buttons.size(), 11); + + QSignalSpy underline_spy(&toolbar, + &olive::ViewerTextEditorToolBar::underline_changed); + QSignalSpy strike_spy( + &toolbar, &olive::ViewerTextEditorToolBar::strikethrough_changed); + QSignalSpy caps_spy(&toolbar, + &olive::ViewerTextEditorToolBar::small_caps_changed); + + toolbar.set_underline(true); + EXPECT_TRUE(buttons.at(k_underline)->isChecked()); + toolbar.set_strikethrough(true); + EXPECT_TRUE(buttons.at(k_strikethrough)->isChecked()); + toolbar.set_small_caps(true); + EXPECT_TRUE(buttons.at(k_small_caps)->isChecked()); + + // Programmatic sync from the editor must not bounce back as user edits + EXPECT_EQ(underline_spy.count(), 0); + EXPECT_EQ(strike_spy.count(), 0); + EXPECT_EQ(caps_spy.count(), 0); +} + +TEST(ViewerTextEditorToolBar, SetAlignmentChecksMatchingButtonOnly) +{ + olive::ViewerTextEditorToolBar toolbar; + const auto buttons = toolbar_buttons(&toolbar); + ASSERT_EQ(buttons.size(), 11); + + toolbar.set_alignment(Qt::AlignHCenter); + EXPECT_FALSE(buttons.at(k_align_left)->isChecked()); + EXPECT_TRUE(buttons.at(k_align_center)->isChecked()); + EXPECT_FALSE(buttons.at(k_align_right)->isChecked()); + EXPECT_FALSE(buttons.at(k_align_justify)->isChecked()); + + toolbar.set_alignment(Qt::AlignRight); + EXPECT_FALSE(buttons.at(k_align_left)->isChecked()); + EXPECT_FALSE(buttons.at(k_align_center)->isChecked()); + EXPECT_TRUE(buttons.at(k_align_right)->isChecked()); + EXPECT_FALSE(buttons.at(k_align_justify)->isChecked()); +} + +TEST(ViewerTextEditorToolBar, SetVerticalAlignmentChecksMatchingButtonOnly) +{ + olive::ViewerTextEditorToolBar toolbar; + const auto buttons = toolbar_buttons(&toolbar); + ASSERT_EQ(buttons.size(), 11); + + toolbar.set_vertical_alignment(Qt::AlignVCenter); + EXPECT_FALSE(buttons.at(k_align_top)->isChecked()); + EXPECT_TRUE(buttons.at(k_align_middle)->isChecked()); + EXPECT_FALSE(buttons.at(k_align_bottom)->isChecked()); + + toolbar.set_vertical_alignment(Qt::AlignBottom); + EXPECT_FALSE(buttons.at(k_align_top)->isChecked()); + EXPECT_FALSE(buttons.at(k_align_middle)->isChecked()); + EXPECT_TRUE(buttons.at(k_align_bottom)->isChecked()); +} + +TEST(ViewerTextEditorToolBar, AlignmentButtonsEmitTheirAlignment) +{ + olive::ViewerTextEditorToolBar toolbar; + const auto buttons = toolbar_buttons(&toolbar); + ASSERT_EQ(buttons.size(), 11); + + // Lambda capture instead of QSignalSpy: Qt::Alignment is not guaranteed + // to be a registered metatype for the spy on every platform + QVector h_received; + QObject::connect(&toolbar, + &olive::ViewerTextEditorToolBar::alignment_changed, + [&h_received](Qt::Alignment a) { h_received.append(a); }); + QVector v_received; + QObject::connect( + &toolbar, &olive::ViewerTextEditorToolBar::vertical_alignment_changed, + [&v_received](Qt::Alignment a) { v_received.append(a); }); + + buttons.at(k_align_center)->click(); + ASSERT_EQ(h_received.size(), 1); + EXPECT_EQ(h_received.first(), Qt::AlignHCenter); + + buttons.at(k_align_justify)->click(); + ASSERT_EQ(h_received.size(), 2); + EXPECT_EQ(h_received.last(), Qt::AlignJustify); + + buttons.at(k_align_middle)->click(); + ASSERT_EQ(v_received.size(), 1); + EXPECT_EQ(v_received.first(), Qt::AlignVCenter); + + buttons.at(k_align_bottom)->click(); + ASSERT_EQ(v_received.size(), 2); + EXPECT_EQ(v_received.last(), Qt::AlignBottom); +} + +TEST(ViewerTextEditorToolBar, ToggleButtonsEmitTheirState) +{ + olive::ViewerTextEditorToolBar toolbar; + const auto buttons = toolbar_buttons(&toolbar); + ASSERT_EQ(buttons.size(), 11); + + QSignalSpy underline_spy(&toolbar, + &olive::ViewerTextEditorToolBar::underline_changed); + QSignalSpy strike_spy( + &toolbar, &olive::ViewerTextEditorToolBar::strikethrough_changed); + QSignalSpy caps_spy(&toolbar, + &olive::ViewerTextEditorToolBar::small_caps_changed); + + buttons.at(k_underline)->click(); + ASSERT_EQ(underline_spy.count(), 1); + EXPECT_TRUE(underline_spy.first().first().toBool()); + + buttons.at(k_strikethrough)->click(); + ASSERT_EQ(strike_spy.count(), 1); + EXPECT_TRUE(strike_spy.first().first().toBool()); + + buttons.at(k_small_caps)->click(); + ASSERT_EQ(caps_spy.count(), 1); + EXPECT_TRUE(caps_spy.first().first().toBool()); + + // Unchecking emits false + buttons.at(k_underline)->click(); + ASSERT_EQ(underline_spy.count(), 2); + EXPECT_FALSE(underline_spy.last().first().toBool()); +} + +TEST(ViewerTextEditorToolBar, SetColorPaintsColorButton) +{ + olive::ViewerTextEditorToolBar toolbar; + const auto buttons = toolbar_buttons(&toolbar); + ASSERT_EQ(buttons.size(), 11); + + const QColor c(QStringLiteral("#FF0000")); + toolbar.set_color(c); + + QPushButton *color_btn = buttons.at(k_color); + EXPECT_EQ(color_btn->property("color").value(), c); + EXPECT_TRUE(color_btn->styleSheet().contains(c.name())); +} + +TEST(ViewerTextEditorToolBar, SetFontFamilyUpdatesComboWithoutEmitting) +{ + olive::ViewerTextEditorToolBar toolbar; + + QSignalSpy family_spy(&toolbar, + &olive::ViewerTextEditorToolBar::family_changed); + + // Pick a family from the combo's own model: QFontDatabase also lists + // system fonts the QFontComboBox filters out (e.g. ".Apple Color Emoji" + // on macOS), which setCurrentFont can only approximate + auto *combo = toolbar.findChild(); + ASSERT_NE(combo, nullptr); + ASSERT_GT(combo->count(), 1); + const QString family = combo->itemText(combo->count() / 2); + ASSERT_FALSE(family.isEmpty()); + + toolbar.set_font_family(family); + + EXPECT_EQ(toolbar.get_font_family(), family); + // set_font_family blocks the combo's signals while syncing + EXPECT_EQ(family_spy.count(), 0); +} + +TEST(ViewerTextEditorToolBar, FirstPaintEmittedOnce) +{ + olive::ViewerTextEditorToolBar toolbar; + QSignalSpy spy(&toolbar, &olive::ViewerTextEditorToolBar::first_paint); + + // Deliver paint events directly: whether the offscreen QPA paints on + // expose is platform-dependent, but QWidget::event always dispatches a + // QPaintEvent to paintEvent + QPaintEvent first(toolbar.rect()); + QApplication::sendEvent(&toolbar, &first); + EXPECT_EQ(spy.count(), 1); + + // Later repaints don't re-emit + QPaintEvent second(toolbar.rect()); + QApplication::sendEvent(&toolbar, &second); + EXPECT_EQ(spy.count(), 1); +} + +TEST(ViewerTextEditorToolBar, CloseEventIsIgnored) +{ + olive::ViewerTextEditorToolBar toolbar; + + QCloseEvent ev; + QApplication::sendEvent(&toolbar, &ev); + + // The toolbar is a floating child of the viewer; closing must never hide it + EXPECT_FALSE(ev.isAccepted()); +} + +TEST(ViewerTextEditorToolBar, LeftDragMovesToolbarWithinParent) +{ + QWidget parent; + parent.resize(600, 400); + + olive::ViewerTextEditorToolBar toolbar(&parent); + toolbar.move(100, 100); + parent.show(); + ASSERT_TRUE(QTest::qWaitForWindowExposed(&parent)); + + QTest::mousePress(&toolbar, Qt::LeftButton, Qt::NoModifier, QPoint(10, 10)); + + // QTest::mouseMove can't report held buttons, so deliver the move manually + QMouseEvent move(QEvent::MouseMove, QPointF(30, 25), QPointF(130, 125), + Qt::NoButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(&toolbar, &move); + + EXPECT_EQ(toolbar.pos(), QPoint(120, 115)); +} + +TEST(ViewerTextEditor, CursorWidthScalesWithInverseZoom) +{ + olive::ViewerTextEditor at_full(1.0); + EXPECT_EQ(at_full.cursorWidth(), 1); + + olive::ViewerTextEditor zoomed_out(0.5); + EXPECT_EQ(zoomed_out.cursorWidth(), 2); + + olive::ViewerTextEditor far_out(0.25); + EXPECT_EQ(far_out.cursorWidth(), 4); +} + +TEST(ViewerTextEditor, ConstructionDefaults) +{ + olive::ViewerTextEditor editor(1.0); + + // Rich text paste is disabled; colors default to white on transparent + EXPECT_FALSE(editor.acceptRichText()); + EXPECT_EQ(editor.palette().color(QPalette::Text), QColor(Qt::white)); + + // Scrolling is handled by the viewer gizmo, not the text edit + EXPECT_EQ(editor.horizontalScrollBarPolicy(), Qt::ScrollBarAlwaysOff); + EXPECT_EQ(editor.verticalScrollBarPolicy(), Qt::ScrollBarAlwaysOff); +} + +TEST(ViewerTextEditor, ConnectToolBarSyncsCurrentFormat) +{ + olive::ViewerTextEditor editor(1.0); + + // Set a distinctive format before connecting; connect_tool_bar pushes the + // editor's current format into the toolbar exactly once + editor.setFontUnderline(true); + editor.setAlignment(Qt::AlignRight); + + olive::ViewerTextEditorToolBar toolbar; + editor.connect_tool_bar(&toolbar); + + const auto buttons = toolbar_buttons(&toolbar); + ASSERT_EQ(buttons.size(), 11); + EXPECT_TRUE(buttons.at(k_underline)->isChecked()); + EXPECT_TRUE(buttons.at(k_align_right)->isChecked()); + EXPECT_FALSE(buttons.at(k_align_left)->isChecked()); + EXPECT_FALSE(buttons.at(k_align_center)->isChecked()); +} + +TEST(ViewerTextEditor, ToolBarTogglesApplyToEditorFormat) +{ + olive::ViewerTextEditor editor(1.0); + olive::ViewerTextEditorToolBar toolbar; + editor.connect_tool_bar(&toolbar); + + const auto buttons = toolbar_buttons(&toolbar); + ASSERT_EQ(buttons.size(), 11); + + buttons.at(k_underline)->click(); + EXPECT_TRUE(editor.currentCharFormat().fontUnderline()); + + buttons.at(k_strikethrough)->click(); + EXPECT_TRUE(editor.currentCharFormat().fontStrikeOut()); + + buttons.at(k_small_caps)->click(); + EXPECT_EQ(editor.currentCharFormat().fontCapitalization(), + QFont::SmallCaps); + + buttons.at(k_align_center)->click(); + EXPECT_EQ(editor.alignment(), Qt::AlignHCenter); + + // The toolbar follows the editor back (same alignment echo) + EXPECT_TRUE(buttons.at(k_align_center)->isChecked()); + EXPECT_FALSE(buttons.at(k_align_left)->isChecked()); +} + +TEST(ViewerTextEditor, PaintNeverRendersTextByDesign) +{ + olive::ViewerTextEditor editor(1.0); + editor.resize(200, 80); + editor.setPlainText(QStringLiteral("Hello")); + + // document_changed() swaps in a clone whose text is fully transparent (the + // HACK in viewertexteditor.cpp: the gizmo underneath renders the text, the + // editor must not render it a second time). Verify that contract: nothing + // is painted, with or without a selection, at any vertical alignment. + QTextCursor c = editor.textCursor(); + c.select(QTextCursor::Document); + editor.setTextCursor(c); + + for (const Qt::Alignment valign : + { Qt::AlignTop, Qt::AlignVCenter, Qt::AlignBottom }) { + QImage img(200, 80, QImage::Format_ARGB32); + img.fill(Qt::transparent); + { + QPainter p(&img); + editor.paint(&p, valign); + EXPECT_TRUE(p.isActive()); + } + for (int y = 0; y < img.height(); y++) { + for (int x = 0; x < img.width(); x++) { + EXPECT_EQ(qAlpha(img.pixel(x, y)), 0) + << "painted pixel at " << x << "," << y; + } + } + } +}