Repository: biolab/orange3

Issue:
Loading pre-defined colour schemes
- Loading pre-defined colour schemes (e.g. for categorical variables with many groups)
Applicability: General.
Importance: High. Sometimes multiple datasets have the same categorical variables. Colour widget could try to read colours from a HEX column. Then a single table of unique category-colour mapping entries could be merged with data sets that have the categorical variable with same categories to assign the colours (I am not sure if this can be currently done by Merge Data - e.g. if the Data has more rows with the same category to which to append the Extra Data columns). (KH)
    - **JD:** Could this be an enhancement of the Color widget?


Discussion:
What about the color widget having an option to read and write a file with mapping of categories to colors? The file would have some simple form like *category, possibly with spaces*, a tabulator, then color as name or hex?

```
male     blue
female   red
above average    red
average  yellow
below average    #2222ff
```

When you read the file, it assigns colors to all categories of all variables that are found in the file. Other categories are not affected. You can thus also read multiple files; later files override the earlier.
Let us use json.

Resolving patch:
diff --git a/Orange/widgets/data/owcolor.py b/Orange/widgets/data/owcolor.py
--- a/Orange/widgets/data/owcolor.py
+++ b/Orange/widgets/data/owcolor.py
@@ -1,26 +1,36 @@
+import os
 from itertools import chain
+import json
 
 import numpy as np
 
-from AnyQt.QtCore import Qt, QSize, QAbstractTableModel, QModelIndex, QTimer
+from AnyQt.QtCore import Qt, QSize, QAbstractTableModel, QModelIndex, QTimer, \
+    QSettings
 from AnyQt.QtGui import QColor, QFont, QBrush
-from AnyQt.QtWidgets import QHeaderView, QColorDialog, QTableView, QComboBox
+from AnyQt.QtWidgets import QHeaderView, QColorDialog, QTableView, QComboBox, \
+    QFileDialog, QMessageBox
+
+from orangewidget.settings import IncompatibleContext
 
 import Orange
 from Orange.preprocess.transformation import Identity
-from Orange.util import color_to_hex
+from Orange.util import color_to_hex, hex_to_color
 from Orange.widgets import widget, settings, gui
 from Orange.widgets.gui import HorizontalGridDelegate
 from Orange.widgets.utils import itemmodels, colorpalettes
 from Orange.widgets.utils.widgetpreview import WidgetPreview
 from Orange.widgets.utils.state_summary import format_summary_details
+from Orange.widgets.report import colored_square as square
 from Orange.widgets.widget import Input, Output
-from orangewidget.settings import IncompatibleContext
 
 ColorRole = next(gui.OrangeUserRole)
 StripRole = next(gui.OrangeUserRole)
 
 
+class InvalidFileFormat(Exception):
+    pass
+
+
 class AttrDesc:
     """
     Describes modifications that will be applied to variable.
@@ -46,6 +56,24 @@ def name(self):
     def name(self, name):
         self.new_name = name
 
+    def to_dict(self):
+        d = {}
+        if self.new_name is not None:
+            d["rename"] = self.new_name
+        return d
+
+    @classmethod
+    def from_dict(cls, var, data):
+        desc = cls(var)
+        if not isinstance(data, dict):
+            raise InvalidFileFormat
+        new_name = data.get("rename")
+        if new_name is not None:
+            if not isinstance(new_name, str):
+                raise InvalidFileFormat
+            desc.name = new_name
+        return desc, []
+
 
 class DiscAttrDesc(AttrDesc):
     """
@@ -96,6 +124,57 @@ def create_variable(self):
         new_var.colors = np.asarray(self.colors)
         return new_var
 
