diff --git a/scripts/utils/color_converter.py b/scripts/utils/color_converter.py deleted file mode 100644 index 12c7b33..0000000 --- a/scripts/utils/color_converter.py +++ /dev/null @@ -1,42 +0,0 @@ -import colorsys -from abc import ABC, abstractmethod - - -class ColorConverter(ABC): - @staticmethod - @abstractmethod - def hex_to_rgba(hex_color): - pass - - @staticmethod - @abstractmethod - def hsl_to_rgb(hue, saturation, lightness): - pass - - -class ColorConverterImpl(ColorConverter): - @staticmethod - def hex_to_rgba(hex_color): - try: - if len(hex_color) in range(6, 10): - hex_color = hex_color.lstrip('#') + "ff" - # if is convertable - int(hex_color[:], 16) - else: - raise ValueError - - except ValueError: - raise ValueError(f'Error: Invalid HEX color code: {hex_color}') - - else: - return int(hex_color[0:2], 16), \ - int(hex_color[2:4], 16), \ - int(hex_color[4:6], 16), \ - int(hex_color[6:8], 16) / 255 - - @staticmethod - def hsl_to_rgb(hue, saturation, lightness): - # colorsys works in range(0, 1) - h = hue / 360 - red, green, blue = [int(item * 255) for item in colorsys.hls_to_rgb(h, lightness, saturation)] - return red, green, blue \ No newline at end of file diff --git a/scripts/utils/color_converter/__init__.py b/scripts/utils/color_converter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/utils/color_converter/color_converter.py b/scripts/utils/color_converter/color_converter.py new file mode 100644 index 0000000..6683659 --- /dev/null +++ b/scripts/utils/color_converter/color_converter.py @@ -0,0 +1,26 @@ +from abc import ABC, abstractmethod + + +class ColorConverter(ABC): + @staticmethod + @abstractmethod + def hex_to_rgba(hex_color): + """ + Converts a HEX color code to RGBA format. + :param hex_color: HEX color code (e.g., '#ff5733' or 'ff5733'). + :return: Tuple of RGBA values (red, green, blue, alpha). + :raises ValueError: If the HEX color code is invalid. + """ + pass + + @staticmethod + @abstractmethod + def hsl_to_rgb(hue, saturation, lightness): + """ + Converts HSL color values to RGB format. + :param hue: Hue value (0-360). + :param saturation: Saturation value (0-1). + :param lightness: Lightness value (0-1). + :return: Tuple of RGB values (red, green, blue) in range(0-255). + """ + pass diff --git a/scripts/utils/color_converter/color_converter_impl.py b/scripts/utils/color_converter/color_converter_impl.py new file mode 100644 index 0000000..aff09ad --- /dev/null +++ b/scripts/utils/color_converter/color_converter_impl.py @@ -0,0 +1,43 @@ +import colorsys + +from scripts.utils.color_converter.color_converter import ColorConverter + + +class ColorConverterImpl(ColorConverter): + @staticmethod + def hex_to_rgba(hex_color): + try: + hex_color = hex_color.lstrip('#') + + # Handle shorthand hex colors (e.g., #fff) + if len(hex_color) == 3: + hex_color = ''.join([char * 2 for char in hex_color]) + + # Add alpha channel if missing + if len(hex_color) == 6: + hex_color += "ff" + + # Validate the hex color + int(hex_color, 16) + + except ValueError: + raise ValueError(f'Error: Invalid HEX color code: #{hex_color}') + + else: + return int(hex_color[0:2], 16), \ + int(hex_color[2:4], 16), \ + int(hex_color[4:6], 16), \ + int(hex_color[6:8], 16) / 255 + + @staticmethod + def hsl_to_rgb(hue, saturation, lightness): + if hue > 360 or hue < 0: + raise ValueError(f'Hue must be between 0 and 360, not {hue}') + if saturation > 1 or saturation < 0: + raise ValueError(f'Saturation must be between 0 and 1, not {saturation}') + if lightness > 1 or lightness < 0: + raise ValueError(f'Lightness must be between 0 and 1, not {lightness}') + + h = hue / 360 + red, green, blue = [round(item * 255) for item in colorsys.hls_to_rgb(h, lightness, saturation)] + return red, green, blue diff --git a/scripts/utils/get_version_folder.py b/scripts/utils/get_version_folder.py index 92848fc..4ba2418 100644 --- a/scripts/utils/get_version_folder.py +++ b/scripts/utils/get_version_folder.py @@ -7,6 +7,9 @@ def get_version_folders(version, base_path): :param base_path: base path to version folders :return: list of matching version folders """ + if not os.path.exists(base_path): + return [] + version_folders = os.listdir(base_path) version = int(version.split('.')[0]) # Use only the major version for comparison matching_folders = [] diff --git a/scripts/utils/style_manager.py b/scripts/utils/style_manager.py index 2c0aab4..13e978c 100644 --- a/scripts/utils/style_manager.py +++ b/scripts/utils/style_manager.py @@ -1,19 +1,40 @@ +import os + from scripts.utils import generate_file class StyleManager: + """Manages the style files for the theme.""" + def __init__(self, output_file: str): + """ + :param output_file: The path to the output file where styles will be combined. + """ self.output_file = output_file def append_content(self, content: str): + """ + Append content to the output file. + :raises FileNotFoundError: if the file does not exist. + """ + if not os.path.exists(self.output_file): + raise FileNotFoundError(f"The file {self.output_file} does not exist.") with open(self.output_file, 'a') as output: - output.write(content + '\n') + output.write('\n' + content) def prepend_content(self, content: str): + """ + Prepend content to the output file. + :raises FileNotFoundError: if the file does not exist. + """ with open(self.output_file, 'r') as output: main_content = output.read() with open(self.output_file, 'w') as output: output.write(content + '\n' + main_content) def generate_combined_styles(self, sources_location: str, temp_folder: str): + """ + Generate the combined styles file + by merging all styles from the source location. + """ generate_file(sources_location, temp_folder, self.output_file) \ No newline at end of file diff --git a/scripts/utils/theme/color_replacement_generator.py b/scripts/utils/theme/color_replacement_generator.py new file mode 100644 index 0000000..904467b --- /dev/null +++ b/scripts/utils/theme/color_replacement_generator.py @@ -0,0 +1,44 @@ +import copy + +from scripts.install.colors_definer import ColorsDefiner +from scripts.types.installation_color import InstallationMode, InstallationColor +from scripts.utils.color_converter.color_converter import ColorConverter + + +class ColorReplacementGenerator: + def __init__(self, colors_provider: ColorsDefiner, color_converter: ColorConverter): + self.colors = copy.deepcopy(colors_provider) + self.color_converter = color_converter + + def convert(self, mode: InstallationMode, theme_color: InstallationColor) -> list[tuple[str, str]]: + """Generate a list of color replacements for the given theme color and mode""" + return [ + (element, self._create_rgba_value(element, mode, theme_color)) + for element in self.colors.replacers + ] + + def _create_rgba_value(self, element: str, mode: str, theme_color: InstallationColor) -> str: + """Create RGBA value for the specified element""" + color_def = self._get_color_definition(element, mode) + + lightness = int(color_def["l"]) / 100 + saturation = int(color_def["s"]) / 100 + if theme_color.saturation is not None: + saturation *= theme_color.saturation / 100 + alpha = color_def["a"] + + red, green, blue = self.color_converter.hsl_to_rgb( + theme_color.hue, saturation, lightness + ) + + return f"rgba({red}, {green}, {blue}, {alpha})" + + def _get_color_definition(self, element: str, mode: str) -> dict: + """Get color definition for element, handling defaults if needed""" + replacer = self.colors.replacers[element] + + if mode not in replacer and replacer["default"]: + default_element = replacer["default"] + return self.colors.replacers[default_element][mode] + + return replacer[mode] diff --git a/scripts/utils/theme/gnome_shell_theme_builder.py b/scripts/utils/theme/gnome_shell_theme_builder.py index 5a6a586..c686499 100644 --- a/scripts/utils/theme/gnome_shell_theme_builder.py +++ b/scripts/utils/theme/gnome_shell_theme_builder.py @@ -2,11 +2,12 @@ import os.path from scripts import config from scripts.install.colors_definer import ColorsDefiner -from scripts.utils.color_converter import ColorConverterImpl +from scripts.utils.color_converter.color_converter_impl import ColorConverterImpl from scripts.utils.logger.console import Console from scripts.utils.style_manager import StyleManager from scripts.utils.theme.theme import Theme -from scripts.utils.theme.theme_color_applier import ColorReplacementGenerator, ThemeColorApplier +from scripts.utils.theme.theme_color_applier import ThemeColorApplier +from scripts.utils.theme.color_replacement_generator import ColorReplacementGenerator from scripts.utils.theme.theme_installer import ThemeInstaller from scripts.utils.theme.theme_path_provider import ThemePathProvider from scripts.utils.theme.theme_preparation import ThemePreparation diff --git a/scripts/utils/theme/theme_color_applier.py b/scripts/utils/theme/theme_color_applier.py index e1a3b98..fc88aef 100644 --- a/scripts/utils/theme/theme_color_applier.py +++ b/scripts/utils/theme/theme_color_applier.py @@ -1,16 +1,14 @@ -import copy import os -from scripts.install.colors_definer import ColorsDefiner from scripts.types.installation_color import InstallationColor, InstallationMode from scripts.utils import replace_keywords -from scripts.utils.color_converter import ColorConverter +from scripts.utils.theme.color_replacement_generator import ColorReplacementGenerator class ThemeColorApplier: """Class to apply theme colors to files in a directory.""" - def __init__(self, color_replacement_generator: "ColorReplacementGenerator"): + def __init__(self, color_replacement_generator: ColorReplacementGenerator): self.color_replacement_generator = color_replacement_generator def apply(self, theme_color: InstallationColor, destination: str, mode: InstallationMode): @@ -20,42 +18,3 @@ class ThemeColorApplier: for filename in os.listdir(destination): file_path = os.path.join(destination, filename) replace_keywords(file_path, *replacements) - - -class ColorReplacementGenerator: - def __init__(self, colors_provider: ColorsDefiner, color_converter: ColorConverter): - self.colors = copy.deepcopy(colors_provider) - self.color_converter = color_converter - - def convert(self, mode: InstallationMode, theme_color: InstallationColor) -> list[tuple[str, str]]: - """Generate a list of color replacements for the given theme color and mode""" - return [ - (element, self._create_rgba_value(element, mode, theme_color)) - for element in self.colors.replacers - ] - - def _create_rgba_value(self, element: str, mode: str, theme_color: InstallationColor) -> str: - """Create RGBA value for the specified element""" - color_def = self._get_color_definition(element, mode) - - lightness = int(color_def["l"]) / 100 - saturation = int(color_def["s"]) / 100 - if theme_color.saturation is not None: - saturation *= theme_color.saturation / 100 - alpha = color_def["a"] - - red, green, blue = self.color_converter.hsl_to_rgb( - theme_color.hue, saturation, lightness - ) - - return f"rgba({red}, {green}, {blue}, {alpha})" - - def _get_color_definition(self, element: str, mode: str) -> dict: - """Get color definition for element, handling defaults if needed""" - replacer = self.colors.replacers[element] - - if mode not in replacer and replacer["default"]: - default_element = replacer["default"] - return self.colors.replacers[default_element][mode] - - return replacer[mode] \ No newline at end of file diff --git a/scripts/utils/theme/theme_path_provider.py b/scripts/utils/theme/theme_path_provider.py index 1dfcec2..bfa1c3e 100644 --- a/scripts/utils/theme/theme_path_provider.py +++ b/scripts/utils/theme/theme_path_provider.py @@ -1,4 +1,20 @@ +import os + + class ThemePathProvider: @staticmethod - def get_theme_path(themes_folder: str, path_name: str, theme_mode: str, theme_type: str) -> str: - return f"{themes_folder}/Marble-{path_name}-{theme_mode}/{theme_type}/" + def get_theme_path(themes_folder: str, color_name: str, theme_mode: str, theme_type: str) -> str: + """ + Generates the path for the theme based on the provided parameters. + :param themes_folder: The base folder where themes are stored. + :param color_name: The name of the color scheme. + :param theme_mode: The mode of the theme (e.g., 'light' or 'dark'). + :param theme_type: The type of the theme (e.g., 'gnome-shell', 'gtk'). + """ + if not themes_folder or not color_name or not theme_mode or not theme_type: + raise ValueError("All parameters must be non-empty strings.") + + marble_name = '-'.join(["Marble", color_name, theme_mode]) + final_path = os.path.join(themes_folder, marble_name, theme_type, "") + + return final_path diff --git a/scripts/utils/theme/theme_preparation.py b/scripts/utils/theme/theme_preparation.py index 6d12c83..c36a12a 100644 --- a/scripts/utils/theme/theme_preparation.py +++ b/scripts/utils/theme/theme_preparation.py @@ -1,5 +1,4 @@ import os -import warnings from scripts.utils import replace_keywords from scripts.utils.theme.theme_temp_manager import ThemeTempManager @@ -45,11 +44,10 @@ class ThemePreparation: """ Extract theme from source folder and prepare it for installation. """ - self.file_manager.prepare_files(self.sources_location) + self.file_manager.copy_to_temp(self.sources_location) self.style_manager.generate_combined_styles(self.sources_location, self.temp_folder) self.file_manager.cleanup() - @warnings.deprecated def replace_filled_keywords(self): """ Replace keywords in the theme files for filled mode. @@ -62,4 +60,4 @@ class ThemePreparation: ("BUTTON_ACTIVE", "ACCENT-FILLED_ACTIVE"), ("BUTTON_INSENSITIVE", "ACCENT-FILLED_INSENSITIVE"), ("BUTTON-TEXT-COLOR", "TEXT-BLACK-COLOR"), - ("BUTTON-TEXT_SECONDARY", "TEXT-BLACK_SECONDARY")) \ No newline at end of file + ("BUTTON-TEXT_SECONDARY", "TEXT-BLACK_SECONDARY")) diff --git a/scripts/utils/theme/theme_temp_manager.py b/scripts/utils/theme/theme_temp_manager.py index 1ac6e38..2294a57 100644 --- a/scripts/utils/theme/theme_temp_manager.py +++ b/scripts/utils/theme/theme_temp_manager.py @@ -1,8 +1,6 @@ import os import shutil -from scripts.utils import copy_files - class ThemeTempManager: """ @@ -10,18 +8,21 @@ class ThemeTempManager: """ def __init__(self, temp_folder: str): self.temp_folder = temp_folder + os.makedirs(self.temp_folder, exist_ok=True) def copy_to_temp(self, content: str): + """ + Copy a file or directory to the temporary folder. + If the content is a file, it will be copied directly. + If the content is a directory, all its contents will be copied to the temp folder. + """ if os.path.isfile(content): - shutil.copy(content, self.temp_folder) + final_path = os.path.join(self.temp_folder, os.path.basename(content)) + shutil.copy(content, final_path) else: - shutil.copytree(content, self.temp_folder) + shutil.copytree(content, self.temp_folder, dirs_exist_ok=True) return self - def prepare_files(self, sources_location: str): - """Prepare files in temp folder""" - copy_files(sources_location, self.temp_folder) - def cleanup(self): """Remove temporary folders""" shutil.rmtree(f"{self.temp_folder}/.css/", ignore_errors=True) diff --git a/tests/utils/color_converter/__init__.py b/tests/utils/color_converter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/color_converter/test_color_converter_impl.py b/tests/utils/color_converter/test_color_converter_impl.py new file mode 100644 index 0000000..e90a8de --- /dev/null +++ b/tests/utils/color_converter/test_color_converter_impl.py @@ -0,0 +1,64 @@ +import unittest + +from scripts.utils.color_converter.color_converter_impl import ColorConverterImpl + + +class ColorConverterImplTestCase(unittest.TestCase): + def setUp(self): + self.converter = ColorConverterImpl() + + def test_hex_to_rgba_is_valid(self): + hex_color = "#ff5733" + expected_rgba = (255, 87, 51, 1.0) + + result = self.converter.hex_to_rgba(hex_color) + + self.assertEqual(result, expected_rgba) + + def test_hex_to_rgba_is_invalid(self): + hex_color = "#invalid" + + with self.assertRaises(ValueError): + self.converter.hex_to_rgba(hex_color) + + def test_hex_to_rgba_with_alpha_is_valid(self): + hex_color = "#ff5733ff" + expected_rgba = (255, 87, 51, 1.0) + + result = self.converter.hex_to_rgba(hex_color) + + self.assertEqual(result, expected_rgba) + + def test_hex_to_rgba_with_shorthand_is_valid(self): + hex_color = "#fff" + expected_rgba = (255, 255, 255, 1.0) + + result = self.converter.hex_to_rgba(hex_color) + + self.assertEqual(result, expected_rgba) + + def test_hsl_to_rgb_is_valid(self): + hue = 360 + saturation = 1 + lightness = 0.5 + expected_rgb = (255, 0, 0) + + result = self.converter.hsl_to_rgb(hue, saturation, lightness) + + self.assertEqual(result, expected_rgb) + + def test_hsl_to_rgb_with_overflow_hue_is_invalid(self): + hue = 400 + saturation = 1 + lightness = 0.5 + + with self.assertRaises(ValueError): + self.converter.hsl_to_rgb(hue, saturation, lightness) + + def test_hsl_to_rgb_with_invalid_saturation_and_lightness_is_invalid(self): + hue = 360 + saturation = 1.5 + lightness = -2 + + with self.assertRaises(ValueError): + self.converter.hsl_to_rgb(hue, saturation, lightness) \ No newline at end of file diff --git a/tests/utils/test_style_manager.py b/tests/utils/test_style_manager.py new file mode 100644 index 0000000..0ec39e6 --- /dev/null +++ b/tests/utils/test_style_manager.py @@ -0,0 +1,78 @@ +import os +import shutil +import unittest + +from scripts import config +from scripts.utils.style_manager import StyleManager +from .._helpers import create_dummy_file + + +class StyleManagerTestCase(unittest.TestCase): + def setUp(self): + self.temp_folder = os.path.join(config.temp_tests_folder, "style_manager") + os.makedirs(self.temp_folder, exist_ok=True) + self.output_file = os.path.join(self.temp_folder, "output.css") + self.manager = StyleManager(output_file=self.output_file) + + def tearDown(self): + shutil.rmtree(self.temp_folder, ignore_errors=True) + + def test_append_content(self): + start_css = "body { background-color: blue; }" + create_dummy_file(self.output_file, start_css) + end_css = "h1 { color: red; }" + + self.manager.append_content(end_css) + + with open(self.output_file, 'r') as f: + content = f.read() + split_content = content.splitlines() + assert split_content[0] == start_css + assert split_content[1] == end_css + os.remove(self.output_file) + + def test_append_does_not_create_file(self): + end_css = "h1 { color: red; }" + + with self.assertRaises(FileNotFoundError): + self.manager.append_content(end_css) + + def test_prepend_content(self): + start_css = "body { background-color: blue; }" + create_dummy_file(self.output_file, start_css) + prepend_css = "h1 { color: red; }" + + self.manager.prepend_content(prepend_css) + + with open(self.output_file, 'r') as f: + content = f.read() + split_content = content.splitlines() + assert split_content[0] == prepend_css + assert split_content[1] == start_css + os.remove(self.output_file) + + def test_prepend_does_not_create_file(self): + prepend_css = "h1 { color: red; }" + + with self.assertRaises(FileNotFoundError): + self.manager.prepend_content(prepend_css) + + def test_generate_combined_styles(self): + source_folder = os.path.join(config.temp_tests_folder, "style_manager_source") + source_css_folder = os.path.join(source_folder, ".css") + first_file = os.path.join(source_css_folder, "file1.css") + second_file = os.path.join(source_css_folder, "file2.css") + first_css = "body { background-color: blue; }" + second_css = "h1 { color: red; }" + create_dummy_file(first_file, first_css) + create_dummy_file(second_file, second_css) + + self.manager.generate_combined_styles(source_folder, self.temp_folder) + + with open(self.output_file, 'r') as f: + content = f.read() + split_content = content.splitlines() + assert first_css in split_content + assert second_css in split_content + os.remove(self.output_file) + shutil.rmtree(source_folder, ignore_errors=True) \ No newline at end of file diff --git a/tests/utils/theme/__init__.py b/tests/utils/theme/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/theme/assets/colors.json b/tests/utils/theme/assets/colors.json new file mode 100644 index 0000000..42fbe85 --- /dev/null +++ b/tests/utils/theme/assets/colors.json @@ -0,0 +1,72 @@ +{ + "elements": { + "BUTTON-COLOR": { + "default": "ACCENT-COLOR" + }, + "ACCENT-COLOR": { + "light": { + "s": 52, + "l": 67, + "a": 1 + }, + "dark": { + "s": 42, + "l": 26, + "a": 1 + } + }, + "ACCENT_HOVER": { + "light": { + "s": 50, + "l": 60, + "a": 0.8 + }, + "dark": { + "s": 66, + "l": 22, + "a": 0.4 + } + } + }, + "colors": { + "red": { + "h": 0 + }, + "gray": { + "h": 0, + "s": 0 + }, + "expected": { + "BUTTON-COLOR": { + "0,100": { + "light": "rgba(215, 127, 127, 1)", + "dark": "rgba(94, 38, 38, 1)" + }, + "0,0": { + "light": "rgba(171, 171, 171, 1)", + "dark": "rgba(66, 66, 66, 1)" + } + }, + "ACCENT-COLOR": { + "0,100": { + "light": "rgba(215, 127, 127, 1)", + "dark": "rgba(94, 38, 38, 1)" + }, + "0,0": { + "light": "rgba(171, 171, 171, 1)", + "dark": "rgba(66, 66, 66, 1)" + } + }, + "ACCENT_HOVER": { + "0,100": { + "light": "rgba(204, 102, 102, 0.8)", + "dark": "rgba(93, 19, 19, 0.4)" + }, + "0,0": { + "light": "rgba(153, 153, 153, 0.8)", + "dark": "rgba(56, 56, 56, 0.4)" + } + } + } + } +} \ No newline at end of file diff --git a/tests/utils/theme/test_color_replacement_generator.py b/tests/utils/theme/test_color_replacement_generator.py new file mode 100644 index 0000000..96c0764 --- /dev/null +++ b/tests/utils/theme/test_color_replacement_generator.py @@ -0,0 +1,64 @@ +import os.path +import unittest + +from scripts.install.colors_definer import ColorsDefiner +from scripts.types.installation_color import InstallationColor, InstallationMode +from scripts.utils.color_converter.color_converter_impl import ColorConverterImpl +from scripts.utils.theme.color_replacement_generator import ColorReplacementGenerator + +class ColorReplacementGeneratorTestCase(unittest.TestCase): + def setUp(self): + colors_location = os.path.join(os.path.dirname(__file__), "assets", "colors.json") + self.colors_provider = ColorsDefiner(colors_location) + self.color_converter = ColorConverterImpl() + self.generator = ColorReplacementGenerator(self.colors_provider, self.color_converter) + + def test_convert_red_color_in_dark_mode_generates_correct_rgba(self): + theme_color = InstallationColor(hue=0, saturation=None, modes=[]) + mode: InstallationMode = "dark" + expected_output = self._get_expected_output(theme_color, mode) + + actual_output = self.generator.convert(mode, theme_color) + + self._assert_expected_and_actual_replacers_match(expected_output, actual_output) + + def test_convert_gray_color_in_light_mode_generates_correct_rgba(self): + theme_color = InstallationColor(hue=0, saturation=0, modes=[]) + mode: InstallationMode = "light" + expected_output = self._get_expected_output(theme_color, mode) + + actual_output = self.generator.convert(mode, theme_color) + + self._assert_expected_and_actual_replacers_match(expected_output, actual_output) + + def test_convert_not_existent_mode_raises_key_error(self): + theme_color = InstallationColor(hue=0, saturation=0, modes=[]) + mode = "not_existent_mode" + + with self.assertRaises(KeyError): + # noinspection PyTypeChecker + self.generator.convert(mode, theme_color) + + def _get_expected_output(self, theme_color: InstallationColor, mode: str): + return [ + ("ACCENT-COLOR", self._get_rgba("ACCENT-COLOR", theme_color, mode)), + ("ACCENT_HOVER", self._get_rgba("ACCENT_HOVER", theme_color, mode)), + ("BUTTON-COLOR", self._get_rgba("BUTTON-COLOR", theme_color, mode)), + ] + + def _get_rgba(self, replacer_name: str, theme_color: InstallationColor, mode: str): + expected_colors: dict = self.colors_provider.colors.get("expected") + replacer_colors = expected_colors.get(replacer_name) + saturation = theme_color.saturation if theme_color.saturation is not None else 100 + variant_colors = replacer_colors.get(f"{theme_color.hue},{saturation}") + expected_rgba = variant_colors.get(mode) + return expected_rgba + + @staticmethod + def _assert_expected_and_actual_replacers_match(expected: list, actual: list): + for expected_element, expected_rgba in expected: + actual_rgba = next( + (rgba for element, rgba in actual if element == expected_element), None + ) + assert actual_rgba is not None + assert expected_rgba == actual_rgba \ No newline at end of file diff --git a/tests/utils/theme/test_gnome_shell_theme_builder.py b/tests/utils/theme/test_gnome_shell_theme_builder.py new file mode 100644 index 0000000..a6c6a37 --- /dev/null +++ b/tests/utils/theme/test_gnome_shell_theme_builder.py @@ -0,0 +1,76 @@ +import unittest +from unittest.mock import Mock +import os + +from scripts.utils.theme.gnome_shell_theme_builder import GnomeShellThemeBuilder + + +class GnomeShellThemeBuilderTestCase(unittest.TestCase): + def setUp(self): + self.colors_provider = Mock() + self.builder = GnomeShellThemeBuilder(self.colors_provider) + + def test_builder_method_chaining_works_correctly(self): + result = (self.builder.with_theme_name("test-theme") + .with_mode("dark") + .filled() + .with_temp_folder("/tmp/test")) + + self.assertIs(result, self.builder) + self.assertEqual("test-theme", self.builder.theme_name) + self.assertEqual("dark", self.builder.mode) + self.assertTrue(self.builder.is_filled) + self.assertEqual("/tmp/test", self.builder._base_temp_folder) + + def test_paths_update_when_base_folder_changes(self): + self.builder.with_temp_folder("/custom/temp") + + expected_temp_folder = os.path.join("/custom/temp", self.builder.theme_name) + expected_main_styles = os.path.join(expected_temp_folder, f"{self.builder.theme_name}.css") + + self.assertEqual(expected_temp_folder, self.builder.temp_folder) + self.assertEqual(expected_main_styles, self.builder.main_styles) + + def test_paths_update_when_theme_name_changes(self): + original_temp_folder = self.builder.temp_folder + original_main_styles = self.builder.main_styles + + self.builder.with_theme_name("custom-theme") + + self.assertNotEqual(original_temp_folder, self.builder.temp_folder) + self.assertNotEqual(original_main_styles, self.builder.main_styles) + self.assertEqual(os.path.join(self.builder._base_temp_folder, "custom-theme"), self.builder.temp_folder) + self.assertEqual(os.path.join(self.builder.temp_folder, "custom-theme.css"), self.builder.main_styles) + + def test_default_values_are_set_properly(self): + builder = GnomeShellThemeBuilder(self.colors_provider) + + self.assertEqual("gnome-shell", builder.theme_name) + self.assertIsNone(builder.mode) + self.assertFalse(builder.is_filled) + self.assertIsNone(builder.preparation) + self.assertIsNone(builder.installer) + + def test_build_correctly_resolves_dependencies(self): + self.builder.build() + + self.assertIsNotNone(self.builder.preparation) + self.assertIsNotNone(self.builder.installer) + + def test_build_correctly_creates_theme(self): + self.builder.with_mode("light").filled() + + theme = self.builder.build() + + self.assertEqual(theme._preparation, self.builder.preparation) + self.assertEqual(theme._installer, self.builder.installer) + self.assertTrue(theme.is_filled) + self.assertTrue(len(theme.modes) == 1) + self.assertEqual(theme.modes[0], "light") + + def test_filled_method_with_parameter(self): + self.builder.filled(False) + self.assertFalse(self.builder.is_filled) + + self.builder.filled(True) + self.assertTrue(self.builder.is_filled) \ No newline at end of file diff --git a/tests/utils/theme/test_theme.py b/tests/utils/theme/test_theme.py new file mode 100644 index 0000000..5ed64fd --- /dev/null +++ b/tests/utils/theme/test_theme.py @@ -0,0 +1,90 @@ +import os.path +import unittest +from unittest.mock import MagicMock + +from scripts import config +from scripts.types.installation_color import InstallationColor +from scripts.utils.theme.theme import Theme + + +class ThemeTestCase(unittest.TestCase): + def setUp(self): + self.mock_preparation = MagicMock() + self.mock_installer = MagicMock() + + temp_folder = os.path.join(config.temp_tests_folder, "theme_temp") + destination_folder = os.path.join(temp_folder, "theme_destination") + + self.mock_preparation.temp_folder = temp_folder + self.mock_preparation.combined_styles_location = os.path.join(temp_folder, "styles.css") + self.mock_installer.destination_folder = destination_folder + self.mock_installer.theme_type = "gtk" + + self.theme = Theme(self.mock_preparation, self.mock_installer) + + def test_default_initialization_works_correctly(self): + self.assertEqual(self.theme.modes, ['light', 'dark']) + self.assertFalse(self.theme.is_filled) + + def test_initialization_with_specific_mode_works_correctly(self): + theme_light = Theme(self.mock_preparation, self.mock_installer, mode='light') + self.assertEqual(theme_light.modes, ['light']) + + def test_initialization_with_is_filled_works_correctly(self): + theme_filled = Theme(self.mock_preparation, self.mock_installer, is_filled=True) + self.assertTrue(theme_filled.is_filled) + + def test_properties_fetch_values_correctly(self): + temp_folder = os.path.join(config.temp_tests_folder, "theme_temp") + destination_folder = os.path.join(temp_folder, "theme_destination") + + self.assertEqual(self.theme.temp_folder, temp_folder) + self.assertEqual(self.theme.destination_folder, destination_folder) + self.assertEqual(self.theme.main_styles, os.path.join(temp_folder, "styles.css")) + self.assertEqual(self.theme.theme_name, "gtk") + + def test_add_operator_called_once_and_return_value(self): + result = self.theme + "additional styles" + + self.mock_preparation.__iadd__.assert_called_once_with("additional styles") + self.assertEqual(result, self.theme) + + def test_mul_operator_called_once_and_return_value(self): + result = self.theme * "/path/to/file" + + self.mock_preparation.__imul__.assert_called_once_with("/path/to/file") + self.assertEqual(result, self.theme) + + def test_add_to_start_called_once_and_return_value(self): + result = self.theme.add_to_start("starting content") + + self.mock_preparation.add_to_start.assert_called_once_with("starting content") + self.assertEqual(result, self.theme) + + def test_prepare_called_once(self): + self.theme.prepare() + + self.mock_preparation.prepare.assert_called_once() + + def test_install_without_optional_params_called_correctly(self): + self.theme.install(200, "Green") + + args = self.mock_installer.install.call_args[0] + self.assertEqual(args[0].hue, 200) + self.assertIsNone(args[0].saturation) + self.assertEqual(args[1], "Green") + self.assertIsNone(args[2]) + + def test_install_with_optional_params_called_correctly(self): + self.theme.install(hue=180, name="Blue", sat=0.5, destination="/custom/dest") + + self.mock_installer.install.assert_called_once() + args = self.mock_installer.install.call_args[0] + + theme_color = args[0] + self.assertIsInstance(theme_color, InstallationColor) + self.assertEqual(theme_color.hue, 180) + self.assertEqual(theme_color.saturation, 0.5) + self.assertEqual(theme_color.modes, ['light', 'dark']) + self.assertEqual(args[1], "Blue") + self.assertEqual(args[2], "/custom/dest") diff --git a/tests/utils/theme/test_theme_color_applier.py b/tests/utils/theme/test_theme_color_applier.py new file mode 100644 index 0000000..5c442da --- /dev/null +++ b/tests/utils/theme/test_theme_color_applier.py @@ -0,0 +1,52 @@ +import os.path +import shutil +import unittest +from unittest.mock import Mock + +from scripts import config +from scripts.utils.theme.theme_color_applier import ThemeColorApplier +from ..._helpers import create_dummy_file + + +class ThemeColorApplierTestCase(unittest.TestCase): + def setUp(self): + color_replacement_generator = Mock() + color_replacement_generator.convert.return_value = [ + ("ACCENT-COLOR", "rgba(255, 0, 0, 1)"), + ("ACCENT_HOVER", "rgba(255, 0, 0, 0.8)"), + ("BACKGROUND-COLOR", "rgba(0, 0, 0, 1)") + ] + + self.temp_folder = os.path.join(config.temp_tests_folder, "color_applier") + self._setup_temp_folder() + + self.color_applier = ThemeColorApplier(color_replacement_generator) + + def tearDown(self): + shutil.rmtree(self.temp_folder, ignore_errors=True) + + def _setup_temp_folder(self): + self.first_file = os.path.join(self.temp_folder, "file1.css") + self.second_file = os.path.join(self.temp_folder, "file2.css") + self.first_css = "body { background-color: ACCENT-COLOR; color: ACCENT_HOVER; }" + self.second_css = "body { background-color: BACKGROUND-COLOR; }" + create_dummy_file(self.first_file, self.first_css) + create_dummy_file(self.second_file, self.second_css) + + def test_colors_in_files_are_replaced_correctly(self): + theme_color = Mock() + + self.color_applier.apply(theme_color, self.temp_folder, "dark") + + with open(self.first_file, "r") as file: + content = file.read() + replaced = self.first_css + replaced = replaced.replace("ACCENT-COLOR", "rgba(255, 0, 0, 1)") + replaced = replaced.replace("ACCENT_HOVER", "rgba(255, 0, 0, 0.8)") + assert content == replaced + + with open(self.second_file, "r") as file: + content = file.read() + replaced = self.second_css + replaced = replaced.replace("BACKGROUND-COLOR", "rgba(0, 0, 0, 1)") + assert content == replaced \ No newline at end of file diff --git a/tests/utils/theme/test_theme_installer.py b/tests/utils/theme/test_theme_installer.py new file mode 100644 index 0000000..f6f623a --- /dev/null +++ b/tests/utils/theme/test_theme_installer.py @@ -0,0 +1,109 @@ +import os +import shutil +import unittest +from unittest.mock import Mock + +from scripts import config +from scripts.utils.theme.theme_installer import ThemeInstaller +from scripts.utils.theme.theme_path_provider import ThemePathProvider +from ..._helpers import create_dummy_file + + +class ThemeInstallerTestCase(unittest.TestCase): + def setUp(self): + self.theme_type = "gnome-shell" + self.source_folder = os.path.join(config.temp_tests_folder, "theme_installer_source") + self.destination_folder = os.path.join(config.temp_tests_folder, "theme_installer_destination") + self.custom_destination_folder = os.path.join(config.temp_tests_folder, "theme_installer_custom_destination") + + self.logger_factory = Mock() + self.color_applier = Mock() + self.path_provider = ThemePathProvider() + self.path_provider.get_theme_path = Mock(return_value=self.destination_folder) + + self.theme_installer = ThemeInstaller( + theme_type=self.theme_type, + source_folder=self.source_folder, + destination_folder=self.destination_folder, + logger_factory=self.logger_factory, + color_applier=self.color_applier, + path_provider=self.path_provider, + ) + + self._setup_source_folder() + + def tearDown(self): + shutil.rmtree(self.source_folder, ignore_errors=True) + shutil.rmtree(self.destination_folder, ignore_errors=True) + shutil.rmtree(self.custom_destination_folder, ignore_errors=True) + + def _setup_source_folder(self): + os.makedirs(self.source_folder, exist_ok=True) + first_file = os.path.join(self.source_folder, "file1.css") + second_file = os.path.join(self.source_folder, "file2.css") + first_css = "body { background-color: ACCENT-COLOR; color: ACCENT_HOVER; }" + second_css = "body { background-color: BACKGROUND-COLOR; }" + create_dummy_file(first_file, first_css) + create_dummy_file(second_file, second_css) + + def test_install_calls_get_theme_path_and_apply_methods_with_correct_parameters(self): + theme_color = Mock() + theme_color.modes = ["light"] + name = "test-theme" + + self.theme_installer.install(theme_color, name) + + # noinspection PyUnresolvedReferences + self.path_provider.get_theme_path.assert_called_once_with( + self.destination_folder, name, "light", self.theme_type + ) + self.color_applier.apply.assert_called_once_with(theme_color, self.destination_folder, "light") + + def test_install_with_custom_destination_calls_get_theme_path_and_apply_methods_with_correct_parameters(self): + theme_color = Mock() + theme_color.modes = ["light"] + name = "test-theme" + os.makedirs(self.custom_destination_folder, exist_ok=True) + + self.theme_installer.install(theme_color, name, self.custom_destination_folder) + + # noinspection PyUnresolvedReferences + self.path_provider.get_theme_path.assert_not_called() + self.color_applier.apply.assert_called_once_with(theme_color, self.custom_destination_folder, "light") + + def test_install_with_multiple_modes_calls_get_theme_path_and_apply_methods_for_each_mode(self): + theme_color = Mock() + theme_color.modes = ["light", "dark"] + name = "test-theme" + + self.theme_installer.install(theme_color, name) + + # noinspection PyUnresolvedReferences + self.assertEqual(self.path_provider.get_theme_path.call_count, 2) + self.assertEqual(self.color_applier.apply.call_count, 2) + + def test_install_raises_exception_and_logs_error(self): + theme_color = Mock() + theme_color.modes = ["light"] + name = "test-theme" + self.color_applier.apply.side_effect = Exception("Test error") + + with self.assertRaises(Exception): + self.theme_installer.install(theme_color, name) + + logger_mock = self.logger_factory.create_logger.return_value + self.assertTrue(logger_mock.error.called) + + def test_install_copies_files_to_destination(self): + theme_color = Mock() + theme_color.modes = ["light"] + name = "test-theme" + destination = os.path.join(self.destination_folder, "actual_destination") + self.path_provider.get_theme_path.return_value = destination + + self.theme_installer.install(theme_color, name) + + first_file_exists = os.path.exists(os.path.join(destination, "file1.css")) + second_file_exists = os.path.exists(os.path.join(destination, "file2.css")) + self.assertTrue(first_file_exists) + self.assertTrue(second_file_exists) \ No newline at end of file diff --git a/tests/utils/theme/test_theme_path_provider.py b/tests/utils/theme/test_theme_path_provider.py new file mode 100644 index 0000000..b1e7a1e --- /dev/null +++ b/tests/utils/theme/test_theme_path_provider.py @@ -0,0 +1,28 @@ +import unittest + +from scripts.utils.theme.theme_path_provider import ThemePathProvider + + +class ThemePathProviderTestCase(unittest.TestCase): + def setUp(self): + self.theme_path_provider = ThemePathProvider() + + def test_get_theme_path_with_valid_values_returns_correct_path(self): + themes_folder = "/usr/share/themes" + color_name = "Marble" + theme_mode = "dark" + theme_type = "gnome-shell" + + expected_path = f"{themes_folder}/Marble-{color_name}-{theme_mode}/{theme_type}/" + actual_path = self.theme_path_provider.get_theme_path(themes_folder, color_name, theme_mode, theme_type) + + assert expected_path == actual_path + + def test_get_theme_path_with_empty_values_raises_exception(self): + themes_folder = "" + color_name = "" + theme_mode = "" + theme_type = "" + + with self.assertRaises(ValueError): + self.theme_path_provider.get_theme_path(themes_folder, color_name, theme_mode, theme_type) \ No newline at end of file diff --git a/tests/utils/theme/test_theme_temp_manager.py b/tests/utils/theme/test_theme_temp_manager.py new file mode 100644 index 0000000..3e9a084 --- /dev/null +++ b/tests/utils/theme/test_theme_temp_manager.py @@ -0,0 +1,71 @@ +import os.path +import shutil +import unittest + +from scripts import config +from scripts.utils.theme.theme_temp_manager import ThemeTempManager +from ..._helpers import create_dummy_file + + +class ThemeTempManagerTestCase(unittest.TestCase): + def setUp(self): + self.source_folder = os.path.join(config.temp_tests_folder, "theme_temp_manager_source") + self.temp_folder = os.path.join(config.temp_tests_folder, "theme_temp_manager") + self.manager = ThemeTempManager(temp_folder=self.temp_folder) + + def tearDown(self): + shutil.rmtree(self.temp_folder, ignore_errors=True) + shutil.rmtree(self.source_folder, ignore_errors=True) + + @staticmethod + def _verify_file_copied(source, destination): + assert os.path.exists(destination) + assert os.path.getsize(destination) == os.path.getsize(source) + assert open(destination).read() == open(source).read() + + def test_file_copies_correctly_to_temp(self): + test_file_name = "test_file.txt" + test_file_location = os.path.join(self.source_folder, test_file_name) + create_dummy_file(test_file_location) + + self.manager.copy_to_temp(test_file_location) + + final_file_location = os.path.join(self.temp_folder, test_file_name) + self._verify_file_copied(test_file_location, final_file_location) + os.remove(final_file_location) + + def test_directory_content_copies_correctly_to_temp(self): + test_dir_name = "test_dir" + test_dir_location = os.path.join(self.source_folder, test_dir_name) + os.makedirs(test_dir_location, exist_ok=True) + + test_file_name = "test_file.txt" + test_file_location = os.path.join(test_dir_location, test_file_name) + create_dummy_file(test_file_location) + + self.manager.copy_to_temp(test_dir_location) + + final_file_location = os.path.join(self.temp_folder, test_file_name) + self._verify_file_copied(test_file_location, final_file_location) + os.remove(final_file_location) + + def test_cleanup_removes_temp_folders(self): + css_folder = os.path.join(self.temp_folder, ".css") + versions_folder = os.path.join(self.temp_folder, ".versions") + os.makedirs(css_folder, exist_ok=True) + os.makedirs(versions_folder, exist_ok=True) + + self.manager.cleanup() + + assert not os.path.exists(css_folder) + assert not os.path.exists(versions_folder) + + def test_cleanup_non_existent_folders_do_not_raise_error(self): + css_folder = os.path.join(self.temp_folder, ".css") + versions_folder = os.path.join(self.temp_folder, ".versions") + + self.manager.cleanup() + + # Check that no error is raised and the method completes successfully + assert not os.path.exists(css_folder) + assert not os.path.exists(versions_folder) \ No newline at end of file diff --git a/tweaks/panel/tweak.py b/tweaks/panel/tweak.py index bb941e9..e58e89f 100755 --- a/tweaks/panel/tweak.py +++ b/tweaks/panel/tweak.py @@ -1,5 +1,5 @@ from scripts import config -from scripts.utils.color_converter import ColorConverterImpl +from scripts.utils.color_converter.color_converter_impl import ColorConverterImpl panel_folder = f"{config.tweaks_folder}/panel"