Repository: biolab/orange3

Issue:
Scatter Plot Categorical Misplacement
<!-- 
Thanks for taking the time to report a bug!
If you're raising an issue about an add-on (i.e., installed via Options > Add-ons), raise an issue in the relevant add-on's issue tracker instead. See: https://github.com/biolab?q=orange3
To fix the bug, we need to be able to reproduce it. Please answer the following questions to the best of your ability.
-->


**What's wrong?**
<!-- Be specific, clear, and concise. Include screenshots if relevant. -->
<!-- If you're getting an error message, copy it, and enclose it with three backticks (```). -->
I have a set of data that goes from 27 -> 45 C, to extract this for a variable I used a "create class" widget to pull them from the file names. This creates a categorical. If the order of these categoricals is not directly sequential then when I plot them I get: 
![image](https://user-images.githubusercontent.com/47462814/124178263-5d8aa080-da7f-11eb-94c2-d251c1ebce84.png)
Where both the colour and the axes are ostensibly pulling from the same data set yet display completely different results. And it's not like I can just use the "feature constructor" to generate a feature with the numerical representations as it just gives me some numbers between 1 and 12 instead that aren't the actual values I care about.



**How can we reproduce the problem?** 
<!-- Upload a zip with the .ows file and data. -->
<!-- Describe the steps (open this widget, click there, then add this...) -->
[Scatterplot.zip](https://github.com/biolab/orange3/files/6750328/Scatterplot.zip)

All you need to do is open and inspect, if you rewrite the order of the numbers in the class creation widget then it will work just fine. It seems like the axes are recognizing these are numbers and then placing them as they should along the axis, but that's not mirrored in the way the rest of the program treats the categorical variable. This means that when these are plotted the order of the original categoricals in a separate list is maintained but that is not informing the axis construction as it should. Ideally, I'd be able to have it automatically recognize the temperature as an integer value and sort according to the way the axis does.


**What's your environment?**
<!-- To find your Orange version, see "Help → About → Version" or `Orange.version.full_version` in code -->
- Operating system: Windows 10
- Orange version: 3.30.0.dev
- How you installed Orange: command line



Discussion:
Orange will, in general, not try to guess the order of categorical values, e.g. by sorting them or even recognizing that category names represent numbers (to put '9' before '10').

You can use the Edit Domain widget to order the values or, even better in your case, reinterpret the variable as numeric.
The problem is that the scatter plot widget is attempting to guess the order of my categorical values when it shouldn't be, meaning that it is essentially randomly placing my values along the axes.

The Edit Domain widget works great though, thank you!
Actually, you could also use Feature Constructor: you just have to use `featureName.value` to get to the actual numbers. Otherwise, you get the value index.

@janezd, isn't this a bit hard for the users? I do not see how a user would benefit from getting the value index in Feature Constructor. Maybe we could add `.value` to DiscreteVariables automatically? I remember that even we, Orange devs, have problems with the Feature Constructor for nominal features. I remember complaints from @ajdapretnar. I also had to try it to answer this.
I think it's unavoidable for scatterplot to randomly guess the order of categorical variables.

Look at the legend and the values though. It says green is 27, but the point is at 29??

Resolving patch:
diff --git a/Orange/widgets/data/owfeatureconstructor.py b/Orange/widgets/data/owfeatureconstructor.py
--- a/Orange/widgets/data/owfeatureconstructor.py
+++ b/Orange/widgets/data/owfeatureconstructor.py
@@ -26,8 +26,8 @@
 from AnyQt.QtWidgets import (
     QSizePolicy, QAbstractItemView, QComboBox, QFormLayout, QLineEdit,
     QHBoxLayout, QVBoxLayout, QStackedWidget, QStyledItemDelegate,
-    QPushButton, QMenu, QListView, QFrame, QLabel)
-from AnyQt.QtGui import QKeySequence, QColor
+    QPushButton, QMenu, QListView, QFrame, QLabel, QMessageBox)
+from AnyQt.QtGui import QKeySequence
 from AnyQt.QtCore import Qt, pyqtSignal as Signal, pyqtProperty as Property
 from orangewidget.utils.combobox import ComboBoxSearch
 
@@ -101,6 +101,13 @@ def selected_row(view):
 
 
 class FeatureEditor(QFrame):
+    ExpressionTooltip = """
+Use variable names as values in expression.
+Categorical features are passed as strings
+(note the change in behaviour from Orange 3.30).
+
+""".lstrip()
+
     FUNCTIONS = dict(chain([(key, val) for key, val in math.__dict__.items()
                             if not key.startswith("_")],
                            [(key, val) for key, val in builtins.__dict__.items()
@@ -231,7 +238,8 @@ def insert_into_expression(self, what):
 
 
 class ContinuousFeatureEditor(FeatureEditor):
-    ExpressionTooltip = "A numeric expression"
+    ExpressionTooltip = "A numeric expression\n\n" \
+                        + FeatureEditor.ExpressionTooltip
 
     def editorData(self):
         return ContinuousDescriptor(
@@ -242,7 +250,7 @@ def editorData(self):
 
 
 class DateTimeFeatureEditor(FeatureEditor):
-    ExpressionTooltip = \
+    ExpressionTooltip = FeatureEditor.ExpressionTooltip + \
         "Result must be a string in ISO-8601 format " \
         "(e.g. 2019-07-30T15:37:27 or a part thereof),\n" \
         "or a number of seconds since Jan 1, 1970."
@@ -255,7 +263,7 @@ def editorData(self):
 
 
 class DiscreteFeatureEditor(FeatureEditor):
-    ExpressionTooltip = \
+    ExpressionTooltip = FeatureEditor.ExpressionTooltip + \
         "Result must be a string, if values are not explicitly given\n" \
         "or a zero-based integer indices into a list of values given below."
 
@@ -292,7 +300,8 @@ def editorData(self):
 
 
 class StringFeatureEditor(FeatureEditor):
-    ExpressionTooltip = "A string expression"
+    ExpressionTooltip = "A string expression\n\n" \
+                        + FeatureEditor.ExpressionTooltip
 
     def editorData(self):
         return StringDescriptor(
@@ -330,6 +339,111 @@ def data(self, index, role=Qt.DisplayRole):
             return super().data(index, role)
 
 
+def freevars(exp, env):
+    """
+    Return names of all free variables in a parsed (expression) AST.
+
+    Parameters
+    ----------
+    exp : ast.AST
+        An expression ast (ast.parse(..., mode="single"))
+    env : List[str]
+        Environment
+
+    Returns
+    -------
+    freevars : List[str]
+
+    See also
+    --------
+    ast
+
+    """
+    # pylint: disable=too-many-return-statements,too-many-branches
+    etype = type(exp)
+    if etype in [ast.Expr, ast.Expression]:
+        return freevars(exp.body, env)
+    elif etype == ast.BoolOp:
+        return sum((freevars(v, env) for v in exp.values), [])
+    elif etype == ast.BinOp:
+        return freevars(exp.left, env) + freevars(exp.right, env)
+    elif etype == ast.UnaryOp:
+        return freevars(exp.operand, env)
+    elif etype == ast.Lambda:
+        args = exp.args
+        assert isinstance(args, ast.arguments)
+        argnames = [a.arg for a in args.args]
+        argnames += [args.vararg.arg] if args.vararg else []
+        argnames += [a.arg for a in args.kwonlyargs] if args.kwonlyargs else []
+        argnames += [args.kwarg] if args.kwarg else []
+        return freevars(exp.body, env + argnames)
+    elif etype == ast.IfExp:
+        return (freevars(exp.test, env) + freevars(exp.body, env) +
+                freevars(exp.orelse, env))
+    elif etype == ast.Dict:
+        return sum((freevars(v, env)
+                    for v in chain(exp.keys, exp.values)), [])
+    elif etype == ast.Set:
+        return sum((freevars(v, env) for v in exp.elts), [])
+    elif etype in [ast.SetComp, ast.ListComp, ast.GeneratorExp, ast.DictComp]:
+        env_ext = []
+        vars_ = []
+        for gen in exp.generators:
+            target_names = freevars(gen.target, [])  # assigned names
+            vars_iter = freevars(gen.iter, env)
+            env_ext += target_names
+            vars_ifs = list(chain(*(freevars(ifexp, env + target_names)
+                                    for ifexp in gen.ifs or [])))
+            vars_ += vars_iter + vars_ifs
+
+        if etype == ast.DictComp:
+            vars_ = (freevars(exp.key, env_ext) +
+                     freevars(exp.value, env_ext) +
+                     vars_)
+        else:
+            vars_ = freevars(exp.elt, env + env_ext) + vars_
+        return vars_
+    # Yield, YieldFrom???
+    elif etype == ast.Compare:
+        return sum((freevars(v, env)
+                    for v in [exp.left] + exp.comparators), [])
+    elif etype == ast.Call:
+        return sum(map(lambda e: freevars(e, env),
+                       chain([exp.func],
+                             exp.args or [],
+                             [k.value for k in exp.keywords or []])),
+                   [])
+    elif etype == ast.Starred:
+        # a 'starred' call parameter (e.g. a and b in `f(x, *a, *b)`
+        return freevars(exp.value, env)
+    elif etype in [ast.Num, ast.Str, ast.Ellipsis, ast.Bytes, ast.NameConstant]:
+        return []
+    elif etype == ast.Constant:
+        return []
+    elif etype == ast.Attribute:
+        return freevars(exp.value, env)
+    elif etype == ast.Subscript:
+        return freevars(exp.value, env) + freevars(exp.slice, env)
+    elif etype == ast.Name:
+        return [exp.id] if exp.id not in env else []
+    elif etype == ast.List:
+        return sum((freevars(e, env) for e in exp.elts), [])
+    elif etype == ast.Tuple:
+        return sum((freevars(e, env) for e in exp.elts), [])
+    elif etype == ast.Slice:
+        return sum((freevars(e, env)
+                    for e in filter(None, [exp.lower, exp.upper, exp.step])),
+                   [])
+    elif etype == ast.ExtSlice:
+        return sum((freevars(e, env) for e in exp.dims), [])
+    elif etype == ast.Index:
+        return freevars(exp.value, env)
+    elif etype == ast.keyword:
+        return freevars(exp.value, env)
+    else:
+        raise ValueError(exp)
+
+
 class FeatureConstructorHandler(DomainContextHandler):
     """Context handler that filters descriptors"""
 
@@ -379,6 +493,8 @@ class Outputs:
     settingsHandler = FeatureConstructorHandler()
     descriptors = ContextSetting([])
     currentIndex = ContextSetting(-1)
+    expressions_with_values = ContextSetting(False)
+    settings_version = 2
 
     EDITORS = [
         (ContinuousDescriptor, ContinuousFeatureEditor),
@@ -497,6 +613,10 @@ def generate_newname(fmt):
 
         box.layout().addLayout(layout, 1)
 
+        self.fix_button = gui.button(
+            self.buttonsArea, self, "Upgrade Expressions",
+            callback=self.fix_expressions)
+        self.fix_button.setHidden(True)
         gui.button(self.buttonsArea, self, "Send", callback=self.apply, default=True)
 
     def setCurrentIndex(self, index):
@@ -524,13 +644,13 @@ def _on_modified(self):
             self.Warning.clear()
             editor = self.editorstack.currentWidget()
             proposed = editor.editorData().name
-            unique = get_unique_names(self.reserved_names(self.currentIndex),
-                                      proposed)
+            uniq = get_unique_names(self.reserved_names(self.currentIndex),
+                                    proposed)
 
             feature = editor.editorData()
-            if editor.editorData().name != unique:
+            if editor.editorData().name != uniq:
                 self.Warning.renamed_var()
-                feature = feature.__class__(unique, *feature[1:])
+                feature = feature.__class__(uniq, *feature[1:])
 
             self.featuremodel[self.currentIndex] = feature
             self.descriptors = list(self.featuremodel)
@@ -558,6 +678,7 @@ def setData(self, data=None):
         self.closeContext()
 
         self.data = data
+        self.expressions_with_values = False
 
         if self.data is not None:
             descriptors = list(self.descriptors)
@@ -565,6 +686,7 @@ def setData(self, data=None):
             self.descriptors = []
             self.currentIndex = -1
             self.openContext(data)
+            self.fix_button.setHidden(not self.expressions_with_values)
 
             if descriptors != self.descriptors or \
                     self.currentIndex != currindex:
@@ -586,6 +708,7 @@ def handleNewSignals(self):
             self.apply()
         else:
             self.Outputs.data.send(None)
+            self.fix_button.setHidden(True)
 
     def addFeature(self, descriptor):
         self.featuremodel.append(descriptor)
@@ -658,7 +781,8 @@ def report_error(err):
         desc = list(self.featuremodel)
         desc = self._validate_descriptors(desc)
         try:
-            new_variables = construct_variables(desc, self.data)
+            new_variables = construct_variables(
+                desc, self.data, self.expressions_with_values)
         # user's expression can contain arbitrary errors
         except Exception as err:  # pylint: disable=broad-except
             report_error(err)
@@ -697,122 +821,77 @@ def send_report(self):
         items = OrderedDict()
         for feature in self.featuremodel:
             if isinstance(feature, DiscreteDescriptor):
-                items[feature.name] = "{} (categorical with values {}{})".format(
-                    feature.expression, feature.values,
-                    "; ordered" * feature.ordered)
+                desc = "categorical"
+                if feature.values:
+                    desc += " with values " \
+                            + ", ".join(f"'{val}'" for val in feature.values)
+                if feature.ordered:
+                    desc += "; ordered"
             elif isinstance(feature, ContinuousDescriptor):
-                items[feature.name] = "{} (numeric)".format(feature.expression)
+                desc = "numeric"
             elif isinstance(feature, DateTimeDescriptor):
-                items[feature.name] = "{} (date/time)".format(feature.expression)
+                desc = "date/time"
             else:
-                items[feature.name] = "{} (text)".format(feature.expression)
+                desc = "text"
+            items[feature.name] = f"{feature.expression} ({desc})"
         self.report_items(
             report.plural("Constructed feature{s}", len(items)), items)
 
+    def fix_expressions(self):
+        dlg = QMessageBox(
+            QMessageBox.Question,
+            "Fix Expressions",
+            "This widget's behaviour has changed. Values of categorical "
+            "variables are now inserted as their textual representations "
+            "(strings); previously they appeared as integer numbers, with an "
+            "attribute '.value' that contained the text.\n\n"
+            "The widget currently runs in compatibility mode. After "
+            "expressions are updated, manually check for their correctness.")
+        dlg.addButton("Update", QMessageBox.ApplyRole)
+        dlg.addButton("Cancel", QMessageBox.RejectRole)
+        if dlg.exec() == QMessageBox.RejectRole:
+            return
 
-def freevars(exp, env):
-    """
-    Return names of all free variables in a parsed (expression) AST.
-
-    Parameters
-    ----------
-    exp : ast.AST
-        An expression ast (ast.parse(..., mode="single"))
-    env : List[str]
-        Environment
-
-    Returns
-    -------
-    freevars : List[str]
-
-    See also
-    --------
-    ast
-
-    """
-    # pylint: disable=too-many-return-statements,too-many-branches
-    etype = type(exp)
-    if etype in [ast.Expr, ast.Expression]:
-        return freevars(exp.body, env)
-    elif etype == ast.BoolOp:
-        return sum((freevars(v, env) for v in exp.values), [])
-    elif etype == ast.BinOp:
-        return freevars(exp.left, env) + freevars(exp.right, env)
-    elif etype == ast.UnaryOp:
-        return freevars(exp.operand, env)
-    elif etype == ast.Lambda:
-        args = exp.args
-        assert isinstance(args, ast.arguments)
-        argnames = [a.arg for a in args.args]
-        argnames += [args.vararg.arg] if args.vararg else []
-        argnames += [a.arg for a in args.kwonlyargs] if args.kwonlyargs else []
-        argnames += [args.kwarg] if args.kwarg else []
-        return freevars(exp.body, env + argnames)
-    elif etype == ast.IfExp:
-        return (freevars(exp.test, env) + freevars(exp.body, env) +
-                freevars(exp.orelse, env))
-    elif etype == ast.Dict:
-        return sum((freevars(v, env)
-                    for v in chain(exp.keys, exp.values)), [])
-    elif etype == ast.Set:
-        return sum((freevars(v, env) for v in exp.elts), [])
-    elif etype in [ast.SetComp, ast.ListComp, ast.GeneratorExp, ast.DictComp]:
-        env_ext = []
-        vars_ = []
-        for gen in exp.generators:
-            target_names = freevars(gen.target, [])  # assigned names
-            vars_iter = freevars(gen.iter, env)
-            env_ext += target_names
-            vars_ifs = list(chain(*(freevars(ifexp, env + target_names)
-                                    for ifexp in gen.ifs or [])))
-            vars_ += vars_iter + vars_ifs
-
-        if etype == ast.DictComp:
-            vars_ = (freevars(exp.key, env_ext) +
-                     freevars(exp.value, env_ext) +
-                     vars_)
-        else:
-            vars_ = freevars(exp.elt, env + env_ext) + vars_
-        return vars_
-    # Yield, YieldFrom???
-    elif etype == ast.Compare:
-        return sum((freevars(v, env)
-                    for v in [exp.left] + exp.comparators), [])
-    elif etype == ast.Call:
-        return sum(map(lambda e: freevars(e, env),
-                       chain([exp.func],
-                             exp.args or [],
-                             [k.value for k in exp.keywords or []])),
-                   [])
-    elif etype == ast.Starred:
-        # a 'starred' call parameter (e.g. a and b in `f(x, *a, *b)`
-        return freevars(exp.value, env)
-    elif etype in [ast.Num, ast.Str, ast.Ellipsis, ast.Bytes, ast.NameConstant]:
-        return []
-    elif etype == ast.Constant:
-        return []
-    elif etype == ast.Attribute:
-        return freevars(exp.value, env)
-    elif etype == ast.Subscript:
-        return freevars(exp.value, env) + freevars(exp.slice, env)
-    elif etype == ast.Name:
-        return [exp.id] if exp.id not in env else []
-    elif etype == ast.List:
-        return sum((freevars(e, env) for e in exp.elts), [])
-    elif etype == ast.Tuple:
-        return sum((freevars(e, env) for e in exp.elts), [])
-    elif etype == ast.Slice:
-        return sum((freevars(e, env)
-                    for e in filter(None, [exp.lower, exp.upper, exp.step])),
-                   [])
-    elif etype == ast.ExtSlice:
-        return sum((freevars(e, env) for e in exp.dims), [])
-    elif etype == ast.Index:
-        return freevars(exp.value, env)
-    elif etype == ast.keyword:
-        return freevars(exp.value, env)
-    else:
-        raise ValueError(exp)
+        def fixer(mo):
+            var = domain[mo.group(2)]
+            if mo.group(3) == ".value":  # uses string; remove `.value`
+                return "".join(mo.group(1, 2, 4))
+            # Uses ints: get them by indexing
+            return mo.group(1) + "{" + \
+                   ", ".join(f"'{val}': {i}"
+                             for i, val in enumerate(var.values)) + \
+                   f"}}[{var.name}]" + mo.group(4)
+
+        domain = self.data.domain
+        disc_vars = "|".join(f"{var.name}"
+                             for var in chain(domain.variables, domain.metas)
+                             if var.is_discrete)
+        expr = re.compile(r"(^|\W)(" + disc_vars + r")(\.value)?(\W|$)")
+        self.descriptors[:] = [
+            descriptor._replace(
+                expression=expr.sub(fixer, descriptor.expression))
+            for descriptor in self.descriptors]
+
+        self.expressions_with_values = False
+        self.fix_button.hide()
+        index = self.currentIndex
+        self.featuremodel[:] = list(self.descriptors)
+        self.setCurrentIndex(index)
+        self.apply()
+
+    @classmethod
+    def migrate_context(cls, context, version):
+        if version is None or version < 2:
+            used_vars = set(chain(*(
+                freevars(ast.parse(descriptor.expression, mode="eval"), [])
+                for descriptor in context.values["descriptors"]
+                if descriptor.expression)))
+            disc_vars = {name
+                         for (name, vtype) in chain(context.attributes.items(),
+                                                    context.metas.items())
+                         if vtype == 1}
+            if used_vars & disc_vars:
+                context.values["expressions_with_values"] = True
 
 
 def validate_exp(exp):
@@ -882,12 +961,12 @@ def validate_exp(exp):
         raise ValueError(exp)
 
 
-def construct_variables(descriptions, data):
+def construct_variables(descriptions, data, use_values=False):
     # subs
     variables = []
     source_vars = data.domain.variables + data.domain.metas
     for desc in descriptions:
-        desc, func = bind_variable(desc, source_vars, data)
+        desc, func = bind_variable(desc, source_vars, data, use_values)
         var = make_variable(desc, func)
         variables.append(var)
     return variables
@@ -900,7 +979,7 @@ def sanitized_name(name):
     return sanitized
 
 
-def bind_variable(descriptor, env, data):
+def bind_variable(descriptor, env, data, use_values):
     """
     (descriptor, env) ->
         (descriptor, (instance -> value) | (table -> value list))
@@ -921,7 +1000,8 @@ def bind_variable(descriptor, env, data):
 
     if isinstance(descriptor, DiscreteDescriptor):
         if not descriptor.values:
-            str_func = FeatureFunc(descriptor.expression, source_vars)
+            str_func = FeatureFunc(descriptor.expression, source_vars,
+                                   use_values=use_values)
             values = sorted({str(x) for x in str_func(data)})
             values = {name: i for i, name in enumerate(values)}
             descriptor = descriptor._replace(values=values)
@@ -943,7 +1023,8 @@ def cast(e):  # pylint: disable=function-redefined
                 return np.nan
             return parse(e)
 
-    func = FeatureFunc(descriptor.expression, source_vars, values, cast)
+    func = FeatureFunc(descriptor.expression, source_vars, values, cast,
+                       use_values=use_values)
     return descriptor, func
 
 
@@ -1078,7 +1159,7 @@ class FeatureFunc:
         A function for casting the expressions result to the appropriate
         type (e.g. string representation of date/time variables to floats)
     """
-    def __init__(self, expression, args, extra_env=None, cast=None):
+    def __init__(self, expression, args, extra_env=None, cast=None, use_values=False):
         self.expression = expression
         self.args = args
         self.extra_env = dict(extra_env or {})
@@ -1086,15 +1167,17 @@ def __init__(self, expression, args, extra_env=None, cast=None):
                                 [name for name, _ in args], self.extra_env)
         self.cast = cast
         self.mask_exceptions = True
+        self.use_values = use_values
 
     def __call__(self, instance, *_):
         if isinstance(instance, Orange.data.Table):
             return [self(inst) for inst in instance]
         else:
             try:
-                args = [str(instance[var])
-                        if instance.domain[var].is_string else instance[var]
-                        for _, var in self.args]
+                args = [str(instance[var]) if var.is_string
+                    else var.values[int(instance[var])] if var.is_discrete and not self.use_values
+                    else instance[var]
+                    for _, var in self.args]
                 y = self.func(*args)
             # user's expression can contain arbitrary errors
             # this also covers missing attributes


Test patch:
diff --git a/Orange/widgets/data/tests/test_owfeatureconstructor.py b/Orange/widgets/data/tests/test_owfeatureconstructor.py
--- a/Orange/widgets/data/tests/test_owfeatureconstructor.py
+++ b/Orange/widgets/data/tests/test_owfeatureconstructor.py
@@ -5,9 +5,12 @@
 import math
 import pickle
 import copy
+from unittest.mock import patch, Mock
 
 import numpy as np
 
+from orangewidget.settings import Context
+
 from Orange.data import (Table, Domain, StringVariable,
                          ContinuousVariable, DiscreteVariable, TimeVariable)
 from Orange.widgets.tests.base import WidgetTest
@@ -94,7 +97,7 @@ def test_construct_variables_datetime(self):
                                      data.domain.metas))
         self.assertTrue(isinstance(data.domain[name], TimeVariable))
         for row in data:
-            self.assertEqual("2019-07-{:02}".format(int(row["MEDV"] / 3)),
+            self.assertEqual(f"2019-07-{int(row['MEDV'] / 3):02}",
                              str(row["Date"])[:10])
 
     def test_construct_variables_string(self):
@@ -366,7 +369,7 @@ def test_discrete_no_values(self):
 
         discreteFeatureEditor.valuesedit.setText("A")
         discreteFeatureEditor.nameedit.setText("D1")
-        discreteFeatureEditor.expressionedit.setText("iris")
+        discreteFeatureEditor.expressionedit.setText("1")
         self.widget.addFeature(
             discreteFeatureEditor.editorData()
         )
@@ -374,6 +377,125 @@ def test_discrete_no_values(self):
         self.widget.apply()
         self.assertTrue(self.widget.Error.more_values_needed.is_shown())
 
+    @patch("Orange.widgets.data.owfeatureconstructor.QMessageBox")
+    def test_fix_values(self, msgbox):
+        w = self.widget
+
+        msgbox.ApplyRole, msgbox.RejectRole = object(), object()
+        msgbox.return_value = Mock()
+        dlgexec = msgbox.return_value.exec = Mock()
+
+        v = [DiscreteVariable(name, values=tuple("abc"))
+             for name in ("ana", "berta", "cilka")]
+        domain = Domain(v, [])
+        self.send_signal(w.Inputs.data, Table.from_numpy(domain, [[0, 1, 2]]))
+
+        w.descriptors = [StringDescriptor(
+            "y", "ana.value + berta.value + cilka.value")]
+
+        # Reject fixing - no changes
+        dlgexec.return_value=msgbox.RejectRole
+        w.fix_expressions()
+        self.assertEqual(w.descriptors[0].expression,
+                         "ana.value + berta.value + cilka.value")
+
+        dlgexec.return_value = Mock(return_value=msgbox.AcceptRole)
+
+        w.fix_expressions()
+        self.assertEqual(w.descriptors[0].expression, "ana + berta + cilka")
+
+        w.descriptors = [StringDescriptor(
+            "y", "ana.value + dani.value + cilka.value")]
+        with patch.object(w, "apply"):  # dani doesn't exist and will fail
+            w.fix_expressions()
+        self.assertEqual(w.descriptors[0].expression,
+                         "ana + dani.value + cilka")
+
+        w.descriptors = [ContinuousDescriptor("y", "sqrt(berta)", 1)]
+        w.fix_expressions()
+        self.assertEqual(w.descriptors[0].expression,
+                         "sqrt({'a': 0, 'b': 1, 'c': 2}[berta])")
+
+    def test_migration_discrete_strings(self):
+        v = [DiscreteVariable("Ana", values=tuple("012")),
+             ContinuousVariable("Cilka")]
+        domain = Domain(v)
+        data = Table.from_numpy(domain, [[1, 3.14]])
+
+        settings_w_discrete = {
+            "context_settings":
+            [Context(
+                attributes=dict(Ana=1, Cilka=2), metas={},
+                values=dict(
+                    descriptors=[
+                        ContinuousDescriptor("y", "Ana + int(Cilka)", 1),
+                        StringDescriptor("u", "Ana.value + 'X'")
+                    ],
+                    currentIndex=0)
+             )]
+        }
+        widget = self.create_widget(OWFeatureConstructor, settings_w_discrete)
+        self.send_signal(widget.Inputs.data, data)
+        self.assertTrue(widget.expressions_with_values)
+        self.assertFalse(widget.fix_button.isHidden())
+        out = self.get_output(widget.Outputs.data)
+        np.testing.assert_almost_equal(out.X, [[1, 3.14, 4]])
+        np.testing.assert_equal(out.metas, [["1X"]])
+
+        settings_no_discrete = {
+            "context_settings":
+            [Context(
+                attributes=dict(Ana=1, Cilka=2), metas={},
+                values=dict(
+                    descriptors=[
+                        ContinuousDescriptor("y", "int(Cilka)", 1),
+                    ],
+                    currentIndex=0)
+             )]
+        }
+        widget = self.create_widget(OWFeatureConstructor, settings_no_discrete)
+        self.send_signal(widget.Inputs.data, data)
+        self.assertFalse(widget.expressions_with_values)
+        self.assertTrue(widget.fix_button.isHidden())
+        out = self.get_output(widget.Outputs.data)
+        np.testing.assert_almost_equal(out.X, [[1, 3.14, 3]])
+
+        widget = self.create_widget(OWFeatureConstructor, settings_w_discrete)
+        self.send_signal(widget.Inputs.data, data)
+        self.assertTrue(widget.expressions_with_values)
+        self.assertFalse(widget.fix_button.isHidden())
+        self.send_signal(widget.Inputs.data, None)
+        self.assertFalse(widget.expressions_with_values)
+        self.assertTrue(widget.fix_button.isHidden())
+
+    def test_report(self):
+        settings = {
+            "context_settings":
+                [Context(
+                    attributes=dict(x=2, y=2, z=2), metas={},
+                    values=dict(
+                        descriptors=[
+                            ContinuousDescriptor("a", "x + 2", 1),
+                            DiscreteDescriptor("b", "x < 3", (), False),
+                            DiscreteDescriptor("c", "x > 15", (), True),
+                            DiscreteDescriptor("d", "y > x", ("foo", "bar"), False),
+                            DiscreteDescriptor("e", "x ** 2 + y == 5", ("foo", "bar"), True),
+                            StringDescriptor("f", "str(x)"),
+                            DateTimeDescriptor("g", "z")
+                        ],
+                        currentIndex=0)
+                )]
+        }
+
+        w = self.create_widget(OWFeatureConstructor, settings)
+        v = [ContinuousVariable(name) for name in "xyz"]
+        domain = Domain(v, [])
+        self.send_signal(w.Inputs.data, Table.from_numpy(domain, [[0, 1, 2]]))
+        w.report_items = Mock()
+        w.send_report()
+        args = w.report_items.call_args[0][1]
+        self.assertEqual(list(args), list("abcdefg"))
+
 
 class TestFeatureEditor(unittest.TestCase):
     def test_has_functions(self):