fix: bug-fix sweep across node, audio, render, plugin subsystems

Node core:
- MathNode/TrigonometryNode combo strings realigned with Operation enums
- mathbase scalar/vector operand pick no longer uses bitwise type checks
- NodeSetPositionAndDependenciesRecursively moves dependencies again
- RemoveAllKeyframes undo actually restores keyframes
- NodeGroup GetInputName null-deref guard, passthrough ids use input id
- NodeValueTable::Has is an exact type match; tag fallback only for
  empty tags; kStrCombo/kPushButton get data type names
- delete_all_keyframes no longer loops forever on unparented keyframes;
  keyframe-load failures propagate; rational interpolation falls back to
  double; OpacityEffect no longer leaks its internal MathNode

Audio/footage:
- AudioVisualWaveform: GetSummaryFromTime underflow OOB read, TrimIn
  prepend length bookkeeping, OverwriteSums source channel indexing
- PanNode inserts the pan value into the sample job (keyframed pan
  works); OutputParamsChanged is emitted on device change; PortAudio
  device indices are validated before Pa_GetDeviceInfo
- Footage: AdjustTimeByLoopMode no longer hangs/UBs on degenerate
  lengths, GetStreamIndex bounds-checked, CheckFootage clears stale
  state on missing files, failed probes are not cached,
  FootageDescription::Load requires its own root element

Render/track:
- ViewerOutput pushes the tagged samples value; TrackList disconnects
  the track-height lambda; GetTrackFromReference validity check
- RenderManager dummy backend: null-initialized threads, guarded
  decoder-cache/timer paths; Renderer::Destroy releases color cache
  shaders/textures; unknown dynamic backends no longer alias to oakgl
- SharedMemoryRegion POSIX attach validates segment size; ReadMessage
  skips blank lines instead of failing; GC counter clamped;
  IsRenderingCustomRange implemented; TimeOffsetNode gets a true
  inverse OutputTimeAdjustment; zero-speed clips return the held frame

Plugin/nodes:
- OliveClip: stored default region of definition is honored, on-demand
  images are cached; OliveHost sets host identity properties and logs
  instead of showing modal dialogs offscreen; Plugin.h dead decls gone
- DespillNode guards graph-less use with Rec.709 fallback; description
  typos fixed (despill, swirl); mosaic applies when only one axis
  matches; Windows-only Project filename separator test fixed
