Repository: Avaiga/taipy

Issue:
[Refactor] Informative error messages on Scenario build failure
### Description

The error message `raise InvalidScenario(scenario.id)`  when scenario building fails is not very informative, because it leads to something like:
```
taipy.core.exceptions.exceptions.InvalidScenario: SCENARIO_MS_analysis_afc4e19b-be39-4651-bc9d-11f7807fd3af
```
with Traceback pointing only at the Scenario Factory.


I suggest adding a warning to 
https://github.com/Avaiga/taipy/blob/9cc59f2fbd5ed3d261905923a9cb85dcd623e3f3/taipy/core/scenario/scenario.py#L608-L621

like:
```python
import warnings
warnings.warn( f"Graph directed: {nx.is_directed( dag )}, Cycle found at: {nx.cycles.find_cycle( dag )}" )
```
and
```python
if not (isinstance(left_node, DataNode) and isinstance(right_node, Task)) and 
   not (isinstance(left_node, Task) and isinstance(right_node, DataNode) ):
    warnings.warn(f"Edge between left node:{left_node.get_label()} and right node:{right_node.get_label()} does not connect {Task} and {DataNode}")
    return False
```

### Acceptance Criteria

- [ ] Any new code is covered by a unit tested.
- [ ] Check code coverage is at least 90%.


### Code of Conduct