+    def to_dict(self):
+        d = super().to_dict()
+        if self.new_values is not None:
+            d["renamed_values"] = \
+                {k: v
+                 for k, v in zip(self.var.values, self.new_values)
+                 if k != v}
+        if self.new_colors is not None:
+            d["colors"] = {
+                value: color_to_hex(color)
+                for value, color in zip(self.var.values, self.colors)}
+        return d
+
+    @classmethod
+    def from_dict(cls, var, data):
+
+        def _check_dict_str_str(d):
+            if not isinstance(d, dict) or \
+                    not all(isinstance(val, str)
+                            for val in chain(d, d.values())):
+                raise InvalidFileFormat
+
+        obj, warnings = super().from_dict(var, data)
+
+        val_map = data.get("renamed_values")
+        if val_map is not None:
+            _check_dict_str_str(val_map)
+            mapped_values = [val_map.get(value, value) for value in var.values]
+            if len(set(mapped_values)) != len(mapped_values):
+                warnings.append(
+                    f"{var.name}: "
+                    "renaming of values ignored due to duplicate names")
+            else:
+                obj.new_values = mapped_values
+
+        new_colors = data.get("colors")
+        if new_colors is not None:
+            _check_dict_str_str(new_colors)
+            colors = []
+            for value, def_color in zip(var.values, var.palette.palette):
+                if value in new_colors:
+                    try:
+                        color = hex_to_color(new_colors[value])
+                    except ValueError as exc:
+                        raise InvalidFileFormat from exc
+                    colors.append(color)
+                else:
+                    colors.append(def_color)
+                obj.new_colors = colors
+        return obj, warnings
+
 
 class ContAttrDesc(AttrDesc):
     """
@@ -136,6 +215,22 @@ def create_variable(self):
         new_var.attributes["palette"] = self.palette_name
         return new_var
 
+    def to_dict(self):
+        d = super().to_dict()
+        if self.new_palette_name is not None:
+            d["colors"] = self.palette_name
+        return d
+
+    @classmethod
+    def from_dict(cls, var, data):
+        obj, warnings = super().from_dict(var, data)
+        colors = data.get("colors")
+        if colors is not None:
+            if colors not in colorpalettes.ContinuousPalettes:
+                raise InvalidFileFormat
+            obj.palette_name = colors
+        return obj, warnings
+
 
 class ColorTableModel(QAbstractTableModel):
     """
@@ -312,7 +407,7 @@ def __init__(self, view):
         super().__init__()
         self.view = view
 
-    def createEditor(self, parent, option, index):
+    def createEditor(self, parent, _, index):
         class Combo(QComboBox):
             def __init__(self, parent, initial_data, view):
                 super().__init__(parent)
@@ -454,7 +549,6 @@ class Outputs:
         match_values=settings.PerfectDomainContextHandler.MATCH_VALUES_ALL)
     disc_descs = settings.ContextSetting([])
     cont_descs = settings.ContextSetting([])
-    color_settings = settings.Setting(None)
     selected_schema_index = settings.Setting(0)
     auto_apply = settings.Setting(True)
 
@@ -481,9 +575,13 @@ def __init__(self):
 
         box = gui.auto_apply(self.controlArea, self, "auto_apply")
         box.button.setFixedWidth(180)
+        save = gui.button(None, self, "Save", callback=self.save)
+        load = gui.button(None, self, "Load", callback=self.load)
         reset = gui.button(None, self, "Reset", callback=self.reset)
-        box.layout().insertWidget(0, reset)
-        box.layout().insertStretch(1)
+        box.layout().insertWidget(0, save)
+        box.layout().insertWidget(0, load)
+        box.layout().insertWidget(2, reset)
+        box.layout().insertStretch(3)
 
         self.info.set_input_summary(self.info.NoInput)
         self.info.set_output_summary(self.info.NoOutput)
@@ -524,6 +622,114 @@ def reset(self):
         self.cont_model.reset()
         self.commit()
 