This commit is contained in:
2026-07-17 08:26:48 +08:00
parent d9a4e27045
commit 2aa921b215
61 changed files with 731 additions and 254 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ function(olive_add_test GROUP NAME SOURCE)
string(APPEND TEST_FILE_CONTENT "\n${TEST_BODY}")
file(WRITE "${OUTPUT_FILE}" "${TEST_FILE_CONTENT}")
add_executable(${NAME} ${OUTPUT_FILE} $<TARGET_OBJECTS:libolive-editor>)
add_executable(${NAME} ${OUTPUT_FILE} $<TARGET_OBJECTS:libolive-editor> $<TARGET_OBJECTS:olive-version-obj>)
target_include_directories(
${NAME}
PRIVATE
+1 -1
View File
@@ -116,7 +116,7 @@ add_executable(olive-gtest
find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Test)
target_sources(olive-gtest PRIVATE $<TARGET_OBJECTS:libolive-editor>)
target_sources(olive-gtest PRIVATE $<TARGET_OBJECTS:libolive-editor> $<TARGET_OBJECTS:olive-version-obj>)
target_include_directories(
olive-gtest
+5 -2
View File
@@ -583,9 +583,12 @@ TEST_F(ViewerOutputTest, ValueRepushTagsStreams)
EXPECT_EQ(table.Get(olive::NodeValue::kTexture, video_tag).type(),
olive::NodeValue::kTexture);
// The samples value is re-pushed and stays retrievable
EXPECT_EQ(table.Get(olive::NodeValue::kSamples).type(),
// The samples value is re-pushed tagged as audio stream 0
const QString audio_tag =
olive::Track::Reference(olive::Track::kAudio, 0).ToString();
EXPECT_EQ(table.Get(olive::NodeValue::kSamples, audio_tag).type(),
olive::NodeValue::kSamples);
EXPECT_EQ(table.Get(olive::NodeValue::kSamples).tag(), audio_tag);
}
TEST_F(ViewerOutputTest, LastUsedEncodingParamsRoundTrip)
+3 -4
View File
@@ -225,10 +225,9 @@ TEST(AudioVisualWaveform, OverwriteSamplesBeforeExistingDataPrependsZeros)
ASSERT_EQ(summary.size(), 1);
ExpectSummary(summary, 0, 0.75f, 0.75f);
// BUG: the data now spans [0, 3), but prepending via a negative TrimIn
// subtracts the negated length from length_ instead of keeping the
// absolute end time, so length() reports 1 instead of 3
EXPECT_EQ(waveform.length(), olive::core::rational(1));
// The data now spans [0, 3): prepending via a negative TrimIn keeps the
// absolute end time tracked by length()
EXPECT_EQ(waveform.length(), olive::core::rational(3));
}
// ---------------------------------------------------------------------------
+7 -8
View File
@@ -439,9 +439,8 @@ TEST_F(FootageProbeTest, CheckFootageOnlyRespondsWithActiveWindow)
EXPECT_TRUE(footage->IsValid());
// With an active window, CheckFootage notices the missing file and
// re-probes. The re-probe resets the timestamp but, because Reprobe()
// never clears existing state for a missing file, the (now stale) probe
// data is kept until the filename itself changes.
// re-probes. The re-probe clears the existing state first, and since the
// file no longer exists, the footage is left invalid with no streams.
{
QWidget window;
window.show();
@@ -455,8 +454,8 @@ TEST_F(FootageProbeTest, CheckFootageOnlyRespondsWithActiveWindow)
ASSERT_EQ(qApp->activeWindow(), nullptr);
EXPECT_EQ(footage->timestamp(), 0);
EXPECT_TRUE(footage->IsValid());
EXPECT_EQ(footage->GetVideoStreamCount(), 1);
EXPECT_FALSE(footage->IsValid());
EXPECT_EQ(footage->GetVideoStreamCount(), 0);
}
TEST_F(FootageProbeTest, ProbingExistingButInvalidMediaStaysInvalid)
@@ -480,7 +479,7 @@ TEST_F(FootageProbeTest, ProbingExistingButInvalidMediaStaysInvalid)
EXPECT_EQ(footage->GetAudioStreamCount(), 0);
EXPECT_EQ(footage->GetSubtitleStreamCount(), 0);
// Note: Reprobe caches even this failed probe result, so future reprobes
// of the same path reload the invalid description instead of re-probing
EXPECT_TRUE(QFileInfo::exists(MetadataCacheFileFor(path)));
// A failed probe is not written to the metadata cache, so future reprobes
// of the same path probe again instead of reloading an invalid description
EXPECT_FALSE(QFileInfo::exists(MetadataCacheFileFor(path)));
}
+23
View File
@@ -301,6 +301,23 @@ TEST(FootageStatic, AdjustTimeByLoopModeLoopsAroundLength)
olive::rational(5));
}
TEST(FootageStatic, AdjustTimeByLoopModeWithEmptyRangeReturnsNaN)
{
// Looping an empty range would never terminate; return NaN instead
EXPECT_TRUE(olive::Footage::AdjustTimeByLoopMode(
olive::rational(1), olive::LoopMode::kLoopModeLoop,
olive::rational(0), olive::VideoParams::kVideoTypeVideo,
olive::rational(1, 24))
.isNaN());
// Clamping a range shorter than one frame has no frame to clamp to
EXPECT_TRUE(olive::Footage::AdjustTimeByLoopMode(
olive::rational(1), olive::LoopMode::kLoopModeClamp,
olive::rational(0), olive::VideoParams::kVideoTypeVideo,
olive::rational(1, 24))
.isNaN());
}
TEST(FootageStatic, RetranslateSetsInputNames)
{
TestableFootage footage;
@@ -375,6 +392,12 @@ TEST_F(FootageTest, ManuallyAddedStreamsMapBetweenReferencesAndIndices)
EXPECT_EQ(footage.GetStreamIndex(olive::Track::kNone, 0), -1);
EXPECT_EQ(footage.GetStreamIndex(olive::Track::kCount, 0), -1);
// Out-of-range indices report -1 rather than a default-constructed stream
EXPECT_EQ(footage.GetStreamIndex(olive::Track::kVideo, 1), -1);
EXPECT_EQ(footage.GetStreamIndex(olive::Track::kVideo, -1), -1);
EXPECT_EQ(footage.GetStreamIndex(olive::Track::kAudio, 1), -1);
EXPECT_EQ(footage.GetStreamIndex(olive::Track::kSubtitle, 1), -1);
EXPECT_EQ(footage.GetReferenceFromRealIndex(5),
olive::Track::Reference(olive::Track::kVideo, 0));
EXPECT_EQ(footage.GetReferenceFromRealIndex(2),
+8 -8
View File
@@ -364,7 +364,7 @@ TEST(PanNode, NoSamplesInputProducesNoOutput)
olive::NodeValue::kNone);
}
TEST(PanNode, KeyframedPanProducesSampleJobButLosesPanValue)
TEST(PanNode, KeyframedPanProducesSampleJobWithPanValue)
{
olive::ColorManager::SetUpDefaultConfig();
olive::Project project;
@@ -383,22 +383,22 @@ TEST(PanNode, KeyframedPanProducesSampleJobButLosesPanValue)
ASSERT_EQ(result.type(), olive::NodeValue::kSamples);
ASSERT_TRUE(result.canConvert<olive::SampleJob>());
// NOTE: Unlike VolumeNode, PanNode::Value() never inserts the panning
// value into the SampleJob, so the job's value map is empty and
// ProcessSamples() always sees a pan of 0 (a plain copy). The production
// RenderProcessor only re-evaluates inputs present in the job, so
// keyframed pan is silently ignored (suspected bug, documented here).
// Like VolumeNode, PanNode::Value() inserts the panning value into the
// SampleJob so ProcessSamples() sees the keyframed pan
const olive::SampleJob job = result.value<olive::SampleJob>();
EXPECT_FALSE(job.GetValues().contains(olive::PanNode::kPanningInput));
ASSERT_TRUE(job.GetValues().contains(olive::PanNode::kPanningInput));
EXPECT_DOUBLE_EQ(job.GetValues().value(olive::PanNode::kPanningInput).toDouble(),
1.0);
SampleResolvingTraverser resolver;
resolver.Resolve(result);
// Pan 1.0 (full right) silences the left channel and leaves the right
const olive::core::SampleBuffer out = result.toSamples();
ASSERT_TRUE(out.is_allocated());
ASSERT_EQ(out.sample_count(), 4u);
for (int i = 0; i < 4; i++) {
EXPECT_FLOAT_EQ(out.data(0)[i], float(i + 1));
EXPECT_FLOAT_EQ(out.data(0)[i], 0.0f);
EXPECT_FLOAT_EQ(out.data(1)[i], float(i + 5));
}
}
+2 -3
View File
@@ -1362,9 +1362,8 @@ TEST(SwirlDistortNode, MetadataIsCorrect)
// NOTE: "org.oliveeditor.*" domain, inconsistent with most Olive nodes
EXPECT_EQ(node.id(), QStringLiteral("org.oliveeditor.Olive.swirl"));
EXPECT_EQ(node.Name(), QStringLiteral("Swirl"));
// NOTE: the description reads "Distorts an image along a sine wave.",
// identical to WaveDistortNode's (copy-paste, documented here)
EXPECT_FALSE(node.Description().isEmpty());
EXPECT_EQ(node.Description(),
QStringLiteral("Distorts an image by swirling it around a center point."));
EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryDistort));
EXPECT_TRUE(node.GetFlags() & olive::Node::kVideoEffect);
+53 -2
View File
@@ -836,6 +836,28 @@ TEST(MosaicFilterNode, ValueWithMatchingResolutionPassesTextureThrough)
EXPECT_FALSE(out->IsJob());
}
TEST(MosaicFilterNode, ValueWithSingleAxisMatchingResolutionRunsJob)
{
olive::MosaicFilterNode node;
// Only one axis matching the texture size still changes the image, so
// the effect must run; passthrough requires BOTH axes to match.
olive::TexturePtr tex = MakeDummyTexture();
olive::NodeValueRow row =
MakeTextureRow(olive::MosaicFilterNode::kTextureInput, tex);
row.insert(olive::MosaicFilterNode::kHorizInput, FloatValue(16.0));
row.insert(olive::MosaicFilterNode::kVertInput, FloatValue(8.0));
olive::NodeValueTable table;
node.Value(row, olive::NodeGlobals(), &table);
ASSERT_EQ(table.Count(), 1);
const olive::TexturePtr out =
table.Get(olive::NodeValue::kTexture).toTexture();
ASSERT_TRUE(out);
EXPECT_TRUE(out->IsJob());
}
TEST(MosaicFilterNode, ValuePushesJobWithLinearInterpolation)
{
olive::MosaicFilterNode node;
@@ -1444,8 +1466,6 @@ TEST(DespillNode, ShaderCodeLoadsFragmentResource)
TEST(DespillNode, ValueInProjectWithoutTexturePushesNothing)
{
// DespillNode::Value() unconditionally queries the project's color
// manager, so it can only be exercised with the node in a project.
olive::ColorManager::SetUpDefaultConfig();
olive::Project project;
@@ -1501,3 +1521,34 @@ TEST(DespillNode, ValueInProjectPushesJobWithLumaCoefficients)
EXPECT_EQ(values.value(olive::DespillNode::kTextureInput).toTexture(),
tex);
}
TEST(DespillNode, ValueWithoutProjectUsesRec709LumaFallback)
{
// A graph-less node has no project color manager; it must fall back to
// Rec. 709 luma coefficients instead of crashing.
olive::DespillNode node;
olive::TexturePtr tex = MakeDummyTexture();
olive::NodeValueRow row =
MakeTextureRow(olive::DespillNode::kTextureInput, tex);
olive::NodeValueTable table;
node.Value(row, olive::NodeGlobals(), &table);
ASSERT_EQ(table.Count(), 1);
const olive::TexturePtr out =
table.Get(olive::NodeValue::kTexture).toTexture();
ASSERT_TRUE(out);
ASSERT_TRUE(out->IsJob());
auto *job = static_cast<olive::ShaderJob *>(out->job());
ASSERT_NE(job, nullptr);
const olive::NodeValueRow &values = job->GetValues();
ASSERT_TRUE(values.contains(QStringLiteral("luma_coeffs")));
const QVector3D coeffs =
values.value(QStringLiteral("luma_coeffs")).toVec3();
EXPECT_NEAR(coeffs.x(), 0.2126f, 0.0001f);
EXPECT_NEAR(coeffs.y(), 0.7152f, 0.0001f);
EXPECT_NEAR(coeffs.z(), 0.0722f, 0.0001f);
}
+2 -5
View File
@@ -129,15 +129,12 @@ TEST_F(NodeGroupTest, AddInputPassthroughGeneratesUniqueIdForDuplicateInputId)
EXPECT_EQ(id_a, olive::MathNode::kParamAIn);
// A second passthrough of the same input ID (on a different node) must
// not collide with the first
math_b->Retranslate();
// not collide with the first; the suffix is derived from the input ID
const QString id_b = group->AddInputPassthrough(
olive::NodeInput(math_b, olive::MathNode::kParamAIn));
// NOTE: the suffix is derived from the input's display name rather than
// its ID, so a retranslated MathNode param becomes "Value_2"
EXPECT_NE(id_a, id_b);
EXPECT_EQ(id_b, QStringLiteral("Value_2"));
EXPECT_EQ(id_b, QStringLiteral("param_a_in_2"));
ASSERT_EQ(group->GetInputPassthroughs().size(), 2);
EXPECT_TRUE(group->HasInputWithID(id_b));
+15 -18
View File
@@ -214,16 +214,14 @@ TEST(MathNode, RetranslateSetsInputNamesAndComboStrings)
math.GetInputProperty(olive::MathNode::kMethodIn,
QStringLiteral("combo_str"))
.toStringList();
ASSERT_EQ(operations.size(), 6);
ASSERT_EQ(operations.size(), 5);
EXPECT_EQ(operations.at(0), QStringLiteral("Add"));
EXPECT_EQ(operations.at(1), QStringLiteral("Subtract"));
EXPECT_EQ(operations.at(2), QStringLiteral("Multiply"));
EXPECT_EQ(operations.at(3), QStringLiteral("Divide"));
// NOTE: kOpPower == 4, but the combo list has an empty string at index 4
// and "Power" at index 5, so the combo box is misaligned with the
// Operation enum (suspected bug, documented here).
EXPECT_TRUE(operations.at(4).isEmpty());
EXPECT_EQ(operations.at(5), QStringLiteral("Power"));
// The combo list matches the Operation enum exactly, so kOpPower == 4
// selects "Power"
EXPECT_EQ(operations.at(4), QStringLiteral("Power"));
}
TEST(MathNode, AddNumbers)
@@ -720,7 +718,7 @@ TEST(MathNode, AddVectorAndNumberIsNoOp)
EXPECT_FLOAT_EQ(result.toVec2().y(), 2.0f);
}
TEST(MathNode, NumberTimesVectorYieldsNoComputedValue)
TEST(MathNode, NumberTimesVectorScalesVector)
{
olive::ColorManager::SetUpDefaultConfig();
olive::Project project;
@@ -736,18 +734,17 @@ TEST(MathNode, NumberTimesVectorYieldsNoComputedValue)
QVector4D(1.0f, 2.0f, 3.0f, 4.0f)));
olive::Node::ConnectEdge(b, olive::NodeInput(math, olive::MathNode::kParamBIn));
// NOTE: With the number in parameter A and the vector in parameter B,
// ValueInternal() picks the *vector* as the number operand (the
// `val_a.type() & NodeValue::kMatrix` check is true for kFloat) and then
// pushes the result with type kFloat, which PushVector() drops. No
// computed value is produced, and nothing passes through into the output
// table either (suspected bug, documented here).
// With the number in parameter A and the vector in parameter B, the
// number is still picked as the number operand and the vector is scaled,
// mirroring MultiplyVectorByNumber with the operands swapped
olive::NodeValueTable table = GenerateMathTable(math);
EXPECT_EQ(table.Get(olive::NodeValue::kVec4).type(),
olive::NodeValue::kNone);
EXPECT_EQ(table.Get(olive::NodeValue::kFloat).type(),
olive::NodeValue::kNone);
olive::NodeValue result = table.Get(olive::NodeValue::kVec4);
ASSERT_EQ(result.type(), olive::NodeValue::kVec4);
const QVector4D vec = result.toVec4();
EXPECT_FLOAT_EQ(vec.x(), 2.0f);
EXPECT_FLOAT_EQ(vec.y(), 4.0f);
EXPECT_FLOAT_EQ(vec.z(), 6.0f);
EXPECT_FLOAT_EQ(vec.w(), 8.0f);
}
TEST(MathNode, MultiplyMatrixByVector)
+20 -19
View File
@@ -184,23 +184,19 @@ TEST(TrigonometryNode, RetranslateSetsInputNamesAndComboStrings)
node.GetInputProperty(olive::TrigonometryNode::kMethodIn,
QStringLiteral("combo_str"))
.toStringList();
ASSERT_EQ(methods.size(), 11);
ASSERT_EQ(methods.size(), 9);
EXPECT_EQ(methods.at(0), QStringLiteral("Sine"));
EXPECT_EQ(methods.at(1), QStringLiteral("Cosine"));
EXPECT_EQ(methods.at(2), QStringLiteral("Tangent"));
EXPECT_TRUE(methods.at(3).isEmpty());
EXPECT_EQ(methods.at(4), QStringLiteral("Inverse Sine"));
EXPECT_EQ(methods.at(5), QStringLiteral("Inverse Cosine"));
EXPECT_EQ(methods.at(6), QStringLiteral("Inverse Tangent"));
EXPECT_TRUE(methods.at(7).isEmpty());
EXPECT_EQ(methods.at(8), QStringLiteral("Hyperbolic Sine"));
EXPECT_EQ(methods.at(9), QStringLiteral("Hyperbolic Cosine"));
EXPECT_EQ(methods.at(10), QStringLiteral("Hyperbolic Tangent"));
EXPECT_EQ(methods.at(3), QStringLiteral("Inverse Sine"));
EXPECT_EQ(methods.at(4), QStringLiteral("Inverse Cosine"));
EXPECT_EQ(methods.at(5), QStringLiteral("Inverse Tangent"));
EXPECT_EQ(methods.at(6), QStringLiteral("Hyperbolic Sine"));
EXPECT_EQ(methods.at(7), QStringLiteral("Hyperbolic Cosine"));
EXPECT_EQ(methods.at(8), QStringLiteral("Hyperbolic Tangent"));
// NOTE: The combo list contains separator entries at indexes 3 and 7 that
// the Operation enum used by Value() does not have, so combo indexes 4
// and above no longer match the enum (suspected bug, documented here and
// in ComboIndexBeyondSeparatorComputesWrongFunction).
// The combo list contains no separator entries, so combo indexes match
// the Operation enum used by Value() exactly
}
TEST(TrigonometryNode, SineCosineTangent)
@@ -264,7 +260,7 @@ TEST(TrigonometryNode, HyperbolicOperations)
EXPECT_NEAR(GenerateTrigResult(node), std::tanh(1.0), 1e-12);
}
TEST(TrigonometryNode, ComboIndexBeyondSeparatorComputesWrongFunction)
TEST(TrigonometryNode, ComboIndexMatchesOperationEnum)
{
olive::ColorManager::SetUpDefaultConfig();
olive::Project project;
@@ -272,15 +268,20 @@ TEST(TrigonometryNode, ComboIndexBeyondSeparatorComputesWrongFunction)
auto *node = AddNode<olive::TrigonometryNode>(&project);
// NOTE: The combo box labels index 4 as "Inverse Sine", but Value()
// casts the index to the Operation enum where 4 is kOpArcCosine, because
// the combo string list contains separator entries that the enum does
// not have (suspected bug, test documents current behavior).
// The combo list has no separator entries, so a combo index selects the
// Operation enum value with the same index
node->SetStandardValue(olive::TrigonometryNode::kMethodIn, 3);
node->SetStandardValue(olive::TrigonometryNode::kXIn, 0.5);
EXPECT_NEAR(GenerateTrigResult(node), std::asin(0.5), 1e-12);
node->SetStandardValue(olive::TrigonometryNode::kMethodIn, 4);
node->SetStandardValue(olive::TrigonometryNode::kXIn, 0.5);
EXPECT_NEAR(GenerateTrigResult(node), std::acos(0.5), 1e-12);
// Index 8 is labeled "Hyperbolic Sine" but computes hyperbolic tangent
node->SetStandardValue(olive::TrigonometryNode::kMethodIn, 6);
node->SetStandardValue(olive::TrigonometryNode::kXIn, 1.0);
EXPECT_NEAR(GenerateTrigResult(node), std::sinh(1.0), 1e-12);
node->SetStandardValue(olive::TrigonometryNode::kMethodIn, 8);
node->SetStandardValue(olive::TrigonometryNode::kXIn, 1.0);
EXPECT_NEAR(GenerateTrigResult(node), std::tanh(1.0), 1e-12);
+18 -6
View File
@@ -184,22 +184,34 @@ TEST_F(NodeTimeTest, TimeOffsetZeroOffsetIsIdentity)
range);
}
TEST_F(NodeTimeTest, TimeOffsetOutputAdjustmentPassesThrough)
TEST_F(NodeTimeTest, TimeOffsetOutputAdjustmentAppliesInverseOffset)
{
auto *offset = AddNode<olive::TimeOffsetNode>();
offset->SetStandardValue(olive::TimeOffsetNode::kTimeInput,
QVariant::fromValue(olive::core::rational(3)));
// The inverse mapping is not implemented, so output time is never
// adjusted
// The inverse mapping subtracts the offset again: input-side times are
// mapped back to the output by the negated offset
EXPECT_EQ(offset->OutputTimeAdjustment(
olive::TimeOffsetNode::kInputInput, -1,
olive::TimeRange(olive::core::rational(2),
olive::core::rational(4))),
olive::TimeRange(olive::core::rational(-1),
olive::core::rational(1)));
// Non-input inputs never adjust time
const olive::TimeRange range(olive::core::rational(2),
olive::core::rational(4));
EXPECT_EQ(offset->OutputTimeAdjustment(olive::TimeOffsetNode::kInputInput,
-1, range),
range);
EXPECT_EQ(offset->OutputTimeAdjustment(olive::TimeOffsetNode::kTimeInput,
-1, range),
range);
// Round trip through both adjustments returns the original range
EXPECT_EQ(offset->OutputTimeAdjustment(
olive::TimeOffsetNode::kInputInput, -1,
offset->InputTimeAdjustment(
olive::TimeOffsetNode::kInputInput, -1, range, true)),
range);
}
TEST_F(NodeTimeTest, TimeOffsetAppliesKeyframedOffset)
+18
View File
@@ -243,8 +243,15 @@ TEST_F(NodeUndoTest, SetPositionCommandRestoresPreviousPosition)
TEST_F(NodeUndoTest, SetPositionAndDependenciesRecursivelyMovesNode)
{
auto *dep = AddNode<olive::MathNode>();
auto *node = AddNode<olive::MathNode>();
auto *context = AddNode<olive::Folder>();
olive::Node::ConnectEdge(
dep, olive::NodeInput(node, olive::MathNode::kParamAIn));
context->SetNodePositionInContext(
dep, olive::Node::Position(QPointF(1.0, 1.0)));
context->SetNodePositionInContext(
node, olive::Node::Position(QPointF(2.0, 3.0)));
@@ -253,9 +260,12 @@ TEST_F(NodeUndoTest, SetPositionAndDependenciesRecursivelyMovesNode)
cmd.redo_now();
EXPECT_EQ(context->GetNodePositionInContext(node), QPointF(8.0, 9.0));
// The dependency moves by the same delta as the node
EXPECT_EQ(context->GetNodePositionInContext(dep), QPointF(7.0, 7.0));
cmd.undo_now();
EXPECT_EQ(context->GetNodePositionInContext(node), QPointF(2.0, 3.0));
EXPECT_EQ(context->GetNodePositionInContext(dep), QPointF(1.0, 1.0));
}
TEST_F(NodeUndoTest, RemovePositionFromContextCommandRestoresPosition)
@@ -909,4 +919,12 @@ TEST_F(NodeUndoTest, ImmediateRemoveAllKeyframesCommandRemovesKeys)
EXPECT_TRUE(immediate->keyframe_tracks().at(0).isEmpty());
EXPECT_NE(key_a->parent(), node);
EXPECT_NE(key_b->parent(), node);
cmd.undo_now();
// Undo restores the keyframes to the node and its tracks
ASSERT_EQ(immediate->keyframe_tracks().at(0).size(), 2);
EXPECT_EQ(immediate->keyframe_tracks().at(0).at(0), key_a);
EXPECT_EQ(immediate->keyframe_tracks().at(0).at(1), key_b);
EXPECT_EQ(key_a->parent(), node);
EXPECT_EQ(key_b->parent(), node);
}
+38 -35
View File
@@ -416,14 +416,14 @@ TEST(NodeValueExtended, PrettyDataTypeNames)
olive::NodeValue::kDataTypeCount),
QStringLiteral("Unknown"));
// NOTE: kStrCombo and kPushButton have no dedicated pretty name and fall
// through to "Unknown" (naming gap, documented here).
// kStrCombo and kPushButton have dedicated pretty names like every other
// type
EXPECT_EQ(
olive::NodeValue::GetPrettyDataTypeName(olive::NodeValue::kStrCombo),
QStringLiteral("Unknown"));
QStringLiteral("String Combo"));
EXPECT_EQ(
olive::NodeValue::GetPrettyDataTypeName(olive::NodeValue::kPushButton),
QStringLiteral("Unknown"));
QStringLiteral("Push Button"));
}
TEST(NodeValueExtended, TypeClassificationRemainingCases)
@@ -463,14 +463,12 @@ TEST(NodeValueExtended, TypeClassificationRemainingCases)
EXPECT_FALSE(olive::NodeValue::type_is_buffer(olive::NodeValue::kFloat));
}
TEST(NodeValueExtended, UnnamedTypesHaveEmptyDataTypeNames)
TEST(NodeValueExtended, AllRealTypesHaveDataTypeNames)
{
EXPECT_TRUE(
olive::NodeValue::GetDataTypeName(olive::NodeValue::kStrCombo)
.isEmpty());
EXPECT_TRUE(
olive::NodeValue::GetDataTypeName(olive::NodeValue::kPushButton)
.isEmpty());
EXPECT_EQ(olive::NodeValue::GetDataTypeName(olive::NodeValue::kStrCombo),
QStringLiteral("strcombo"));
EXPECT_EQ(olive::NodeValue::GetDataTypeName(olive::NodeValue::kPushButton),
QStringLiteral("pushbutton"));
EXPECT_TRUE(
olive::NodeValue::GetDataTypeName(olive::NodeValue::kDataTypeCount)
.isEmpty());
@@ -479,11 +477,17 @@ TEST(NodeValueExtended, UnnamedTypesHaveEmptyDataTypeNames)
QStringLiteral("not-a-type")),
olive::NodeValue::kNone);
// NOTE: an empty name matches the first type with an empty serialized
// name (kStrCombo) rather than producing kNone (suspected bug, documented
// here).
// An empty name matches no type
EXPECT_EQ(olive::NodeValue::GetDataTypeFromName(QString()),
olive::NodeValue::kStrCombo);
olive::NodeValue::kNone);
// The newly named types round-trip
EXPECT_EQ(
olive::NodeValue::GetDataTypeFromName(QStringLiteral("strcombo")),
olive::NodeValue::kStrCombo);
EXPECT_EQ(
olive::NodeValue::GetDataTypeFromName(QStringLiteral("pushbutton")),
olive::NodeValue::kPushButton);
}
TEST(NodeValueExtended, ArrayValuesRoundTrip)
@@ -536,13 +540,14 @@ TEST(NodeValueTableExtended, GetWithTagSelectsMatchingValue)
QStringLiteral("b")),
1);
// NOTE: an unknown tag does not yield an empty value; the search keeps
// scanning and returns the oldest value of the type instead (suspected
// bug, documented here).
EXPECT_DOUBLE_EQ(
table.Get(olive::NodeValue::kFloat, QStringLiteral("missing"))
.toDouble(),
1.0);
// An unknown tag yields an empty value; the fallback to the oldest value
// of the type only applies when no tag is requested
olive::NodeValue missing =
table.Get(olive::NodeValue::kFloat, QStringLiteral("missing"));
EXPECT_EQ(missing.type(), olive::NodeValue::kNone);
EXPECT_EQ(table.GetValueIndex({ olive::NodeValue::kFloat },
QStringLiteral("missing")),
-1);
}
TEST(NodeValueTableExtended, GetWithMultipleTypes)
@@ -603,12 +608,13 @@ TEST(NodeValueTableExtended, TakeWithTagAndMissingType)
EXPECT_EQ(absent.type(), olive::NodeValue::kNone);
EXPECT_EQ(table.Count(), 1);
// NOTE: like Get(), an unmatched tag falls back to the oldest value of
// the type (suspected bug, documented here).
// Taking with an unmatched tag returns an empty value and leaves the table
// unchanged; the oldest value of the type is not used as a fallback
olive::NodeValue fallback =
table.Take(olive::NodeValue::kText, QStringLiteral("missing"));
EXPECT_EQ(fallback.toString(), QStringLiteral("b"));
EXPECT_TRUE(table.isEmpty());
EXPECT_EQ(fallback.type(), olive::NodeValue::kNone);
ASSERT_EQ(table.Count(), 1);
EXPECT_EQ(table.at(0).toString(), QStringLiteral("b"));
}
TEST(NodeValueTableExtended, TakeWithMultipleTypes)
@@ -657,24 +663,21 @@ TEST(NodeValueTableExtended, RemoveDeletesNewestEqualValue)
EXPECT_EQ(table.Count(), 2);
}
TEST(NodeValueTableExtended, HasUsesBitmaskComparison)
TEST(NodeValueTableExtended, HasUsesExactTypeMatch)
{
olive::NodeValueTable table;
table.Push(olive::NodeValue(olive::NodeValue::kFloat, 1.0));
EXPECT_TRUE(table.Has(olive::NodeValue::kFloat));
EXPECT_FALSE(table.Has(olive::NodeValue::kInt));
// NOTE: Has() compares types with a bitwise AND even though Type is a
// sequential enum, so unrelated types alias: kFloat (2) also satisfies
// kRational (3) and kText (7) because 2 & 3 != 0 and 2 & 7 != 0
// (suspected bug, documented here).
EXPECT_TRUE(table.Has(olive::NodeValue::kRational));
EXPECT_TRUE(table.Has(olive::NodeValue::kText));
// Type is a sequential enum, so no other type aliases kFloat
EXPECT_FALSE(table.Has(olive::NodeValue::kRational));
EXPECT_FALSE(table.Has(olive::NodeValue::kText));
// kNone is zero, so a table holding a kNone value never reports it
// A table holding a kNone value reports it too
olive::NodeValueTable none_table;
none_table.Push(olive::NodeValue());
EXPECT_FALSE(none_table.Has(olive::NodeValue::kNone));
EXPECT_TRUE(none_table.Has(olive::NodeValue::kNone));
}
TEST(NodeValueTableExtended, PushTableAppendsAllValues)
+68 -4
View File
@@ -23,6 +23,7 @@
#include <QDir>
#include <QFile>
#include <QGuiApplication>
#include <QTemporaryDir>
#include "ofxCore.h"
@@ -34,6 +35,7 @@
#include "common/Current.h"
#include "node/plugins/Plugin.h"
#include "pluginSupport/OliveHost.h"
#include "version.h"
namespace
{
@@ -202,11 +204,12 @@ TEST(OliveHost, DestroyInstanceIgnoresNull)
}
// ============================================================================
// OliveHost message routing (error paths only)
// OliveHost message routing
//
// The successful vmessage/setPersistentMessage paths pop modal QMessageBox
// dialogs whenever a QApplication exists, which a headless test cannot
// dismiss, so only the early-return failure paths are exercised here.
// With a QApplication on a non-offscreen platform the successful
// vmessage/setPersistentMessage paths pop modal QMessageBox dialogs, which a
// headless test cannot dismiss; on the offscreen platform they log to stderr
// instead, so those paths are exercised here behind a platform check.
// ============================================================================
TEST(OliveHost, VMessageRejectsNullArguments)
@@ -236,6 +239,44 @@ TEST(OliveHost, SetPersistentMessageRejectsUnknownType)
kOfxStatFailed);
}
TEST(OliveHost, VMessageOffscreenLogsInsteadOfDialog)
{
if (QGuiApplication::platformName() != QLatin1String("offscreen")) {
GTEST_SKIP() << "requires the offscreen QPA platform";
}
olive::plugin::OliveHost host;
// No modal dialog is shown on the offscreen platform; the message is
// logged to stderr and acknowledged.
EXPECT_EQ(CallVMessage(host, kOfxMessageError, "id", "%s", "boom"),
kOfxStatOK);
EXPECT_EQ(CallVMessage(host, kOfxMessageWarning, "id", "%s", "boom"),
kOfxStatOK);
EXPECT_EQ(CallVMessage(host, kOfxMessageMessage, "id", "%s", "boom"),
kOfxStatOK);
// A question cannot be answered headlessly, so it is a "no".
EXPECT_EQ(CallVMessage(host, kOfxMessageQuestion, "id", "%s", "boom"),
kOfxStatReplyNo);
}
TEST(OliveHost, SetPersistentMessageOffscreenSucceeds)
{
if (QGuiApplication::platformName() != QLatin1String("offscreen")) {
GTEST_SKIP() << "requires the offscreen QPA platform";
}
olive::plugin::OliveHost host;
EXPECT_EQ(CallSetPersistentMessage(host, kOfxMessageError, "id", "%s",
"boom"),
kOfxStatOK);
EXPECT_EQ(CallSetPersistentMessage(host, kOfxMessageWarning, "id", "%s",
"boom"),
kOfxStatOK);
EXPECT_EQ(CallSetPersistentMessage(host, kOfxMessageMessage, "id", "%s",
"boom"),
kOfxStatOK);
}
TEST(OliveHost, ClearPersistentMessageSucceeds)
{
olive::plugin::OliveHost host;
@@ -268,6 +309,29 @@ TEST(OliveHost, HostPropertiesIdentifyAsOfxHost)
EXPECT_EQ(host.getProperties().getStringProperty(kOfxPropType), "Host");
}
TEST(OliveHost, HostPropertiesIdentifyApplication)
{
// The ctor stamps the app identity over HostSupport's "UNKNOWN" defaults
// so plugins querying the host description see real values.
olive::plugin::OliveHost host;
const auto &props = host.getProperties();
EXPECT_EQ(props.getStringProperty(kOfxPropName), "Oak Video Editor");
EXPECT_EQ(props.getStringProperty(kOfxPropLabel), "Oak Video Editor");
EXPECT_EQ(props.getStringProperty(kOfxPropVersionLabel),
olive::kAppVersion.toStdString());
const QStringList version_parts =
olive::kAppVersion.section(QLatin1Char('-'), 0, 0)
.split(QLatin1Char('.'));
EXPECT_EQ(props.getIntProperty(kOfxPropVersion, 0),
version_parts.value(0).toInt());
EXPECT_EQ(props.getIntProperty(kOfxPropVersion, 1),
version_parts.value(1).toInt());
EXPECT_EQ(props.getIntProperty(kOfxPropVersion, 2),
version_parts.value(2).toInt());
}
#ifdef OFX_SUPPORTS_OPENGLRENDER
TEST(OliveHost, FlushOpenGLResourcesReportsFailure)
{
+35 -3
View File
@@ -566,6 +566,38 @@ TEST(PluginClipInstance, RegionOfDefinitionPerTimeOverride)
EXPECT_DOUBLE_EQ(at_four.y2, 80.0);
}
TEST(PluginClipInstance, RegionOfDefinitionFallsBackToStoredDefault)
{
OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName);
// Zero-sized params yield no usable params-derived region, so the stored
// default is used.
olive::VideoParams empty_params =
MakeParams(0, 0, olive::core::PixelFormat::U8, 4, false);
olive::plugin::OliveClipInstance empty_clip(nullptr, desc, empty_params);
OfxRectD stored = { 2.0, 4.0, 202.0, 104.0 };
empty_clip.setDefaultRegionOfDefinition(stored);
OfxRectD fallback = empty_clip.getRegionOfDefinition(0.0);
EXPECT_DOUBLE_EQ(fallback.x1, 2.0);
EXPECT_DOUBLE_EQ(fallback.y1, 4.0);
EXPECT_DOUBLE_EQ(fallback.x2, 202.0);
EXPECT_DOUBLE_EQ(fallback.y2, 104.0);
// A params-derived region still takes precedence over the stored default.
olive::VideoParams params =
MakeParams(100, 80, olive::core::PixelFormat::U8, 4, false);
olive::plugin::OliveClipInstance clip(nullptr, desc, params);
clip.setDefaultRegionOfDefinition(stored);
OfxRectD derived = clip.getRegionOfDefinition(0.0);
EXPECT_DOUBLE_EQ(derived.x1, 0.0);
EXPECT_DOUBLE_EQ(derived.y1, 0.0);
EXPECT_DOUBLE_EQ(derived.x2, 100.0);
EXPECT_DOUBLE_EQ(derived.y2, 80.0);
}
TEST(PluginClipInstance, OutputImageBoundsFollowRegionOfDefinition)
{
OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName);
@@ -787,11 +819,11 @@ TEST(PluginClipInstance, PruneImagesCacheEvictsOldestInputImages)
}
clip.pruneImagesCache();
// The oldest entry (time 1) was evicted and is recreated on demand,
// while later entries remain cached.
// The oldest entry (time 1) was evicted; its on-demand recreation is
// cached again, while later entries remained cached throughout.
OFX::Host::ImageEffect::Image *evicted_a = clip.getImage(1.0, nullptr);
OFX::Host::ImageEffect::Image *evicted_b = clip.getImage(1.0, nullptr);
EXPECT_NE(evicted_a, evicted_b);
EXPECT_EQ(evicted_a, evicted_b);
OFX::Host::ImageEffect::Image *cached_a = clip.getImage(2.0, nullptr);
OFX::Host::ImageEffect::Image *cached_b = clip.getImage(2.0, nullptr);
+7 -2
View File
@@ -75,19 +75,24 @@ TEST(PluginSupportClip, GetImageClampsBoundsAndCachesOutput)
EXPECT_EQ(image, image_again);
}
TEST(PluginSupportClip, GetImageReturnsNewImageForNonOutput)
TEST(PluginSupportClip, GetImageCachesImageForNonOutput)
{
OFX::Host::ImageEffect::ClipDescriptor desc("Source");
olive::VideoParams params =
MakeParams(64, 64, olive::core::PixelFormat::U8, 4, false);
olive::plugin::OliveClipInstance clip(nullptr, desc, params);
EXPECT_FALSE(clip.getConnected());
OFX::Host::ImageEffect::Image *first = clip.getImage(0.0, nullptr);
OFX::Host::ImageEffect::Image *second = clip.getImage(0.0, nullptr);
EXPECT_NE(first, nullptr);
EXPECT_NE(second, nullptr);
EXPECT_NE(first, second);
// On-demand images are cached per time, so both fetches return the
// same image and the clip now reports as connected.
EXPECT_EQ(first, second);
EXPECT_TRUE(clip.getConnected());
first->releaseReference();
second->releaseReference();
+4 -2
View File
@@ -73,10 +73,12 @@ TEST(Project, FilenameNamePrettyAndSignals)
[&name_changes]() { ++name_changes; });
const QString filename = QStringLiteral("/tmp/some/dir/my_edit.ove");
// Project::set_filename converts to native separators on Windows
const QString stored_filename = QDir::toNativeSeparators(filename);
project.set_filename(filename);
EXPECT_EQ(project.filename(), filename);
EXPECT_EQ(project.filename(), stored_filename);
EXPECT_EQ(project.name(), QStringLiteral("my_edit"));
EXPECT_EQ(project.pretty_filename(), filename);
EXPECT_EQ(project.pretty_filename(), stored_filename);
EXPECT_FALSE(project.is_new());
EXPECT_EQ(name_changes, 1);
+27
View File
@@ -384,6 +384,33 @@ TEST(IpcMessage, MalformedLineIsSkipped)
EXPECT_TRUE(reader.isEmpty());
}
TEST(IpcMessage, BlankLinesAreSkippedSilently)
{
CancelMsg c;
c.ticket_id = 7;
const QByteArray line =
QByteArray(QJsonDocument(c.ToJson()).toJson(QJsonDocument::Compact)) +
'\n';
// Blank lines (even repeated) are consumed without flagging an error, and
// the following real message is still parsed.
QByteArray reader = QByteArray("\n \n\n") + line;
QJsonObject obj;
bool ok = false;
ASSERT_TRUE(ReadMessage(&reader, &obj, &ok));
EXPECT_TRUE(ok);
CancelMsg c2;
ASSERT_TRUE(CancelMsg::FromJson(obj, &c2));
EXPECT_EQ(c2.ticket_id, 7);
// Only blank lines left: nothing more to read, but still not an error.
ok = true;
EXPECT_FALSE(ReadMessage(&reader, &obj, &ok));
EXPECT_TRUE(ok);
EXPECT_TRUE(reader.isEmpty());
}
TEST(IpcMessage, WrongTypeRejected)
{
// FromJson must reject an object whose "type" does not match the target struct.
+4 -3
View File
@@ -216,9 +216,10 @@ TEST(DynamicRenderer, ConstructorNormalizesBackendName)
EXPECT_FALSE(vk.IsOpenGL());
}
// Documents that an unrecognized backend name is kept verbatim (even though
// LibraryFilename() silently maps it to the OpenGL library basename), so the
// IsOpenGL()/IsVulkan() predicates both report false for it.
// Documents that an unrecognized backend name is kept verbatim (and
// LibraryFilename() uses that verbatim name as the library basename, so Load()
// fails and the OpenGL fallback engages), so the IsOpenGL()/IsVulkan()
// predicates both report false for it.
TEST(DynamicRenderer, UnknownBackendNameIsReportedVerbatim)
{
olive::DynamicRenderer renderer(QStringLiteral("Metal"));
+2 -6
View File
@@ -646,16 +646,12 @@ TEST(IpcMessage, ReadMessageSkipsBlankLines)
const QByteArray line =
QJsonDocument(cancel.ToJson()).toJson(QJsonDocument::Compact);
// A reader loop sees: blank line, whitespace-only line, then a real message.
// A reader loop sees: blank line, whitespace-only line, then a real
// message. Blank lines are skipped silently.
QByteArray reader = QByteArray("\n \n") + line + '\n';
QJsonObject obj;
bool ok = true;
EXPECT_FALSE(olive::ipc::ReadMessage(&reader, &obj, &ok)); // blank
EXPECT_FALSE(ok);
EXPECT_FALSE(olive::ipc::ReadMessage(&reader, &obj, &ok)); // whitespace
EXPECT_FALSE(ok);
ASSERT_TRUE(olive::ipc::ReadMessage(&reader, &obj, &ok));
EXPECT_TRUE(ok);
olive::ipc::CancelMsg back;
+21
View File
@@ -97,6 +97,14 @@ TEST(Sequence, DefaultState)
olive::Track::Reference(olive::Track::kVideo, 0)),
nullptr);
// Invalid and out-of-range reference types return null instead of crashing
EXPECT_EQ(sequence.GetTrackFromReference(
olive::Track::Reference(olive::Track::kNone, 0)),
nullptr);
EXPECT_EQ(sequence.GetTrackFromReference(
olive::Track::Reference(olive::Track::kCount, 0)),
nullptr);
// Length verification over empty track lists keeps everything at zero
sequence.VerifyLength();
EXPECT_EQ(sequence.GetLength(), olive::core::rational(0));
@@ -282,6 +290,15 @@ TEST(Sequence, TrackDisconnectResetsTrackState)
++sequence_removed;
});
// While connected, track height changes are forwarded by the list
int height_changed = 0;
QObject::connect(list, &olive::TrackList::TrackHeightChanged,
[&height_changed](olive::Track *, int) {
++height_changed;
});
first->SetTrackHeight(first->GetTrackHeight() + 1.0);
EXPECT_EQ(height_changed, 1);
olive::Node::DisconnectEdge(first, list->track_input(0));
EXPECT_EQ(list_removed, 1);
@@ -311,6 +328,10 @@ TEST(Sequence, TrackDisconnectResetsTrackState)
EXPECT_EQ(sequence->GetTrackFromReference(
olive::Track::Reference(olive::Track::kVideo, 1)),
nullptr);
// Height changes on the removed track must no longer be forwarded
first->SetTrackHeight(first->GetTrackHeight() + 1.0);
EXPECT_EQ(height_changed, 1);
}
TEST(TrackList, CacheOrderFollowsArrayIndex)