diff --git a/providers/base/bin/randr_cycle.py b/providers/base/bin/randr_cycle.py new file mode 100755 index 0000000000..97c0ca5b95 --- /dev/null +++ b/providers/base/bin/randr_cycle.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +# +# This file is part of Checkbox. +# +# Copyright 2025 Canonical Ltd. +# Written by: +# Hanhsuan Lee +# +# Checkbox is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, +# as published by the Free Software Foundation. +# +# Checkbox is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Checkbox. If not, see . + + +from checkbox_support.helpers import display_info +from checkbox_support.dbus.gnome_monitor import Mode +from fractions import Fraction +from typing import List +import subprocess +import argparse +import tarfile +import time +import sys +import os + + +def resolution_filter(modes: List[Mode]): + """ + For filtering resolution then returning needed, + Following will be ignored: + 1. aspect is too small + 2. the same resoultion + 3. smaller width with the same aspect + This function will be called by the cycle method in the + checkbox_support.dbus.gnome_monitor + + :param modes: The list of Mode that defined + in checkbox_support.dbus.gnome_monitor + """ + new_modes = [] + tmp_resolution = [] + sort_modes = sorted( + modes, key=lambda m: int(m.resolution.split("x")[0]), reverse=True + ) + top_res_per_aspect = {} + for m in sort_modes: + width, height = [int(x) for x in m.resolution.split("x")] + aspect = Fraction(width, height) + # Igonre the too small one + if width < 675 or width / aspect < 530: + continue + # Igonre the same one + if m.resolution in tmp_resolution: + continue + # Only take the widthest one with the same aspect + if aspect not in top_res_per_aspect: + top_res_per_aspect[aspect] = (m, width) + new_modes.append(m) + else: + pre_m, pre_width = top_res_per_aspect[aspect] + if pre_width < width: + top_res_per_aspect[aspect] = width + new_modes.append(m) + new_modes.remove(pre_m) + tmp_resolution.append(m.resolution) + + return new_modes + + +def action(filename, **kwargs): + """ + For extra steps for each cycle. + The extra steps is typing and moving mouse randomly + then take a screenshot. + This function will be called by the cycle method in the + checkbox_support.dbus.gnome_monitor + + :param filename: The string is constructed by + [monitor name]_[resolution]_[transform]_. + """ + print("Test: {}".format(filename)) + if "path" in kwargs: + path_and_filename = "{}/{}.jpg".format(kwargs.get("path"), filename) + else: + path_and_filename = "{}.jpg".format(filename) + time.sleep(5) + # subprocess.check_output(["sudo", "keyboard_mouse"]) + subprocess.check_output(["gnome-screenshot", "-f", path_and_filename]) + + +class MonitorTest: + def gen_screenshot_path(self, keyword: str, screenshot_dir: str) -> str: + """ + Generate the screenshot path and create the folder. + If the keyword is not defined, it will check the suspend_stats to + decide the keyowrd should be after_suspend or not + + :param keyword: the postfix for the path + + :param screenshot_dir: the dictionary for screenshot + """ + path = os.path.join(screenshot_dir, "xrandr_screens") + if keyword and keyword != "": + path = path + "_" + keyword + else: + # check the status is before or after suspend + with open("/sys/power/suspend_stats/success", "r") as s: + suspend_count = s.readline().strip("\n") + if suspend_count != "0": + path = "{}_after_suspend".format(path) + os.makedirs(path, exist_ok=True) + + return path + + def tar_screenshot_dir(self, path: str): + """ + Tar up the screenshots for uploading. + + :param path: the dictionary for screenshot + """ + try: + with tarfile.open(path + ".tgz", "w:gz") as screen_tar: + for screen in os.listdir(path): + screen_tar.add(path + "/" + screen, screen) + except (IOError, OSError): + pass + + def parse_args(self, args=sys.argv[1:]): + """ + command line arguments parsing + + :param args: arguments from sys + :type args: sys.argv + """ + parser = argparse.ArgumentParser( + prog="monitor tester", + description="Test monitor that could rotate and change resoultion", + ) + + parser.add_argument( + "-c", + "--cycle", + type=str, + default="both", + help="cycling resolution, transform or both(default: %(default)s)", + ) + parser.add_argument( + "--keyword", + default="", + help=( + "A keyword to distinguish the screenshots " + "taken in this run of the script(default: %(default)s)" + ), + ) + parser.add_argument( + "--screenshot_dir", + default=os.getenv("HOME", "~"), + help=( + "Specify a directory to store screenshots in. " + "(default: %(default)s)" + ), + ) + + return parser.parse_args(args) + + def main(self): + args = self.parse_args() + + try: + monitor_config = display_info.get_monitor_config() + except ValueError as e: + raise SystemExit("Current host is not support: {}".format(e)) + + screenshot_path = self.gen_screenshot_path( + args.keyword, args.screenshot_dir + ) + if args.cycle == "resolution": + monitor_config.cycle( + resolution=True, + resolution_filter=resolution_filter, + transform=False, + action=action, + path=screenshot_path, + ) + elif args.cycle == "transform": + monitor_config.cycle( + resolution=False, + resolution_filter=resolution_filter, + transform=True, + action=action, + path=screenshot_path, + ) + else: + monitor_config.cycle( + resolution=True, + resolution_filter=resolution_filter, + transform=True, + action=action, + path=screenshot_path, + ) + self.tar_screenshot_dir(screenshot_path) + + +if __name__ == "__main__": + MonitorTest().main() diff --git a/providers/base/tests/test_randr_cycle.py b/providers/base/tests/test_randr_cycle.py new file mode 100644 index 0000000000..35e9a44ff0 --- /dev/null +++ b/providers/base/tests/test_randr_cycle.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# Written by: +# Hanhsuan Lee +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, +# as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from unittest.mock import patch, MagicMock, mock_open +import sys + +sys.modules["dbus"] = MagicMock() +sys.modules["dbus.mainloop.glib"] = MagicMock() + +from randr_cycle import resolution_filter, action, MonitorTest +from checkbox_support.dbus.gnome_monitor import Mode +import subprocess +import unittest +import os + + +class TestResolutionFilter(unittest.TestCase): + def test_ignore_too_small(self): + mode1 = Mode( + id="1", + resolution="500x300", + is_preferred="True", + is_current="True", + ) + mode2 = Mode( + id="2", + resolution="1024x768", + is_preferred="False", + is_current="False", + ) + modes = [mode1, mode2] + + filtered_modes = resolution_filter(modes) + + self.assertEqual(len(filtered_modes), 1) + self.assertEqual(filtered_modes[0].resolution, "1024x768") + + def test_ignore_same_resolution(self): + mode1 = Mode( + id="1", + resolution="1024x768", + is_preferred="True", + is_current="True", + ) + mode2 = Mode( + id="2", + resolution="1024x768", + is_preferred="False", + is_current="False", + ) + modes = [mode1, mode2] + + filtered_modes = resolution_filter(modes) + + self.assertEqual(len(filtered_modes), 1) + self.assertEqual(filtered_modes[0].resolution, "1024x768") + + def test_ignore_smaller_width_same_aspect(self): + mode1 = Mode( + id="1", + resolution="1024x768", + is_preferred="False", + is_current="False", + ) # Aspect ratio: 4/3 + mode2 = Mode( + id="2", + resolution="800x600", + is_preferred="False", + is_current="False", + ) # Aspect ratio: 4/3 + mode3 = Mode( + id="3", + resolution="900x675", + is_preferred="False", + is_current="False", + ) # Aspect ratio: 4/3 + modes = [mode1, mode2, mode3] + + filtered_modes = resolution_filter(modes) + + self.assertEqual(len(filtered_modes), 1) + self.assertEqual(filtered_modes[0].resolution, "1024x768") + + def test_multiple_aspects(self): + mode1 = Mode( + id="1", + resolution="1920x1080", + is_preferred="True", + is_current="True", + ) # Aspect ratio: 7/4 + mode2 = Mode( + id="2", + resolution="800x600", + is_preferred="False", + is_current="False", + ) # Aspect ratio: 4/3 + mode3 = Mode( + id="3", + resolution="900x675", + is_preferred="False", + is_current="False", + ) # Aspect ratio: 4/3 + modes = [mode1, mode2, mode3] + + filtered_modes = resolution_filter(modes) + + self.assertEqual(len(filtered_modes), 2) + self.assertIn("1920x1080", [m.resolution for m in filtered_modes]) + self.assertIn("900x675", [m.resolution for m in filtered_modes]) + + def test_empty_input(self): + filtered_modes = resolution_filter([]) + self.assertEqual(filtered_modes, []) + + +class TestActionFunction(unittest.TestCase): + @patch("subprocess.check_output") + @patch("time.sleep") + def test_action_with_path(self, mock_sleep, mock_subprocess): + filename = "monitor_1920x1080_normal_" + path = "/tmp/screenshots" + action(filename, path=path) + + expected_path_and_filename = "{}/{}.jpg".format(path, filename) + mock_subprocess.assert_called_once_with( + ["gnome-screenshot", "-f", expected_path_and_filename] + ) + mock_sleep.assert_called_once_with(5) + + @patch("subprocess.check_output") + @patch("time.sleep") + def test_action_without_path(self, mock_sleep, mock_subprocess): + filename = "monitor_1920x1080_normal_" + action(filename) + + expected_path_and_filename = filename + ".jpg" + mock_subprocess.assert_called_once_with( + ["gnome-screenshot", "-f", expected_path_and_filename] + ) + mock_sleep.assert_called_once_with(5) + + @patch("subprocess.check_output") + @patch("time.sleep") + def test_action_subprocess_error(self, mock_sleep, mock_subprocess): + filename = "monitor_1920x1080_normal_" + mock_subprocess.side_effect = subprocess.CalledProcessError( + 1, "gnome-screenshot" + ) + + with self.assertRaises(subprocess.CalledProcessError): + action(filename) + + +class GenScreenshotPath(unittest.TestCase): + """ + This function should generate dictionary such as + [screenshot_dir]_[keyword] + """ + + @patch("os.makedirs") + def test_before_suspend_without_keyword(self, mock_mkdir): + + mt = MonitorTest() + with patch("builtins.open", mock_open(read_data="0")) as mock_file: + self.assertEqual( + mt.gen_screenshot_path("", "test"), "test/xrandr_screens" + ) + mock_file.assert_called_with("/sys/power/suspend_stats/success", "r") + mock_mkdir.assert_called_with("test/xrandr_screens", exist_ok=True) + + @patch("os.makedirs") + def test_after_suspend_without_keyword(self, mock_mkdir): + + mt = MonitorTest() + with patch("builtins.open", mock_open(read_data="1")) as mock_file: + self.assertEqual( + mt.gen_screenshot_path(None, "test"), + "test/xrandr_screens_after_suspend", + ) + mock_file.assert_called_with("/sys/power/suspend_stats/success", "r") + mock_mkdir.assert_called_with( + "test/xrandr_screens_after_suspend", exist_ok=True + ) + + @patch("os.makedirs") + def test_with_keyword(self, mock_mkdir): + + mt = MonitorTest() + self.assertEqual( + mt.gen_screenshot_path("key", "test"), "test/xrandr_screens_key" + ) + mock_mkdir.assert_called_with("test/xrandr_screens_key", exist_ok=True) + + +class TestScreenshotTarring(unittest.TestCase): + @patch("os.listdir") + @patch("tarfile.open") + @patch("os.path.join") + def test_tar_screenshot_dir(self, mock_join, mock_tar_open, mock_listdir): + + mt = MonitorTest() + path = "screenshots" + mock_listdir.return_value = ["screenshot1.png", "screenshot2.png"] + + mock_tar = MagicMock() + mock_tar_open.return_value.__enter__.return_value = mock_tar + + mock_join.side_effect = lambda *args: "/".join(args) + + mt.tar_screenshot_dir(path) + + mock_tar_open.assert_called_once_with("screenshots.tgz", "w:gz") + self.assertEqual(mock_tar.add.call_count, 2) + mock_tar.add.assert_any_call( + "screenshots/screenshot1.png", "screenshot1.png" + ) + mock_tar.add.assert_any_call( + "screenshots/screenshot2.png", "screenshot2.png" + ) + + @patch("os.listdir") + @patch("tarfile.open") + def test_tar_screenshot_dir_io_error(self, mock_tar_open, mock_listdir): + + mt = MonitorTest() + path = "screenshots" + mock_listdir.return_value = ["screenshot1.png"] + + mock_tar_open.side_effect = IOError("Unable to open tar file") + + try: + mt.tar_screenshot_dir(path) + result = ( + True # If no exception is raised, we consider it successful. + ) + except Exception: + result = False + + self.assertTrue( + result + ) # Ensure it handles IOError without raising an unhandled exception. + + +class ParseArgsTests(unittest.TestCase): + def test_success(self): + mt = MonitorTest() + + home = os.getenv("HOME", "~") + # no arguments, load default + args = [] + rv = mt.parse_args(args) + self.assertEqual(rv.cycle, "both") + self.assertEqual(rv.keyword, "") + self.assertEqual(rv.screenshot_dir, home) + + # change cycle type + args = ["--cycle", "resolution"] + rv = mt.parse_args(args) + self.assertEqual(rv.cycle, "resolution") + self.assertEqual(rv.keyword, "") + self.assertEqual(rv.screenshot_dir, home) + + # change keyword + args = ["--keyword", "key"] + rv = mt.parse_args(args) + self.assertEqual(rv.cycle, "both") + self.assertEqual(rv.keyword, "key") + self.assertEqual(rv.screenshot_dir, home) + + # change screenshot_dir + args = ["--screenshot_dir", "dir"] + rv = mt.parse_args(args) + self.assertEqual(rv.cycle, "both") + self.assertEqual(rv.keyword, "") + self.assertEqual(rv.screenshot_dir, "dir") + + # change all + args = [ + "-c", + "transform", + "--keyword", + "key", + "--screenshot_dir", + "dir", + ] + rv = mt.parse_args(args) + self.assertEqual(rv.cycle, "transform") + self.assertEqual(rv.keyword, "key") + self.assertEqual(rv.screenshot_dir, "dir") + + +class MainTests(unittest.TestCase): + @patch("randr_cycle.MonitorTest.parse_args") + @patch("checkbox_support.helpers.display_info.get_monitor_config") + @patch("randr_cycle.MonitorTest.gen_screenshot_path") + @patch("randr_cycle.MonitorTest.tar_screenshot_dir") + def test_cycle_both( + self, mock_dir, mock_path, mock_config, mock_parse_args + ): + args_mock = MagicMock() + args_mock.cycle = "both" + args_mock.keyword = "" + args_mock.screenshot_dir = "test" + mock_parse_args.return_value = args_mock + + mock_path.return_value = "test" + + monitor_config_mock = MagicMock() + mock_config.return_value = monitor_config_mock + + self.assertEqual(MonitorTest().main(), None) + monitor_config_mock.cycle.assert_called_with( + resolution=True, + resolution_filter=resolution_filter, + transform=True, + action=action, + path="test", + ) + + mock_dir.assert_called_with("test") + + @patch("randr_cycle.MonitorTest.parse_args") + @patch("checkbox_support.helpers.display_info.get_monitor_config") + @patch("randr_cycle.MonitorTest.gen_screenshot_path") + @patch("randr_cycle.MonitorTest.tar_screenshot_dir") + def test_cycle_resolution( + self, mock_dir, mock_path, mock_config, mock_parse_args + ): + args_mock = MagicMock() + args_mock.cycle = "resolution" + args_mock.keyword = "" + args_mock.screenshot_dir = "test" + mock_parse_args.return_value = args_mock + + mock_path.return_value = "test" + + monitor_config_mock = MagicMock() + mock_config.return_value = monitor_config_mock + + self.assertEqual(MonitorTest().main(), None) + monitor_config_mock.cycle.assert_called_with( + resolution=True, + resolution_filter=resolution_filter, + transform=False, + action=action, + path="test", + ) + + mock_dir.assert_called_with("test") + + @patch("randr_cycle.MonitorTest.parse_args") + @patch("checkbox_support.helpers.display_info.get_monitor_config") + @patch("randr_cycle.MonitorTest.gen_screenshot_path") + @patch("randr_cycle.MonitorTest.tar_screenshot_dir") + def test_cycle_transform( + self, mock_dir, mock_path, mock_config, mock_parse_args + ): + args_mock = MagicMock() + args_mock.cycle = "transform" + args_mock.keyword = "" + args_mock.screenshot_dir = "test" + mock_parse_args.return_value = args_mock + + mock_path.return_value = "test" + + monitor_config_mock = MagicMock() + mock_config.return_value = monitor_config_mock + + self.assertEqual(MonitorTest().main(), None) + monitor_config_mock.cycle.assert_called_with( + resolution=False, + resolution_filter=resolution_filter, + transform=True, + action=action, + path="test", + ) + + mock_dir.assert_called_with("test")