Repository: beeware/toga

Issue:
Cocoa MultilineTextInput.value returns an ObjCStrInstance
```
rubicon-objc       0.4.5
toga-cocoa         0.3.0
toga-core          0.3.0
```

```pycon
Python 3.8.10 (v3.8.10:3d8993a744, May  3 2021, 09:09:08) 
[Clang 12.0.5 (clang-1205.0.22.9)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import toga
>>> mti = toga.MultilineTextInput(value="hello")
[Cocoa] Not implemented: MultilineTextInput.set_on_change()
>>> mti.value
<rubicon.objc.collections.ObjCStrInstance 0x1110204c0: NSBigMutableString at 0x111639ae0: hello>
```

This can cause apparently impossible situations like a string changing its value:
```pycon
>>> x = mti.value
>>> print(x)
hello
>>> mti.value = "world"
>>> print(x)
world
```

When we come to do the backend tests for this widget, or any other widget with string attributes, we should assert that the value returned through the Toga public API is actually a Python `str`.


Discussion:
I just ran into this. 

TextInput casts the value to `str`:
https://github.com/beeware/toga/blob/main/cocoa/src/toga_cocoa/widgets/textinput.py#L211-L212

while MultilineTextInput does not:
https://github.com/beeware/toga/blob/main/cocoa/src/toga_cocoa/widgets/multilinetextinput.py#L78-L79

Seems like a simple fix; are you waiting to address this during the widget audit?
It looks like we missed this during the audit. We should go through all the widget properties that return strings, and add `isinstance` checks to the testbed.

Resolving patch:
diff --git a/cocoa/src/toga_cocoa/widgets/multilinetextinput.py b/cocoa/src/toga_cocoa/widgets/multilinetextinput.py
--- a/cocoa/src/toga_cocoa/widgets/multilinetextinput.py
+++ b/cocoa/src/toga_cocoa/widgets/multilinetextinput.py
@@ -54,7 +54,7 @@ def create(self):
         self.add_constraints()
 
     def get_placeholder(self):
-        return self.native_text.placeholderString
+        return str(self.native_text.placeholderString)
 
     def set_placeholder(self, value):
         self.native_text.placeholderString = value
@@ -73,7 +73,7 @@ def set_readonly(self, value):
         self.native_text.editable = not value
 
     def get_value(self):
-        return self.native_text.string
+        return str(self.native_text.string)
 
     def set_value(self, value):
         self.native_text.string = value
diff --git a/cocoa/src/toga_cocoa/widgets/textinput.py b/cocoa/src/toga_cocoa/widgets/textinput.py
--- a/cocoa/src/toga_cocoa/widgets/textinput.py
+++ b/cocoa/src/toga_cocoa/widgets/textinput.py
@@ -178,7 +178,7 @@ def set_readonly(self, value):
         self.native.selectable = True
 
     def get_placeholder(self):
-        return self.native.cell.placeholderString
+        return str(self.native.cell.placeholderString)
 
     def set_placeholder(self, value):
         self.native.cell.placeholderString = value
diff --git a/iOS/src/toga_iOS/widgets/multilinetextinput.py b/iOS/src/toga_iOS/widgets/multilinetextinput.py
--- a/iOS/src/toga_iOS/widgets/multilinetextinput.py
+++ b/iOS/src/toga_iOS/widgets/multilinetextinput.py
@@ -117,7 +117,7 @@ def constrain_placeholder_label(self):
         )
 
     def get_placeholder(self):
-        return self.placeholder_label.text
+        return str(self.placeholder_label.text)
 
     def set_placeholder(self, value):
         self.placeholder_label.text = value
@@ -129,7 +129,7 @@ def set_readonly(self, value):
         self.native.editable = not value
 
     def get_value(self):
-        return self.native.text
+        return str(self.native.text)
 
     def set_value(self, value):
         self.native.text = value


Test patch:
diff --git a/testbed/tests/widgets/properties.py b/testbed/tests/widgets/properties.py
--- a/testbed/tests/widgets/properties.py
+++ b/testbed/tests/widgets/properties.py
@@ -108,6 +108,7 @@ async def test_text(widget, probe):
         widget.text = text
         await probe.redraw(f"Widget text should be {str(text)!r}")
 
+        assert isinstance(widget.text, str)
         assert widget.text == str(text)
         assert probe.text == str(text)
 
@@ -118,6 +119,7 @@ async def test_text_value(widget, probe):
         widget.value = text
         await probe.redraw(f"Widget value should be {str(text)!r}")
 
+        assert isinstance(widget.value, str)
         assert widget.value == str(text)
         assert probe.value == str(text)
 
@@ -129,6 +131,7 @@ async def test_placeholder(widget, probe):
     widget.value = "Hello"
     widget.placeholder = "placeholder"
     await probe.redraw("Widget placeholder should not be visible")
+    assert isinstance(widget.placeholder, str)
     assert widget.value == "Hello"
     assert widget.placeholder == "placeholder"
     assert probe.value == "Hello"
diff --git a/testbed/tests/widgets/test_button.py b/testbed/tests/widgets/test_button.py
--- a/testbed/tests/widgets/test_button.py
+++ b/testbed/tests/widgets/test_button.py
@@ -40,6 +40,7 @@ async def test_text(widget, probe):
         await probe.redraw(f"Button text should be {text}")
 
         # Text after a newline will be stripped.
+        assert isinstance(widget.text, str)
         expected = str(text).split("\n")[0]
         assert widget.text == expected
         assert probe.text == expected
diff --git a/testbed/tests/widgets/test_switch.py b/testbed/tests/widgets/test_switch.py
--- a/testbed/tests/widgets/test_switch.py
+++ b/testbed/tests/widgets/test_switch.py
@@ -37,6 +37,7 @@ async def test_text(widget, probe):
 
         # Text after a newline will be stripped.
         expected = str(text).split("\n")[0]
+        assert isinstance(widget.text, str)
         assert widget.text == expected
         assert probe.text == expected
         assert probe.height == initial_height