- [X] I have checked the [existing issues](https://github.com/Avaiga/taipy/issues?q=is%3Aissue+).
- [X] I am willing to work on this issue (optional)

Discussion:
@jrobinAV: Hello @JosuaCarl 

Thank you for your issue. It's a great idea. Would you like to be assigned?

A few minor remarks:

1. In Taipy, we handle logs with a logger that you can retrieve using the following code:
    ```python
    from taipy.common.logger._taipy_logger import _TaipyLogger

    _logger = _TaipyLogger._get_logger()

    _logger.error("My error message!")
    _logger.warning("My warning.")
    _logger.info("My info")
    ```

2. They should be error messages and not warnings. Especially because we raise an exception in case it is inconsistent.
 
3. We have a similar but slightly different piece of code for sequences. It should be covered by the issue as well. https://github.com/Avaiga/taipy/blob/9cc59f2fbd5ed3d261905923a9cb85dcd623e3f3/taipy/core/sequence/sequence.py#L341-L355
@JosuaCarl: Yes, I would like to be assigned. Although I am not sure how much I will be able to do until the new year.@jrobinAV: > Yes, I would like to be assigned. Although I am not sure how much I will be able to do until the new year.

Thanks a lot! Don't worry, we are not in a rush for this issue.@JosuaCarl: Small question before I start:
The purpose of the `_is_constent` method is to evaluate consistency and return it. I suggested a warning, in order to enable the code to finish and pass on a boolean value (which is expected by following methods). Should I refactor the methods, that rely on `_is_constent`, such that an error is watched and raised later , resulting in equation to `False` or should the `_is_constent` method itself serve as the main Error handler, such that it can called without expecting a return and the code simply continues on, when no Error is raised ?@jrobinAV: That's a good question. 

I would keep the current behavior for both `is_consistent` methods. They should still return a boolean. The only difference would be that they log a meaningful error before returning False.
And we let the responsibility of raising an exception or not to the piece of code that calls the `is_consistent` method.@github-actions: This issue has been labelled as "🥶Waiting for contributor" because it has been inactive for more than 14 days. If you would like to continue working on this issue, please add another comment or create a PR that links to this issue. If a PR has already been created which refers to this issue, then you should explicitly mention this issue in the relevant PR. Otherwise, you will be unassigned in 14 days. For more information please refer to the contributing guidelines.@github-actions: This issue has been unassigned automatically because it has been marked as "🥶Waiting for contributor" for more than 14 days with no activity.

Solution patch:
diff --git a/.github/workflows/overall-tests.yml b/.github/workflows/overall-tests.yml
index ccf20c23c..a396c3ef9 100644
--- a/.github/workflows/overall-tests.yml
+++ b/.github/workflows/overall-tests.yml
@@ -20,9 +20,12 @@ jobs:
   coverage:
     timeout-minutes: 50
     runs-on: ubuntu-latest
+
     if: github.event_name == 'pull_request' && github.event.pull_request.base.repo.full_name == github.event.pull_request.head.repo.full_name
     steps:
       - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0  # Fetch full history to enable proper diff
 
       - uses: actions/setup-python@v5
         with:
@@ -38,13 +41,22 @@ jobs:
 
       - name: Pytest
         run: |
-          pipenv run pytest --cov=taipy --cov-report="xml:overall-coverage.xml" tests
+          python -m pip install xmltodict
+          pipenv run pytest --cov=taipy --cov-report=xml:${{ github.workspace }}/coverage.xml --cov-config=.coveragerc
 
-      - name: Coverage
-        uses: orgoro/coverage@v3.1
-        with:
-          coverageFile: overall-coverage.xml
-          token: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Fetch base branch
+        run: |
+          git fetch origin ${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}
+
+
+      - name: Check total project coverage
+        run: |
+          python tools/coverage_check.py check-total --coverage-file ${{ github.workspace }}/coverage.xml --threshold 80
+
+      - name: Check pull request coverage
+        run: |
+          python tools/coverage_check.py check-changed --coverage-file ${{ github.workspace }}/coverage.xml --threshold 80 --base-branch ${{ github.event.pull_request.base.ref }}
 
   overall-tests:
     needs: [partial-tests]
diff --git a/frontend/taipy-gui/src/components/Taipy/Chart.tsx b/frontend/taipy-gui/src/components/Taipy/Chart.tsx
index 86bebfc2e..a959679c2 100644
--- a/frontend/taipy-gui/src/components/Taipy/Chart.tsx
+++ b/frontend/taipy-gui/src/components/Taipy/Chart.tsx
@@ -294,7 +294,7 @@ const getData = (
     data: Record<string, TraceValueType>,
     additionalDatas: Array<Record<string, TraceValueType>>,
     idx: number
-) => (idx === 0 ? data : (idx <= additionalDatas.length ? additionalDatas[idx - 1]: undefined));
+) => (idx === 0 ? data : idx <= additionalDatas.length ? additionalDatas[idx - 1] : undefined);
 
 const Chart = (props: ChartProp) => {
     const {
@@ -506,10 +506,11 @@ const Chart = (props: ChartProp) => {
             return lastDataPl.current || [];
         }
         let changed = false;
+        let baseDataPl = (lastDataPl.current.length && lastDataPl.current[0]) || {};
         const newDataPl = config.traces.map((trace, idx) => {
-            const currentData = idx < lastDataPl.current.length ? lastDataPl.current[idx] : {};
+            const currentData = (idx < lastDataPl.current.length && lastDataPl.current[idx]) || baseDataPl;
             const dataKey = idx < dataKeys.length ? dataKeys[idx] : dataKeys[0];
-            const lData = idx < dataList.length ? dataList[idx] : dataList[0];
+            const lData = (idx < dataList.length && dataList[idx]) || dataList[0];
             if (!lData || isDataRefresh(lData) || !Object.keys(lData).length) {
                 return currentData;
             }
@@ -517,12 +518,12 @@ const Chart = (props: ChartProp) => {
                 idx < config.columns?.length ? config.columns[idx] : undefined,
                 config.decimators
             )[1];
-            if (!dataKey.startsWith(dtKey) || !dtKey.length) {
+            if (!dataKey.startsWith(dtKey)) {
                 return currentData;
             }
             changed = true;
             const datum = lData[dataKey];
-            const columns = config.columns[idx];
+            const columns = config.columns[idx] || config.columns[0];
             const ret = {
                 ...getArrayValue(config.options, idx, {}),
                 type: config.types[idx],
@@ -598,6 +599,9 @@ const Chart = (props: ChartProp) => {
             if (selectedMarker) {
                 ret.selected = { marker: selectedMarker };
             }
+            if (idx == 0) {
+                baseDataPl = ret;
+            }
             return ret as Data;
         });
         if (changed) {
diff --git a/pyproject.toml b/pyproject.toml
index 943381566..837001344 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -51,6 +51,7 @@ exclude = [
     "build",
     "dist",
     "releases",
+    "tools",
     ".venv",
     ".mypy_cache",
     ".pytest_cache",
diff --git a/taipy/core/scenario/scenario.py b/taipy/core/scenario/scenario.py
index bdb565296..1a7c39141 100644
--- a/taipy/core/scenario/scenario.py
+++ b/taipy/core/scenario/scenario.py
@@ -17,6 +17,7 @@ from typing import Any, Callable, Dict, List, Optional, Set, Union
 import networkx as nx
 
 from taipy.common.config.common._validate_id import _validate_id
+from taipy.common.logger._taipy_logger import _TaipyLogger
 
 from .._entity._entity import _Entity
 from .._entity._labeled import _Labeled
@@ -106,6 +107,8 @@ class Scenario(_Entity, Submittable, _Labeled):
     id: ScenarioId
     """The unique identifier of this scenario."""
 
+    _logger = _TaipyLogger._get_logger()
+
     def __init__(
         self,
         config_id: str,
@@ -611,12 +614,32 @@ class Scenario(_Entity, Submittable, _Labeled):
         if dag.number_of_nodes() == 0:
             return True
         if not nx.is_directed_acyclic_graph(dag):
+            self._logger.error(f'The DAG of scenario "{self.id}" is not a directed acyclic graph')
             return False
         for left_node, right_node in dag.edges:
             if (isinstance(left_node, DataNode) and isinstance(right_node, Task)) or (
                 isinstance(left_node, Task) and isinstance(right_node, DataNode)
             ):
                 continue
+
+            left_node_desc = (
+                f'{left_node.__class__.__name__} "{left_node.get_label()}"'
+                if isinstance(left_node, _Labeled)
+                else left_node.__class__.__name__
+                if left_node
+                else "None"
+            )
+            right_node_desc = (
+                f'{right_node.__class__.__name__} "{right_node.get_label()}"'
+                if isinstance(right_node, _Labeled)
+                else right_node.__class__.__name__
+                if right_node
+                else "None"
+            )
+            self._logger.error(
+                f'Invalid edge detected in scenario "{self.id}": left node {left_node_desc} and right node '
+                f"{right_node_desc} must connect a Task and a DataNode"
+            )
             return False
         return True
 
diff --git a/taipy/core/sequence/sequence.py b/taipy/core/sequence/sequence.py
index b07d0e348..2e8d1c33e 100644
--- a/taipy/core/sequence/sequence.py
+++ b/taipy/core/sequence/sequence.py
@@ -16,6 +16,7 @@ from typing import Any, Callable, Dict, List, Optional, Set, Union
 import networkx as nx
 
 from taipy.common.config.common._validate_id import _validate_id
+from taipy.common.logger._taipy_logger import _TaipyLogger
 
 from .._entity._entity import _Entity
 from .._entity._labeled import _Labeled
@@ -118,6 +119,8 @@ class Sequence(_Entity, Submittable, _Labeled):
     _MANAGER_NAME = "sequence"
     __CHECK_INIT_DONE_ATTR_NAME = "_init_done"
 
+    _logger = _TaipyLogger._get_logger()
+
     id: SequenceId
     """The unique identifier of the sequence."""
 
@@ -343,15 +346,37 @@ class Sequence(_Entity, Submittable, _Labeled):
         if dag.number_of_nodes() == 0:
             return True
         if not nx.is_directed_acyclic_graph(dag):
+            self._logger.error(f'The DAG of sequence "{self.id}" is not a directed acyclic graph')
             return False
         if not nx.is_weakly_connected(dag):
+            self._logger.error(f'The DAG of sequence "{self.id}" is not weakly connected')
             return False
         for left_node, right_node in dag.edges:
             if (isinstance(left_node, DataNode) and isinstance(right_node, Task)) or (
                 isinstance(left_node, Task) and isinstance(right_node, DataNode)
             ):
                 continue
+
+            left_node_desc = (
+                f'{left_node.__class__.__name__} "{left_node.get_label()}"'
+                if isinstance(left_node, _Labeled)
+                else left_node.__class__.__name__
+                if left_node
+                else "None"
+            )
+            right_node_desc = (
+                f'{right_node.__class__.__name__} "{right_node.get_label()}"'
+                if isinstance(right_node, _Labeled)
+                else right_node.__class__.__name__
+                if right_node
+                else "None"
+            )
+            self._logger.error(
+                f'Invalid edge detected in sequence "{self.id}": left node {left_node_desc} and right node '
+                f"{right_node_desc} must connect a Task and a DataNode"
+            )
             return False
+
         return True
 
     def _get_tasks(self) -> Dict[str, Task]:
diff --git a/tools/coverage_check.py b/tools/coverage_check.py
new file mode 100644
index 000000000..4cd307d14
--- /dev/null
+++ b/tools/coverage_check.py
@@ -0,0 +1,101 @@
+# Copyright 2021-2025 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+import argparse
+import xmltodict
+import sys
+import subprocess
+
+
+def check_total_coverage(coverage_file, threshold=80):
+    """Check the total project coverage."""
+    with open(coverage_file) as f:
+        data = xmltodict.parse(f.read())
+    total_coverage = float(data['coverage']['@line-rate']) * 100
+    print(f"Total Coverage: {total_coverage:.2f}%")
+    if total_coverage < threshold:
+        print(f"Total project coverage is below {threshold}%: {total_coverage:.2f}%")
+        sys.exit(1)
+
+
+def check_changed_files_coverage(coverage_file, changed_files, threshold=80):
+    """Check the coverage of changed files."""
+    with open(coverage_file) as f:
+        data = xmltodict.parse(f.read())
+
+    # Handle multiple packages in the coverage report
+    packages = data["coverage"]["packages"]["package"]
+    if not isinstance(packages, list):
+        packages = [packages]
+
+    # Extract coverage data for all files
+    files = {}
+    for package in packages:
+        classes = package["classes"]["class"]
+        if not isinstance(classes, list):
+            classes = [classes]
+        for cls in classes:
+            files[cls["@filename"]] = float(cls["@line-rate"]) * 100
+    qty = 0
+    sum_coverage = 0
+    for file in changed_files:
+        if file in files:
+            coverage = files[file]
+            print(f"Coverage for {file}: {coverage:.2f}%")
+            sum_coverage += coverage
+            qty += 1
+        else:
+            print(f"No coverage data found for {file}")
+
+    if sum_coverage/qty < threshold:
+        print(f"Coverage for changed files is below {threshold}%: {sum_coverage/qty:.2f}%")
+        sys.exit(1)
+    print(f"Coverage for changed files: {sum_coverage/qty:.2f}%")
+
+
+def get_changed_files(base_branch):
+    """Get the list of changed Python files in the pull request."""
+    try:
+        result = subprocess.run(
+            ['git', 'diff', '--name-only', f"origin/{base_branch}", '--', '*.py'],
+            capture_output=True,
+            text=True,
+            check=True,
+        )
+        changed_files = [
+            file.replace("taipy/", "") for file in result.stdout.strip().splitlines() if not file.startswith(('tests/', 'tools/'))
+        ]
+        return changed_files
+    except subprocess.CalledProcessError as e:
+        print(f"Error fetching changed files: {e}")
+        sys.exit(1)
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description="Coverage check script.")
+    parser.add_argument('command', choices=['check-total', 'check-changed'], help="Command to execute")
+    parser.add_argument('--coverage-file', default='coverage.xml', help="Path to the coverage XML file")
+    parser.add_argument('--threshold', type=float, default=80, help="Coverage threshold percentage")
+    parser.add_argument('--base-branch', help="Base branch for comparing changed files")
+
+    args = parser.parse_args()
+
+    if args.command == 'check-total':
+        check_total_coverage(args.coverage_file, args.threshold)
+    elif args.command == 'check-changed':
+        if not args.base_branch:
+            print("Error: --base-branch is required for check-changed")
+            sys.exit(1)
+        changed_files = get_changed_files(args.base_branch)
+        if not changed_files:
+            print("No relevant Python files changed.")
+            sys.exit(0)
+        check_changed_files_coverage(args.coverage_file, changed_files, args.threshold)


Test patch:
diff --git a/tests/core/scenario/test_scenario.py b/tests/core/scenario/test_scenario.py
index 72899c20e..515fe369f 100644
--- a/tests/core/scenario/test_scenario.py
+++ b/tests/core/scenario/test_scenario.py
@@ -1538,3 +1538,25 @@ def test_check_consistency():
     task_5 = Task("bob", {}, print, [data_node_5], [data_node_3], TaskId("t5"))
     scenario_9 = Scenario("scenario_9", [task_1, task_2, task_3, task_4, task_5], {}, [], scenario_id=ScenarioId("s8"))
     assert scenario_9._is_consistent()
+
+
+def test_check_inconsistency(caplog):
+    class FakeDataNode:
+        config_id = "config_id_of_a_fake_dn"
+
+    data_node_1 = InMemoryDataNode("foo", Scope.SCENARIO, "s1")
+    data_node_2 = InMemoryDataNode("bar", Scope.SCENARIO, "s2")
+
+    task_1 = Task("grault", {}, print, [data_node_1, data_node_2], [FakeDataNode()], TaskId("t1"))
+    task_2 = Task("garply", {}, print, [data_node_1], [data_node_2], id=TaskId("t2"))
+    scenario_1 = Scenario("scenario_1", [task_1, task_2], {}, [], scenario_id=ScenarioId("s1"))
+    assert not scenario_1._is_consistent()
+    assert (
+        'Invalid edge detected in scenario "s1": left node Task "grault" and right node FakeDataNode'
+        " must connect a Task and a DataNode" in caplog.text
+    )
+
+    task_3 = Task("waldo", {}, print, [data_node_2], [data_node_1], id=TaskId("t3"))
+    scenario_2 = Scenario("scenario_2", [task_2, task_3], {}, [], scenario_id=ScenarioId("s2"))
+    assert not scenario_2._is_consistent()
+    assert 'The DAG of scenario "s2" is not a directed acyclic graph' in caplog.text
diff --git a/tests/core/sequence/test_sequence.py b/tests/core/sequence/test_sequence.py
index 9b78cb53d..f5cb7ca53 100644
--- a/tests/core/sequence/test_sequence.py
+++ b/tests/core/sequence/test_sequence.py
@@ -30,6 +30,10 @@ from taipy.core.task._task_manager import _TaskManager
 from taipy.core.task.task import Task, TaskId
 
 
+class FakeDataNode:
+    config_id = "config_id_of_a_fake_dn"
+
+
 def test_sequence_equals():
     task_config = Config.configure_task("mult_by_3", print, [], None)
     scenario_config = Config.configure_scenario("scenario", [task_config])
@@ -143,7 +147,7 @@ def test_get_set_attribute():
         sequence.bar = "KeyAlreadyUsed"
 
 
-def test_check_consistency():
+def test_check_consistency(caplog):
     sequence_1 = Sequence({}, [], "name_1")
     assert sequence_1._is_consistent()
 
@@ -157,6 +161,7 @@ def test_check_consistency():
     task_3 = Task("tfoo", {}, print, [data_node_3], [data_node_3], TaskId("task_id_3"))
     sequence_3 = Sequence({}, [task_3], "name_3")
     assert not sequence_3._is_consistent()  # Not a dag
+    assert 'The DAG of sequence "name_3" is not a directed acyclic graph' in caplog.text
 
     input_4 = InMemoryDataNode("foo", Scope.SCENARIO)
     output_4 = InMemoryDataNode("bar", Scope.SCENARIO)
@@ -164,9 +169,7 @@ def test_check_consistency():
     task_4_2 = Task("tbar", {}, print, [output_4], [input_4], TaskId("task_id_4_2"))
     sequence_4 = Sequence({}, [task_4_1, task_4_2], "name_4")
     assert not sequence_4._is_consistent()  # Not a Dag
-
-    class FakeDataNode:
-        config_id = "config_id_of_a_fake_dn"
+    assert 'The DAG of sequence "name_4" is not a directed acyclic graph' in caplog.text
 
     input_6 = DataNode("foo", Scope.SCENARIO, "input_id_5")
     output_6 = DataNode("bar", Scope.SCENARIO, "output_id_5")
@@ -174,6 +177,10 @@ def test_check_consistency():
     task_6_2 = Task("tbar", {}, print, [output_6], [FakeDataNode()], TaskId("task_id_5_2"))
     sequence_6 = Sequence({}, [task_6_1, task_6_2], "name_5")
     assert not sequence_6._is_consistent()  # Not a DataNode
+    assert (
+        'Invalid edge detected in sequence "name_5": left node Task "tbar" and right node FakeDataNode '
+        "must connect a Task and a DataNode" in caplog.text
+    )
 
     intermediate_7 = DataNode("foo", Scope.SCENARIO, "intermediate_id_7")
     output_7 = DataNode("bar", Scope.SCENARIO, "output_id_7")
@@ -197,6 +204,7 @@ def test_check_consistency():
     task_9_2 = Task("tbar", {}, print, [input_9_2], [output_9_2], TaskId("task_id_9_2"))
     sequence_9 = Sequence({}, [task_9_1, task_9_2], "name_9")
     assert not sequence_9._is_consistent()  # Not connected
+    assert 'The DAG of sequence "name_9" is not weakly connected' in caplog.text
 
     input_10_1 = DataNode("foo", Scope.SCENARIO, "output_id_10_1")
     intermediate_10_1 = DataNode("bar", Scope.SCENARIO, "intermediate_id_10_1")