+    def save(self):
+        fname, _ = QFileDialog.getSaveFileName(
+            self, "File name", self._start_dir(),
+            "Variable definitions (*.colors)")
+        if not fname:
+            return
+        QSettings().setValue("colorwidget/last-location",
+                             os.path.split(fname)[0])
+        self._save_var_defs(fname)
+
+    def _save_var_defs(self, fname):
+        with open(fname, "w") as f:
+            json.dump(
+                {vartype: {
+                    var.name: var_data
+                    for var, var_data in (
+                        (desc.var, desc.to_dict()) for desc in repo)
+                    if var_data}
+                 for vartype, repo in (("categorical", self.disc_descs),
+                                       ("numeric", self.cont_descs))
+                },
+                f,
+                indent=4)
+
+    def load(self):
+        fname, _ = QFileDialog.getOpenFileName(
+            self, "File name", self._start_dir(),
+            "Variable definitions (*.colors)")
+        if not fname:
+            return
+
+        try:
+            f = open(fname)
+        except IOError:
+            QMessageBox.critical(self, "File error", "File cannot be opened.")
+            return
+
+        try:
+            js = json.load(f)  #: dict
+            self._parse_var_defs(js)
+        except (json.JSONDecodeError, InvalidFileFormat):
+            QMessageBox.critical(self, "File error", "Invalid file format.")
+
+    def _parse_var_defs(self, js):
+        if not isinstance(js, dict) or set(js) != {"categorical", "numeric"}:
+            raise InvalidFileFormat
+        try:
+            renames = {
+                var_name: desc["rename"]
+                for repo in js.values() for var_name, desc in repo.items()
+                if "rename" in desc
+            }
+        # js is an object coming from json file that can be manipulated by
+        # the user, so there are too many things that can go wrong.
+        # Catch all exceptions, therefore.
+        except Exception as exc:
+            raise InvalidFileFormat from exc
+        if not all(isinstance(val, str)
+                   for val in chain(renames, renames.values())):
+            raise InvalidFileFormat
+        renamed_vars = {
+            renames.get(desc.var.name, desc.var.name)
+            for desc in chain(self.disc_descs, self.cont_descs)
+        }
+        if len(renamed_vars) != len(self.disc_descs) + len(self.cont_descs):
+            QMessageBox.warning(
+                self,
+                "Duplicated variable names",
+                "Variables will not be renamed due to duplicated names.")
+            for repo in js.values():
+                for desc in repo.values():
+                    desc.pop("rename", None)
+
+        # First, construct all descriptions; assign later, after we know
+        # there won't be exceptions due to invalid file format
+        both_descs = []
+        warnings = []
+        for old_desc, repo, desc_type in (
+                (self.disc_descs, "categorical", DiscAttrDesc),
+                (self.cont_descs, "numeric", ContAttrDesc)):
+            var_by_name = {desc.var.name: desc.var for desc in old_desc}
+            new_descs = {}
+            for var_name, var_data in js[repo].items():
+                var = var_by_name.get(var_name)
+                if var is None:
+                    continue
+                # This can throw InvalidFileFormat
+                new_descs[var_name], warn = desc_type.from_dict(var, var_data)
+                warnings += warn
+            both_descs.append(new_descs)
+
+        self.disc_descs = [both_descs[0].get(desc.var.name, desc)
+                           for desc in self.disc_descs]
+        self.cont_descs = [both_descs[1].get(desc.var.name, desc)
+                           for desc in self.cont_descs]
+        if warnings:
+            QMessageBox.warning(
+                self, "Invalid definitions", "\n".join(warnings))
+
+        self.disc_model.set_data(self.disc_descs)
+        self.cont_model.set_data(self.cont_descs)
+        self.unconditional_commit()
+
+    def _start_dir(self):
+        return self.workflowEnv().get("basedir") \
+               or QSettings().value("colorwidget/last-location") \
+               or os.path.expanduser(f"~{os.sep}")
+
     def commit(self):
         def make(variables):
             new_vars = []
@@ -552,8 +758,6 @@ def make(variables):
     def send_report(self):
         """Send report"""
         def _report_variables(variables):
-            from Orange.widgets.report import colored_square as square
-
             def was(n, o):
                 return n if n == o else f"{n} (was: {o})"
 
@@ -597,10 +801,10 @@ def was(n, o):
         table = "".join(f"<tr><th>{name}</th></tr>{rows}"
                         for name, rows in sections if rows)
         if table:
-            self.report_raw(r"<table>{table}</table>")
+            self.report_raw(f"<table>{table}</table>")
 
     @classmethod
-    def migrate_context(cls, context, version):
+    def migrate_context(cls, _, version):
         if not version or version < 2:
             raise IncompatibleContext
 


Test patch:
diff --git a/Orange/widgets/data/tests/test_owcolor.py b/Orange/widgets/data/tests/test_owcolor.py
--- a/Orange/widgets/data/tests/test_owcolor.py
+++ b/Orange/widgets/data/tests/test_owcolor.py
@@ -1,5 +1,8 @@
 # Test methods with long descriptive names can omit docstrings
 # pylint: disable=missing-docstring, protected-access,unsubscriptable-object
+import json
+import os
+import tempfile
 import unittest
 from unittest.mock import patch, Mock
 
@@ -7,6 +10,8 @@
 from AnyQt.QtCore import Qt, QSize, QRect
 from AnyQt.QtGui import QBrush
 
