diff --git a/cueman/README.md b/cueman/README.md index 909359d0f..53020b614 100644 --- a/cueman/README.md +++ b/cueman/README.md @@ -217,10 +217,10 @@ cueman -retry job_name -state DEAD ## Frame Manipulation ```bash -# Stagger frames by increment +# Stagger frames by increment (must be positive integer) cueman -stagger job_name 1-100 5 -# Reorder frames +# Reorder frames (position must be FIRST, LAST, or REVERSE) cueman -reorder job_name 1-100 FIRST cueman -reorder job_name 50-100 LAST cueman -reorder job_name 1-100 REVERSE diff --git a/cueman/cueman/main.py b/cueman/cueman/main.py index 041e98220..f24ef3070 100644 --- a/cueman/cueman/main.py +++ b/cueman/cueman/main.py @@ -567,6 +567,9 @@ def handleArgs(args): elif args.stagger: name, frame_range, increment = args.stagger try: + if not increment.isdigit() or int(increment) < 1: + logger.error("Error: Increment must be a positive integer.") + sys.exit(1) job = opencue.api.findJob(name) layers = args.layer common.confirm( @@ -591,6 +594,9 @@ def handleArgs(args): elif args.reorder: name, frame_range, position = args.reorder try: + if position not in ["FIRST", "LAST", "REVERSE"]: + logger.error("Error: Position must be one of FIRST, LAST, or REVERSE.") + sys.exit(1) job = opencue.api.findJob(name) layers = args.layer common.confirm( @@ -725,6 +731,14 @@ def buildFrameSearch(args): if args.layer: s["layer"] = args.layer if args.range: + if not re.match(r"^\d+$|^\d+-\d+$", args.range): + logger.error("Invalid range format: %s", args.range) + sys.exit(1) + if "-" in args.range: + r = args.range.partition("-") + if r[0] > r[2]: + logger.error("Invalid range format: %s", args.range) + sys.exit(1) s["range"] = args.range if args.state: s["state"] = [common.Convert.strToFrameState(st) for st in args.state] diff --git a/cueman/cueman_tutorial.md b/cueman/cueman_tutorial.md index ecd4034cb..546c39a15 100644 --- a/cueman/cueman_tutorial.md +++ b/cueman/cueman_tutorial.md @@ -376,6 +376,8 @@ cueman -stagger job_name 1-100 5 cueman -stagger job_name 1-50 10 -layer sim_layer ``` +**Note:** Increment must be a positive integer. Zero, negative, and non-numeric values will be rejected. + ### Reorder Frames Change frame execution order: @@ -393,6 +395,8 @@ cueman -reorder job_name 1-100 REVERSE cueman -reorder job_name 1-50 FIRST -layer hero_layer ``` +**Note:** Position must be one of: `FIRST`, `LAST`, or `REVERSE`. Other values will be rejected. + ## Real-World Scenarios ### Scenario 1: Handling Stuck Frames diff --git a/cueman/tests/test_frame_operations.py b/cueman/tests/test_frame_operations.py new file mode 100644 index 000000000..20896526b --- /dev/null +++ b/cueman/tests/test_frame_operations.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenCue Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Unit tests for frame operations cueman""" + +#pylint: disable=invalid-name +#pylint: disable=too-many-public-methods +import unittest +from unittest.mock import MagicMock, patch +import argparse +from cueman import main as cuemain + +class TestFrameOperations (unittest.TestCase): + """Test cases for cueman frame operations""" + + def _ns(self, **overrides): + """Build a minimal argparse.Namespace matching cueman.main expectations.""" + base = { + "lf": None, + "lp": None, + "ll": None, + "info": None, + "pause": None, + "resume": None, + "term": None, + "eat": None, + "kill": None, + "retry": None, + "done": None, + "stagger": None, + "reorder": None, + "retries": None, + "autoeaton": None, + "autoeatoff": None, + "layer": None, + "range": None, + "state": None, + "page": None, + "limit": None, + "duration": None, + "memory": None, + "force": False, + } + base.update(overrides) + return argparse.Namespace(**base) + + # ------------- Frame eat/kill/retry operations with layer and range filters tests ------------- + + @patch("opencue.api.findJob") + def test_eatFrames_with_valid_layer(self, mock_findJob): + """Test eatFrames with valid layer and range filters.""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(eat="test_job", layer=["render_layer"], range="1-10", force=True) + cuemain.handleArgs(args) + + mock_findJob.assert_called_once_with("test_job") + mock_job.eatFrames.assert_called_once_with(layer=["render_layer"], range="1-10") + + + @patch("opencue.api.findJob") + def test_killFrames_with_valid_layer(self, mock_findJob): + """Test killFrames with valid layer and range filters.""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(kill="test_job", layer=["render_layer"], range="1-10", force=True) + cuemain.handleArgs(args) + + mock_findJob.assert_called_once_with("test_job") + mock_job.killFrames.assert_called_once_with(layer=["render_layer"], range="1-10") + + + @patch("opencue.api.findJob") + def test_retryFrames_with_valid_layer(self, mock_findJob): + """Test retryFrames with valid layer and range filters.""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(retry="test_job", layer=["render_layer"], range="1-10", force=True) + cuemain.handleArgs(args) + + mock_findJob.assert_called_once_with("test_job") + mock_job.retryFrames.assert_called_once_with(layer=["render_layer"], range="1-10") + + + # -------------- eatFrame state filtering tests -------------- + + @patch("opencue.api.findJob") + def test_eatFrames_with_waiting_state_filter(self, mock_findJob): + """Test eatFrames with waiting state filter""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(eat="test_job", state=["waiting"], force=True) + cuemain.handleArgs(args) + + mock_findJob.assert_called_once_with("test_job") + mock_job.eatFrames.assert_called_once_with(state=[0]) + + + @patch("opencue.api.findJob") + def test_eatFrames_with_running_state_filter(self, mock_findJob): + """Test eatFrames with running state filter""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(eat="test_job", state=["running"], force=True) + cuemain.handleArgs(args) + + mock_findJob.assert_called_once_with("test_job") + mock_job.eatFrames.assert_called_once_with(state=[2]) + + + @patch("opencue.api.findJob") + def test_eatFrames_with_succeeded_state_filter(self, mock_findJob): + """Test eatFrames with succeeded state filter""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(eat="test_job", state=["succeeded"], force=True) + cuemain.handleArgs(args) + + mock_findJob.assert_called_once_with("test_job") + mock_job.eatFrames.assert_called_once_with(state=[3]) + + + @patch("opencue.api.findJob") + def test_eatFrames_with_dead_state_filter(self, mock_findJob): + """Test eatFrames with dead state filter""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(eat="test_job", state=["dead"], force=True) + cuemain.handleArgs(args) + + mock_findJob.assert_called_once_with("test_job") + mock_job.eatFrames.assert_called_once_with(state=[5]) + + + # -------------- eatFrame range parsing and validation tests -------------- + + @patch("opencue.api.findJob") + def test_valid_range_inputs(self, mock_findJob): + """Test eatFrame with valid range input""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(eat="test_job", range="1-10", force=True) + cuemain.handleArgs(args) + + mock_findJob.assert_called_once_with("test_job") + mock_job.eatFrames.assert_called_once_with(range="1-10") + + + @patch("opencue.api.findJob") + def test_valid_single_range_inputs(self, mock_findJob): + """Test eatFrame with single frame input""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(eat="test_job", range="1", force=True) + cuemain.handleArgs(args) + + mock_findJob.assert_called_once_with("test_job") + mock_job.eatFrames.assert_called_once_with(range="1") + + + @patch("opencue.api.findJob") + def test_invalid_nonnumeric_range_inputs(self, mock_findJob): + """Test eatFrame with invalid range input (non numeric)""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(eat="test_job", range="1-a", force=True) + with self.assertRaises(SystemExit) as e: + cuemain.handleArgs(args) + + self.assertEqual(e.exception.code, 1) + + + @patch("opencue.api.findJob") + def test_invalid_reverse_range_inputs(self, mock_findJob): + """Test eatFrame with invalid range inputs (reverse)""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(eat="test_job", range="10-1", force=True) + with self.assertRaises(SystemExit) as e: + cuemain.handleArgs(args) + + self.assertEqual(e.exception.code, 1) + + + # -------------- Mark done functionality test -------------- + + @patch("opencue.api.findJob") + def test_done_functionality(self, mock_findJob): + """Test mark done functionality""" + mock_job = MagicMock() + mock_job.markdoneFrames = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(done="test_job", force=True) + cuemain.handleArgs(args) + + mock_findJob.assert_called_once_with("test_job") + mock_job.markdoneFrames.assert_called_once() + + + # -------------- Stagger Operation test -------------- + + @patch("opencue.api.findJob") + def test_stagger_increments(self, mock_findJob): + """Test stagger operations with increment validation""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(stagger=["test_job", "1-10", "2"], force=True) + cuemain.handleArgs(args) + + mock_findJob.assert_called_once_with("test_job") + mock_job.staggerFrames.assert_called_once_with("1-10", 2) + + + @patch("opencue.api.findJob") + def test_stagger_zero_increments(self, mock_findJob): + """Test stagger operations with zero increment validation""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(stagger=["test_job", "1-10", "0"], force=True) + with self.assertRaises(SystemExit) as e: + cuemain.handleArgs(args) + + self.assertEqual(e.exception.code, 1) + + + @patch("opencue.api.findJob") + def test_stagger_negative_increments(self, mock_findJob): + """Test stagger operations with negative increment validation""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(stagger=["test_job", "1-10", "-1"], force=True) + with self.assertRaises(SystemExit) as e: + cuemain.handleArgs(args) + + self.assertEqual(e.exception.code, 1) + + + @patch("opencue.api.findJob") + def test_stagger_nonnumeric_increments(self, mock_findJob): + """Test stagger operations with non numeric increment validation""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(stagger=["test_job", "1-10", "a"], force=True) + with self.assertRaises(SystemExit) as e: + cuemain.handleArgs(args) + + self.assertEqual(e.exception.code, 1) + + + # -------------- Reorder operations with position valdiation test -------------- + + @patch("opencue.api.findJob") + def test_reorder_first_operation(self, mock_findJob): + """Test reorder operation to first position""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(reorder=["test_job", "1-10", "FIRST"], force=True) + cuemain.handleArgs(args) + + mock_findJob.assert_called_once_with("test_job") + mock_job.reorderFrames.assert_called_once_with("1-10", "FIRST") + + + @patch("opencue.api.findJob") + def test_reorder_last_operation(self, mock_findJob): + """Test reorder operation to last position""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(reorder=["test_job", "1-10", "LAST"], force=True) + cuemain.handleArgs(args) + + mock_findJob.assert_called_once_with("test_job") + mock_job.reorderFrames.assert_called_once_with("1-10", "LAST") + + + @patch("opencue.api.findJob") + def test_reorder_reverse_operation(self, mock_findJob): + """Test reorder operation to reverse position""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(reorder=["test_job", "1-10", "REVERSE"], force=True) + cuemain.handleArgs(args) + + mock_findJob.assert_called_once_with("test_job") + mock_job.reorderFrames.assert_called_once_with("1-10", "REVERSE") + + + @patch("opencue.api.findJob") + def test_reorder_invalid_operation(self, mock_findJob): + """Test reorder operation to invalid position""" + mock_job = MagicMock() + mock_findJob.return_value = mock_job + + args = self._ns(reorder=["test_job", "1-10", "ab"], force=True) + with self.assertRaises(SystemExit) as e: + cuemain.handleArgs(args) + + self.assertEqual(e.exception.code, 1) + + + # -------------- Reorder operations with position valdiation test -------------- + + @patch("opencue.api.findJob") + @patch("cueadmin.util.promptYesNo") + def test_eat_confirmation_prompt(self, mock_promptYesNo, mock_findJob): + """Test prompt confirmation for eatFrames operation""" + mock_job = MagicMock() + mock_job.eatFrames = MagicMock() + mock_findJob.return_value = mock_job + mock_promptYesNo.return_value = True + + args = self._ns(eat="test_job", layer=["render_layer"], range="1-10", force=False) + cuemain.handleArgs(args) + + mock_findJob.assert_called_once_with("test_job") + mock_promptYesNo.assert_called_once_with("Eat specified frames on job test_job", False) + mock_job.eatFrames.assert_called_once_with(layer=["render_layer"], range="1-10") + + + @patch("opencue.api.findJob") + @patch("cueadmin.util.promptYesNo") + def test_kill_confirmation_prompt(self, mock_promptYesNo, mock_findJob): + """Test prompt confirmation for killFrames operation""" + mock_job = MagicMock() + mock_job.killFrames = MagicMock() + mock_findJob.return_value = mock_job + mock_promptYesNo.return_value = True + + args = self._ns(kill="test_job", layer=["render_layer"], range="1-10", force=False) + cuemain.handleArgs(args) + + mock_findJob.assert_called_once_with("test_job") + mock_promptYesNo.assert_called_once_with("Kill specified frames on job test_job", False) + mock_job.killFrames.assert_called_once_with(layer=["render_layer"], range="1-10") + + + @patch("opencue.api.findJob") + @patch("cueadmin.util.promptYesNo") + def test_retry_confirmation_prompt(self, mock_promptYesNo, mock_findJob): + """Test prompt confirmation for retryFrames operation""" + mock_job = MagicMock() + mock_job.retryFrames = MagicMock() + mock_findJob.return_value = mock_job + mock_promptYesNo.return_value = True + + args = self._ns(retry="test_job", layer=["render_layer"], range="1-10", force=False) + cuemain.handleArgs(args) + + mock_findJob.assert_called_once_with("test_job") + mock_promptYesNo.assert_called_once_with("Retry specified frames on job test_job", False) + mock_job.retryFrames.assert_called_once_with(layer=["render_layer"], range="1-10") + + + @patch("opencue.api.findJob") + @patch("cueadmin.util.promptYesNo") + def test_done_confirmation_prompt(self, mock_promptYesNo, mock_findJob): + """Test prompt confirmation for done Frames operation""" + mock_job = MagicMock() + mock_job.markdoneFrames = MagicMock() + mock_findJob.return_value = mock_job + mock_promptYesNo.return_value = True + + args = self._ns(done="test_job", layer=["render_layer"], range="1-10", force=False) + cuemain.handleArgs(args) + + mock_findJob.assert_called_once_with("test_job") + mock_promptYesNo.assert_called_once_with("Mark done specified frames on job test_job", + False) + mock_job.markdoneFrames.assert_called_once_with(layer=["render_layer"], range="1-10") + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/_docs/reference/tools/cueman.md b/docs/_docs/reference/tools/cueman.md index 2372240e7..c5af8770f 100644 --- a/docs/_docs/reference/tools/cueman.md +++ b/docs/_docs/reference/tools/cueman.md @@ -218,6 +218,8 @@ Add delays between frame starts: cueman -stagger job_name 1-100 5 # Stagger by 5 frame increments ``` +**Note:** Increment must be a positive integer. Zero, negative, and non-numeric values will be rejected. + #### Reorder Frames Change execution order: @@ -228,6 +230,8 @@ cueman -reorder job_name 1-49 LAST # Move to back cueman -reorder job_name 1-100 REVERSE # Reverse order ``` +**Note:** Position must be one of: `FIRST`, `LAST`, or `REVERSE`. Other values will be rejected. + ## Filtering Options ### State Filter @@ -250,6 +254,8 @@ cueman -lf job_name -range 1,3,5,7,9 # Individual frames cueman -lf job_name -range 1-10,20,30-40 # Mixed ranges ``` +**Validation:** Range must be numeric (single frame like `5` or range like `1-100`). For ranges, the start frame must be less than or equal to the end frame (e.g., `10-1` is invalid). + ### Layer Filter Work with specific layers: @@ -391,6 +397,30 @@ cueman -server cuebot.example.com:8443 -lf job_name cueman -v -info job_name ``` +**Invalid stagger increment:** +```bash +$ cueman -stagger job_name 1-100 0 +Error: Increment must be a positive integer. + +$ cueman -stagger job_name 1-100 -5 +Error: Increment must be a positive integer. +``` + +**Invalid reorder position:** +```bash +$ cueman -reorder job_name 1-50 MIDDLE +Error: Position must be one of FIRST, LAST, or REVERSE. +``` + +**Invalid frame range:** +```bash +$ cueman -eat job_name -range 10-1 +Error: Invalid range format: 10-1 + +$ cueman -eat job_name -range 1-a +Error: Invalid range format: 1-a +``` + ### Getting Help ```bash diff --git a/docs/_docs/tutorials/cueman-tutorial.md b/docs/_docs/tutorials/cueman-tutorial.md index b430d0093..3df7dfd0f 100644 --- a/docs/_docs/tutorials/cueman-tutorial.md +++ b/docs/_docs/tutorials/cueman-tutorial.md @@ -225,6 +225,8 @@ cueman -stagger show_shot_lighting_v001 1-100 5 cueman -stagger show_shot_lighting_v001 1-50 10 -layer sim_layer ``` +**Note:** The increment must be a positive integer. Values like `0`, `-5`, or `abc` will be rejected. + ### Reordering Frames Control execution priority: @@ -240,6 +242,8 @@ cueman -reorder show_shot_lighting_v001 1-49 LAST cueman -reorder show_shot_lighting_v001 1-100 REVERSE ``` +**Note:** The position must be one of `FIRST`, `LAST`, or `REVERSE`. Other values like `MIDDLE` will be rejected. + ## Part 6: Real-World Scenarios ### Scenario 1: Morning Farm Cleanup @@ -463,6 +467,24 @@ $ cueman -kill show_shot_001 -state SUCCEEDED No frames found matching criteria ``` +**Invalid stagger increment:** +```bash +$ cueman -stagger show_shot_001 1-100 0 +Error: Increment must be a positive integer. +``` + +**Invalid reorder position:** +```bash +$ cueman -reorder show_shot_001 1-50 MIDDLE +Error: Position must be one of FIRST, LAST, or REVERSE. +``` + +**Invalid frame range:** +```bash +$ cueman -eat show_shot_001 -range 50-10 +Error: Invalid range format: 50-10 +``` + ## Summary You've learned how to: