Repository: Textualize/textual

Issue:
Validators and watchers should run hand-in-hand
When `init=True` in a `reactive` attribute, the `watch_` method for that attribute run, but the `validate_` method does not.
This seems rather confusing and we think that validators and watchers should always run hand-in-hand.
If the watcher is called, you should be confident that the incoming `new_value` has already been validated by the `validate_` method.


Discussion:

Resolving patch:
diff --git a/src/textual/reactive.py b/src/textual/reactive.py
--- a/src/textual/reactive.py
+++ b/src/textual/reactive.py
@@ -177,8 +177,8 @@ def __set__(self, obj: Reactable, value: ReactiveType) -> None:
         validate_function = getattr(obj, f"validate_{name}", None)
         # Check if this is the first time setting the value
         first_set = getattr(obj, f"__first_set_{self.internal_name}", True)
-        # Call validate, but not on first set.
-        if callable(validate_function) and not first_set:
+        # Call validate
+        if callable(validate_function):
             value = validate_function(value)
         # If the value has changed, or this is the first time setting the value
         if current_value != value or first_set or self._always_update:


Test patch:
diff --git a/tests/test_reactive.py b/tests/test_reactive.py
--- a/tests/test_reactive.py
+++ b/tests/test_reactive.py
@@ -2,9 +2,8 @@
 
 import pytest
 
-from textual.app import App, ComposeResult
+from textual.app import App
 from textual.reactive import reactive, var
-from textual.widget import Widget
 
 OLD_VALUE = 5_000
 NEW_VALUE = 1_000_000
@@ -81,14 +80,16 @@ async def watch_count(self, old_value: int, new_value: int) -> None:
         try:
             await asyncio.wait_for(app.watcher_called_event.wait(), timeout=0.05)
         except TimeoutError:
-            pytest.fail("Async watcher wasn't called within timeout when reactive init = True")
+            pytest.fail(
+                "Async watcher wasn't called within timeout when reactive init = True")
 
     assert app.count == OLD_VALUE
     assert app.watcher_old_value == OLD_VALUE
     assert app.watcher_new_value == OLD_VALUE  # The value wasn't changed
 
 
-@pytest.mark.xfail(reason="Reactive watcher is incorrectly always called the first time it is set, even if value is same [issue#1230]")
+@pytest.mark.xfail(
+    reason="Reactive watcher is incorrectly always called the first time it is set, even if value is same [issue#1230]")
 async def test_watch_init_false_always_update_false():
     class WatcherInitFalse(App):
         count = reactive(0, init=False)
@@ -173,20 +174,45 @@ def watch_value(self, new_value):
     assert app.watcher_called_with == OLD_VALUE
 
 
-@pytest.mark.xfail(reason="Validator methods not running when init=True [issue#1220]")
 async def test_validate_init_true():
     """When init is True for a reactive attribute, Textual should call the validator
     AND the watch method when the app starts."""
+    validator_call_count = 0
 
     class ValidatorInitTrue(App):
         count = var(5, init=True)
 
         def validate_count(self, value: int) -> int:
+            nonlocal validator_call_count
+            validator_call_count += 1
             return value + 1
 
     app = ValidatorInitTrue()
     async with app.run_test():
+        app.count = 5
         assert app.count == 6  # Validator should run, so value should be 5+1=6
+        assert validator_call_count == 1
+
+
+async def test_validate_init_true_set_before_dom_ready():
+    """When init is True for a reactive attribute, Textual should call the validator
+    AND the watch method when the app starts."""
+    validator_call_count = 0
+
+    class ValidatorInitTrue(App):
+        count = var(5, init=True)
+
+        def validate_count(self, value: int) -> int:
+            nonlocal validator_call_count
+            validator_call_count += 1
+            return value + 1
+
+    app = ValidatorInitTrue()
+    app.count = 5
+    async with app.run_test():
+        assert app.count == 6  # Validator should run, so value should be 5+1=6
+        assert validator_call_count == 1
+
 
 
 @pytest.mark.xfail(reason="Compute methods not called when init=True [issue#1227]")