+from orangewidget.tests.base import GuiTest
+
 from Orange.data import Table, ContinuousVariable, DiscreteVariable, Domain
 from Orange.preprocess.transformation import Identity
 from Orange.util import color_to_hex
@@ -15,7 +20,6 @@
 from Orange.widgets.data import owcolor
 from Orange.widgets.data.owcolor import ColorRole
 from Orange.widgets.tests.base import WidgetTest
-from orangewidget.tests.base import GuiTest
 
 
 class AttrDescTest(unittest.TestCase):
@@ -37,6 +41,29 @@ def test_reset(self):
         desc.reset()
         self.assertEqual(desc.name, "x")
 
+    def test_to_dict(self):
+        x = ContinuousVariable("x")
+        desc = owcolor.AttrDesc(x)
+        self.assertEqual(desc.to_dict(), {})
+        desc2, warns = owcolor.AttrDesc.from_dict(x, desc.to_dict())
+        self.assertEqual(warns, [])
+        self.assertIsNone(desc2.new_name)
+
+        desc.name = "y"
+        self.assertEqual(desc.to_dict(), {"rename": "y"})
+
+        desc2, warns = owcolor.AttrDesc.from_dict(x, desc.to_dict())
+        self.assertEqual(warns, [])
+        self.assertEqual(desc2.new_name, "y")
+
+        self.assertRaises(owcolor.InvalidFileFormat,
+                          owcolor.AttrDesc.from_dict, x, {"rename": 42})
+        self.assertRaises(owcolor.InvalidFileFormat,
+                          owcolor.AttrDesc.from_dict, x, [])
+
+        # Additional keys shouldn't cause exceptions
+        owcolor.AttrDesc.from_dict(x, {"foo": 42})
+
 
 class DiscAttrTest(unittest.TestCase):
     def setUp(self):
@@ -88,8 +115,119 @@ def test_reset(self):
         np.testing.assert_equal(desc.colors, self.var.colors)
         self.assertEqual(desc.values, self.var.values)
 
+    def test_to_dict(self):
+        desc = owcolor.DiscAttrDesc(self.var)
+        self.assertEqual(desc.to_dict(), {})
+        desc2, warns = owcolor.DiscAttrDesc.from_dict(self.var, desc.to_dict())
+        self.assertEqual(warns, [])
+        self.assertIsNone(desc2.new_name)
+        self.assertIsNone(desc2.new_values)
+        self.assertIsNone(desc2.new_colors)
 
-class ContAttrDesc(unittest.TestCase):
+        desc.name = "y"
+        self.assertEqual(desc.to_dict(), {"rename": "y"})
+
+        desc2, warns = owcolor.DiscAttrDesc.from_dict(self.var, desc.to_dict())
+        self.assertEqual(warns, [])
+        self.assertEqual(desc2.new_name, "y")
+        self.assertIsNone(desc2.new_values)
+        self.assertIsNone(desc2.new_colors)
+
+        desc.set_value(1, "b2")
+        desc.set_color(1, [1, 2, 3])
+        desc.set_color(2, [2, 3, 4])
+        self.assertEqual(desc.to_dict(), {
+            "rename": "y",
+            "renamed_values": {"b": "b2"},
+            "colors": {"a": color_to_hex(desc.colors[0]),
+                       "b": "#010203",
+                       "c": "#020304"}
+        })
+
+        desc2, warns = owcolor.DiscAttrDesc.from_dict(self.var, desc.to_dict())
+        self.assertEqual(warns, [])
+        cols = list(desc.colors)
+        cols[1:] = [[1, 2, 3], [2, 3, 4]]
+        np.testing.assert_equal(desc2.colors, cols)
+        self.assertEqual(desc2.values, ("a", "b2", "c"))
+
+        desc2, warns = owcolor.DiscAttrDesc.from_dict(
+            self.var,
+            {"rename": "y",
+             "renamed_values": {"b": "b2", "d": "x"},  # d is redundant
+             "colors": {"b": "#010203",
+                        "c": "#020304",
+                        "d": "#123456"}  # d is redundant and must be ignored
+            })
+        self.assertEqual(warns, [])
+        cols = list(desc.colors)
+        cols[1:] = [[1, 2, 3], [2, 3, 4]]
+        np.testing.assert_equal(desc2.colors, cols)
+        self.assertEqual(desc2.values, ("a", "b2", "c"))
+
+    def test_from_dict_coliding_values(self):
+        desc2, warns = owcolor.DiscAttrDesc.from_dict(
+            self.var, {"renamed_values": {"a": "b"}})
+        self.assertEqual(len(warns), 1)
+        self.assertTrue("duplicate names" in warns[0])
+        self.assertIsNone(desc2.new_values)
+        self.assertEqual(desc2.values, ("a", "b", "c"))
+
+        desc2, warns = owcolor.DiscAttrDesc.from_dict(
+            self.var, {"renamed_values": {"a": "e", "b": "e"}})
+        self.assertEqual(len(warns), 1)
+        self.assertTrue("duplicate names" in warns[0])
+        self.assertIsNone(desc2.new_values)
+        self.assertEqual(desc2.values, ("a", "b", "c"))
+
+        desc2, warns = owcolor.DiscAttrDesc.from_dict(
+            self.var, {"renamed_values": {"a": "b", "b": "a"}})
+        self.assertEqual(warns, [])
+        self.assertEqual(desc2.values, ("b", "a", "c"))
+
+    def test_from_dict_exceptions(self):
+        self.assertRaises(
+            owcolor.InvalidFileFormat,
+            owcolor.AttrDesc.from_dict, self.var, [])
+        self.assertRaises(
+            owcolor.InvalidFileFormat,
+            owcolor.DiscAttrDesc.from_dict, self.var, {"rename": 42}
+        )
+        self.assertRaises(
+            owcolor.InvalidFileFormat,
+            owcolor.DiscAttrDesc.from_dict, self.var, {"colors": []}
+        )
+        self.assertRaises(
+            owcolor.InvalidFileFormat,
+            owcolor.DiscAttrDesc.from_dict, self.var, {"colors": {"a": 42}}
+        )
+        self.assertRaises(
+            owcolor.InvalidFileFormat,
+            owcolor.DiscAttrDesc.from_dict, self.var, {"colors": {4: "#000000"}}
+        )
+        self.assertRaises(
+            owcolor.InvalidFileFormat,
+            owcolor.DiscAttrDesc.from_dict, self.var, {"colors": {"a": "#00"}}
+        )
+        self.assertRaises(
+            owcolor.InvalidFileFormat,
+            owcolor.DiscAttrDesc.from_dict, self.var, {"colors": {"a": "#qwerty"}}
+        )
+        self.assertRaises(
+            owcolor.InvalidFileFormat,
+            owcolor.DiscAttrDesc.from_dict, self.var, {"renamed_values": []}
+        )
+        self.assertRaises(
+            owcolor.InvalidFileFormat,
+            owcolor.DiscAttrDesc.from_dict, self.var, {"renamed_values": {"a": 42}}
+        )
+        self.assertRaises(
+            owcolor.InvalidFileFormat,
+            owcolor.DiscAttrDesc.from_dict, self.var, {"renamed_values": {4: "#000000"}}
+        )
+
+
+class ContAttrDescTest(unittest.TestCase):
     def setUp(self):
         x = ContinuousVariable("x")
         self.desc = owcolor.ContAttrDesc(x)
@@ -131,6 +269,42 @@ def test_reset(self):
         desc.reset()
         np.testing.assert_equal(desc.palette_name, palette_name)
 
+    def test_to_dict(self):
+        x = ContinuousVariable("x")
+        desc = owcolor.ContAttrDesc(x)
+        self.assertEqual(desc.to_dict(), {})
+        desc2, warns = owcolor.ContAttrDesc.from_dict(x, desc.to_dict())
+        self.assertEqual(warns, [])
+        self.assertIsNone(desc2.new_name)
+        self.assertIsNone(desc2.new_palette_name)
+
+        desc.name = "y"
+        self.assertEqual(desc.to_dict(), {"rename": "y"})
+
+        desc2, warns = owcolor.ContAttrDesc.from_dict(x, desc.to_dict())
+        self.assertEqual(warns, [])
+        self.assertEqual(desc2.new_name, "y")
+        self.assertIsNone(desc2.new_palette_name)
+
+        desc = owcolor.ContAttrDesc(x)
+        desc.palette_name = "linear_viridis"
+        self.assertEqual(desc.to_dict(), {"colors": "linear_viridis"})
+
+    def test_from_dict_exceptions(self):
+        x = ContinuousVariable("x")
+        self.assertRaises(
+            owcolor.InvalidFileFormat,
+            owcolor.ContAttrDesc.from_dict, x, []
+        )
+        self.assertRaises(
+            owcolor.InvalidFileFormat,
+            owcolor.ContAttrDesc.from_dict, x, {"colors": 42}
+        )
+        self.assertRaises(
+            owcolor.InvalidFileFormat,
+            owcolor.ContAttrDesc.from_dict, x, {"colors": "no such palette"}
+        )
+
 
 class BaseTestColorTableModel:
     def test_row_count(self):
@@ -563,6 +737,176 @@ def test_reset(self):
         self.assertEqual(
             outp.domain.class_var.name, self.iris.domain.class_var.name)
 
+    def test_save(self):
+        self.widget._save_var_defs = Mock()
+        with patch.object(owcolor.QFileDialog, "getSaveFileName",
+                          return_value=("", "")):
+            self.widget.save()
+            self.widget._save_var_defs.assert_not_called()
+        with patch.object(owcolor.QFileDialog, "getSaveFileName",
+                          return_value=("foo", "bar")):
+            self.widget.save()
+            self.widget._save_var_defs.assert_called_with("foo")
+
+    def test_save_low(self):
+        descA = owcolor.DiscAttrDesc(
+            DiscreteVariable("varA", values=tuple("abc")))
+        descA.name = "a2"
+        descB = owcolor.DiscAttrDesc(
+            DiscreteVariable("varB", values=tuple("abc")))
+        descB.set_value(1, "X")
+        descC = owcolor.ContAttrDesc(ContinuousVariable("varC"))
+        descC.name = "c2"
+        descD = owcolor.ContAttrDesc(ContinuousVariable("varD"))
+        descD.palette_name = "linear_viridis"
+        descE = owcolor.ContAttrDesc(ContinuousVariable("varE"))
+        self.widget.disc_descs = [descA, descB]
+        self.widget.cont_descs = [descC, descD, descE]
+
+        with tempfile.TemporaryDirectory() as path:
+            fname = os.path.join(path, "foo.colors")
+            self.widget._save_var_defs(fname)
+            with open(fname) as f:
+                js = json.load(f)
+            self.assertEqual(js["categorical"],
+                             {"varA": {"rename": "a2"},
+                              "varB": {"renamed_values": {"b": "X"}}})
+            self.assertEqual(js["numeric"],
+                             {"varC": {"rename": "c2"},
+                              "varD": {"colors": "linear_viridis"}})
+
+    @patch("Orange.widgets.data.owcolor.QMessageBox.critical")
+    def test_load(self, msg_box):
+        self.widget._parse_var_defs = Mock()
+        self.widget._save_var_defs = Mock()
+        with patch.object(owcolor.QFileDialog, "getOpenFileName",
+                          return_value=("", "")):
+            with patch("builtins.open"):
+                self.widget.load()
+                open.assert_not_called()
+                self.widget._parse_var_defs.assert_not_called()
+
+        with patch.object(owcolor.QFileDialog, "getOpenFileName",
+                          return_value=("foo.colors", "*.colors")):
+            with patch("json.load") as json_load, \
+                    patch("builtins.open", side_effect=IOError):
+                self.widget.load()
+                msg_box.assert_called()
+                msg_box.reset_mock()
+                json_load.assert_not_called()
+                self.widget._parse_var_defs.assert_not_called()
+
+        with patch.object(owcolor.QFileDialog, "getOpenFileName",
+                          return_value=("foo.colors", "*.colors")):
+            with patch("json.load", side_effect=json.JSONDecodeError("err", "d", 42)), \
+                    patch("builtins.open"):
+                self.widget.load()
+                msg_box.assert_called()
+                msg_box.reset_mock()
+                self.widget._parse_var_defs.assert_not_called()
+
+        with patch.object(owcolor.QFileDialog, "getOpenFileName",
+                          return_value=("foo.colors", "*.colors")):
+            with patch("json.load"), patch("builtins.open"):
+                self.widget.load()
+                msg_box.assert_not_called()
+                msg_box.reset_mock()
+                self.widget._parse_var_defs.assert_called_with(json.load.return_value)
+
+    def _create_descs(self):
+        disc_vars = [DiscreteVariable(f"var{c}", values=("a", "b", "c"))
+                     for c in "AB"]
+        cont_vars = [ContinuousVariable(f"var{c}") for c in "CDE"]
+        self.widget.disc_descs = [owcolor.DiscAttrDesc(v) for v in disc_vars]
+        self.widget.cont_descs = [owcolor.ContAttrDesc(v) for v in cont_vars]
+        return disc_vars, cont_vars
+
+    def test_parse_var_defs(self):
+        js = {"categorical": {"varA": {"rename": "a2"},
+                              "varB": {"renamed_values": {"b": "X"}}},
+              "numeric": {"varC": {"rename": "c2"},
+                          "varD": {"colors": "linear_viridis"}}}
+
+        self._create_descs()
+        descE = self.widget.cont_descs[-1]
+
+        self.widget._parse_var_defs(js)
+        self.assertEqual(len(self.widget.disc_descs), 2)
+        descA = self.widget.disc_descs[0]
+        self.assertEqual(descA.name, "a2")
+        self.assertIsNone(descA.new_values)
+        self.assertIsNone(descA.new_colors)
+
+        descB = self.widget.disc_descs[1]
+        self.assertIsNone(descB.new_name)
+        self.assertEqual(descB.new_values, ["a", "X", "c"])
+        self.assertIsNone(descB.new_colors)
+
+        self.assertEqual(len(self.widget.cont_descs), 3)
+        descC = self.widget.cont_descs[0]
+        self.assertEqual(descC.name, "c2")
+        self.assertIsNone(descC.new_palette_name)
+
+        descD = self.widget.cont_descs[1]
+        self.assertIsNone(descD.new_name)
+        self.assertEqual(descD.new_palette_name, "linear_viridis")
+
+        self.assertIs(self.widget.cont_descs[2], descE)
+
+    def test_parse_var_defs_invalid(self):
+        self.assertRaises(
+            owcolor.InvalidFileFormat,
+            self.widget._parse_var_defs, 42)
+        self.assertRaises(
+            owcolor.InvalidFileFormat,
+            self.widget._parse_var_defs,
+            {"categorical": {"a": 42}, "numeric": {}})
+        self.assertRaises(
+            owcolor.InvalidFileFormat,
+            self.widget._parse_var_defs,
+            {"categorical": {"a": {"rename": 4}}, "numeric": {}})
+        self.assertRaises(
+            owcolor.InvalidFileFormat,
+            self.widget._parse_var_defs,
+            {"categorical": {42: {"rename": "b"}}, "numeric": {}})
+
+    @patch("Orange.widgets.data.owcolor.QMessageBox.warning")
+    def test_parse_var_defs_shows_warnings(self, msg_box):
+        self._create_descs()
+        self.widget._parse_var_defs(
+            {"categorical": {"varA": {"renamed_values": {"a": "b"}}},
+             "numeric": {}})
+        msg_box.assert_called()
+        self.assertTrue("duplicate names" in msg_box.call_args[0][2])
+
+    @patch("Orange.widgets.data.owcolor.QMessageBox.warning")
+    def test_parse_var_defs_no_rename(self, msg_box):
+        self._create_descs()
+
+        self.widget._parse_var_defs(
+            {"categorical": {"varA": {"rename": "varB"}},
+             "numeric": {}})
+        msg_box.assert_called()
+        self.assertTrue("duplicated names" in msg_box.call_args[0][2])
+        msg_box.reset_mock()
+
+        self.widget._parse_var_defs(
+            {"categorical": {"varA": {"rename": "X"}},
+             "numeric": {"varD": {"rename": "X"}}})
+        msg_box.assert_called()
+        self.assertTrue("duplicated names" in msg_box.call_args[0][2])
+        msg_box.reset_mock()
+
+        self.widget._parse_var_defs(
+            {"categorical": {"varA": {"rename": "varD"}},
+             "numeric": {"varD": {"rename": "varA"}}})
+        msg_box.assert_not_called()
+
+        self.widget._parse_var_defs(
+            {"categorical": {"varA": {"rename": "X"}},
+             "numeric": {"var not": {"rename": "X"}}})
+        msg_box.assert_not_called()
+
 
 if __name__ == "__main__":
     unittest.main()