diff --git a/.clangd b/.clangd.in
similarity index 89%
rename from .clangd
rename to .clangd.in
index f11186d..e675b71 100644
--- a/.clangd
+++ b/.clangd.in
@@ -5,3 +5,5 @@
# Pin clangd to the tree that has both the library and the tests.
CompileFlags:
CompilationDatabase: build
+ Add:
+ - -I@CMAKE_CURRENT_SOURCE_DIR@/include
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5e28364..c9991d0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,6 +15,9 @@ jobs:
style:
runs-on: ubuntu-latest
+ env:
+ LLVM_VERSION: "21"
+
steps:
- uses: actions/checkout@v4
@@ -25,7 +28,18 @@ jobs:
- name: Install clang-tidy and Ninja
run: |
sudo apt-get update
- sudo apt-get install -y clang-tidy ninja-build
+ sudo apt-get install -y ninja-build lsb-release wget software-properties-common gnupg
+ wget -q https://apt.llvm.org/llvm.sh
+ chmod +x llvm.sh
+ sudo ./llvm.sh "$LLVM_VERSION"
+ sudo apt-get install -y "clang-tidy-$LLVM_VERSION"
+
+ - name: Report toolchain
+ run: |
+ "clang++-$LLVM_VERSION" --version
+ "clang-tidy-$LLVM_VERSION" --version
+ "clang++-$LLVM_VERSION" -std=c++23 -x c++ -E -dM /dev/null \
+ | grep -E '__cplusplus|__cpp_concepts'
- name: Install pre-commit
run: pip install pre-commit
@@ -42,8 +56,10 @@ jobs:
cmake -B build-lint -S . \
-G Ninja \
-DCMAKE_BUILD_TYPE=Debug \
+ -DCMAKE_CXX_COMPILER="clang++-$LLVM_VERSION" \
-DSTGX_BUILD_TESTS=OFF \
- -DSTGX_ENABLE_CLANG_TIDY=ON
+ -DSTGX_ENABLE_CLANG_TIDY=ON \
+ -DSTGX_CLANG_TIDY="clang-tidy-$LLVM_VERSION"
- name: Build with clang-tidy
run: cmake --build build-lint -j "$(nproc)"
diff --git a/.gitignore b/.gitignore
index 822c84f..59a97c3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,4 @@ out/
# clangd
.cache/
+.clangd
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 75a87b8..dc92fe0 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -19,6 +19,8 @@ repos:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
+ exclude: ^tests/data/invalid_bad_yaml\.yaml$
+
- id: check-added-large-files
- id: check-merge-conflict
- id: mixed-line-ending
diff --git a/CMakeLists.txt b/CMakeLists.txt
index e4bc0bc..23c6069 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,4 +1,4 @@
-cmake_minimum_required(VERSION 3.20)
+cmake_minimum_required(VERSION 3.21)
project(libstgx
VERSION 0.1.0
@@ -6,21 +6,25 @@ project(libstgx
LANGUAGES CXX
)
-include(cmake/ProjectSettings.cmake)
+# Developer conveniences
+if(PROJECT_IS_TOP_LEVEL)
+ include(cmake/ProjectSettings.cmake)
+endif()
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
-option(STGX_BUILD_TESTS "Build unit tests" ON)
+option(STGX_BUILD_TESTS "Build unit tests" ${PROJECT_IS_TOP_LEVEL})
option(STGX_BUILD_EXAMPLES "Build examples" OFF)
option(STGX_ENABLE_CLANG_TIDY "Enable clang-tidy on stgx targets" OFF)
+set(STGX_CLANG_TIDY "" CACHE STRING "clang-tidy executable to use (name or full path)")
-# --- dependencies -----------------------------------------------------------
+# --- dependencies
add_subdirectory(thirdparty)
-# --- library ----------------------------------------------------------------
+# --- library
file(GLOB_RECURSE libstg_sources
CONFIGURE_DEPENDS
@@ -44,9 +48,16 @@ target_link_libraries(stgx
target_compile_features(stgx PUBLIC cxx_std_23)
target_compile_options(stgx PRIVATE -Wall -Wextra -Wpedantic)
+# Carried on the target rather than picked up from ProjectSettings.cmake, so it
+# also holds when a consumer links stgx into a shared library.
+set_target_properties(stgx PROPERTIES POSITION_INDEPENDENT_CODE ON)
+
if(STGX_ENABLE_CLANG_TIDY)
find_program(CLANG_TIDY_EXE
- NAMES clang-tidy
+ NAMES
+ ${STGX_CLANG_TIDY}
+ clang-tidy-22 clang-tidy-21 clang-tidy-20 clang-tidy-19
+ clang-tidy
HINTS
"$ENV{HOMEBREW_PREFIX}/opt/llvm/bin"
/opt/homebrew/opt/llvm/bin
@@ -59,14 +70,14 @@ if(STGX_ENABLE_CLANG_TIDY)
endif()
-# --- examples ---------------------------------------------------------------
+# --- examples
if(STGX_BUILD_EXAMPLES)
add_subdirectory(examples)
endif()
-# --- tests ------------------------------------------------------------------
+# --- tests
if(STGX_BUILD_TESTS)
enable_testing()
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..121bf94
--- /dev/null
+++ b/README.md
@@ -0,0 +1,199 @@
+
+
+

+
+[](https://github.com/peplxx/libstgx/actions/workflows/ci.yml)
+
+
+
+
+
+
+**S**tate **T**ransition **G**raph e**X**tended — a C++ library for building, editing and
+validating state-transition graphs of schedules for real-time systems.
+
+---
+
+libstgx reads and writes the YAML that
+[state-graph-visualizer](https://github.com/peplxx/state-graph-visualizer) renders, and is the
+source of truth for that format — it validates stricter than the viewer does.
+
+
+## Add to project
+
+```cmake
+include(FetchContent)
+
+FetchContent_Declare(
+ libstgx
+ GIT_REPOSITORY https://github.com/peplxx/libstgx.git
+ GIT_TAG v0.1.0
+ GIT_SHALLOW TRUE
+)
+FetchContent_MakeAvailable(libstgx)
+
+target_link_libraries(my_app PRIVATE stgx::stgx)
+```
+
+**As a subdirectory** — submodule or vendored copy:
+
+```cmake
+add_subdirectory(libstgx)
+target_link_libraries(my_app PRIVATE stgx::stgx)
+```
+
+There is no `install()` / `find_package(stgx)` yet — both routes above go through
+`add_subdirectory`.
+
+## Building the library itself
+
+```bash
+cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug
+cmake --build build -j
+ctest --test-dir build --output-on-failure
+```
+
+CMake 3.21+ and a C++23 compiler (Apple Clang 17, GCC 13, Clang 17). Catch2 v3 is used for tests —
+taken from the system if present, otherwise fetched.
+
+| Option | Default | |
+|---|---|---|
+| `STGX_BUILD_TESTS` | on for a top-level build, off as a subproject | build the Catch2 test binary |
+| `STGX_BUILD_EXAMPLES` | `OFF` | build the two example programs |
+| `STGX_ENABLE_CLANG_TIDY` | `OFF` | run clang-tidy over the library sources |
+
+## Graphs and the visualizer format
+
+Everything below is the half of libstgx that faces
+[state-graph-visualizer](https://github.com/peplxx/state-graph-visualizer): a model of the picture
+— states, transitions, styling — and the YAML document the viewer reads.
+
+
+The API and the wire format
+
+
+
+### Usage
+
+```cpp
+#include
+
+#include
+
+int main() {
+ auto graph = stgx::GraphBuilder()
+ .system([](auto& s) { s.description("EDF").m(1).task(2, 5, "τ₁").task(3, 7, "τ₂"); })
+ .node("n00", [](auto& n) { n.initial().task(0, 0).task(0, 0).to("n10").to("n20"); })
+ .node("n10", [](auto& n) { n.task(2, 5, stgx::Release::Up).task(0, 0).to("n20"); })
+ .node("n20", [](auto& n) { n.task(0, 3, stgx::Release::Down).task(0, 0).loop_to("n00"); })
+ .build();
+
+ if (!graph) {
+ std::println("{}", graph.error().to_string());
+ return 1;
+ }
+ if (auto saved = stgx::save_yaml_file(*graph, "graph.yaml"); !saved) {
+ std::println("{}", saved.error().to_string());
+ return 1;
+ }
+}
+```
+> Graph is not checked until `build()`, which then reports everything that is wrong at once
+
+
+```cpp
+stgx::Diagnostics warnings;
+auto graph = stgx::load_yaml_file("graph.yaml", &warnings);
+if (!graph) return std::println("{}", graph.error().to_string()), 1;
+if (!warnings.empty()) std::println("{}", warnings.to_string()); // fields we drop when saving
+```
+> Reading one back. Warnings cannot ride along in `expected`'s success case, so loading takes an
+optional `Diagnostics*` for them
+
+## Model
+
+| Type | Holds |
+|---|---|
+| `System` | `description`, `m` (processor count), `tasks` — the `(c, d)` parameters, one per task |
+| `Node` | `id`, `initial`, `label`, `border_color`, `fill_color`, `tasks` — per-task `(c, d, release)` state |
+| `Edge` | `source`, `target`, `kind`: `Transition` (`children`) or `Release` (`loopback`) |
+
+`Graph` owns nodes and edges and keeps them indexed: `find_node`, `has_node`, `initial_node`, and
+`out_edges(id)` — which returns a `std::span` into the edge storage, no copies. Edges stay grouped
+by source, so every mutation re-groups them in O(V+E); deliberate, and fine at the sizes these
+graphs come in.
+
+
+Mutations — each one keeps the graph referentially sound
+
+`add_node`, `remove_node`, `rename_node`, `add_edge`, `remove_edge`, `set_initial`,
+`set_node_tasks`, `set_node_label`, `set_node_border_color`, `set_node_fill_color`.
+
+Removing a node takes every edge touching it with it; renaming one repoints them. A dangling edge
+cannot be produced through this API.
+
+
+
+
+Diagnostic codes — {code, severity, path, message}
+
+| Code | Meaning |
+|---|---|
+| `yaml_parse` | the bytes are not YAML, or the document is empty |
+| `wrong_type` | a value has the wrong YAML shape (mapping expected, etc.) |
+| `schema_version` | `schemaVersion` is not a version this build reads |
+| `nodes_missing` | `nodes` is absent or empty |
+| `node_id_empty` / `node_id_duplicate` | node ids must be present and unique |
+| `dangling_edge` | an edge names a node that does not exist |
+| `initial_count` | `initial` must be set on exactly one node |
+| `task_arity_mismatch` | a node has a different number of tasks than `system.tasks` |
+| `not_an_int` | `c`, `d`, `m`, `schemaVersion` must be plain integers — `2.0` and `2e1` are not |
+| `bad_release` | `release` is not `up` / `down` / `none` (or `↑` / `↓` / `1` / `-1`) |
+| `unsupported_field` | recognised but not modelled yet; dropped on save *(warning)* |
+| `unknown_field` | not part of the wire format *(warning)* |
+| `io_error` | a file could not be opened, read or written |
+
+One `load_yaml_file` on a badly broken document reports the bad integer *and* the dangling edge
+*and* the missing initial node.
+
+
+
+## Wire format
+
+```yaml
+schemaVersion: 1
+system:
+ description: EDF feasibility graph
+ m: 1
+ tasks:
+ - {c: 2, d: 5, name: τ₁}
+ - {c: 3, d: 7, name: τ₂}
+nodes:
+ - id: n00
+ initial: true
+ tasks:
+ - {c: 0, d: 0}
+ - {c: 0, d: 0}
+ children: [n10, n20]
+ - id: n10
+ tasks:
+ - {c: 2, d: 5, release: up}
+ - {c: 0, d: 0}
+ children: [n20]
+ - id: n20
+ label: "custom: label"
+ borderColor: "#C0392B"
+ fillColor: "#F9D5D3"
+ tasks:
+ - {c: 0, d: 3, release: down}
+ - {c: 0, d: 0}
+ loopback: [n00]
+```
+
+**On read** — `schemaVersion` may be omitted (assumed 1); `isInitial` is accepted as a legacy
+spelling of `initial`; `release` accepts `↑` / `1` for up and `↓` / `-1` for down; absent fields
+take their defaults.
+
+**On write** — key order is as above, and defaults are omitted: `initial: false`, empty `tasks` /
+`children` / `loopback`, `release: none`, absent `label` and colours. Colours are passed through
+verbatim, any CSS value the viewer accepts.
diff --git a/assets/libstgx-banner.png b/assets/libstgx-banner.png
new file mode 100644
index 0000000..6034cd3
Binary files /dev/null and b/assets/libstgx-banner.png differ
diff --git a/cmake/ProjectSettings.cmake b/cmake/ProjectSettings.cmake
index d6ed36b..bbf60fb 100644
--- a/cmake/ProjectSettings.cmake
+++ b/cmake/ProjectSettings.cmake
@@ -1,3 +1,13 @@
+# Developer-only settings for a top-level libstgx build. Every one of these
+# reaches outside the project's own scope — the source tree, the cache, global
+# properties — so the caller only includes this file when PROJECT_IS_TOP_LEVEL.
+
+configure_file(
+ "${PROJECT_SOURCE_DIR}/.clangd.in"
+ "${PROJECT_SOURCE_DIR}/.clangd"
+ @ONLY
+)
+
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
message(STATUS "Setting build type to 'RelWithDebInfo' as none was specified.")
set(CMAKE_BUILD_TYPE
diff --git a/include/stgx/builder.hpp b/include/stgx/builder.hpp
new file mode 100644
index 0000000..39c35c2
--- /dev/null
+++ b/include/stgx/builder.hpp
@@ -0,0 +1,154 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+namespace stgx {
+
+class SystemBuilder {
+ public:
+ SystemBuilder& description(std::string text) {
+ system_.description = std::move(text);
+ return *this;
+ }
+
+ /// Number of processors.
+ SystemBuilder& m(int processors) {
+ system_.m = processors;
+ return *this;
+ }
+
+ SystemBuilder& task(int c, int d) {
+ system_.tasks.push_back(TaskParam{.c = c, .d = d, .name = std::nullopt});
+ return *this;
+ }
+
+ SystemBuilder& task(int c, int d, std::string name) {
+ system_.tasks.push_back(TaskParam{.c = c, .d = d, .name = std::move(name)});
+ return *this;
+ }
+
+ private:
+ friend class GraphBuilder;
+
+ System system_;
+};
+
+class NodeBuilder {
+ public:
+ NodeBuilder& initial(bool value = true) {
+ node_.initial = value;
+ return *this;
+ }
+
+ NodeBuilder& label(std::string text) {
+ node_.label = std::move(text);
+ return *this;
+ }
+
+ /// Node styling, any CSS colour.
+ NodeBuilder& border_color(std::string color) {
+ node_.border_color = std::move(color);
+ return *this;
+ }
+
+ NodeBuilder& fill_color(std::string color) {
+ node_.fill_color = std::move(color);
+ return *this;
+ }
+
+ NodeBuilder& task(int c, int d, Release release = Release::None) {
+ node_.tasks.push_back(TaskState{.c = c, .d = d, .release = release});
+ return *this;
+ }
+
+ NodeBuilder& task(TaskState state) {
+ node_.tasks.push_back(state);
+ return *this;
+ }
+
+ /// `children` in YAML.
+ NodeBuilder& to(std::string target) {
+ edges_.push_back(
+ Edge{.source = node_.id, .target = std::move(target), .kind = EdgeKind::Transition});
+ return *this;
+ }
+
+ /// `loopback` in YAML.
+ NodeBuilder& loop_to(std::string target) {
+ edges_.push_back(
+ Edge{.source = node_.id, .target = std::move(target), .kind = EdgeKind::Loopback});
+ return *this;
+ }
+
+ private:
+ friend class GraphBuilder;
+
+ explicit NodeBuilder(std::string id) { node_.id = std::move(id); }
+
+ Node node_;
+ std::vector edges_;
+};
+
+/// Assemble a graph declaratively.
+///
+/// Nothing is checked while building,
+// `build()` validates once and reports everything
+///
+/// ```cpp
+/// auto graph = stgx::GraphBuilder()
+/// .system([](auto& s) { s.m(1).task(2, 5, "τ₁").task(3, 7, "τ₂"); })
+/// .node("n00", [](auto& n) { n.initial().task(0, 0).task(0, 0).to("n10"); })
+/// .node("n10", [](auto& n) { n.task(2, 5, stgx::Release::Up).task(0, 0).loop_to("n00"); })
+/// .build();
+/// ```
+class GraphBuilder {
+ public:
+ /// Configure the system through a callback taking `SystemBuilder&`.
+ template SbFn>
+ GraphBuilder& system(SbFn&& configure) {
+ SystemBuilder builder;
+ std::forward(configure)(builder);
+
+ system_ = std::move(builder.system_);
+ return *this;
+ }
+
+ GraphBuilder& system(System value) {
+ system_ = std::move(value);
+ return *this;
+ }
+
+ /// Add a node with `id`, configured through a callback taking `NodeBuilder&`.
+ template NbFn>
+ GraphBuilder& node(std::string id, NbFn&& configure) {
+ NodeBuilder builder{std::move(id)};
+ std::forward(configure)(builder);
+
+ nodes_.push_back(std::move(builder.node_));
+ edges_.insert(edges_.end(), std::make_move_iterator(builder.edges_.begin()),
+ std::make_move_iterator(builder.edges_.end()));
+ return *this;
+ }
+
+ GraphBuilder& node(Node value);
+
+ /// Add an edge without going through a node callback.
+ GraphBuilder& edge(std::string source, std::string target, EdgeKind kind = EdgeKind::Transition);
+
+ /// Build and validate. The builder stays usable afterwards.
+ [[nodiscard]] Result build() const;
+
+ private:
+ System system_;
+ std::vector nodes_;
+ std::vector edges_;
+};
+
+} // namespace stgx
diff --git a/include/stgx/diagnostics.hpp b/include/stgx/diagnostics.hpp
new file mode 100644
index 0000000..df1afb1
--- /dev/null
+++ b/include/stgx/diagnostics.hpp
@@ -0,0 +1,133 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace stgx {
+
+/// Severity of a single diagnostic.
+enum class Severity : uint8_t { Error, Warning };
+
+/// Machine-readable diagnostic codes.
+// NOLINTNEXTLINE(performance-enum-size)
+enum class DiagCode : uint16_t {
+ /// The input is not well-formed YAML, or its shape is not a graph document.
+ YamlParse,
+ /// `schemaVersion` is not a positive int, or names a version we cannot read.
+ SchemaVersion,
+ /// `nodes` is absent or empty — a graph needs at least one node.
+ NodesMissing,
+ /// A node has an empty `id`.
+ NodeIdEmpty,
+ /// Two nodes share the same `id`.
+ NodeIdDuplicate,
+ /// A requested node does not exist.
+ NodeNotFound,
+ /// An edge points at a node that does not exist.
+ DanglingEdge,
+ /// `initial` is not set on exactly one node.
+ InitialCount,
+ /// `system.tasks` is set and a node carries a different number of tasks.
+ TaskArityMismatch,
+ /// A value that must be an int is not one (`2.0`, `2e1`, `true`, ...).
+ NotAnInt,
+ /// A value has the wrong YAML shape — a scalar where a sequence is required,
+ /// a sequence where a mapping is required, a non-boolean `initial`, ...
+ WrongType,
+ /// A `release` value is neither `up`/`down`/`none` nor a known alias.
+ BadRelease,
+ /// A field of the wire format that this version does not model (warning).
+ UnsupportedField,
+ /// A field that is not part of the wire format at all (warning).
+ UnknownField,
+ /// A file could not be read or written.
+ IoError,
+};
+
+/// Stable lower_case name of a diagnostic code.
+[[nodiscard]] std::string_view to_string(DiagCode code);
+
+/// Stable lower_case name of a severity.
+[[nodiscard]] std::string_view to_string(Severity severity);
+
+/// One problem found while loading, validating or saving a graph.
+struct Diagnostic {
+ DiagCode code = DiagCode::YamlParse;
+ Severity severity = Severity::Error;
+ /// Location in wire-format terms, e.g. `nodes[3].children[0]`. May be empty.
+ std::string path;
+ /// Human-readable explanation.
+ std::string message;
+
+ [[nodiscard]] bool is_error() const noexcept { return severity == Severity::Error; }
+
+ /// `error[dangling_edge] nodes[3].children[0]: unknown node "n99"`
+ [[nodiscard]] std::string to_string() const;
+
+ bool operator==(const Diagnostic&) const = default;
+};
+
+class Diagnostics {
+ public:
+ using value_type = Diagnostic;
+ using const_iterator = std::vector::const_iterator;
+
+ Diagnostics() = default;
+ Diagnostics(std::initializer_list items) : items_(items) {}
+
+ void add(Diagnostic diag) { items_.push_back(std::move(diag)); }
+
+ void add(DiagCode code, std::string path, std::string message,
+ Severity severity = Severity::Error) {
+ items_.push_back(Diagnostic{.code = code,
+ .severity = severity,
+ .path = std::move(path),
+ .message = std::move(message)});
+ }
+
+ /// Append everything from `other`, keeping order.
+ void merge(const Diagnostics& other) {
+ items_.insert(items_.end(), other.items_.begin(), other.items_.end());
+ }
+
+ [[nodiscard]] bool empty() const noexcept { return items_.empty(); }
+ [[nodiscard]] std::size_t size() const noexcept { return items_.size(); }
+ [[nodiscard]] const Diagnostic& operator[](std::size_t index) const { return items_[index]; }
+ [[nodiscard]] const_iterator begin() const noexcept { return items_.begin(); }
+ [[nodiscard]] const_iterator end() const noexcept { return items_.end(); }
+
+ /// True if at least one diagnostic has `Severity::Error`.
+ [[nodiscard]] bool has_errors() const noexcept;
+
+ /// True if any diagnostic carries `code` — handy in tests and dispatch.
+ [[nodiscard]] bool contains(DiagCode code) const noexcept;
+
+ /// Only the diagnostics with `Severity::Error`.
+ [[nodiscard]] Diagnostics errors() const;
+
+ /// Only the diagnostics with `Severity::Warning`.
+ [[nodiscard]] Diagnostics warnings() const;
+
+ /// One diagnostic per line, in order. No trailing newline.
+ [[nodiscard]] std::string to_string() const;
+
+ bool operator==(const Diagnostics&) const = default;
+
+ private:
+ std::vector items_;
+};
+
+namespace diag {
+
+[[nodiscard]] Diagnostic node_not_found(std::string_view id);
+[[nodiscard]] Diagnostic node_id_empty();
+[[nodiscard]] Diagnostic node_id_duplicate(std::string_view id);
+
+} // namespace diag
+
+} // namespace stgx
diff --git a/include/stgx/graph.hpp b/include/stgx/graph.hpp
index 0856e27..e6627fb 100644
--- a/include/stgx/graph.hpp
+++ b/include/stgx/graph.hpp
@@ -1,10 +1,147 @@
#pragma once
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
namespace stgx {
+namespace detail {
+
+/// Hash that lets the id maps be queried with `std::string_view` without
+/// materializing a `std::string`.
+struct StringHash {
+ using is_transparent = void;
+ [[nodiscard]] std::size_t operator()(std::string_view key) const noexcept {
+ return std::hash{}(key);
+ }
+};
+
+} // namespace detail
+
+/// A state-transition graph: system metadata, nodes and edges.
class Graph {
public:
Graph() = default;
+
+ /// Build a graph and validate it. On failure returns every diagnostic found.
+ [[nodiscard]] static Result create(System system, std::vector nodes,
+ std::vector edges);
+
+ // --- reading
+
+ [[nodiscard]] const System& system() const noexcept { return system_; }
+ [[nodiscard]] std::span nodes() const noexcept { return nodes_; }
+ [[nodiscard]] std::span edges() const noexcept { return edges_; }
+ [[nodiscard]] std::size_t node_count() const noexcept { return nodes_.size(); }
+ [[nodiscard]] std::size_t edge_count() const noexcept { return edges_.size(); }
+
+ /// Node with this id, or `nullptr`. The pointer is invalidated by mutations.
+ [[nodiscard]] const Node* find_node(std::string_view id) const;
+ [[nodiscard]] bool has_node(std::string_view id) const;
+
+ /// The node flagged `initial`, or `nullptr` if none is.
+ [[nodiscard]] const Node* initial_node() const;
+
+ /// Edges leaving `id`, in insertion order. Empty for an unknown id.
+ [[nodiscard]] std::span out_edges(std::string_view id) const;
+
+ [[nodiscard]] bool has_edge(std::string_view source, std::string_view target,
+ EdgeKind kind) const;
+
+ // --- mutating
+
+ void set_system(System system) { system_ = std::move(system); }
+
+ /// Add a node. Fails on an empty or already used id.
+ [[nodiscard]] Result add_node(Node node);
+
+ /// Remove a node together with every edge touching it.
+ /// Returns false if there was no such node.
+ bool remove_node(std::string_view id);
+
+ /// Rename a node and repoint every edge at it.
+ /// Fails if `from` is unknown, or `to` is empty or already taken.
+ [[nodiscard]] Result rename_node(std::string_view from, std::string_view to);
+
+ /// Add an edge. Fails if either endpoint is unknown. Adding an edge that is
+ /// already there succeeds and changes nothing.
+ [[nodiscard]] Result add_edge(std::string_view source, std::string_view target,
+ EdgeKind kind);
+
+ /// Remove one edge. Returns false if there was no such edge.
+ bool remove_edge(std::string_view source, std::string_view target, EdgeKind kind);
+
+ /// Flag `id` as the initial node, clearing the flag on whichever node had it.
+ /// Fails if `id` is unknown.
+ [[nodiscard]] Result set_initial(std::string_view id) {
+ return mutate_node(id, [this](Node& node) {
+ for (Node& other : nodes_) other.initial = false;
+ node.initial = true;
+ });
+ }
+
+ /// Replace the task states of a node. Fails if `id` is unknown.
+ [[nodiscard]] Result set_node_tasks(std::string_view id, std::vector tasks) {
+ return mutate_node(id, [&tasks](Node& node) { node.tasks = std::move(tasks); });
+ }
+
+ /// Set or clear the label override of a node. Fails if `id` is unknown.
+ [[nodiscard]] Result set_node_label(std::string_view id, std::optional label) {
+ return mutate_node(id, [&label](Node& node) { node.label = std::move(label); });
+ }
+
+ /// Set or clear the styling of a node; `std::nullopt` falls back to the theme
+ /// of the visualizer. Fails if `id` is unknown.
+ [[nodiscard]] Result set_node_border_color(std::string_view id,
+ std::optional color) {
+ return mutate_node(id, [&color](Node& node) { node.border_color = std::move(color); });
+ }
+ [[nodiscard]] Result set_node_fill_color(std::string_view id,
+ std::optional color) {
+ return mutate_node(id, [&color](Node& node) { node.fill_color = std::move(color); });
+ }
+
+ // --- checking
+
+ /// Run every graph-level check. Warnings are never produced here.
+ [[nodiscard]] Result validate() const;
+
+ [[nodiscard]] bool operator==(const Graph& other) const;
+
+ private:
+ /// Half-open slice of `edges_` belonging to one source node.
+ struct EdgeRange {
+ std::size_t first = 0;
+ std::size_t count = 0;
+ };
+
+ template
+ using IdMap = std::unordered_map>;
+
+ /// Regroup `edges_` by source node and refill both indexes.
+ void rebuild_indexes();
+
+ [[nodiscard]] Node* find_node_mut(std::string_view id);
+
+ System system_;
+ std::vector nodes_;
+ /// Grouped by source in node order; within a group, insertion order.
+ std::vector edges_;
+ IdMap node_index_;
+ IdMap out_index_;
+
+ [[nodiscard]] Result mutate_node(std::string_view id,
+ const std::function& mutation);
};
} // namespace stgx
diff --git a/include/stgx/model.hpp b/include/stgx/model.hpp
new file mode 100644
index 0000000..b279b2f
--- /dev/null
+++ b/include/stgx/model.hpp
@@ -0,0 +1,80 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+namespace stgx {
+
+/// Job release event attached to a task inside a node.
+///
+/// Wire values: `up` / `down` / `none`. On load the aliases `↑`, `1` map to
+/// `Up` and `↓`, `-1` map to `Down`.
+enum class Release : uint8_t { None, Up, Down };
+
+/// What an edge means.
+enum class EdgeKind : uint8_t { Transition, Loopback };
+
+[[nodiscard]] std::string_view to_string(Release release);
+[[nodiscard]] std::string_view to_string(EdgeKind kind);
+
+/// Task parameters of the system
+struct TaskParam {
+ int c = 0;
+ int d = 0;
+ std::optional name;
+
+ bool operator==(const TaskParam&) const = default;
+};
+
+/// Scheduling-system metadata: what the graph is a graph of.
+struct System {
+ std::optional description;
+ /// Number of processors.
+ std::optional m;
+ std::vector tasks;
+
+ /// True when nothing is set — such a `system` is omitted on save.
+ [[nodiscard]] bool empty() const noexcept {
+ return !description.has_value() && !m.has_value() && tasks.empty();
+ }
+
+ bool operator==(const System&) const = default;
+};
+
+/// State of one task inside one node: remaining computation and deadline.
+struct TaskState {
+ int c = 0;
+ int d = 0;
+ Release release = Release::None;
+
+ bool operator==(const TaskState&) const = default;
+};
+
+/// A global state of the system.
+struct Node {
+ std::string id;
+ bool initial = false;
+
+ std::vector tasks;
+ std::optional label;
+ std::optional border_color;
+ std::optional fill_color;
+
+ bool operator==(const Node&) const = default;
+};
+
+struct Edge {
+ std::string source;
+ std::string target;
+ EdgeKind kind = EdgeKind::Transition;
+
+ bool operator==(const Edge&) const = default;
+};
+
+/// Wire-format version this build reads and writes.
+inline constexpr int kSchemaVersion = 1;
+
+} // namespace stgx
diff --git a/include/stgx/result.hpp b/include/stgx/result.hpp
new file mode 100644
index 0000000..c58358a
--- /dev/null
+++ b/include/stgx/result.hpp
@@ -0,0 +1,49 @@
+#pragma once
+
+#include
+
+#ifndef __cpp_lib_expected
+#error "libstgx needs std::expected: build as C++23 with a toolchain that provides ."
+#endif
+
+#include
+
+namespace stgx {
+
+/// Fallible outcome of a libstgx operation.
+///
+/// On failure the error side carries every diagnostic that was found, not just
+/// the first one:
+///
+/// ```cpp
+/// auto graph = stgx::load_yaml_file("in.yaml");
+/// if (!graph) {
+/// std::cerr << graph.error().to_string() << '\n';
+/// return 1;
+/// }
+/// ```
+template
+using Result = std::expected;
+
+/// Wrap a single diagnostic as a failed `Result`.
+[[nodiscard]] inline std::unexpected fail(Diagnostic diag) {
+ Diagnostics diags;
+ diags.add(std::move(diag));
+ return std::unexpected{std::move(diags)};
+}
+
+/// Wrap a single diagnostic as a failed `Result`.
+[[nodiscard]] inline std::unexpected fail(DiagCode code, std::string path,
+ std::string message) {
+ return fail(Diagnostic{.code = code,
+ .severity = Severity::Error,
+ .path = std::move(path),
+ .message = std::move(message)});
+}
+
+/// Wrap a collection of diagnostics as a failed `Result`.
+[[nodiscard]] inline std::unexpected fail(Diagnostics diags) {
+ return std::unexpected{std::move(diags)};
+}
+
+} // namespace stgx
diff --git a/include/stgx/stgx.hpp b/include/stgx/stgx.hpp
new file mode 100644
index 0000000..dc5a603
--- /dev/null
+++ b/include/stgx/stgx.hpp
@@ -0,0 +1,11 @@
+#pragma once
+
+/// @file
+/// Everything libstgx offers, in one include.
+
+#include
+#include
+#include
+#include
+#include
+#include
diff --git a/include/stgx/yaml.hpp b/include/stgx/yaml.hpp
new file mode 100644
index 0000000..5732a49
--- /dev/null
+++ b/include/stgx/yaml.hpp
@@ -0,0 +1,43 @@
+#pragma once
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+namespace stgx {
+
+/// Parse a graph document.
+///
+/// Errors are collected, not thrown: a failed load reports every problem it
+/// found at once. Warnings — fields of the wire format this version does not
+/// model, unknown fields — cannot ride on the error side of a successful load,
+/// so pass `warnings` if you care about them. They are filled in on failure
+/// too.
+///
+/// ```cpp
+/// stgx::Diagnostics warnings;
+/// auto graph = stgx::load_yaml_file("in.yaml", &warnings);
+/// if (!graph) return std::cerr << graph.error().to_string() << '\n', 1;
+/// if (!warnings.empty()) std::cerr << warnings.to_string() << '\n';
+/// ```
+[[nodiscard]] Result load_yaml_string(std::string_view yaml,
+ Diagnostics* warnings = nullptr);
+
+/// Read and parse a graph document from disk.
+[[nodiscard]] Result load_yaml_file(const std::filesystem::path& path,
+ Diagnostics* warnings = nullptr);
+
+/// Serialize a graph. Validates first — an invalid graph is never written.
+///
+/// Fields left at their default are omitted, so the output stays as close to a
+/// hand-written document as the model allows.
+[[nodiscard]] Result save_yaml_string(const Graph& graph);
+
+/// Serialize a graph to disk. Validates first.
+[[nodiscard]] Result save_yaml_file(const Graph& graph, const std::filesystem::path& path);
+
+} // namespace stgx
diff --git a/src/builder.cpp b/src/builder.cpp
new file mode 100644
index 0000000..f09cad0
--- /dev/null
+++ b/src/builder.cpp
@@ -0,0 +1,24 @@
+#include
+
+#include
+#include
+
+#include
+#include
+#include
+
+namespace stgx {
+
+GraphBuilder& GraphBuilder::node(Node value) {
+ nodes_.push_back(std::move(value));
+ return *this;
+}
+
+GraphBuilder& GraphBuilder::edge(std::string source, std::string target, EdgeKind kind) {
+ edges_.push_back(Edge{.source = std::move(source), .target = std::move(target), .kind = kind});
+ return *this;
+}
+
+Result GraphBuilder::build() const { return Graph::create(system_, nodes_, edges_); }
+
+} // namespace stgx
diff --git a/src/diagnostics.cpp b/src/diagnostics.cpp
new file mode 100644
index 0000000..edc308a
--- /dev/null
+++ b/src/diagnostics.cpp
@@ -0,0 +1,157 @@
+#include
+
+#include
+#include
+#include
+#include
+
+#include
+
+namespace stgx::diag {
+
+Diagnostic node_not_found(std::string_view id) {
+ return {
+ .code = DiagCode::NodeNotFound,
+ .severity = Severity::Error,
+ .path = {},
+ .message = std::format(R"(node "{}" does not exist)", id),
+ };
+}
+
+Diagnostic node_id_empty() {
+ return {
+ .code = DiagCode::NodeIdEmpty,
+ .severity = Severity::Error,
+ .path = {},
+ .message = "node id must not be empty",
+ };
+}
+
+Diagnostic node_id_duplicate(std::string_view id) {
+ return {
+ .code = DiagCode::NodeIdDuplicate,
+ .severity = Severity::Error,
+ .path = {},
+ .message = std::format(R"(node "{}" already exists)", id),
+ };
+}
+} // namespace stgx::diag
+
+namespace stgx {
+
+std::string_view to_string(DiagCode code) {
+ switch (code) {
+ case DiagCode::YamlParse:
+ return "yaml_parse";
+ case DiagCode::SchemaVersion:
+ return "schema_version";
+ case DiagCode::NodesMissing:
+ return "nodes_missing";
+ case DiagCode::NodeIdEmpty:
+ return "node_id_empty";
+ case DiagCode::NodeIdDuplicate:
+ return "node_id_duplicate";
+ case DiagCode::NodeNotFound:
+ return "node_not_found";
+ case DiagCode::DanglingEdge:
+ return "dangling_edge";
+ case DiagCode::InitialCount:
+ return "initial_count";
+ case DiagCode::TaskArityMismatch:
+ return "task_arity_mismatch";
+ case DiagCode::NotAnInt:
+ return "not_an_int";
+ case DiagCode::WrongType:
+ return "wrong_type";
+ case DiagCode::BadRelease:
+ return "bad_release";
+ case DiagCode::UnsupportedField:
+ return "unsupported_field";
+ case DiagCode::UnknownField:
+ return "unknown_field";
+ case DiagCode::IoError:
+ return "io_error";
+ }
+ return "unknown";
+}
+
+std::string_view to_string(Severity severity) {
+ switch (severity) {
+ case Severity::Error:
+ return "error";
+ case Severity::Warning:
+ return "warning";
+ }
+ return "unknown";
+}
+
+std::string_view to_string(Release release) {
+ switch (release) {
+ case Release::None:
+ return "none";
+ case Release::Up:
+ return "up";
+ case Release::Down:
+ return "down";
+ }
+ return "none";
+}
+
+std::string_view to_string(EdgeKind kind) {
+ switch (kind) {
+ case EdgeKind::Transition:
+ return "transition";
+ case EdgeKind::Loopback:
+ return "loopback";
+ }
+ return "transition";
+}
+
+std::string Diagnostic::to_string() const {
+ std::string out = std::format("{}[{}]", stgx::to_string(severity), stgx::to_string(code));
+
+ if (!path.empty()) {
+ std::format_to(std::back_inserter(out), " {}", path);
+ }
+
+ if (!message.empty()) {
+ std::format_to(std::back_inserter(out), ": {}", message);
+ }
+
+ return out;
+}
+
+bool Diagnostics::has_errors() const noexcept {
+ return std::ranges::any_of(items_, [](const Diagnostic& diag) { return diag.is_error(); });
+}
+
+bool Diagnostics::contains(DiagCode code) const noexcept {
+ return std::ranges::any_of(items_, [code](const Diagnostic& diag) { return diag.code == code; });
+}
+
+Diagnostics Diagnostics::errors() const {
+ Diagnostics out;
+ for (const Diagnostic& diag : items_) {
+ if (diag.is_error()) out.add(diag);
+ }
+ return out;
+}
+
+Diagnostics Diagnostics::warnings() const {
+ Diagnostics out;
+ for (const Diagnostic& diag : items_) {
+ if (!diag.is_error()) out.add(diag);
+ }
+ return out;
+}
+
+std::string Diagnostics::to_string() const {
+ std::string out;
+ for (const Diagnostic& diag : items_) {
+ if (!out.empty()) out += '\n';
+ out += diag.to_string();
+ }
+ return out;
+}
+
+} // namespace stgx
diff --git a/src/graph.cpp b/src/graph.cpp
index eab6d7e..66833aa 100644
--- a/src/graph.cpp
+++ b/src/graph.cpp
@@ -1,3 +1,208 @@
#include
-namespace stgx {} // namespace stgx
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+#include "validate.hpp"
+
+namespace stgx {
+
+Result Graph::mutate_node(std::string_view id, const std::function& mutation) {
+ Node* node = find_node_mut(id);
+ if (node == nullptr) {
+ return fail(diag::node_not_found(id));
+ }
+
+ mutation(*node);
+ return {};
+}
+
+Result Graph::create(System system, std::vector nodes, std::vector edges) {
+ Graph graph;
+ graph.system_ = std::move(system);
+ graph.nodes_ = std::move(nodes);
+ graph.edges_ = std::move(edges);
+ graph.rebuild_indexes();
+
+ if (Diagnostics diags = detail::validate_graph(graph.system_, graph.nodes_, graph.edges_);
+ diags.has_errors()) {
+ return fail(std::move(diags));
+ }
+ return graph;
+}
+
+void Graph::rebuild_indexes() {
+ // Build the node lookup table.
+ node_index_.clear();
+ node_index_.reserve(nodes_.size());
+
+ for (std::size_t node_index = 0; node_index < nodes_.size(); ++node_index) {
+ // Keep the first occurrence; validation reports duplicate IDs.
+ node_index_.try_emplace(nodes_[node_index].id, node_index);
+ }
+
+ // Assign each edge to its source node's bucket.
+ // Edges with an unknown source go into the final bucket.
+ const std::size_t unknown_source_bucket = nodes_.size();
+ const std::size_t bucket_count = nodes_.size() + 1;
+
+ std::vector edge_counts(bucket_count);
+ std::vector edge_buckets(edges_.size());
+
+ for (std::size_t edge_index = 0; edge_index < edges_.size(); ++edge_index) {
+ const auto source = node_index_.find(edges_[edge_index].source);
+ const std::size_t bucket = source == node_index_.end() ? unknown_source_bucket : source->second;
+
+ edge_buckets[edge_index] = bucket;
+ ++edge_counts[bucket];
+ }
+
+ // Compute the start position of each bucket.
+ std::vector bucket_offsets(bucket_count + 1);
+ std::partial_sum(edge_counts.begin(), edge_counts.end(), bucket_offsets.begin() + 1);
+
+ // Group edges by source while preserving insertion order.
+ std::vector next_positions(bucket_offsets.begin(), bucket_offsets.end() - 1);
+
+ std::vector grouped_edges(edges_.size());
+
+ for (std::size_t edge_index = 0; edge_index < edges_.size(); ++edge_index) {
+ const std::size_t bucket = edge_buckets[edge_index];
+ grouped_edges[next_positions[bucket]++] = std::move(edges_[edge_index]);
+ }
+
+ edges_ = std::move(grouped_edges);
+
+ // Build outgoing-edge ranges.
+ out_index_.clear();
+ out_index_.reserve(nodes_.size());
+
+ for (std::size_t node_index = 0; node_index < nodes_.size(); ++node_index) {
+ out_index_.try_emplace(nodes_[node_index].id, EdgeRange{
+ .first = bucket_offsets[node_index],
+ .count = edge_counts[node_index],
+ });
+ }
+}
+
+const Node* Graph::find_node(std::string_view id) const {
+ const auto it = node_index_.find(id);
+ return it == node_index_.end() ? nullptr : &nodes_[it->second];
+}
+
+Node* Graph::find_node_mut(std::string_view id) {
+ const auto it = node_index_.find(id);
+ return it == node_index_.end() ? nullptr : &nodes_[it->second];
+}
+
+bool Graph::has_node(std::string_view id) const { return node_index_.contains(id); }
+
+const Node* Graph::initial_node() const {
+ const auto it = std::ranges::find_if(nodes_, [](const Node& node) { return node.initial; });
+ return it == nodes_.end() ? nullptr : &*it;
+}
+
+std::span Graph::out_edges(std::string_view id) const {
+ const auto it = out_index_.find(id);
+ if (it == out_index_.end()) return {};
+ return std::span{edges_}.subspan(it->second.first, it->second.count);
+}
+
+bool Graph::has_edge(std::string_view source, std::string_view target, EdgeKind kind) const {
+ const std::span out = out_edges(source);
+ return std::ranges::any_of(
+ out, [target, kind](const Edge& edge) { return edge.kind == kind && edge.target == target; });
+}
+
+Result Graph::add_node(Node node) {
+ if (node.id.empty()) {
+ return fail(diag::node_id_empty());
+ }
+ if (has_node(node.id)) {
+ return fail(diag::node_id_duplicate(node.id));
+ }
+
+ nodes_.push_back(std::move(node));
+ rebuild_indexes();
+ return {};
+}
+
+bool Graph::remove_node(std::string_view id) {
+ const auto it = node_index_.find(id);
+ if (it == node_index_.end()) return false;
+
+ nodes_.erase(nodes_.begin() + static_cast(it->second));
+ const auto touches_id = [id](const Edge& edge) { return edge.source == id || edge.target == id; };
+ edges_.erase(std::ranges::remove_if(edges_, touches_id).begin(), edges_.end());
+
+ rebuild_indexes();
+ return true;
+}
+
+Result Graph::rename_node(std::string_view from, std::string_view to) {
+ if (to.empty()) {
+ return fail(diag::node_id_empty());
+ }
+ Node* node = find_node_mut(from);
+ if (node == nullptr) {
+ return fail(diag::node_not_found(from));
+ }
+ if (from == to) return {};
+ if (has_node(to)) {
+ return fail(diag::node_id_duplicate(to));
+ }
+ const std::string old_id = node->id;
+ node->id = std::string{to};
+ for (Edge& edge : edges_) {
+ if (edge.source == old_id) edge.source = to;
+ if (edge.target == old_id) edge.target = to;
+ }
+
+ rebuild_indexes();
+ return {};
+}
+
+Result Graph::add_edge(std::string_view source, std::string_view target, EdgeKind kind) {
+ if (!has_node(source)) return fail(diag::node_not_found(source));
+ if (!has_node(target)) return fail(diag::node_not_found(target));
+ if (has_edge(source, target, kind)) return {};
+
+ edges_.push_back(
+ Edge{.source = std::string{source}, .target = std::string{target}, .kind = kind});
+ rebuild_indexes();
+ return {};
+}
+
+bool Graph::remove_edge(std::string_view source, std::string_view target, EdgeKind kind) {
+ const auto it = std::ranges::find_if(edges_, [source, target, kind](const Edge& edge) {
+ return edge.kind == kind && edge.source == source && edge.target == target;
+ });
+ if (it == edges_.end()) return false;
+
+ edges_.erase(it);
+ rebuild_indexes();
+ return true;
+}
+
+Result Graph::validate() const {
+ if (Diagnostics diags = detail::validate_graph(system_, nodes_, edges_); diags.has_errors()) {
+ return fail(std::move(diags));
+ }
+ return {};
+}
+bool Graph::operator==(const Graph& other) const {
+ return system_ == other.system_ && nodes_ == other.nodes_ && edges_ == other.edges_;
+}
+
+} // namespace stgx
diff --git a/src/validate.cpp b/src/validate.cpp
new file mode 100644
index 0000000..1b97f0d
--- /dev/null
+++ b/src/validate.cpp
@@ -0,0 +1,118 @@
+#include "validate.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+namespace stgx::detail {
+namespace {
+
+std::string node_path(std::size_t index) { return "nodes[" + std::to_string(index) + "]"; }
+
+std::string_view edge_field(EdgeKind kind) {
+ return kind == EdgeKind::Transition ? "children" : "loopback";
+}
+
+/// Per-source running counters, so a dangling edge can be reported at the
+/// position it occupies in the source node's `children` / `loopback` list.
+struct EdgeOrdinals {
+ std::size_t transitions = 0;
+ std::size_t releases = 0;
+
+ std::size_t next(EdgeKind kind) {
+ return kind == EdgeKind::Transition ? transitions++ : releases++;
+ }
+};
+
+void check_nodes(std::span nodes, Diagnostics& diags,
+ std::unordered_map& first_index) {
+ std::size_t initial_count = 0;
+ for (std::size_t i = 0; i < nodes.size(); ++i) {
+ const Node& node = nodes[i];
+
+ if (node.id.empty()) {
+ diags.add(DiagCode::NodeIdEmpty, node_path(i) + ".id", "node id must not be empty");
+ } else if (const auto [it, inserted] = first_index.try_emplace(node.id, i); !inserted) {
+ diags.add(DiagCode::NodeIdDuplicate, node_path(i) + ".id",
+ "duplicate node id \"" + node.id + "\", first seen at " + node_path(it->second));
+ }
+
+ if (node.initial) ++initial_count;
+ }
+
+ if (initial_count != 1) {
+ diags.add(DiagCode::InitialCount, "nodes",
+ "exactly one node must be initial, found " + std::to_string(initial_count));
+ }
+}
+
+void check_task_arity(const System& system, std::span nodes, Diagnostics& diags) {
+ if (system.tasks.empty()) return;
+
+ const std::size_t expected = system.tasks.size();
+ for (std::size_t i = 0; i < nodes.size(); ++i) {
+ if (nodes[i].tasks.size() == expected) continue;
+ diags.add(DiagCode::TaskArityMismatch, node_path(i) + ".tasks",
+ "node \"" + nodes[i].id + "\" has " + std::to_string(nodes[i].tasks.size()) +
+ " task(s), expected " + std::to_string(expected) + " from system.tasks");
+ }
+}
+
+void check_edges(std::span edges, Diagnostics& diags,
+ const std::unordered_map& first_index) {
+ std::unordered_map ordinals;
+ std::unordered_set unknown_sources;
+
+ for (std::size_t e = 0; e < edges.size(); ++e) {
+ const Edge& edge = edges[e];
+ const auto source_it = first_index.find(edge.source);
+
+ if (source_it == first_index.end()) {
+ // Report an unknown source once, not once per outgoing edge.
+ if (unknown_sources.insert(edge.source).second) {
+ diags.add(DiagCode::DanglingEdge, "edges[" + std::to_string(e) + "].source",
+ "edge leaves unknown node \"" + edge.source + "\"");
+ }
+ continue;
+ }
+
+ const std::size_t ordinal = ordinals[edge.source].next(edge.kind);
+ if (!first_index.contains(edge.target)) {
+ diags.add(DiagCode::DanglingEdge,
+ node_path(source_it->second) + "." + std::string{edge_field(edge.kind)} + "[" +
+ std::to_string(ordinal) + "]",
+ "unknown node \"" + edge.target + "\" referenced from \"" + edge.source + "\"");
+ }
+ }
+}
+
+} // namespace
+
+Diagnostics validate_graph(const System& system, std::span nodes,
+ std::span edges) {
+ Diagnostics diags;
+
+ if (nodes.empty()) {
+ diags.add(DiagCode::NodesMissing, "nodes", "a graph needs at least one node");
+ // Every remaining check is about nodes; with none there is nothing to say.
+ return diags;
+ }
+
+ std::unordered_map first_index;
+ first_index.reserve(nodes.size());
+
+ check_nodes(nodes, diags, first_index);
+ check_task_arity(system, nodes, diags);
+ check_edges(edges, diags, first_index);
+
+ return diags;
+}
+
+} // namespace stgx::detail
diff --git a/src/validate.hpp b/src/validate.hpp
new file mode 100644
index 0000000..d1da396
--- /dev/null
+++ b/src/validate.hpp
@@ -0,0 +1,23 @@
+#pragma once
+
+#include
+
+#include
+#include
+
+namespace stgx::detail {
+
+/// Run every graph-level check over a raw model triple.
+///
+/// Works on loose vectors rather than a `Graph` so that both the YAML loader
+/// and `Graph::create()` can validate before a graph exists. Collects all
+/// findings instead of stopping at the first one.
+///
+/// Diagnostic paths are expressed in wire-format terms — `nodes[3].id`,
+/// `nodes[3].children[0]` — which requires `edges` to be grouped by source in
+/// node order, exactly how `Graph` and the loader keep them. Edges whose source
+/// is unknown are reported as `edges[i]`.
+[[nodiscard]] Diagnostics validate_graph(const System& system, std::span nodes,
+ std::span edges);
+
+} // namespace stgx::detail
diff --git a/src/yaml_load.cpp b/src/yaml_load.cpp
new file mode 100644
index 0000000..5dbfb65
--- /dev/null
+++ b/src/yaml_load.cpp
@@ -0,0 +1,374 @@
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include "validate.hpp"
+
+namespace stgx {
+namespace {
+
+// --- paths
+
+std::string field_path(std::string_view base, std::string_view field) {
+ std::string out{base};
+ if (!out.empty()) out += '.';
+ out += field;
+ return out;
+}
+
+std::string index_path(std::string_view base, std::size_t index) {
+ return std::string{base} + "[" + std::to_string(index) + "]";
+}
+
+// --- scalar readers
+
+/// A key that is present but null counts as absent.
+bool is_absent(const YAML::Node& node) { return !node.IsDefined() || node.IsNull(); }
+
+std::optional read_string(const YAML::Node& node, const std::string& path,
+ Diagnostics& diags) {
+ if (!node.IsScalar()) {
+ diags.add(DiagCode::WrongType, path, "expected a string");
+ return std::nullopt;
+ }
+ return node.Scalar();
+}
+
+/// Strict decimal int. Rejects `2.0`, `2e1`, `0x10`, `true` and empty values —
+/// the wire format says `c`, `d` and `m` are ints, and a silent truncation here
+/// would be a data bug in whatever produced the file.
+std::optional read_int(const YAML::Node& node, const std::string& path, Diagnostics& diags) {
+ if (!node.IsScalar()) {
+ diags.add(DiagCode::WrongType, path, "expected an integer");
+ return std::nullopt;
+ }
+
+ const std::string& text = node.Scalar();
+ std::string_view digits{text};
+ if (digits.starts_with('+')) digits.remove_prefix(1);
+
+ int value = 0;
+ const auto* const last = digits.data() + digits.size();
+ // from_chars takes both ends, so the view needs no terminator.
+ // NOLINTNEXTLINE(bugprone-suspicious-stringview-data-usage)
+ const auto result = std::from_chars(digits.data(), last, value);
+ if (result.ec != std::errc{} || result.ptr != last) {
+ diags.add(DiagCode::NotAnInt, path, "expected an integer, got \"" + text + "\"");
+ return std::nullopt;
+ }
+ return value;
+}
+
+std::optional read_bool(const YAML::Node& node, const std::string& path, Diagnostics& diags) {
+ if (node.IsScalar()) {
+ const std::string& text = node.Scalar();
+ for (std::string_view yes : {"true", "True", "TRUE", "yes", "Yes", "on", "On"}) {
+ if (text == yes) return true;
+ }
+ for (std::string_view no : {"false", "False", "FALSE", "no", "No", "off", "Off"}) {
+ if (text == no) return false;
+ }
+ }
+ diags.add(DiagCode::WrongType, path, "expected a boolean");
+ return std::nullopt;
+}
+
+/// `up` / `down` / `none`, plus the aliases the wire format documents:
+/// `↑` and `1` for up, `↓` and `-1` for down.
+std::optional read_release(const YAML::Node& node, const std::string& path,
+ Diagnostics& diags) {
+ if (node.IsScalar()) {
+ const std::string& text = node.Scalar();
+ if (text == "up" || text == "↑" || text == "1") return Release::Up;
+ if (text == "down" || text == "↓" || text == "-1") return Release::Down;
+ if (text == "none") return Release::None;
+ diags.add(DiagCode::BadRelease, path,
+ "expected up/down/none (or ↑/↓/1/-1), got \"" + text + "\"");
+ return std::nullopt;
+ }
+ diags.add(DiagCode::BadRelease, path, "expected up/down/none (or ↑/↓/1/-1)");
+ return std::nullopt;
+}
+
+// --- key checking
+
+bool contains(std::initializer_list keys, std::string_view key) {
+ for (std::string_view candidate : keys) {
+ if (candidate == key) return true;
+ }
+ return false;
+}
+
+/// Report fields we recognise but do not model yet, and fields we do not know
+/// at all. Both are warnings: the document still loads, but a later save will
+/// not carry them over, and silently eating someone's annotations would be
+/// worse than saying so.
+void check_keys(const YAML::Node& map, const std::string& path,
+ std::initializer_list known,
+ std::initializer_list unsupported, Diagnostics& diags) {
+ for (const auto& entry : map) {
+ if (!entry.first.IsScalar()) {
+ diags.add(DiagCode::UnknownField, path, "non-scalar key", Severity::Warning);
+ continue;
+ }
+ const std::string key = entry.first.Scalar();
+ if (contains(unsupported, key)) {
+ diags.add(DiagCode::UnsupportedField, field_path(path, key),
+ "not modelled in this version; it will be dropped when saving", Severity::Warning);
+ } else if (!contains(known, key)) {
+ diags.add(DiagCode::UnknownField, field_path(path, key), "not part of the wire format",
+ Severity::Warning);
+ }
+ }
+}
+
+// --- section readers ---------------------------------------------------------
+
+TaskParam read_task_param(const YAML::Node& node, const std::string& path, Diagnostics& diags) {
+ TaskParam task;
+ if (!node.IsMap()) {
+ diags.add(DiagCode::WrongType, path, "expected a mapping with c, d and an optional name");
+ return task;
+ }
+ check_keys(node, path, {"c", "d", "name"}, {}, diags);
+
+ if (const auto value = read_int(node["c"], field_path(path, "c"), diags)) task.c = *value;
+ if (const auto value = read_int(node["d"], field_path(path, "d"), diags)) task.d = *value;
+ if (!is_absent(node["name"])) {
+ task.name = read_string(node["name"], field_path(path, "name"), diags);
+ }
+ return task;
+}
+
+System read_system(const YAML::Node& node, const std::string& path, Diagnostics& diags) {
+ System system;
+ if (!node.IsMap()) {
+ diags.add(DiagCode::WrongType, path, "expected a mapping");
+ return system;
+ }
+ check_keys(node, path, {"description", "m", "tasks"}, {}, diags);
+
+ if (!is_absent(node["description"])) {
+ system.description = read_string(node["description"], field_path(path, "description"), diags);
+ }
+ if (!is_absent(node["m"])) {
+ system.m = read_int(node["m"], field_path(path, "m"), diags);
+ }
+
+ const YAML::Node tasks = node["tasks"];
+ if (!is_absent(tasks)) {
+ const std::string tasks_path = field_path(path, "tasks");
+ if (!tasks.IsSequence()) {
+ diags.add(DiagCode::WrongType, tasks_path, "expected a sequence");
+ } else {
+ system.tasks.reserve(tasks.size());
+ for (std::size_t i = 0; i < tasks.size(); ++i) {
+ system.tasks.push_back(read_task_param(tasks[i], index_path(tasks_path, i), diags));
+ }
+ }
+ }
+ return system;
+}
+
+TaskState read_task_state(const YAML::Node& node, const std::string& path, Diagnostics& diags) {
+ TaskState task;
+ if (!node.IsMap()) {
+ diags.add(DiagCode::WrongType, path, "expected a mapping with c, d and an optional release");
+ return task;
+ }
+ check_keys(node, path, {"c", "d", "release"}, {}, diags);
+
+ if (!is_absent(node["c"])) {
+ if (const auto value = read_int(node["c"], field_path(path, "c"), diags)) task.c = *value;
+ }
+ if (!is_absent(node["d"])) {
+ if (const auto value = read_int(node["d"], field_path(path, "d"), diags)) task.d = *value;
+ }
+ if (!is_absent(node["release"])) {
+ if (const auto value = read_release(node["release"], field_path(path, "release"), diags)) {
+ task.release = *value;
+ }
+ }
+ return task;
+}
+
+/// Turn a `children` / `loopback` list into edges leaving `source`.
+void read_targets(const YAML::Node& node, const std::string& path, EdgeKind kind,
+ const std::string& source, std::vector& edges, Diagnostics& diags) {
+ if (is_absent(node)) return;
+ if (!node.IsSequence()) {
+ diags.add(DiagCode::WrongType, path, "expected a sequence of node ids");
+ return;
+ }
+ for (std::size_t i = 0; i < node.size(); ++i) {
+ if (auto target = read_string(node[i], index_path(path, i), diags)) {
+ edges.push_back(Edge{.source = source, .target = *std::move(target), .kind = kind});
+ }
+ }
+}
+
+Node read_node(const YAML::Node& node, const std::string& path, std::vector& edges,
+ Diagnostics& diags) {
+ Node out;
+ if (!node.IsMap()) {
+ diags.add(DiagCode::WrongType, path, "expected a mapping");
+ return out;
+ }
+ check_keys(node, path,
+ {"id", "initial", "isInitial", "tasks", "children", "loopback", "label", "borderColor",
+ "fillColor"},
+ {"hatch", "metadata"}, diags);
+
+ if (auto id = read_string(node["id"], field_path(path, "id"), diags)) {
+ out.id = *std::move(id);
+ }
+
+ // `isInitial` is a legacy spelling the visualizer still accepts on read.
+ for (std::string_view key : {"initial", "isInitial"}) {
+ const YAML::Node flag = node[std::string{key}];
+ if (is_absent(flag)) continue;
+ if (const auto value = read_bool(flag, field_path(path, key), diags)) out.initial |= *value;
+ }
+
+ if (!is_absent(node["label"])) {
+ out.label = read_string(node["label"], field_path(path, "label"), diags);
+ }
+ if (!is_absent(node["borderColor"])) {
+ out.border_color = read_string(node["borderColor"], field_path(path, "borderColor"), diags);
+ }
+ if (!is_absent(node["fillColor"])) {
+ out.fill_color = read_string(node["fillColor"], field_path(path, "fillColor"), diags);
+ }
+
+ const YAML::Node tasks = node["tasks"];
+ if (!is_absent(tasks)) {
+ const std::string tasks_path = field_path(path, "tasks");
+ if (!tasks.IsSequence()) {
+ diags.add(DiagCode::WrongType, tasks_path, "expected a sequence");
+ } else {
+ out.tasks.reserve(tasks.size());
+ for (std::size_t i = 0; i < tasks.size(); ++i) {
+ out.tasks.push_back(read_task_state(tasks[i], index_path(tasks_path, i), diags));
+ }
+ }
+ }
+
+ // Keep edges grouped by source, children before loopback — the order both
+ // Graph and the diagnostics paths assume.
+ read_targets(node["children"], field_path(path, "children"), EdgeKind::Transition, out.id, edges,
+ diags);
+ read_targets(node["loopback"], field_path(path, "loopback"), EdgeKind::Loopback, out.id, edges,
+ diags);
+ return out;
+}
+
+void read_schema_version(const YAML::Node& node, Diagnostics& diags) {
+ if (is_absent(node)) return; // absent means version 1
+
+ const auto version = read_int(node, "schemaVersion", diags);
+ if (!version.has_value()) return;
+ if (*version < 1) {
+ diags.add(DiagCode::SchemaVersion, "schemaVersion",
+ "must be a positive integer, got " + std::to_string(*version));
+ } else if (*version > kSchemaVersion) {
+ diags.add(DiagCode::SchemaVersion, "schemaVersion",
+ "document is version " + std::to_string(*version) + ", this build reads up to " +
+ std::to_string(kSchemaVersion));
+ }
+}
+
+/// Hand the collected diagnostics to the caller and build the graph if the
+/// document was sound.
+///
+/// Field-level problems do not stop the structural checks: one load reports
+/// everything that is wrong with the document, not just the first layer of it.
+Result finish(System system, std::vector nodes, std::vector edges,
+ const Diagnostics& diags, Diagnostics* warnings) {
+ if (warnings != nullptr) warnings->merge(diags.warnings());
+
+ Diagnostics errors = diags.errors();
+ errors.merge(detail::validate_graph(system, nodes, edges));
+ if (!errors.empty()) return fail(std::move(errors));
+
+ return Graph::create(std::move(system), std::move(nodes), std::move(edges));
+}
+
+Result load_document(const YAML::Node& root, Diagnostics* warnings) {
+ if (is_absent(root)) {
+ return fail(DiagCode::YamlParse, "", "document is empty");
+ }
+ if (!root.IsMap()) {
+ return fail(DiagCode::WrongType, "", "top level must be a mapping");
+ }
+
+ Diagnostics diags;
+
+ check_keys(root, "", {"schemaVersion", "system", "nodes"}, {"areas", "layout"}, diags);
+ read_schema_version(root["schemaVersion"], diags);
+
+ System system;
+ if (!is_absent(root["system"])) {
+ system = read_system(root["system"], "system", diags);
+ }
+
+ std::vector nodes;
+ std::vector edges;
+ const YAML::Node nodes_node = root["nodes"];
+ if (is_absent(nodes_node)) {
+ // Left to validate_graph, so that "key absent" and "empty list" read alike.
+ } else if (!nodes_node.IsSequence()) {
+ diags.add(DiagCode::WrongType, "nodes", "expected a sequence");
+ } else {
+ nodes.reserve(nodes_node.size());
+ for (std::size_t i = 0; i < nodes_node.size(); ++i) {
+ nodes.push_back(read_node(nodes_node[i], index_path("nodes", i), edges, diags));
+ }
+ }
+
+ return finish(std::move(system), std::move(nodes), std::move(edges), diags, warnings);
+}
+
+} // namespace
+
+Result load_yaml_string(std::string_view yaml, Diagnostics* warnings) {
+ YAML::Node root;
+ try {
+ root = YAML::Load(std::string{yaml});
+ } catch (const YAML::Exception& error) {
+ return fail(DiagCode::YamlParse, "", error.what());
+ }
+ return load_document(root, warnings);
+}
+
+Result load_yaml_file(const std::filesystem::path& path, Diagnostics* warnings) {
+ std::ifstream file(path, std::ios::binary);
+ if (!file) {
+ return fail(DiagCode::IoError, path.string(), "cannot open file for reading");
+ }
+
+ std::ostringstream buffer;
+ buffer << file.rdbuf();
+ if (!file && !file.eof()) {
+ return fail(DiagCode::IoError, path.string(), "cannot read file");
+ }
+ return load_yaml_string(buffer.str(), warnings);
+}
+
+} // namespace stgx
diff --git a/src/yaml_save.cpp b/src/yaml_save.cpp
new file mode 100644
index 0000000..3eb348c
--- /dev/null
+++ b/src/yaml_save.cpp
@@ -0,0 +1,127 @@
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+namespace stgx {
+namespace {
+
+void emit_system(YAML::Emitter& out, const System& system) {
+ out << YAML::Key << "system" << YAML::Value << YAML::BeginMap;
+ if (system.description.has_value()) {
+ out << YAML::Key << "description" << YAML::Value << *system.description;
+ }
+ if (system.m.has_value()) {
+ out << YAML::Key << "m" << YAML::Value << *system.m;
+ }
+ if (!system.tasks.empty()) {
+ out << YAML::Key << "tasks" << YAML::Value << YAML::BeginSeq;
+ for (const TaskParam& task : system.tasks) {
+ out << YAML::Flow << YAML::BeginMap;
+ out << YAML::Key << "c" << YAML::Value << task.c;
+ out << YAML::Key << "d" << YAML::Value << task.d;
+ if (task.name.has_value()) out << YAML::Key << "name" << YAML::Value << *task.name;
+ out << YAML::EndMap;
+ }
+ out << YAML::EndSeq;
+ }
+ out << YAML::EndMap;
+}
+
+/// Write the `children` or `loopback` list of one node, skipping it entirely
+/// when the node has no edges of that kind.
+void emit_targets(YAML::Emitter& out, std::string_view field, std::span edges,
+ EdgeKind kind) {
+ bool opened = false;
+ for (const Edge& edge : edges) {
+ if (edge.kind != kind) continue;
+ if (!opened) {
+ out << YAML::Key << std::string{field} << YAML::Value << YAML::Flow << YAML::BeginSeq;
+ opened = true;
+ }
+ out << edge.target;
+ }
+ if (opened) out << YAML::EndSeq;
+}
+
+void emit_node(YAML::Emitter& out, const Node& node, std::span out_edges) {
+ out << YAML::BeginMap;
+ out << YAML::Key << "id" << YAML::Value << node.id;
+ if (node.initial) out << YAML::Key << "initial" << YAML::Value << true;
+ if (node.label.has_value()) out << YAML::Key << "label" << YAML::Value << *node.label;
+ if (node.border_color.has_value()) {
+ out << YAML::Key << "borderColor" << YAML::Value << *node.border_color;
+ }
+ if (node.fill_color.has_value()) {
+ out << YAML::Key << "fillColor" << YAML::Value << *node.fill_color;
+ }
+
+ if (!node.tasks.empty()) {
+ out << YAML::Key << "tasks" << YAML::Value << YAML::BeginSeq;
+ for (const TaskState& task : node.tasks) {
+ out << YAML::Flow << YAML::BeginMap;
+ out << YAML::Key << "c" << YAML::Value << task.c;
+ out << YAML::Key << "d" << YAML::Value << task.d;
+ if (task.release != Release::None) {
+ out << YAML::Key << "release" << YAML::Value << std::string{to_string(task.release)};
+ }
+ out << YAML::EndMap;
+ }
+ out << YAML::EndSeq;
+ }
+
+ emit_targets(out, "children", out_edges, EdgeKind::Transition);
+ emit_targets(out, "loopback", out_edges, EdgeKind::Loopback);
+ out << YAML::EndMap;
+}
+
+} // namespace
+
+Result save_yaml_string(const Graph& graph) {
+ if (auto valid = graph.validate(); !valid) {
+ return fail(std::move(valid).error());
+ }
+
+ YAML::Emitter out;
+ out << YAML::BeginMap;
+ out << YAML::Key << "schemaVersion" << YAML::Value << kSchemaVersion;
+ if (!graph.system().empty()) emit_system(out, graph.system());
+
+ out << YAML::Key << "nodes" << YAML::Value << YAML::BeginSeq;
+ for (const Node& node : graph.nodes()) {
+ emit_node(out, node, graph.out_edges(node.id));
+ }
+ out << YAML::EndSeq;
+ out << YAML::EndMap;
+
+ if (!out.good()) {
+ return fail(DiagCode::YamlParse, "", "emitter failed: " + out.GetLastError());
+ }
+ return std::string{out.c_str()} + "\n";
+}
+
+Result save_yaml_file(const Graph& graph, const std::filesystem::path& path) {
+ auto text = save_yaml_string(graph);
+ if (!text) return fail(std::move(text).error());
+
+ std::ofstream file(path, std::ios::binary | std::ios::trunc);
+ if (!file) {
+ return fail(DiagCode::IoError, path.string(), "cannot open file for writing");
+ }
+ file << *text;
+ if (!file) {
+ return fail(DiagCode::IoError, path.string(), "cannot write file");
+ }
+ return {};
+}
+
+} // namespace stgx
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 35295bc..820bab8 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -13,6 +13,11 @@ target_link_libraries(stgx_tests
Catch2::Catch2
)
+target_compile_definitions(stgx_tests
+ PRIVATE
+ STGX_TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data"
+)
+
include(Catch OPTIONAL RESULT_VARIABLE STGX_CATCH_MODULE)
if(STGX_CATCH_MODULE)
catch_discover_tests(stgx_tests)
diff --git a/tests/builder_test.cpp b/tests/builder_test.cpp
new file mode 100644
index 0000000..f981651
--- /dev/null
+++ b/tests/builder_test.cpp
@@ -0,0 +1,138 @@
+#include
+
+#include
+
+#include
+#include
+
+using stgx::DiagCode;
+using stgx::EdgeKind;
+using stgx::GraphBuilder;
+using stgx::Release;
+
+TEST_CASE("the example from the design document builds", "[builder]") {
+ const auto graph =
+ GraphBuilder()
+ .system([](auto& s) { s.description("EDF").m(2).task(1, 2, "τ1").task(1, 2, "τ2"); })
+ .node("n0", [](auto& n) { n.initial().task(0, 0).task(0, 0).to("n11").to("n12"); })
+ .node("n11", [](auto& n) { n.task(1, 2, Release::Up).task(0, 0).loop_to("n0"); })
+ .node("n12", [](auto& n) { n.task(0, 0).task(1, 2, Release::Up).loop_to("n0"); })
+ .build();
+
+ REQUIRE(graph.has_value());
+ CHECK(graph->node_count() == 3);
+ CHECK(graph->edge_count() == 4);
+
+ const stgx::System& system = graph->system();
+ CHECK(system.description == "EDF");
+ CHECK(system.m == 2);
+ REQUIRE(system.tasks.size() == 2);
+ CHECK(system.tasks[1].name == "τ2");
+
+ REQUIRE(graph->initial_node() != nullptr);
+ CHECK(graph->initial_node()->id == "n0");
+
+ const auto out = graph->out_edges("n0");
+ REQUIRE(out.size() == 2);
+ CHECK(out[0].target == "n11");
+ CHECK(out[0].kind == EdgeKind::Transition);
+ CHECK(out[1].target == "n12");
+
+ const auto loopback = graph->out_edges("n11");
+ REQUIRE(loopback.size() == 1);
+ CHECK(loopback[0].kind == EdgeKind::Loopback);
+ CHECK(loopback[0].target == "n0");
+
+ CHECK(graph->find_node("n11")->tasks[0].release == Release::Up);
+}
+
+TEST_CASE("declaration order does not matter", "[builder]") {
+ // n0 points at n1 before n1 exists.
+ const auto graph = GraphBuilder()
+ .node("n0", [](auto& n) { n.initial().to("n1"); })
+ .node("n1", [](auto& n) { n.loop_to("n0"); })
+ .build();
+
+ REQUIRE(graph.has_value());
+ CHECK(graph->has_edge("n0", "n1", EdgeKind::Transition));
+ CHECK(graph->has_edge("n1", "n0", EdgeKind::Loopback));
+}
+
+TEST_CASE("labels and explicit task states go through", "[builder]") {
+ const auto graph = GraphBuilder()
+ .node("only",
+ [](auto& n) {
+ n.initial()
+ .label("the only one")
+ .border_color("#C0392B")
+ .fill_color("#F9D5D3")
+ .task(stgx::TaskState{1, 3, Release::Down});
+ })
+ .build();
+
+ REQUIRE(graph.has_value());
+ const stgx::Node* only = graph->find_node("only");
+ REQUIRE(only != nullptr);
+ CHECK(only->label == "the only one");
+ CHECK(only->border_color == "#C0392B");
+ CHECK(only->fill_color == "#F9D5D3");
+ REQUIRE(only->tasks.size() == 1);
+ CHECK(only->tasks[0].c == 1);
+ CHECK(only->tasks[0].d == 3);
+ CHECK(only->tasks[0].release == Release::Down);
+}
+
+TEST_CASE("prebuilt model values can be handed over directly", "[builder]") {
+ stgx::System system;
+ system.m = 1;
+
+ stgx::Node first;
+ first.id = "a";
+ first.initial = true;
+ stgx::Node second;
+ second.id = "b";
+
+ const auto graph = GraphBuilder()
+ .system(system)
+ .node(first)
+ .node(second)
+ .edge("a", "b")
+ .edge("b", "a", EdgeKind::Loopback)
+ .build();
+
+ REQUIRE(graph.has_value());
+ CHECK(graph->system().m == 1);
+ CHECK(graph->has_edge("a", "b", EdgeKind::Transition));
+ CHECK(graph->has_edge("b", "a", EdgeKind::Loopback));
+}
+
+TEST_CASE("build reports everything that is wrong at once", "[builder]") {
+ const auto graph = GraphBuilder()
+ .node("dup", [](auto& n) { n.to("ghost"); })
+ .node("dup", [](auto& n) { n.initial(); })
+ .build();
+
+ REQUIRE_FALSE(graph.has_value());
+ CHECK(graph.error().contains(DiagCode::NodeIdDuplicate));
+ CHECK(graph.error().contains(DiagCode::DanglingEdge));
+}
+
+TEST_CASE("a builder without nodes fails", "[builder]") {
+ const auto graph = GraphBuilder().build();
+
+ REQUIRE_FALSE(graph.has_value());
+ CHECK(graph.error().contains(DiagCode::NodesMissing));
+}
+
+TEST_CASE("the builder stays usable after build", "[builder]") {
+ GraphBuilder builder;
+ builder.node("a", [](auto& n) { n.initial().to("b"); });
+
+ const auto incomplete = builder.build();
+ CHECK_FALSE(incomplete.has_value()); // b is missing
+
+ builder.node("b", [](auto&) {});
+ const auto complete = builder.build();
+ REQUIRE(complete.has_value());
+ CHECK(complete->node_count() == 2);
+}
diff --git a/tests/data/aliases.yaml b/tests/data/aliases.yaml
new file mode 100644
index 0000000..9e8f3b4
--- /dev/null
+++ b/tests/data/aliases.yaml
@@ -0,0 +1,17 @@
+# release aliases, the legacy isInitial spelling, and omitted fields
+nodes:
+ - id: a
+ isInitial: true
+ tasks:
+ - { c: 1, d: 3, release: ↑ }
+ - { d: 2 }
+ children: [b, c]
+ - id: b
+ tasks:
+ - { c: 0, d: 2, release: ↓ }
+ - { c: 0, d: 0, release: none }
+ loopback: [a]
+ - id: c
+ tasks:
+ - { c: 1, d: 3, release: 1 }
+ - { c: 2, d: 4, release: -1 }
diff --git a/tests/data/invalid_bad_int.yaml b/tests/data/invalid_bad_int.yaml
new file mode 100644
index 0000000..6c28182
--- /dev/null
+++ b/tests/data/invalid_bad_int.yaml
@@ -0,0 +1,5 @@
+nodes:
+ - id: n0
+ initial: true
+ tasks:
+ - {c: 2.5, d: 5}
diff --git a/tests/data/invalid_bad_release.yaml b/tests/data/invalid_bad_release.yaml
new file mode 100644
index 0000000..e284d19
--- /dev/null
+++ b/tests/data/invalid_bad_release.yaml
@@ -0,0 +1,5 @@
+nodes:
+ - id: n0
+ initial: true
+ tasks:
+ - {c: 1, d: 2, release: sideways}
diff --git a/tests/data/invalid_bad_yaml.yaml b/tests/data/invalid_bad_yaml.yaml
new file mode 100644
index 0000000..730a855
--- /dev/null
+++ b/tests/data/invalid_bad_yaml.yaml
@@ -0,0 +1,4 @@
+nodes:
+ - id: n0
+ initial: true
+ children: [oops
diff --git a/tests/data/invalid_dangling.yaml b/tests/data/invalid_dangling.yaml
new file mode 100644
index 0000000..51fb2e7
--- /dev/null
+++ b/tests/data/invalid_dangling.yaml
@@ -0,0 +1,6 @@
+nodes:
+ - id: n0
+ initial: true
+ children: [n1]
+ - id: n1
+ loopback: [n99]
diff --git a/tests/data/invalid_duplicate_id.yaml b/tests/data/invalid_duplicate_id.yaml
new file mode 100644
index 0000000..d3bd975
--- /dev/null
+++ b/tests/data/invalid_duplicate_id.yaml
@@ -0,0 +1,4 @@
+nodes:
+ - id: n0
+ initial: true
+ - id: n0
diff --git a/tests/data/invalid_empty_id.yaml b/tests/data/invalid_empty_id.yaml
new file mode 100644
index 0000000..5393548
--- /dev/null
+++ b/tests/data/invalid_empty_id.yaml
@@ -0,0 +1,3 @@
+nodes:
+ - id: ""
+ initial: true
diff --git a/tests/data/invalid_empty_nodes.yaml b/tests/data/invalid_empty_nodes.yaml
new file mode 100644
index 0000000..2c026b8
--- /dev/null
+++ b/tests/data/invalid_empty_nodes.yaml
@@ -0,0 +1,2 @@
+schemaVersion: 1
+nodes: []
diff --git a/tests/data/invalid_no_initial.yaml b/tests/data/invalid_no_initial.yaml
new file mode 100644
index 0000000..28bde26
--- /dev/null
+++ b/tests/data/invalid_no_initial.yaml
@@ -0,0 +1,3 @@
+nodes:
+ - id: n0
+ - id: n1
diff --git a/tests/data/invalid_no_nodes.yaml b/tests/data/invalid_no_nodes.yaml
new file mode 100644
index 0000000..aed90a8
--- /dev/null
+++ b/tests/data/invalid_no_nodes.yaml
@@ -0,0 +1,3 @@
+schemaVersion: 1
+system:
+ m: 1
diff --git a/tests/data/invalid_schema_version.yaml b/tests/data/invalid_schema_version.yaml
new file mode 100644
index 0000000..dca67a6
--- /dev/null
+++ b/tests/data/invalid_schema_version.yaml
@@ -0,0 +1,4 @@
+schemaVersion: 99
+nodes:
+ - id: n0
+ initial: true
diff --git a/tests/data/invalid_task_arity.yaml b/tests/data/invalid_task_arity.yaml
new file mode 100644
index 0000000..49d36c6
--- /dev/null
+++ b/tests/data/invalid_task_arity.yaml
@@ -0,0 +1,9 @@
+system:
+ tasks:
+ - {c: 1, d: 2}
+ - {c: 1, d: 2}
+nodes:
+ - id: n0
+ initial: true
+ tasks:
+ - {c: 0, d: 0}
diff --git a/tests/data/invalid_two_initial.yaml b/tests/data/invalid_two_initial.yaml
new file mode 100644
index 0000000..8301201
--- /dev/null
+++ b/tests/data/invalid_two_initial.yaml
@@ -0,0 +1,5 @@
+nodes:
+ - id: n0
+ initial: true
+ - id: n1
+ initial: true
diff --git a/tests/data/invalid_wrong_type.yaml b/tests/data/invalid_wrong_type.yaml
new file mode 100644
index 0000000..c8ca1a2
--- /dev/null
+++ b/tests/data/invalid_wrong_type.yaml
@@ -0,0 +1,4 @@
+nodes:
+ - id: n0
+ initial: maybe
+ children: n1
diff --git a/tests/data/minimal.yaml b/tests/data/minimal.yaml
new file mode 100644
index 0000000..6e17362
--- /dev/null
+++ b/tests/data/minimal.yaml
@@ -0,0 +1,4 @@
+schemaVersion: 1
+nodes:
+ - id: n0
+ initial: true
diff --git a/tests/data/two-tasks.yaml b/tests/data/two-tasks.yaml
new file mode 100644
index 0000000..d7d370f
--- /dev/null
+++ b/tests/data/two-tasks.yaml
@@ -0,0 +1,25 @@
+schemaVersion: 1
+system:
+ description: EDF feasibility graph
+ m: 1
+ tasks:
+ - {c: 2, d: 5, name: τ₁}
+ - {c: 3, d: 7, name: τ₂}
+nodes:
+ - id: n00
+ initial: true
+ tasks:
+ - {c: 0, d: 0}
+ - {c: 0, d: 0}
+ children: [n10, n20]
+ - id: n10
+ tasks:
+ - {c: 2, d: 5, release: up}
+ - {c: 0, d: 0}
+ children: [n20]
+ - id: n20
+ label: "custom: label"
+ tasks:
+ - {c: 0, d: 3, release: down}
+ - {c: 0, d: 0}
+ loopback: [n00]
diff --git a/tests/data/unsupported.yaml b/tests/data/unsupported.yaml
new file mode 100644
index 0000000..8c6893d
--- /dev/null
+++ b/tests/data/unsupported.yaml
@@ -0,0 +1,16 @@
+schemaVersion: 1
+nodes:
+ - id: n0
+ initial: true
+ borderColor: "#C0392B"
+ fillColor: "#E8F4F8"
+ hatch: single
+ metadata: { origin: solver }
+ children: [n1]
+ - id: n1
+ dangerouslyUnknown: 42
+areas:
+ - id: burst
+ nodes: [n0, n1]
+layout:
+ algorithm: concentric
diff --git a/tests/graph_test.cpp b/tests/graph_test.cpp
index ec15ddf..7a0676e 100644
--- a/tests/graph_test.cpp
+++ b/tests/graph_test.cpp
@@ -1,8 +1,207 @@
#include
#include
+#include
+#include
-TEST_CASE("Graph default construction", "[graph]") {
- stgx::Graph g;
- SUCCEED("Graph constructs");
+#include
+
+using stgx::DiagCode;
+using stgx::Edge;
+using stgx::EdgeKind;
+using stgx::Graph;
+using stgx::Node;
+
+namespace {
+
+/// a --> b --> c, and c loops back to a.
+Graph triangle() {
+ Node a;
+ a.id = "a";
+ a.initial = true;
+ Node b;
+ b.id = "b";
+ Node c;
+ c.id = "c";
+
+ auto graph =
+ Graph::create({}, {a, b, c},
+ {Edge{"a", "b", EdgeKind::Transition}, Edge{"b", "c", EdgeKind::Transition},
+ Edge{"c", "a", EdgeKind::Loopback}});
+ REQUIRE(graph.has_value());
+ return *graph;
+}
+
+} // namespace
+
+TEST_CASE("an empty graph is empty", "[graph]") {
+ const Graph graph;
+
+ CHECK(graph.node_count() == 0);
+ CHECK(graph.edge_count() == 0);
+ CHECK(graph.initial_node() == nullptr);
+ CHECK(graph.find_node("nope") == nullptr);
+ CHECK(graph.out_edges("nope").empty());
+ // An empty graph is not a valid document.
+ CHECK_FALSE(graph.validate().has_value());
+}
+
+TEST_CASE("reading a graph", "[graph]") {
+ const Graph graph = triangle();
+
+ CHECK(graph.node_count() == 3);
+ CHECK(graph.edge_count() == 3);
+ REQUIRE(graph.initial_node() != nullptr);
+ CHECK(graph.initial_node()->id == "a");
+ CHECK(graph.has_node("b"));
+ CHECK_FALSE(graph.has_node("z"));
+
+ const auto out = graph.out_edges("c");
+ REQUIRE(out.size() == 1);
+ CHECK(out[0].target == "a");
+ CHECK(out[0].kind == EdgeKind::Loopback);
+
+ CHECK(graph.has_edge("a", "b", EdgeKind::Transition));
+ CHECK_FALSE(graph.has_edge("a", "b", EdgeKind::Loopback));
+}
+
+TEST_CASE("out_edges keeps insertion order per source", "[graph]") {
+ Graph graph = triangle();
+ REQUIRE(graph.add_edge("a", "c", EdgeKind::Transition).has_value());
+ REQUIRE(graph.add_edge("a", "b", EdgeKind::Loopback).has_value());
+
+ const auto out = graph.out_edges("a");
+ REQUIRE(out.size() == 3);
+ CHECK(out[0].target == "b");
+ CHECK(out[0].kind == EdgeKind::Transition);
+ CHECK(out[1].target == "c");
+ CHECK(out[2].kind == EdgeKind::Loopback);
+}
+
+TEST_CASE("add_node rejects empty and duplicate ids", "[graph]") {
+ Graph graph = triangle();
+
+ Node duplicate;
+ duplicate.id = "b";
+ const auto added = graph.add_node(duplicate);
+ REQUIRE_FALSE(added.has_value());
+ CHECK(added.error().contains(DiagCode::NodeIdDuplicate));
+
+ const auto empty = graph.add_node(Node{});
+ REQUIRE_FALSE(empty.has_value());
+ CHECK(empty.error().contains(DiagCode::NodeIdEmpty));
+
+ CHECK(graph.node_count() == 3);
+}
+
+TEST_CASE("remove_node drops the edges that touched it", "[graph]") {
+ Graph graph = triangle();
+
+ CHECK(graph.remove_node("b"));
+ CHECK_FALSE(graph.remove_node("b"));
+
+ CHECK(graph.node_count() == 2);
+ // a->b and b->c are gone, c->a survives.
+ REQUIRE(graph.edge_count() == 1);
+ CHECK(graph.edges()[0].source == "c");
+ CHECK(graph.out_edges("a").empty());
+ CHECK(graph.validate().has_value());
+}
+
+TEST_CASE("rename_node repoints its edges", "[graph]") {
+ Graph graph = triangle();
+
+ REQUIRE(graph.rename_node("a", "start").has_value());
+ CHECK(graph.has_node("start"));
+ CHECK_FALSE(graph.has_node("a"));
+ REQUIRE(graph.initial_node() != nullptr);
+ CHECK(graph.initial_node()->id == "start");
+ CHECK(graph.has_edge("start", "b", EdgeKind::Transition));
+ CHECK(graph.has_edge("c", "start", EdgeKind::Loopback));
+ CHECK(graph.validate().has_value());
+
+ SECTION("renaming onto a taken id fails") {
+ const auto renamed = graph.rename_node("b", "c");
+ REQUIRE_FALSE(renamed.has_value());
+ CHECK(renamed.error().contains(DiagCode::NodeIdDuplicate));
+ }
+
+ SECTION("renaming an unknown node fails") {
+ CHECK_FALSE(graph.rename_node("ghost", "x").has_value());
+ }
+
+ SECTION("renaming to itself is a no-op") {
+ CHECK(graph.rename_node("b", "b").has_value());
+ CHECK(graph.node_count() == 3);
+ }
+}
+
+TEST_CASE("edges cannot be added to unknown nodes", "[graph]") {
+ Graph graph = triangle();
+
+ CHECK_FALSE(graph.add_edge("a", "ghost", EdgeKind::Transition).has_value());
+ CHECK_FALSE(graph.add_edge("ghost", "a", EdgeKind::Transition).has_value());
+ CHECK(graph.edge_count() == 3);
+}
+
+TEST_CASE("adding an edge twice changes nothing", "[graph]") {
+ Graph graph = triangle();
+
+ REQUIRE(graph.add_edge("a", "b", EdgeKind::Transition).has_value());
+ CHECK(graph.edge_count() == 3);
+}
+
+TEST_CASE("remove_edge removes exactly one kind", "[graph]") {
+ Graph graph = triangle();
+
+ CHECK_FALSE(graph.remove_edge("c", "a", EdgeKind::Transition));
+ CHECK(graph.remove_edge("c", "a", EdgeKind::Loopback));
+ CHECK(graph.edge_count() == 2);
+ CHECK(graph.out_edges("c").empty());
+}
+
+TEST_CASE("set_initial moves the flag", "[graph]") {
+ Graph graph = triangle();
+
+ REQUIRE(graph.set_initial("c").has_value());
+ REQUIRE(graph.initial_node() != nullptr);
+ CHECK(graph.initial_node()->id == "c");
+ CHECK_FALSE(graph.find_node("a")->initial);
+ CHECK(graph.validate().has_value());
+
+ CHECK_FALSE(graph.set_initial("ghost").has_value());
+}
+
+TEST_CASE("node tasks and labels can be replaced", "[graph]") {
+ Graph graph = triangle();
+
+ REQUIRE(graph.set_node_tasks("b", {{1, 2, stgx::Release::Up}}).has_value());
+ REQUIRE(graph.find_node("b")->tasks.size() == 1);
+ CHECK(graph.find_node("b")->tasks[0].release == stgx::Release::Up);
+
+ REQUIRE(graph.set_node_label("b", "burst").has_value());
+ CHECK(graph.find_node("b")->label == "burst");
+ REQUIRE(graph.set_node_label("b", std::nullopt).has_value());
+ CHECK_FALSE(graph.find_node("b")->label.has_value());
+
+ REQUIRE(graph.set_node_border_color("b", "#C0392B").has_value());
+ REQUIRE(graph.set_node_fill_color("b", "#F9D5D3").has_value());
+ CHECK(graph.find_node("b")->border_color == "#C0392B");
+ CHECK(graph.find_node("b")->fill_color == "#F9D5D3");
+ REQUIRE(graph.set_node_fill_color("b", std::nullopt).has_value());
+ CHECK_FALSE(graph.find_node("b")->fill_color.has_value());
+
+ CHECK_FALSE(graph.set_node_tasks("ghost", {}).has_value());
+ CHECK_FALSE(graph.set_node_label("ghost", "x").has_value());
+ CHECK_FALSE(graph.set_node_border_color("ghost", "#000").has_value());
+ CHECK_FALSE(graph.set_node_fill_color("ghost", "#000").has_value());
+}
+
+TEST_CASE("graphs compare by content", "[graph]") {
+ const Graph one = triangle();
+ Graph other = triangle();
+
+ CHECK(one == other);
+ REQUIRE(other.set_initial("b").has_value());
+ CHECK_FALSE(one == other);
}
diff --git a/tests/helpers.hpp b/tests/helpers.hpp
new file mode 100644
index 0000000..3ae258a
--- /dev/null
+++ b/tests/helpers.hpp
@@ -0,0 +1,32 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+namespace stgx_test {
+
+inline std::filesystem::path data_file(std::string_view name) {
+ return std::filesystem::path{STGX_TEST_DATA_DIR} / name;
+}
+
+inline std::string read_file(const std::filesystem::path& path) {
+ std::ifstream file(path, std::ios::binary);
+ return std::string{std::istreambuf_iterator{file}, std::istreambuf_iterator{}};
+}
+
+/// First diagnostic carrying `code`, so a test can assert on its path.
+inline std::optional find(const stgx::Diagnostics& diags, stgx::DiagCode code) {
+ const auto it = std::ranges::find_if(
+ diags, [code](const stgx::Diagnostic& diag) { return diag.code == code; });
+ if (it == diags.end()) return std::nullopt;
+ return *it;
+}
+
+} // namespace stgx_test
diff --git a/tests/validate_test.cpp b/tests/validate_test.cpp
new file mode 100644
index 0000000..36eb9fb
--- /dev/null
+++ b/tests/validate_test.cpp
@@ -0,0 +1,146 @@
+#include
+#include
+#include
+
+#include
+#include
+
+#include "helpers.hpp"
+
+using stgx::DiagCode;
+using stgx::Edge;
+using stgx::EdgeKind;
+using stgx::Graph;
+using stgx::Node;
+using stgx::System;
+
+namespace {
+
+Node node(std::string id, bool initial = false) {
+ Node out;
+ out.id = std::move(id);
+ out.initial = initial;
+ return out;
+}
+
+} // namespace
+
+TEST_CASE("a graph needs at least one node", "[validate]") {
+ const auto graph = Graph::create({}, {}, {});
+
+ REQUIRE_FALSE(graph.has_value());
+ CHECK(graph.error().contains(DiagCode::NodesMissing));
+}
+
+TEST_CASE("exactly one node must be initial", "[validate]") {
+ SECTION("none") {
+ const auto graph = Graph::create({}, {node("a"), node("b")}, {});
+ REQUIRE_FALSE(graph.has_value());
+
+ const auto diag = stgx_test::find(graph.error(), DiagCode::InitialCount);
+ REQUIRE(diag.has_value());
+ CHECK(diag->message.find("found 0") != std::string::npos);
+ }
+
+ SECTION("two") {
+ const auto graph = Graph::create({}, {node("a", true), node("b", true)}, {});
+ REQUIRE_FALSE(graph.has_value());
+
+ const auto diag = stgx_test::find(graph.error(), DiagCode::InitialCount);
+ REQUIRE(diag.has_value());
+ CHECK(diag->message.find("found 2") != std::string::npos);
+ }
+}
+
+TEST_CASE("node ids must be unique and non-empty", "[validate]") {
+ SECTION("duplicate") {
+ const auto graph = Graph::create({}, {node("a", true), node("a")}, {});
+ REQUIRE_FALSE(graph.has_value());
+
+ const auto diag = stgx_test::find(graph.error(), DiagCode::NodeIdDuplicate);
+ REQUIRE(diag.has_value());
+ CHECK(diag->path == "nodes[1].id");
+ }
+
+ SECTION("empty") {
+ const auto graph = Graph::create({}, {node("", true)}, {});
+ REQUIRE_FALSE(graph.has_value());
+
+ const auto diag = stgx_test::find(graph.error(), DiagCode::NodeIdEmpty);
+ REQUIRE(diag.has_value());
+ CHECK(diag->path == "nodes[0].id");
+ }
+}
+
+TEST_CASE("edges may not dangle", "[validate]") {
+ SECTION("unknown target is reported at its position in the source node") {
+ const std::vector nodes{node("a", true), node("b")};
+ const std::vector edges{
+ Edge{"b", "a", EdgeKind::Transition},
+ Edge{"b", "gone", EdgeKind::Transition},
+ Edge{"b", "also-gone", EdgeKind::Loopback},
+ };
+ const auto graph = Graph::create({}, nodes, edges);
+ REQUIRE_FALSE(graph.has_value());
+
+ const auto diag = stgx_test::find(graph.error(), DiagCode::DanglingEdge);
+ REQUIRE(diag.has_value());
+ CHECK(diag->path == "nodes[1].children[1]");
+ // The release arc is reported separately, indexed within loopback.
+ CHECK(graph.error().to_string().find("nodes[1].loopback[0]") != std::string::npos);
+ }
+
+ SECTION("unknown source is reported once") {
+ const std::vector edges{
+ Edge{"ghost", "a", EdgeKind::Transition},
+ Edge{"ghost", "a", EdgeKind::Loopback},
+ };
+ const auto graph = Graph::create({}, {node("a", true)}, edges);
+ REQUIRE_FALSE(graph.has_value());
+ CHECK(graph.error().size() == 1);
+ CHECK(graph.error()[0].code == DiagCode::DanglingEdge);
+ }
+}
+
+TEST_CASE("node task count must match system.tasks", "[validate]") {
+ System system;
+ system.tasks = {{1, 2}, {1, 2}};
+
+ Node only = node("a", true);
+ only.tasks = {{0, 0}};
+
+ const auto graph = Graph::create(system, {only}, {});
+ REQUIRE_FALSE(graph.has_value());
+
+ const auto diag = stgx_test::find(graph.error(), DiagCode::TaskArityMismatch);
+ REQUIRE(diag.has_value());
+ CHECK(diag->path == "nodes[0].tasks");
+}
+
+TEST_CASE("validation collects every problem at once", "[validate]") {
+ const auto graph =
+ Graph::create({}, {node("a"), node("a")}, {Edge{"a", "nowhere", EdgeKind::Transition}});
+
+ REQUIRE_FALSE(graph.has_value());
+ CHECK(graph.error().contains(DiagCode::NodeIdDuplicate));
+ CHECK(graph.error().contains(DiagCode::InitialCount));
+ CHECK(graph.error().contains(DiagCode::DanglingEdge));
+}
+
+TEST_CASE("a sound graph validates", "[validate]") {
+ System system;
+ system.m = 1;
+ system.tasks = {{2, 5, "τ₁"}};
+
+ Node first = node("a", true);
+ first.tasks = {{0, 0}};
+ Node second = node("b");
+ second.tasks = {{2, 5, stgx::Release::Up}};
+
+ const auto graph =
+ Graph::create(system, {first, second},
+ {Edge{"a", "b", EdgeKind::Transition}, Edge{"b", "a", EdgeKind::Loopback}});
+
+ REQUIRE(graph.has_value());
+ CHECK(graph->validate().has_value());
+}
diff --git a/tests/yaml_load_test.cpp b/tests/yaml_load_test.cpp
new file mode 100644
index 0000000..cda40c7
--- /dev/null
+++ b/tests/yaml_load_test.cpp
@@ -0,0 +1,206 @@
+#include
+#include
+#include
+
+#include