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 @@ +
+ +libstgx + +[![CI](https://github.com/peplxx/libstgx/actions/workflows/ci.yml/badge.svg)](https://github.com/peplxx/libstgx/actions/workflows/ci.yml) +![C++23](https://img.shields.io/badge/C%2B%2B-23-00599C?logo=cplusplus&logoColor=white) +![CMake 3.21+](https://img.shields.io/badge/CMake-3.21%2B-064F8C?logo=cmake&logoColor=white) +![version](https://img.shields.io/badge/version-0.1.0-blue) + +
+ +**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 +#include +#include + +#include "helpers.hpp" + +using stgx::DiagCode; +using stgx::EdgeKind; +using stgx::Release; +using stgx_test::data_file; + +TEST_CASE("a minimal document loads", "[load]") { + stgx::Diagnostics warnings; + const auto graph = stgx::load_yaml_file(data_file("minimal.yaml"), &warnings); + + REQUIRE(graph.has_value()); + CHECK(warnings.empty()); + CHECK(graph->node_count() == 1); + CHECK(graph->edge_count() == 0); + REQUIRE(graph->initial_node() != nullptr); + CHECK(graph->initial_node()->id == "n0"); + CHECK(graph->initial_node()->tasks.empty()); + CHECK(graph->system().empty()); +} + +TEST_CASE("a full document loads", "[load]") { + const auto graph = stgx::load_yaml_file(data_file("two-tasks.yaml")); + + REQUIRE(graph.has_value()); + CHECK(graph->node_count() == 3); + CHECK(graph->edge_count() == 4); + + const stgx::System& system = graph->system(); + REQUIRE(system.description.has_value()); + CHECK(*system.description == "EDF feasibility graph"); + CHECK(system.m == 1); + REQUIRE(system.tasks.size() == 2); + CHECK(system.tasks[0].c == 2); + CHECK(system.tasks[0].d == 5); + CHECK(system.tasks[0].name == "τ₁"); + + const auto children = graph->out_edges("n00"); + REQUIRE(children.size() == 2); + CHECK(children[0].target == "n10"); + CHECK(children[0].kind == EdgeKind::Transition); + CHECK(children[1].target == "n20"); + + const auto loopback = graph->out_edges("n20"); + REQUIRE(loopback.size() == 1); + CHECK(loopback[0].kind == EdgeKind::Loopback); + CHECK(loopback[0].target == "n00"); + + const stgx::Node* n20 = graph->find_node("n20"); + REQUIRE(n20 != nullptr); + CHECK(n20->label == "custom: label"); + REQUIRE(n20->tasks.size() == 2); + CHECK(n20->tasks[0].release == Release::Down); + CHECK(n20->tasks[1].release == Release::None); +} + +TEST_CASE("release aliases and omitted fields are normalized", "[load]") { + stgx::Diagnostics warnings; + const auto graph = stgx::load_yaml_file(data_file("aliases.yaml"), &warnings); + + REQUIRE(graph.has_value()); + CHECK(warnings.empty()); + + // isInitial is the legacy spelling of initial. + REQUIRE(graph->initial_node() != nullptr); + CHECK(graph->initial_node()->id == "a"); + + const stgx::Node* a = graph->find_node("a"); + REQUIRE(a != nullptr); + CHECK(a->tasks[0].release == Release::Up); // ↑ + // An omitted c defaults to 0, d comes from the file. + CHECK(a->tasks[1].c == 0); + CHECK(a->tasks[1].d == 2); + CHECK(a->tasks[1].release == Release::None); + + CHECK(graph->find_node("b")->tasks[0].release == Release::Down); // ↓ + CHECK(graph->find_node("c")->tasks[0].release == Release::Up); // 1 + CHECK(graph->find_node("c")->tasks[1].release == Release::Down); // -1 +} + +TEST_CASE("fields we do not model yet warn instead of vanishing quietly", "[load]") { + stgx::Diagnostics warnings; + const auto graph = stgx::load_yaml_file(data_file("unsupported.yaml"), &warnings); + + REQUIRE(graph.has_value()); + CHECK_FALSE(warnings.has_errors()); + CHECK(warnings.contains(DiagCode::UnsupportedField)); + CHECK(warnings.contains(DiagCode::UnknownField)); + + const std::string report = warnings.to_string(); + for (std::string_view expected : {"areas", "layout", "nodes[0].hatch", "nodes[0].metadata"}) { + CHECK(report.find(expected) != std::string::npos); + } + CHECK(report.find("nodes[1].dangerouslyUnknown") != std::string::npos); + + // Styling is modelled, so it loads instead of warning. + CHECK(report.find("Color") == std::string::npos); + const stgx::Node* styled = graph->find_node("n0"); + REQUIRE(styled != nullptr); + CHECK(styled->border_color == "#C0392B"); + CHECK(styled->fill_color == "#E8F4F8"); +} + +TEST_CASE("warnings are optional", "[load]") { + // Passing no sink must not crash and must not change the outcome. + CHECK(stgx::load_yaml_file(data_file("unsupported.yaml")).has_value()); +} + +namespace { + +struct BadFixture { + std::string_view file; + DiagCode code; + std::string_view path; +}; + +} // namespace + +TEST_CASE("broken documents report the expected diagnostic", "[load]") { + const BadFixture fixtures[] = { + {.file = "invalid_no_nodes.yaml", .code = DiagCode::NodesMissing, .path = "nodes"}, + {.file = "invalid_empty_nodes.yaml", .code = DiagCode::NodesMissing, .path = "nodes"}, + {.file = "invalid_duplicate_id.yaml", + .code = DiagCode::NodeIdDuplicate, + .path = "nodes[1].id"}, + {.file = "invalid_empty_id.yaml", .code = DiagCode::NodeIdEmpty, .path = "nodes[0].id"}, + {.file = "invalid_dangling.yaml", + .code = DiagCode::DanglingEdge, + .path = "nodes[1].loopback[0]"}, + {.file = "invalid_two_initial.yaml", .code = DiagCode::InitialCount, .path = "nodes"}, + {.file = "invalid_no_initial.yaml", .code = DiagCode::InitialCount, .path = "nodes"}, + {.file = "invalid_task_arity.yaml", + .code = DiagCode::TaskArityMismatch, + .path = "nodes[0].tasks"}, + {.file = "invalid_bad_int.yaml", .code = DiagCode::NotAnInt, .path = "nodes[0].tasks[0].c"}, + {.file = "invalid_bad_release.yaml", + .code = DiagCode::BadRelease, + .path = "nodes[0].tasks[0].release"}, + {.file = "invalid_schema_version.yaml", + .code = DiagCode::SchemaVersion, + .path = "schemaVersion"}, + {.file = "invalid_wrong_type.yaml", .code = DiagCode::WrongType, .path = "nodes[0].initial"}, + }; + + for (const BadFixture& fixture : fixtures) { + CAPTURE(fixture.file); + const auto graph = stgx::load_yaml_file(data_file(fixture.file)); + + REQUIRE_FALSE(graph.has_value()); + const auto diag = stgx_test::find(graph.error(), fixture.code); + REQUIRE(diag.has_value()); + CHECK(diag->path == fixture.path); + CHECK(diag->severity == stgx::Severity::Error); + } +} + +TEST_CASE("malformed YAML is a parse error", "[load]") { + const auto graph = stgx::load_yaml_file(data_file("invalid_bad_yaml.yaml")); + + REQUIRE_FALSE(graph.has_value()); + CHECK(graph.error().contains(DiagCode::YamlParse)); +} + +TEST_CASE("a missing file is an io error", "[load]") { + const auto graph = stgx::load_yaml_file(data_file("does-not-exist.yaml")); + + REQUIRE_FALSE(graph.has_value()); + CHECK(graph.error().contains(DiagCode::IoError)); +} + +TEST_CASE("an empty document is rejected", "[load]") { + const auto graph = stgx::load_yaml_string(""); + + REQUIRE_FALSE(graph.has_value()); + CHECK(graph.error().contains(DiagCode::YamlParse)); +} + +TEST_CASE("a scalar document is rejected", "[load]") { + const auto graph = stgx::load_yaml_string("just a string"); + + REQUIRE_FALSE(graph.has_value()); + CHECK(graph.error().contains(DiagCode::WrongType)); +} + +TEST_CASE("loading collects every problem at once", "[load]") { + const auto graph = stgx::load_yaml_string(R"(nodes: + - id: n0 + tasks: + - {c: oops, d: 1} + children: [ghost] +)"); + + REQUIRE_FALSE(graph.has_value()); + CHECK(graph.error().contains(DiagCode::NotAnInt)); + // Structural checks still run once the fields have been read. + CHECK(graph.error().contains(DiagCode::InitialCount)); + CHECK(graph.error().contains(DiagCode::DanglingEdge)); +} diff --git a/tests/yaml_roundtrip_test.cpp b/tests/yaml_roundtrip_test.cpp new file mode 100644 index 0000000..94fffa0 --- /dev/null +++ b/tests/yaml_roundtrip_test.cpp @@ -0,0 +1,104 @@ +#include +#include +#include + +#include +#include +#include + +#include "helpers.hpp" + +using stgx_test::data_file; + +TEST_CASE("the canonical document survives a round trip byte for byte", "[roundtrip]") { + const std::string original = stgx_test::read_file(data_file("two-tasks.yaml")); + REQUIRE_FALSE(original.empty()); + + const auto graph = stgx::load_yaml_string(original); + REQUIRE(graph.has_value()); + + const auto written = stgx::save_yaml_string(*graph); + REQUIRE(written.has_value()); + CHECK(*written == original); +} + +TEST_CASE("every valid fixture round trips through the model", "[roundtrip]") { + for (std::string_view name : + {"minimal.yaml", "two-tasks.yaml", "aliases.yaml", "unsupported.yaml"}) { + CAPTURE(name); + const auto first = stgx::load_yaml_file(data_file(name)); + REQUIRE(first.has_value()); + + const auto written = stgx::save_yaml_string(*first); + REQUIRE(written.has_value()); + + const auto second = stgx::load_yaml_string(*written); + REQUIRE(second.has_value()); + CHECK(*second == *first); + } +} + +TEST_CASE("saving omits everything left at its default", "[roundtrip]") { + stgx::Node only; + only.id = "n0"; + only.initial = true; + + const auto graph = stgx::Graph::create({}, {only}, {}); + REQUIRE(graph.has_value()); + + const auto written = stgx::save_yaml_string(*graph); + REQUIRE(written.has_value()); + CHECK(*written == "schemaVersion: 1\nnodes:\n - id: n0\n initial: true\n"); +} + +TEST_CASE("node styling survives a round trip", "[roundtrip]") { + const std::string original = + "schemaVersion: 1\n" + "nodes:\n" + " - id: n0\n" + " initial: true\n" + " borderColor: \"#C0392B\"\n" + " fillColor: \"#F9D5D3\"\n"; + + const auto graph = stgx::load_yaml_string(original); + REQUIRE(graph.has_value()); + CHECK(graph->find_node("n0")->border_color == "#C0392B"); + CHECK(graph->find_node("n0")->fill_color == "#F9D5D3"); + + const auto written = stgx::save_yaml_string(*graph); + REQUIRE(written.has_value()); + CHECK(*written == original); +} + +TEST_CASE("an invalid graph is never written", "[roundtrip]") { + const stgx::Graph empty; + + const auto written = stgx::save_yaml_string(empty); + REQUIRE_FALSE(written.has_value()); + CHECK(written.error().contains(stgx::DiagCode::NodesMissing)); +} + +TEST_CASE("saving to a file produces the same bytes", "[roundtrip]") { + const auto graph = stgx::load_yaml_file(data_file("two-tasks.yaml")); + REQUIRE(graph.has_value()); + + const std::filesystem::path out = + std::filesystem::temp_directory_path() / "stgx_roundtrip_two_tasks.yaml"; + REQUIRE(stgx::save_yaml_file(*graph, out).has_value()); + + const auto written = stgx::save_yaml_string(*graph); + REQUIRE(written.has_value()); + CHECK(stgx_test::read_file(out) == *written); + + std::filesystem::remove(out); +} + +TEST_CASE("saving to an unwritable path is an io error", "[roundtrip]") { + const auto graph = stgx::load_yaml_file(data_file("minimal.yaml")); + REQUIRE(graph.has_value()); + + const auto written = + stgx::save_yaml_file(*graph, std::filesystem::path{"/no/such/directory/out.yaml"}); + REQUIRE_FALSE(written.has_value()); + CHECK(written.error().contains(stgx::DiagCode::IoError)); +}